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