Skip to content

feat: implement terminal reconnection UI components (Phase 2) #18695

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 6 commits into
base: feature/terminal-reconnection
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 44 additions & 3 deletions site/src/pages/TerminalPage/TerminalAlerts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,30 @@ import { Alert, type AlertProps } from "components/Alert/Alert";
import { Button } from "components/Button/Button";
import { type FC, useEffect, useRef, useState } from "react";
import { docs } from "utils/docs";
import { TerminalRetryConnection } from "./TerminalRetryConnection";
import type { ConnectionStatus } from "./types";

type TerminalAlertsProps = {
agent: WorkspaceAgent | undefined;
status: ConnectionStatus;
onAlertChange: () => void;
// Retry connection props
isRetrying?: boolean;
timeUntilNextRetry?: number | null;
attemptCount?: number;
maxAttempts?: number;
onRetryNow?: () => void;
};

export const TerminalAlerts = ({
agent,
status,
onAlertChange,
isRetrying = false,
timeUntilNextRetry = null,
attemptCount = 0,
maxAttempts = 10,
onRetryNow,
}: TerminalAlertsProps) => {
const lifecycleState = agent?.lifecycle_state;
const prevLifecycleState = useRef(lifecycleState);
Expand Down Expand Up @@ -49,7 +61,13 @@ export const TerminalAlerts = ({
return (
<div ref={wrapperRef}>
{status === "disconnected" ? (
<DisconnectedAlert />
<DisconnectedAlert
isRetrying={isRetrying}
timeUntilNextRetry={timeUntilNextRetry}
attemptCount={attemptCount}
maxAttempts={maxAttempts}
onRetryNow={onRetryNow || (() => {})}
/>
) : lifecycleState === "start_error" ? (
<ErrorScriptAlert />
) : lifecycleState === "starting" ? (
Expand Down Expand Up @@ -170,12 +188,35 @@ const TerminalAlert: FC<AlertProps> = (props) => {
);
};

const DisconnectedAlert: FC<AlertProps> = (props) => {
interface DisconnectedAlertProps extends AlertProps {
isRetrying?: boolean;
timeUntilNextRetry?: number | null;
attemptCount?: number;
maxAttempts?: number;
onRetryNow?: () => void;
}

const DisconnectedAlert: FC<DisconnectedAlertProps> = ({
isRetrying = false,
timeUntilNextRetry = null,
attemptCount = 0,
maxAttempts = 10,
onRetryNow,
...props
}) => {
return (
<TerminalAlert
{...props}
severity="warning"
actions={<RefreshSessionButton />}
actions={
<TerminalRetryConnection
isRetrying={isRetrying}
timeUntilNextRetry={timeUntilNextRetry}
attemptCount={attemptCount}
maxAttempts={maxAttempts}
onRetryNow={onRetryNow || (() => {})}
/>
}
>
Disconnected
</TerminalAlert>
Expand Down
101 changes: 101 additions & 0 deletions site/src/pages/TerminalPage/TerminalRetryConnection.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { action } from "@storybook/addon-actions";
import type { Meta, StoryObj } from "@storybook/react";
import { TerminalRetryConnection } from "./TerminalRetryConnection";

const meta: Meta<typeof TerminalRetryConnection> = {
title: "pages/TerminalPage/TerminalRetryConnection",
component: TerminalRetryConnection,
parameters: {
layout: "padded",
},
args: {
onRetryNow: action("onRetryNow"),
maxAttempts: 10,
},
};

export default meta;
type Story = StoryObj<typeof TerminalRetryConnection>;

// Hidden state - component returns null
export const Hidden: Story = {
args: {
isRetrying: false,
timeUntilNextRetry: null,
attemptCount: 0,
},
};

// Currently retrying state - shows "Reconnecting..." with no button
export const Retrying: Story = {
args: {
isRetrying: true,
timeUntilNextRetry: null,
attemptCount: 1,
},
};

// Countdown to next retry - first attempt (1 second)
export const CountdownFirstAttempt: Story = {
args: {
isRetrying: false,
timeUntilNextRetry: 1000, // 1 second
attemptCount: 1,
},
};

// Countdown to next retry - second attempt (2 seconds)
export const CountdownSecondAttempt: Story = {
args: {
isRetrying: false,
timeUntilNextRetry: 2000, // 2 seconds
attemptCount: 2,
},
};

// Countdown to next retry - longer delay (15 seconds)
export const CountdownLongerDelay: Story = {
args: {
isRetrying: false,
timeUntilNextRetry: 15000, // 15 seconds
attemptCount: 5,
},
};

// Countdown with 1 second remaining (singular)
export const CountdownOneSecond: Story = {
args: {
isRetrying: false,
timeUntilNextRetry: 1000, // 1 second
attemptCount: 3,
},
};

// Countdown with less than 1 second remaining
export const CountdownLessThanOneSecond: Story = {
args: {
isRetrying: false,
timeUntilNextRetry: 500, // 0.5 seconds (should show "1 second")
attemptCount: 3,
},
};

// Max attempts reached - no more automatic retries
export const MaxAttemptsReached: Story = {
args: {
isRetrying: false,
timeUntilNextRetry: null,
attemptCount: 10,
maxAttempts: 10,
},
};

// Connection lost but no retry scheduled yet
export const ConnectionLostNoRetry: Story = {
args: {
isRetrying: false,
timeUntilNextRetry: null,
attemptCount: 1,
maxAttempts: 10,
},
};
85 changes: 85 additions & 0 deletions site/src/pages/TerminalPage/TerminalRetryConnection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { Button } from "components/Button/Button";
import { Spinner } from "components/Spinner/Spinner";
import type { FC } from "react";

interface TerminalRetryConnectionProps {
/**
* Whether a retry is currently in progress
*/
isRetrying: boolean;
/**
* Time in milliseconds until the next automatic retry (null if not scheduled)
*/
timeUntilNextRetry: number | null;
/**
* Number of retry attempts made
*/
attemptCount: number;
/**
* Maximum number of retry attempts
*/
maxAttempts: number;
/**
* Callback to manually trigger a retry
*/
onRetryNow: () => void;
}

/**
* Formats milliseconds into a human-readable countdown
*/
function formatCountdown(ms: number): string {
const seconds = Math.ceil(ms / 1000);
return `${seconds} second${seconds !== 1 ? "s" : ""}`;
}

export const TerminalRetryConnection: FC<TerminalRetryConnectionProps> = ({
isRetrying,
timeUntilNextRetry,
attemptCount,
maxAttempts,
onRetryNow,
}) => {
// Don't show anything if we're not in a retry state
if (!isRetrying && timeUntilNextRetry === null && attemptCount === 0) {
return null;
}

// Show different messages based on state
let message: string;
let showRetryButton = true;

if (isRetrying) {
message = "Reconnecting...";
showRetryButton = false; // Don't show button while actively retrying
} else if (timeUntilNextRetry !== null) {
const countdown = formatCountdown(timeUntilNextRetry);
message = `Retrying in ${countdown}`;
} else if (attemptCount >= maxAttempts) {
message = "Failed after multiple attempts";
} else {
message = "";
}

return (
<div className="flex items-center gap-2">
{message && (
<span className="text-sm text-content-secondary tabular-nums">
{message}
</span>
)}
{showRetryButton && (
<Button
variant="outline"
size="sm"
onClick={onRetryNow}
disabled={isRetrying}
className="flex items-center gap-1"
>
{isRetrying && <Spinner size="sm" />}
Retry now
</Button>
)}
</div>
);
};
Loading