Skip to content

Commit 417b053

Browse files
feat: implement terminal reconnection UI components (Phase 2)
- Create TerminalRetryConnection component with countdown display and retry button - Add comprehensive Storybook stories covering all retry states - Integrate component with TerminalAlerts for proper positioning - Use consistent TerminalAlert styling for seamless integration - Ensure proper resize handling through existing MutationObserver Implements Phase 2 of terminal reconnection feature as outlined in: coder/internal#659 Co-authored-by: BrunoQuaresma <[email protected]>
1 parent dd7adda commit 417b053

File tree

3 files changed

+231
-1
lines changed

3 files changed

+231
-1
lines changed

site/src/pages/TerminalPage/TerminalAlerts.tsx

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,29 @@ import { Button } from "components/Button/Button";
55
import { type FC, useEffect, useRef, useState } from "react";
66
import { docs } from "utils/docs";
77
import type { ConnectionStatus } from "./types";
8+
import { TerminalRetryConnection } from "./TerminalRetryConnection";
89

910
type TerminalAlertsProps = {
1011
agent: WorkspaceAgent | undefined;
1112
status: ConnectionStatus;
1213
onAlertChange: () => void;
14+
// Retry connection props
15+
isRetrying?: boolean;
16+
timeUntilNextRetry?: number | null;
17+
attemptCount?: number;
18+
maxAttempts?: number;
19+
onRetryNow?: () => void;
1320
};
1421

1522
export const TerminalAlerts = ({
1623
agent,
1724
status,
1825
onAlertChange,
26+
isRetrying = false,
27+
timeUntilNextRetry = null,
28+
attemptCount = 0,
29+
maxAttempts = 10,
30+
onRetryNow,
1931
}: TerminalAlertsProps) => {
2032
const lifecycleState = agent?.lifecycle_state;
2133
const prevLifecycleState = useRef(lifecycleState);
@@ -49,7 +61,13 @@ export const TerminalAlerts = ({
4961
return (
5062
<div ref={wrapperRef}>
5163
{status === "disconnected" ? (
52-
<DisconnectedAlert />
64+
<TerminalRetryConnection
65+
isRetrying={isRetrying}
66+
timeUntilNextRetry={timeUntilNextRetry}
67+
attemptCount={attemptCount}
68+
maxAttempts={maxAttempts}
69+
onRetryNow={onRetryNow || (() => {})}
70+
/>
5371
) : lifecycleState === "start_error" ? (
5472
<ErrorScriptAlert />
5573
) : lifecycleState === "starting" ? (
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import type { Meta, StoryObj } from "@storybook/react";
2+
import { TerminalRetryConnection } from "./TerminalRetryConnection";
3+
4+
const meta: Meta<typeof TerminalRetryConnection> = {
5+
title: "pages/TerminalPage/TerminalRetryConnection",
6+
component: TerminalRetryConnection,
7+
parameters: {
8+
layout: "padded",
9+
},
10+
args: {
11+
onRetryNow: () => {
12+
console.log("Retry now clicked");
13+
},
14+
maxAttempts: 10,
15+
},
16+
};
17+
18+
export default meta;
19+
type Story = StoryObj<typeof TerminalRetryConnection>;
20+
21+
// Hidden state - component returns null
22+
export const Hidden: Story = {
23+
args: {
24+
isRetrying: false,
25+
timeUntilNextRetry: null,
26+
attemptCount: 0,
27+
},
28+
};
29+
30+
// Currently retrying state
31+
export const Retrying: Story = {
32+
args: {
33+
isRetrying: true,
34+
timeUntilNextRetry: null,
35+
attemptCount: 1,
36+
},
37+
};
38+
39+
// Countdown to next retry - first attempt (1 second)
40+
export const CountdownFirstAttempt: Story = {
41+
args: {
42+
isRetrying: false,
43+
timeUntilNextRetry: 1000, // 1 second
44+
attemptCount: 1,
45+
},
46+
};
47+
48+
// Countdown to next retry - second attempt (2 seconds)
49+
export const CountdownSecondAttempt: Story = {
50+
args: {
51+
isRetrying: false,
52+
timeUntilNextRetry: 2000, // 2 seconds
53+
attemptCount: 2,
54+
},
55+
};
56+
57+
// Countdown to next retry - longer delay (15 seconds)
58+
export const CountdownLongerDelay: Story = {
59+
args: {
60+
isRetrying: false,
61+
timeUntilNextRetry: 15000, // 15 seconds
62+
attemptCount: 5,
63+
},
64+
};
65+
66+
// Countdown with 1 second remaining (singular)
67+
export const CountdownOneSecond: Story = {
68+
args: {
69+
isRetrying: false,
70+
timeUntilNextRetry: 1000, // 1 second
71+
attemptCount: 3,
72+
},
73+
};
74+
75+
// Countdown with less than 1 second remaining
76+
export const CountdownLessThanOneSecond: Story = {
77+
args: {
78+
isRetrying: false,
79+
timeUntilNextRetry: 500, // 0.5 seconds (should show "1 second")
80+
attemptCount: 3,
81+
},
82+
};
83+
84+
// Max attempts reached - no more automatic retries
85+
export const MaxAttemptsReached: Story = {
86+
args: {
87+
isRetrying: false,
88+
timeUntilNextRetry: null,
89+
attemptCount: 10,
90+
maxAttempts: 10,
91+
},
92+
};
93+
94+
// Connection lost but no retry scheduled yet
95+
export const ConnectionLostNoRetry: Story = {
96+
args: {
97+
isRetrying: false,
98+
timeUntilNextRetry: null,
99+
attemptCount: 1,
100+
maxAttempts: 10,
101+
},
102+
};
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { Alert, type AlertProps } from "components/Alert/Alert";
2+
import { Button } from "components/Button/Button";
3+
import { Spinner } from "components/Spinner/Spinner";
4+
import type { FC } from "react";
5+
6+
interface TerminalRetryConnectionProps {
7+
/**
8+
* Whether a retry is currently in progress
9+
*/
10+
isRetrying: boolean;
11+
/**
12+
* Time in milliseconds until the next automatic retry (null if not scheduled)
13+
*/
14+
timeUntilNextRetry: number | null;
15+
/**
16+
* Number of retry attempts made
17+
*/
18+
attemptCount: number;
19+
/**
20+
* Maximum number of retry attempts
21+
*/
22+
maxAttempts: number;
23+
/**
24+
* Callback to manually trigger a retry
25+
*/
26+
onRetryNow: () => void;
27+
}
28+
29+
/**
30+
* Formats milliseconds into a human-readable countdown
31+
*/
32+
function formatCountdown(ms: number): string {
33+
const seconds = Math.ceil(ms / 1000);
34+
return `${seconds} second${seconds !== 1 ? "s" : ""}`;
35+
}
36+
37+
/**
38+
* Terminal-specific alert component with consistent styling
39+
*/
40+
const TerminalAlert: FC<AlertProps> = (props) => {
41+
return (
42+
<Alert
43+
{...props}
44+
css={(theme) => ({
45+
borderRadius: 0,
46+
borderWidth: 0,
47+
borderBottomWidth: 1,
48+
borderBottomColor: theme.palette.divider,
49+
backgroundColor: theme.palette.background.paper,
50+
borderLeft: `3px solid ${theme.palette[props.severity!].light}`,
51+
marginBottom: 1,
52+
})}
53+
/>
54+
);
55+
};
56+
57+
export const TerminalRetryConnection: FC<TerminalRetryConnectionProps> = ({
58+
isRetrying,
59+
timeUntilNextRetry,
60+
attemptCount,
61+
maxAttempts,
62+
onRetryNow,
63+
}) => {
64+
// Don't show anything if we're not in a retry state
65+
if (!isRetrying && timeUntilNextRetry === null) {
66+
return null;
67+
}
68+
69+
// Show different messages based on state
70+
let message: string;
71+
let showRetryButton = true;
72+
73+
if (isRetrying) {
74+
message = "Reconnecting to terminal...";
75+
showRetryButton = false; // Don't show button while actively retrying
76+
} else if (timeUntilNextRetry !== null) {
77+
const countdown = formatCountdown(timeUntilNextRetry);
78+
message = `Connection lost. Retrying in ${countdown}...`;
79+
} else if (attemptCount >= maxAttempts) {
80+
message = "Connection failed after multiple attempts.";
81+
} else {
82+
message = "Connection lost.";
83+
}
84+
85+
return (
86+
<TerminalAlert
87+
severity="warning"
88+
actions={
89+
showRetryButton ? (
90+
<Button
91+
variant="outline"
92+
size="sm"
93+
onClick={onRetryNow}
94+
disabled={isRetrying}
95+
css={{
96+
display: "flex",
97+
alignItems: "center",
98+
gap: "0.5rem",
99+
}}
100+
>
101+
{isRetrying && <Spinner size="sm" />}
102+
Retry now
103+
</Button>
104+
) : null
105+
}
106+
>
107+
{message}
108+
</TerminalAlert>
109+
);
110+
};

0 commit comments

Comments
 (0)