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) {
18831883 );
18841884 const systemComposerBannerItems = useMemo<ComposerBannerStackItem[]>(() => {
18851885 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 }
19231951 }
19241952 if (
19251953 serverUpdateEnvironmentId &&
1954 !reconnectingThroughVersionSkew &&
19261955 (serverUpdateState.status !== "idle" ||
19271956 (showVersionMismatchBanner && versionMismatch && versionMismatchDismissKey))
19281957 ) {
......@@ -1930,8 +1959,15 @@ function ChatViewContent(props: ChatViewProps) {
19301959 const updateFailed = serverUpdateState.status === "failed";
19311960 items.push({
19321961 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 ),
19351971 title:
19361972 updateInProgress || updateFailed
19371973 ? `${updateFailed ? "Could not update" : "Updating"} ${versionMismatchServerLabel}`
......@@ -1940,7 +1976,6 @@ function ChatViewContent(props: ChatViewProps) {
19401976 updateInProgress || updateFailed ? (
19411977 <ServerUpdateProgress
19421978 fromVersion={serverUpdateState.fromVersion}
1943 serverLabel={versionMismatchServerLabel}
19441979 state={serverUpdateState}
19451980 />
19461981 ) : versionMismatch ? (
apps/web/src/components/ServerUpdateAction.test.tsx+8-5
......@@ -101,11 +101,10 @@ describe("ServerUpdateAction", () => {
101101});
102102
103103describe("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", () => {
105105 const markup = renderToStaticMarkup(
106106 <ServerUpdateProgress
107107 fromVersion="0.0.30"
108 serverLabel="bb-1"
109108 state={{
110109 status: "running",
111110 stage: "resuming",
......@@ -119,8 +118,12 @@ describe("ServerUpdateProgress", () => {
119118 expect(markup).toContain("0.0.31");
120119 expect(markup).toContain("Download");
121120 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");
124127 expect(markup).not.toContain("animate-spin");
125128 });
126129
......@@ -128,7 +131,6 @@ describe("ServerUpdateProgress", () => {
128131 const markup = renderToStaticMarkup(
129132 <ServerUpdateProgress
130133 fromVersion="0.0.30"
131 serverLabel="bb-1"
132134 state={{
133135 status: "failed",
134136 stage: "installing",
......@@ -141,5 +143,6 @@ describe("ServerUpdateProgress", () => {
141143
142144 expect(markup).toContain('role="alert"');
143145 expect(markup).toContain("The package could not be verified.");
146 expect(markup).not.toContain("animate-status-pulse");
144147 });
145148});
apps/web/src/components/ServerUpdateAction.tsx+42-54
......@@ -15,9 +15,9 @@ import { Button } from "./ui/button";
1515import { toastManager } from "./ui/toast";
1616
1717const 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" },
2121] as const;
2222const pendingUpdateEnvironmentIds = new Set<EnvironmentId>();
2323
......@@ -25,37 +25,24 @@ function updateFailureMessage(error: unknown): string {
2525 return error instanceof Error ? error.message : "Server update failed.";
2626}
2727
28function 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 */
4534export function ServerUpdateProgress({
4635 fromVersion,
47 serverLabel,
4836 state,
4937}: {
5038 readonly fromVersion: string;
51 readonly serverLabel: string;
5239 readonly state: Exclude<ServerUpdateState, { status: "idle" }>;
5340}) {
5441 const currentIndex = UPDATE_STEPS.findIndex(({ stage }) => stage === state.stage);
5542
5643 return (
5744 <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">
5946 {fromVersion} <span aria-hidden="true">→</span> {state.targetVersion}
6047 </p>
6148 <ol className="flex min-w-0 items-center" aria-label="Server update progress">
......@@ -63,6 +50,7 @@ export function ServerUpdateProgress({
6350 const complete = index < currentIndex;
6451 const current = index === currentIndex;
6552 const failed = current && state.status === "failed";
53 const running = current && state.status === "running";
6654 return (
6755 <li
6856 key={step.stage}
......@@ -70,36 +58,40 @@ export function ServerUpdateProgress({
7058 "flex min-w-0 items-center text-xs font-medium",
7159 index < UPDATE_STEPS.length - 1 ? "flex-1" : null,
7260 complete
73 ? "text-success"
61 ? "text-muted-foreground"
7462 : failed
7563 ? "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",
7967 )}
80 aria-current={current && state.status === "running" ? "step" : undefined}
68 aria-current={running ? "step" : undefined}
8169 >
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>
9890 {index < UPDATE_STEPS.length - 1 ? (
9991 <span
10092 className={cn(
10193 "mx-2 h-px min-w-3 flex-1",
102 complete ? "bg-success/60" : "bg-border",
94 complete ? "bg-muted-foreground/40" : "bg-border",
10395 )}
10496 aria-hidden="true"
10597 />
......@@ -108,15 +100,11 @@ export function ServerUpdateProgress({
108100 );
109101 })}
110102 </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}
120108 </div>
121109 );
122110}
apps/web/src/components/chat/ComposerBannerStack.tsx+1-1
......@@ -24,7 +24,7 @@ const exitTransitionStyle = {
2424
2525export interface ComposerBannerStackItem {
2626 readonly id: string;
27 readonly variant: "error" | "info" | "success" | "warning";
27 readonly variant: "default" | "error" | "info" | "success" | "warning";
2828 readonly icon: ReactNode;
2929 readonly title: ReactNode;
3030 readonly description?: ReactNode;
apps/web/src/components/settings/ConnectionsSettings.tsx-2
......@@ -1434,7 +1434,6 @@ function SavedBackendListRow({
14341434 <div className="max-w-md">
14351435 <ServerUpdateProgress
14361436 fromVersion={serverUpdateState.fromVersion}
1437 serverLabel={`${environment.label} server`}
14381437 state={serverUpdateState}
14391438 />
14401439 </div>
......@@ -3011,7 +3010,6 @@ export function ConnectionsSettings() {
30113010 primaryServerUpdateState.status !== "idle" ? (
30123011 <ServerUpdateProgress
30133012 fromVersion={primaryServerUpdateState.fromVersion}
3014 serverLabel={primaryEnvironment?.label ?? "this server"}
30153013 state={primaryServerUpdateState}
30163014 />
30173015 ) : primaryVersionMismatch ? (