| author | |
| committer | |
| log | e0513d29839f934df1d5a13551e6ccd76d11006b |
| tree | 0d241f93d0c0b601d881f351fa8efc04f36c5f08 |
| parent | 9dd425b2234c062b4767583e42d4b2c1aabab15d |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>5 files changed, 126 insertions(+), 102 deletions(-)
apps/web/src/components/ChatView.tsx+75-40| ... | @@ -1883,46 +1883,75 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -1883,46 +1883,75 @@ function ChatViewContent(props: ChatViewProps) { |
| 1883 | ); | 1883 | ); |
| 1884 | const systemComposerBannerItems = useMemo<ComposerBannerStackItem[]>(() => { | 1884 | const systemComposerBannerItems = useMemo<ComposerBannerStackItem[]>(() => { |
| 1885 | const items: ComposerBannerStackItem[] = []; | 1885 | const items: ComposerBannerStackItem[] = []; |
| 1886 | const resumingServerUpdate = | 1886 | const updateRunning = serverUpdateState.status === "running"; |
| 1887 | serverUpdateState.status === "running" && serverUpdateState.stage === "resuming"; | 1887 | const unavailableConnection = activeEnvironmentUnavailableState?.connection ?? null; |
| 1888 | if (activeEnvironmentUnavailableState && !resumingServerUpdate) { | 1888 | const environmentReconnecting = |
| 1889 | const connection = activeEnvironmentUnavailableState.connection; | 1889 | unavailableConnection !== null && |
| 1890 | const isReconnecting = | 1890 | (unavailableConnection.phase === "connecting" || |
| 1891 | connection.phase === "connecting" || connection.phase === "reconnecting"; | 1891 | unavailableConnection.phase === "reconnecting"); |
| 1892 | items.push({ | 1892 | // Reconnecting to a version-skewed server with no update in flight |
| 1893 | id: `environment-unavailable:${activeEnvironmentUnavailableState.environmentId}`, | 1893 | // usually means the server is restarting mid-update and a refresh wiped |
| 1894 | variant: connection.phase === "error" ? "error" : "warning", | 1894 | // the in-memory update state. Fold the reconnect and version banners |
| 1895 | icon: <WifiOffIcon />, | 1895 | // into one calm line instead of stacking "Failed to connect" on |
| 1896 | title: `${activeEnvironmentUnavailableState.label}: ${connectionStatusTitle(connection)}`, | 1896 | // "versions differ". A failed update never folds: its error and retry |
| 1897 | description: | 1897 | // action must stay visible. |
| 1898 | connection.error ?? | 1898 | const reconnectingThroughVersionSkew = |
| 1899 | "Reconnect this environment before sending messages or running actions.", | 1899 | serverUpdateState.status === "idle" && environmentReconnecting && versionMismatch !== null; |
| 1900 | actions: ( | 1900 | // While an update runs, transient connect blips are expected (the server |
| 1901 | <> | 1901 | // restarts) and the update banner already shows progress. Hard failure |
| 1902 | <Button | 1902 | // phases still surface so the Reconnect action stays reachable. |
| 1903 | size="xs" | 1903 | const suppressUnavailableBanner = updateRunning && environmentReconnecting; |
| 1904 | disabled={isReconnecting} | 1904 | if (activeEnvironmentUnavailableState && unavailableConnection && !suppressUnavailableBanner) { |
| 1905 | onClick={() => | 1905 | if (reconnectingThroughVersionSkew) { |
| 1906 | void handleReconnectActiveEnvironment( | 1906 | items.push({ |
| 1907 | activeEnvironmentUnavailableState.environmentId, | 1907 | id: `environment-unavailable:${activeEnvironmentUnavailableState.environmentId}`, |
| 1908 | ) | 1908 | variant: "default", |
| 1909 | } | 1909 | icon: ( |
| 1910 | > | 1910 | <span |
| 1911 | {isReconnecting ? "Reconnecting..." : "Reconnect"} | 1911 | className="size-1.5 animate-status-pulse rounded-full bg-foreground" |
| 1912 | </Button> | 1912 | aria-hidden="true" |
| 1913 | <Button | 1913 | /> |
| 1914 | size="xs" | 1914 | ), |
| 1915 | variant="outline" | 1915 | title: `${unavailableConnection.phase === "connecting" ? "Connecting" : "Reconnecting"} to ${activeEnvironmentUnavailableState.label}`, |
| 1916 | onClick={() => void navigate({ to: "/settings/connections" })} | 1916 | description: "It may be finishing an update. One moment.", |
| 1917 | > | 1917 | }); |
| 1918 | Connections | 1918 | } else { |
| 1919 | </Button> | 1919 | items.push({ |
| 1920 | </> | 1920 | id: `environment-unavailable:${activeEnvironmentUnavailableState.environmentId}`, |
| 1921 | ), | 1921 | variant: unavailableConnection.phase === "error" ? "error" : "warning", |
| 1922 | }); | 1922 | icon: <WifiOffIcon />, |
| 1923 | title: `${activeEnvironmentUnavailableState.label}: ${connectionStatusTitle(unavailableConnection)}`, | ||
| 1924 | description: | ||
| 1925 | unavailableConnection.error ?? | ||
| 1926 | "Reconnect this environment before sending messages or running actions.", | ||
| 1927 | actions: ( | ||
| 1928 | <> | ||
| 1929 | <Button | ||
| 1930 | size="xs" | ||
| 1931 | disabled={environmentReconnecting} | ||
| 1932 | onClick={() => | ||
| 1933 | void handleReconnectActiveEnvironment( | ||
| 1934 | activeEnvironmentUnavailableState.environmentId, | ||
| 1935 | ) | ||
| 1936 | } | ||
| 1937 | > | ||
| 1938 | {environmentReconnecting ? "Reconnecting..." : "Reconnect"} | ||
| 1939 | </Button> | ||
| 1940 | <Button | ||
| 1941 | size="xs" | ||
| 1942 | variant="outline" | ||
| 1943 | onClick={() => void navigate({ to: "/settings/connections" })} | ||
| 1944 | > | ||
| 1945 | Connections | ||
| 1946 | </Button> | ||
| 1947 | </> | ||
| 1948 | ), | ||
| 1949 | }); | ||
| 1950 | } | ||
| 1923 | } | 1951 | } |
| 1924 | if ( | 1952 | if ( |
| 1925 | serverUpdateEnvironmentId && | 1953 | serverUpdateEnvironmentId && |
| 1954 | !reconnectingThroughVersionSkew && | ||
| 1926 | (serverUpdateState.status !== "idle" || | 1955 | (serverUpdateState.status !== "idle" || |
| 1927 | (showVersionMismatchBanner && versionMismatch && versionMismatchDismissKey)) | 1956 | (showVersionMismatchBanner && versionMismatch && versionMismatchDismissKey)) |
| 1928 | ) { | 1957 | ) { |
| ... | @@ -1930,8 +1959,15 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -1930,8 +1959,15 @@ function ChatViewContent(props: ChatViewProps) { |
| 1930 | const updateFailed = serverUpdateState.status === "failed"; | 1959 | const updateFailed = serverUpdateState.status === "failed"; |
| 1931 | items.push({ | 1960 | items.push({ |
| 1932 | id: `server-version:${serverUpdateEnvironmentId}`, | 1961 | id: `server-version:${serverUpdateEnvironmentId}`, |
| 1933 | variant: updateFailed ? "error" : "warning", | 1962 | variant: updateFailed ? "error" : updateInProgress ? "default" : "warning", |
| 1934 | icon: <TriangleAlertIcon />, | 1963 | icon: updateInProgress ? ( |
| 1964 | <span | ||
| 1965 | className="size-1.5 animate-status-pulse rounded-full bg-foreground" | ||
| 1966 | aria-hidden="true" | ||
| 1967 | /> | ||
| 1968 | ) : ( | ||
| 1969 | <TriangleAlertIcon /> | ||
| 1970 | ), | ||
| 1935 | title: | 1971 | title: |
| 1936 | updateInProgress || updateFailed | 1972 | updateInProgress || updateFailed |
| 1937 | ? `${updateFailed ? "Could not update" : "Updating"} ${versionMismatchServerLabel}` | 1973 | ? `${updateFailed ? "Could not update" : "Updating"} ${versionMismatchServerLabel}` |
| ... | @@ -1940,7 +1976,6 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -1940,7 +1976,6 @@ function ChatViewContent(props: ChatViewProps) { |
| 1940 | updateInProgress || updateFailed ? ( | 1976 | updateInProgress || updateFailed ? ( |
| 1941 | <ServerUpdateProgress | 1977 | <ServerUpdateProgress |
| 1942 | fromVersion={serverUpdateState.fromVersion} | 1978 | fromVersion={serverUpdateState.fromVersion} |
| 1943 | serverLabel={versionMismatchServerLabel} | ||
| 1944 | state={serverUpdateState} | 1979 | state={serverUpdateState} |
| 1945 | /> | 1980 | /> |
| 1946 | ) : versionMismatch ? ( | 1981 | ) : versionMismatch ? ( |
apps/web/src/components/ServerUpdateAction.test.tsx+8-5| ... | @@ -101,11 +101,10 @@ describe("ServerUpdateAction", () => { | ... | @@ -101,11 +101,10 @@ describe("ServerUpdateAction", () => { |
| 101 | }); | 101 | }); |
| 102 | 102 | ||
| 103 | describe("ServerUpdateProgress", () => { | 103 | describe("ServerUpdateProgress", () => { |
| 104 | it("renders the chosen horizontal step rail without an animated spinner", () => { | 104 | it("renders the monochrome step rail with only the active step highlighted", () => { |
| 105 | const markup = renderToStaticMarkup( | 105 | const markup = renderToStaticMarkup( |
| 106 | <ServerUpdateProgress | 106 | <ServerUpdateProgress |
| 107 | fromVersion="0.0.30" | 107 | fromVersion="0.0.30" |
| 108 | serverLabel="bb-1" | ||
| 109 | state={{ | 108 | state={{ |
| 110 | status: "running", | 109 | status: "running", |
| 111 | stage: "resuming", | 110 | stage: "resuming", |
| ... | @@ -119,8 +118,12 @@ describe("ServerUpdateProgress", () => { | ... | @@ -119,8 +118,12 @@ describe("ServerUpdateProgress", () => { |
| 119 | expect(markup).toContain("0.0.31"); | 118 | expect(markup).toContain("0.0.31"); |
| 120 | expect(markup).toContain("Download"); | 119 | expect(markup).toContain("Download"); |
| 121 | expect(markup).toContain("Install"); | 120 | expect(markup).toContain("Install"); |
| 122 | expect(markup).toContain("Resume"); | 121 | expect(markup).toContain("Resuming"); |
| 123 | expect(markup).toContain("Waiting for bb-1 to accept commands."); | 122 | // The wait state is monochrome and calm: no success/warning colors, no |
| 123 | // status sentence, one duty-cycled pulse on the active step. | ||
| 124 | expect(markup).not.toContain("text-success"); | ||
| 125 | expect(markup).not.toContain("text-primary"); | ||
| 126 | expect(markup).toContain("animate-status-pulse"); | ||
| 124 | expect(markup).not.toContain("animate-spin"); | 127 | expect(markup).not.toContain("animate-spin"); |
| 125 | }); | 128 | }); |
| 126 | 129 | ||
| ... | @@ -128,7 +131,6 @@ describe("ServerUpdateProgress", () => { | ... | @@ -128,7 +131,6 @@ describe("ServerUpdateProgress", () => { |
| 128 | const markup = renderToStaticMarkup( | 131 | const markup = renderToStaticMarkup( |
| 129 | <ServerUpdateProgress | 132 | <ServerUpdateProgress |
| 130 | fromVersion="0.0.30" | 133 | fromVersion="0.0.30" |
| 131 | serverLabel="bb-1" | ||
| 132 | state={{ | 134 | state={{ |
| 133 | status: "failed", | 135 | status: "failed", |
| 134 | stage: "installing", | 136 | stage: "installing", |
| ... | @@ -141,5 +143,6 @@ describe("ServerUpdateProgress", () => { | ... | @@ -141,5 +143,6 @@ describe("ServerUpdateProgress", () => { |
| 141 | 143 | ||
| 142 | expect(markup).toContain('role="alert"'); | 144 | expect(markup).toContain('role="alert"'); |
| 143 | expect(markup).toContain("The package could not be verified."); | 145 | expect(markup).toContain("The package could not be verified."); |
| 146 | expect(markup).not.toContain("animate-status-pulse"); | ||
| 144 | }); | 147 | }); |
| 145 | }); | 148 | }); |
apps/web/src/components/ServerUpdateAction.tsx+42-54| ... | @@ -15,9 +15,9 @@ import { Button } from "./ui/button"; | ... | @@ -15,9 +15,9 @@ import { Button } from "./ui/button"; |
| 15 | import { toastManager } from "./ui/toast"; | 15 | import { toastManager } from "./ui/toast"; |
| 16 | 16 | ||
| 17 | const UPDATE_STEPS = [ | 17 | const UPDATE_STEPS = [ |
| 18 | { stage: "downloading", label: "Download" }, | 18 | { stage: "downloading", label: "Download", activeLabel: "Downloading" }, |
| 19 | { stage: "installing", label: "Install" }, | 19 | { stage: "installing", label: "Install", activeLabel: "Installing" }, |
| 20 | { stage: "resuming", label: "Resume" }, | 20 | { stage: "resuming", label: "Resume", activeLabel: "Resuming" }, |
| 21 | ] as const; | 21 | ] as const; |
| 22 | const pendingUpdateEnvironmentIds = new Set<EnvironmentId>(); | 22 | const pendingUpdateEnvironmentIds = new Set<EnvironmentId>(); |
| 23 | 23 | ||
| ... | @@ -25,37 +25,24 @@ function updateFailureMessage(error: unknown): string { | ... | @@ -25,37 +25,24 @@ function updateFailureMessage(error: unknown): string { |
| 25 | return error instanceof Error ? error.message : "Server update failed."; | 25 | return error instanceof Error ? error.message : "Server update failed."; |
| 26 | } | 26 | } |
| 27 | 27 | ||
| 28 | function updateStatusCopy( | 28 | /** |
| 29 | state: Exclude<ServerUpdateState, { status: "idle" }>, | 29 | * Monochrome step rail for an in-flight server update. The update is a |
| 30 | serverLabel: string, | 30 | * wait, not a warning: done steps recede to gray, the active step is the |
| 31 | ): string { | 31 | * only bright element and pulses. Failure keeps the rail but surfaces the |
| 32 | if (state.status === "failed") { | 32 | * error below it. |
| 33 | return state.message; | 33 | */ |
| 34 | } | ||
| 35 | switch (state.stage) { | ||
| 36 | case "downloading": | ||
| 37 | return "Downloading the matching server version."; | ||
| 38 | case "installing": | ||
| 39 | return `Installing and verifying t3@${state.targetVersion}.`; | ||
| 40 | case "resuming": | ||
| 41 | return `Waiting for ${serverLabel} to accept commands.`; | ||
| 42 | } | ||
| 43 | } | ||
| 44 | |||
| 45 | export function ServerUpdateProgress({ | 34 | export function ServerUpdateProgress({ |
| 46 | fromVersion, | 35 | fromVersion, |
| 47 | serverLabel, | ||
| 48 | state, | 36 | state, |
| 49 | }: { | 37 | }: { |
| 50 | readonly fromVersion: string; | 38 | readonly fromVersion: string; |
| 51 | readonly serverLabel: string; | ||
| 52 | readonly state: Exclude<ServerUpdateState, { status: "idle" }>; | 39 | readonly state: Exclude<ServerUpdateState, { status: "idle" }>; |
| 53 | }) { | 40 | }) { |
| 54 | const currentIndex = UPDATE_STEPS.findIndex(({ stage }) => stage === state.stage); | 41 | const currentIndex = UPDATE_STEPS.findIndex(({ stage }) => stage === state.stage); |
| 55 | 42 | ||
| 56 | return ( | 43 | return ( |
| 57 | <div className="mt-1 min-w-0 space-y-2.5"> | 44 | <div className="mt-1 min-w-0 space-y-2.5"> |
| 58 | <p className="text-xs text-muted-foreground"> | 45 | <p className="text-xs text-muted-foreground/70"> |
| 59 | {fromVersion} <span aria-hidden="true">→</span> {state.targetVersion} | 46 | {fromVersion} <span aria-hidden="true">→</span> {state.targetVersion} |
| 60 | </p> | 47 | </p> |
| 61 | <ol className="flex min-w-0 items-center" aria-label="Server update progress"> | 48 | <ol className="flex min-w-0 items-center" aria-label="Server update progress"> |
| ... | @@ -63,6 +50,7 @@ export function ServerUpdateProgress({ | ... | @@ -63,6 +50,7 @@ export function ServerUpdateProgress({ |
| 63 | const complete = index < currentIndex; | 50 | const complete = index < currentIndex; |
| 64 | const current = index === currentIndex; | 51 | const current = index === currentIndex; |
| 65 | const failed = current && state.status === "failed"; | 52 | const failed = current && state.status === "failed"; |
| 53 | const running = current && state.status === "running"; | ||
| 66 | return ( | 54 | return ( |
| 67 | <li | 55 | <li |
| 68 | key={step.stage} | 56 | key={step.stage} |
| ... | @@ -70,36 +58,40 @@ export function ServerUpdateProgress({ | ... | @@ -70,36 +58,40 @@ export function ServerUpdateProgress({ |
| 70 | "flex min-w-0 items-center text-xs font-medium", | 58 | "flex min-w-0 items-center text-xs font-medium", |
| 71 | index < UPDATE_STEPS.length - 1 ? "flex-1" : null, | 59 | index < UPDATE_STEPS.length - 1 ? "flex-1" : null, |
| 72 | complete | 60 | complete |
| 73 | ? "text-success" | 61 | ? "text-muted-foreground" |
| 74 | : failed | 62 | : failed |
| 75 | ? "text-destructive" | 63 | ? "text-destructive" |
| 76 | : current | 64 | : running |
| 77 | ? "text-primary" | 65 | ? "animate-status-pulse text-foreground" |
| 78 | : "text-muted-foreground/60", | 66 | : "text-muted-foreground/50", |
| 79 | )} | 67 | )} |
| 80 | aria-current={current && state.status === "running" ? "step" : undefined} | 68 | aria-current={running ? "step" : undefined} |
| 81 | > | 69 | > |
| 82 | <span | 70 | {complete ? ( |
| 83 | className={cn( | 71 | <CheckIcon |
| 84 | "grid size-4 shrink-0 place-items-center rounded-full border", | 72 | className="size-3.5 shrink-0 opacity-70" |
| 85 | complete | 73 | strokeWidth={2.5} |
| 86 | ? "border-success bg-success text-success-foreground" | 74 | aria-hidden="true" |
| 87 | : failed | 75 | /> |
| 88 | ? "border-destructive bg-destructive text-destructive-foreground" | 76 | ) : ( |
| 89 | : current | 77 | <span |
| 90 | ? "border-primary bg-primary text-primary-foreground" | 78 | className={cn( |
| 91 | : "border-muted-foreground/35", | 79 | "size-1.5 shrink-0 rounded-full", |
| 92 | )} | 80 | failed |
| 93 | aria-hidden="true" | 81 | ? "bg-destructive" |
| 94 | > | 82 | : running |
| 95 | {complete ? <CheckIcon className="size-3" strokeWidth={3} /> : null} | 83 | ? "bg-foreground" |
| 96 | </span> | 84 | : "border border-muted-foreground/40", |
| 97 | <span className="ml-1.5 truncate">{step.label}</span> | 85 | )} |
| 86 | aria-hidden="true" | ||
| 87 | /> | ||
| 88 | )} | ||
| 89 | <span className="ml-1.5 truncate">{running ? step.activeLabel : step.label}</span> | ||
| 98 | {index < UPDATE_STEPS.length - 1 ? ( | 90 | {index < UPDATE_STEPS.length - 1 ? ( |
| 99 | <span | 91 | <span |
| 100 | className={cn( | 92 | className={cn( |
| 101 | "mx-2 h-px min-w-3 flex-1", | 93 | "mx-2 h-px min-w-3 flex-1", |
| 102 | complete ? "bg-success/60" : "bg-border", | 94 | complete ? "bg-muted-foreground/40" : "bg-border", |
| 103 | )} | 95 | )} |
| 104 | aria-hidden="true" | 96 | aria-hidden="true" |
| 105 | /> | 97 | /> |
| ... | @@ -108,15 +100,11 @@ export function ServerUpdateProgress({ | ... | @@ -108,15 +100,11 @@ export function ServerUpdateProgress({ |
| 108 | ); | 100 | ); |
| 109 | })} | 101 | })} |
| 110 | </ol> | 102 | </ol> |
| 111 | <p | 103 | {state.status === "failed" ? ( |
| 112 | className={cn( | 104 | <p className="text-xs text-destructive" role="alert"> |
| 113 | "text-xs", | 105 | {state.message} |
| 114 | state.status === "failed" ? "text-destructive" : "text-muted-foreground", | 106 | </p> |
| 115 | )} | 107 | ) : null} |
| 116 | role={state.status === "failed" ? "alert" : "status"} | ||
| 117 | > | ||
| 118 | {updateStatusCopy(state, serverLabel)} | ||
| 119 | </p> | ||
| 120 | </div> | 108 | </div> |
| 121 | ); | 109 | ); |
| 122 | } | 110 | } |
apps/web/src/components/chat/ComposerBannerStack.tsx+1-1| ... | @@ -24,7 +24,7 @@ const exitTransitionStyle = { | ... | @@ -24,7 +24,7 @@ const exitTransitionStyle = { |
| 24 | 24 | ||
| 25 | export interface ComposerBannerStackItem { | 25 | export interface ComposerBannerStackItem { |
| 26 | readonly id: string; | 26 | readonly id: string; |
| 27 | readonly variant: "error" | "info" | "success" | "warning"; | 27 | readonly variant: "default" | "error" | "info" | "success" | "warning"; |
| 28 | readonly icon: ReactNode; | 28 | readonly icon: ReactNode; |
| 29 | readonly title: ReactNode; | 29 | readonly title: ReactNode; |
| 30 | readonly description?: ReactNode; | 30 | readonly description?: ReactNode; |
apps/web/src/components/settings/ConnectionsSettings.tsx-2| ... | @@ -1434,7 +1434,6 @@ function SavedBackendListRow({ | ... | @@ -1434,7 +1434,6 @@ function SavedBackendListRow({ |
| 1434 | <div className="max-w-md"> | 1434 | <div className="max-w-md"> |
| 1435 | <ServerUpdateProgress | 1435 | <ServerUpdateProgress |
| 1436 | fromVersion={serverUpdateState.fromVersion} | 1436 | fromVersion={serverUpdateState.fromVersion} |
| 1437 | serverLabel={`${environment.label} server`} | ||
| 1438 | state={serverUpdateState} | 1437 | state={serverUpdateState} |
| 1439 | /> | 1438 | /> |
| 1440 | </div> | 1439 | </div> |
| ... | @@ -3011,7 +3010,6 @@ export function ConnectionsSettings() { | ... | @@ -3011,7 +3010,6 @@ export function ConnectionsSettings() { |
| 3011 | primaryServerUpdateState.status !== "idle" ? ( | 3010 | primaryServerUpdateState.status !== "idle" ? ( |
| 3012 | <ServerUpdateProgress | 3011 | <ServerUpdateProgress |
| 3013 | fromVersion={primaryServerUpdateState.fromVersion} | 3012 | fromVersion={primaryServerUpdateState.fromVersion} |
| 3014 | serverLabel={primaryEnvironment?.label ?? "this server"} | ||
| 3015 | state={primaryServerUpdateState} | 3013 | state={primaryServerUpdateState} |
| 3016 | /> | 3014 | /> |
| 3017 | ) : primaryVersionMismatch ? ( | 3015 | ) : primaryVersionMismatch ? ( |