authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-07-30 07:30:24-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-30 07:30:24-07:00
loge0513d29839f934df1d5a13551e6ccd76d11006b
tree0d241f93d0c0b601d881f351fa8efc04f36c5f08
parent9dd425b2234c062b4767583e42d4b2c1aabab15d
signature Signed by PGP key B5690EEEBB952194

fix(web): server updates no longer look like warnings (#4992)

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 <Button1902 // 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 <Button1913 />
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 Connections1918 } 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 || updateFailed1972 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 <ServerUpdateProgress1977 <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});
102102
103describe("ServerUpdateProgress", () => {103describe("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 <ServerUpdateProgress106 <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 });
126129
...@@ -128,7 +131,6 @@ describe("ServerUpdateProgress", () => {...@@ -128,7 +131,6 @@ describe("ServerUpdateProgress", () => {
128 const markup = renderToStaticMarkup(131 const markup = renderToStaticMarkup(
129 <ServerUpdateProgress132 <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", () => {
141143
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";
15import { toastManager } from "./ui/toast";15import { toastManager } from "./ui/toast";
1616
17const UPDATE_STEPS = [17const 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;
22const pendingUpdateEnvironmentIds = new Set<EnvironmentId>();22const pendingUpdateEnvironmentIds = new Set<EnvironmentId>();
2323
...@@ -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}
2727
28function 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
45export function ServerUpdateProgress({34export 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);
5542
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 <li55 <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 complete60 complete
73 ? "text-success"61 ? "text-muted-foreground"
74 : failed62 : failed
75 ? "text-destructive"63 ? "text-destructive"
76 : current64 : 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 <span70 {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 complete73 strokeWidth={2.5}
86 ? "border-success bg-success text-success-foreground"74 aria-hidden="true"
87 : failed75 />
88 ? "border-destructive bg-destructive text-destructive-foreground"76 ) : (
89 : current77 <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 <span91 <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 <p103 {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 = {
2424
25export interface ComposerBannerStackItem {25export 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 <ServerUpdateProgress1435 <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 <ServerUpdateProgress3011 <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 ? (