authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-05 04:42:13-07:00
committergravatar for me@theo.liTheo Browne <me@theo.li> 2026-08-06 08:09:51-04:00
log80720ad592dc68c29677444fd7b6abb97b82c03e
treeceecb04a5b4daac2cf9c286705e4a08c3e9f66c1
parenta483337a02d4ac641db0219517816c300a33be6b

fix(web): calm one-row server update status and banner

The version-skew banner is no longer an amber warning: it reads "Server update available" with the raw versions (unreadable for nightlies) moved to a tooltip. The in-flight rail (Download/Install/ Resume) becomes a single status row, "Downloading…" then "Restarting…", since the wire installing stage is a sub-second launcher handoff and "resuming" meant nothing to most people. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

5 files changed, 114 insertions(+), 146 deletions(-)

apps/web/src/components/ChatView.tsx+28-25
...@@ -160,7 +160,6 @@ import {...@@ -160,7 +160,6 @@ import {
160 CheckCircle2Icon,160 CheckCircle2Icon,
161 ChevronDownIcon,161 ChevronDownIcon,
162 GitBranchIcon,162 GitBranchIcon,
163 TriangleAlertIcon,
164 WifiOffIcon,163 WifiOffIcon,
165} from "lucide-react";164} from "lucide-react";
166import { cn, randomHex } from "~/lib/utils";165import { cn, randomHex } from "~/lib/utils";
...@@ -1986,31 +1985,35 @@ function ChatViewContent(props: ChatViewProps) {...@@ -1986,31 +1985,35 @@ function ChatViewContent(props: ChatViewProps) {
1986 const updateFailed = serverUpdateState.status === "failed";1985 const updateFailed = serverUpdateState.status === "failed";
1987 items.push({1986 items.push({
1988 id: `server-version:${serverUpdateEnvironmentId}`,1987 id: `server-version:${serverUpdateEnvironmentId}`,
1989 variant: updateFailed ? "error" : updateInProgress ? "default" : "warning",1988 variant: updateFailed ? "error" : "default",
1990 icon: updateInProgress ? (1989 // In-flight and failed states carry their own status dot inside
1991 <span1990 // ServerUpdateProgress; only the idle offer needs an icon.
1992 className="size-1.5 animate-status-pulse rounded-full bg-foreground"1991 icon:
1993 aria-hidden="true"1992 updateInProgress || updateFailed ? null : (
1994 />1993 <span
1995 ) : (1994 className="size-1.5 rounded-full border border-muted-foreground/40"
1996 <TriangleAlertIcon />1995 aria-hidden="true"
1997 ),1996 />
1997 ),
1998 title:1998 title:
1999 updateInProgress || updateFailed
2000 ? `${updateFailed ? "Could not update" : "Updating"} ${versionMismatchServerLabel}`
2001 : "Client and server versions differ",
2002 description:
2003 updateInProgress || updateFailed ? (1999 updateInProgress || updateFailed ? (
2004 <ServerUpdateProgress2000 `${updateFailed ? "Could not update" : "Updating"} ${versionMismatchServerLabel}`
2005 fromVersion={serverUpdateState.fromVersion}
2006 state={serverUpdateState}
2007 />
2008 ) : versionMismatch ? (2001 ) : versionMismatch ? (
2009 <>2002 <Tooltip>
2010 Client {versionMismatch.clientVersion} is connected to {versionMismatchServerLabel}{" "}2003 <TooltipTrigger render={<span>Server update available</span>} />
2011 {versionMismatch.serverVersion}.{" "}2004 <TooltipPopup side="top">
2012 {serverUpdateGuidance(versionMismatchSelfUpdate, versionMismatchServerLabel)}2005 {versionMismatchServerLabel} {versionMismatch.serverVersion}{" "}
2013 </>2006 <span aria-hidden="true">→</span> {versionMismatch.clientVersion}
2007 </TooltipPopup>
2008 </Tooltip>
2009 ) : (
2010 "Server update available"
2011 ),
2012 description:
2013 updateInProgress || updateFailed ? (
2014 <ServerUpdateProgress state={serverUpdateState} />
2015 ) : versionMismatchSelfUpdate === "desktop-managed" ? (
2016 serverUpdateGuidance(versionMismatchSelfUpdate, versionMismatchServerLabel)
2014 ) : null,2017 ) : null,
2015 // The desktop-managed guidance is already the description; the action2018 // The desktop-managed guidance is already the description; the action
2016 // slot would only repeat it.2019 // slot would only repeat it.
...@@ -2023,13 +2026,13 @@ function ChatViewContent(props: ChatViewProps) {...@@ -2023,13 +2026,13 @@ function ChatViewContent(props: ChatViewProps) {
2023 serverLabel={versionMismatchServerLabel}2026 serverLabel={versionMismatchServerLabel}
2024 selfUpdate={versionMismatchSelfUpdate}2027 selfUpdate={versionMismatchSelfUpdate}
2025 targetVersion={versionMismatch.clientVersion}2028 targetVersion={versionMismatch.clientVersion}
2026 {...(updateFailed ? { label: "Retry update" } : {})}2029 label={updateFailed ? "Retry" : "Update"}
2027 />2030 />
2028 ),2031 ),
2029 ...(updateInProgress || updateFailed || !versionMismatchDismissKey2032 ...(updateInProgress || updateFailed || !versionMismatchDismissKey
2030 ? {}2033 ? {}
2031 : {2034 : {
2032 dismissLabel: "Dismiss version mismatch warning",2035 dismissLabel: "Dismiss update notice",
2033 onDismiss: () => {2036 onDismiss: () => {
2034 dismissVersionMismatch(versionMismatchDismissKey);2037 dismissVersionMismatch(versionMismatchDismissKey);
2035 setDismissedVersionMismatchKey(versionMismatchDismissKey);2038 setDismissedVersionMismatchKey(versionMismatchDismissKey);
apps/web/src/components/ServerUpdateAction.test.tsx+23-11
...@@ -101,10 +101,9 @@ describe("ServerUpdateAction", () => {...@@ -101,10 +101,9 @@ describe("ServerUpdateAction", () => {
101});101});
102102
103describe("ServerUpdateProgress", () => {103describe("ServerUpdateProgress", () => {
104 it("renders the monochrome step rail with only the active step highlighted", () => {104 it("shows one calm status row for the restart wait", () => {
105 const markup = renderToStaticMarkup(105 const markup = renderToStaticMarkup(
106 <ServerUpdateProgress106 <ServerUpdateProgress
107 fromVersion="0.0.30"
108 state={{107 state={{
109 status: "running",108 status: "running",
110 stage: "resuming",109 stage: "resuming",
...@@ -114,23 +113,36 @@ describe("ServerUpdateProgress", () => {...@@ -114,23 +113,36 @@ describe("ServerUpdateProgress", () => {
114 />,113 />,
115 );114 );
116115
117 expect(markup).toContain("0.0.30");116 expect(markup).toContain("Restarting…");
118 expect(markup).toContain("0.0.31");117 // The wait state is monochrome and calm: no versions, no step rail, no
119 expect(markup).toContain("Download");118 // success/warning colors, one duty-cycled pulse on the dot.
120 expect(markup).toContain("Install");119 expect(markup).not.toContain("0.0.30");
121 expect(markup).toContain("Resuming");120 expect(markup).not.toContain("Resum");
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");121 expect(markup).not.toContain("text-success");
125 expect(markup).not.toContain("text-primary");122 expect(markup).not.toContain("text-primary");
126 expect(markup).toContain("animate-status-pulse");123 expect(markup).toContain("animate-status-pulse");
127 expect(markup).not.toContain("animate-spin");124 expect(markup).not.toContain("animate-spin");
128 });125 });
129126
130 it("keeps the failed stage visible with its retryable error", () => {127 it("folds the sub-second installing handoff into the download phase", () => {
128 const markup = renderToStaticMarkup(
129 <ServerUpdateProgress
130 state={{
131 status: "running",
132 stage: "installing",
133 fromVersion: "0.0.30",
134 targetVersion: "0.0.31",
135 }}
136 />,
137 );
138
139 expect(markup).toContain("Downloading…");
140 expect(markup).not.toContain("Install");
141 });
142
143 it("keeps the failure visible with its retryable error", () => {
131 const markup = renderToStaticMarkup(144 const markup = renderToStaticMarkup(
132 <ServerUpdateProgress145 <ServerUpdateProgress
133 fromVersion="0.0.30"
134 state={{146 state={{
135 status: "failed",147 status: "failed",
136 stage: "installing",148 stage: "installing",
apps/web/src/components/ServerUpdateAction.tsx+34-81
...@@ -1,110 +1,63 @@...@@ -1,110 +1,63 @@
1import type { EnvironmentId, ServerSelfUpdateCapability } from "@t3tools/contracts";1import type { EnvironmentId, ServerSelfUpdateCapability } from "@t3tools/contracts";
2import type { ServerUpdateState } from "@t3tools/client-runtime/state/server";2import type { ServerUpdateStage, ServerUpdateState } from "@t3tools/client-runtime/state/server";
3import {3import {
4 isAtomCommandInterrupted,4 isAtomCommandInterrupted,
5 squashAtomCommandFailure,5 squashAtomCommandFailure,
6} from "@t3tools/client-runtime/state/runtime";6} from "@t3tools/client-runtime/state/runtime";
7import { CheckIcon } from "lucide-react";
87
9import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";8import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
10import { cn } from "~/lib/utils";
11import { serverEnvironment } from "~/state/server";9import { serverEnvironment } from "~/state/server";
12import { useAtomCommand } from "~/state/use-atom-command";10import { useAtomCommand } from "~/state/use-atom-command";
13import { manualServerUpdateCommand } from "~/versionSkew";11import { manualServerUpdateCommand } from "~/versionSkew";
14import { Button } from "./ui/button";12import { Button } from "./ui/button";
15import { toastManager } from "./ui/toast";13import { toastManager } from "./ui/toast";
1614
17const UPDATE_STEPS = [15// The wire "installing" stage is a sub-second launcher handoff, so the UI
18 { stage: "downloading", label: "Download", activeLabel: "Downloading" },16// folds it into the download phase; everything after the handoff is the
19 { stage: "installing", label: "Install", activeLabel: "Installing" },17// restart the user is actually waiting through.
20 { stage: "resuming", label: "Resume", activeLabel: "Resuming" },18const UPDATE_STAGE_LABELS: Record<ServerUpdateStage, string> = {
21] as const;19 downloading: "Downloading…",
20 installing: "Downloading…",
21 resuming: "Restarting…",
22};
22const pendingUpdateEnvironmentIds = new Set<EnvironmentId>();23const pendingUpdateEnvironmentIds = new Set<EnvironmentId>();
2324
25export function serverUpdateStageLabel(stage: ServerUpdateStage): string {
26 return UPDATE_STAGE_LABELS[stage];
27}
28
24function updateFailureMessage(error: unknown): string {29function updateFailureMessage(error: unknown): string {
25 return error instanceof Error ? error.message : "Server update failed.";30 return error instanceof Error ? error.message : "Server update failed.";
26}31}
2732
28/**33/**
29 * Monochrome step rail for an in-flight server update. The update is a34 * One-row status for an in-flight server update: "Downloading…" then
30 * wait, not a warning: done steps recede to gray, the active step is the35 * "Restarting…". The update is a wait, not a warning: a single pulsing dot
31 * only bright element and pulses. Failure keeps the rail but surfaces the36 * and label, no step rail, no versions. Failure turns the row red with the
32 * error below it.37 * rollback reason.
33 */38 */
34export function ServerUpdateProgress({39export function ServerUpdateProgress({
35 fromVersion,
36 state,40 state,
37}: {41}: {
38 readonly fromVersion: string;
39 readonly state: Exclude<ServerUpdateState, { status: "idle" }>;42 readonly state: Exclude<ServerUpdateState, { status: "idle" }>;
40}) {43}) {
41 const currentIndex = UPDATE_STEPS.findIndex(({ stage }) => stage === state.stage);44 if (state.status === "failed") {
4245 return (
43 return (46 <div className="mt-1 flex min-w-0 items-center gap-2 text-xs text-destructive" role="alert">
44 <div className="mt-1 min-w-0 space-y-2.5">47 <span className="size-1.5 shrink-0 rounded-full bg-destructive" aria-hidden="true" />
45 <p className="text-xs text-muted-foreground/70">48 <span className="min-w-0 truncate" title={state.message}>
46 {fromVersion} <span aria-hidden="true">→</span> {state.targetVersion}
47 </p>
48 <ol className="flex min-w-0 items-center" aria-label="Server update progress">
49 {UPDATE_STEPS.map((step, index) => {
50 const complete = index < currentIndex;
51 const current = index === currentIndex;
52 const failed = current && state.status === "failed";
53 const running = current && state.status === "running";
54 return (
55 <li
56 key={step.stage}
57 className={cn(
58 "flex min-w-0 items-center text-xs font-medium",
59 index < UPDATE_STEPS.length - 1 ? "flex-1" : null,
60 complete
61 ? "text-muted-foreground"
62 : failed
63 ? "text-destructive"
64 : running
65 ? "animate-status-pulse text-foreground"
66 : "text-muted-foreground/50",
67 )}
68 aria-current={running ? "step" : undefined}
69 >
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>
90 {index < UPDATE_STEPS.length - 1 ? (
91 <span
92 className={cn(
93 "mx-2 h-px min-w-3 flex-1",
94 complete ? "bg-muted-foreground/40" : "bg-border",
95 )}
96 aria-hidden="true"
97 />
98 ) : null}
99 </li>
100 );
101 })}
102 </ol>
103 {state.status === "failed" ? (
104 <p className="text-xs text-destructive" role="alert">
105 {state.message}49 {state.message}
106 </p>50 </span>
107 ) : null}51 </div>
52 );
53 }
54 return (
55 <div className="mt-1 flex items-center gap-2 text-xs font-medium text-foreground">
56 <span
57 className="size-1.5 shrink-0 animate-status-pulse rounded-full bg-foreground"
58 aria-hidden="true"
59 />
60 <span>{serverUpdateStageLabel(state.stage)}</span>
108 </div>61 </div>
109 );62 );
110}63}
...@@ -119,7 +72,7 @@ export function ServerUpdateAction({...@@ -119,7 +72,7 @@ export function ServerUpdateAction({
119 serverLabel,72 serverLabel,
120 selfUpdate,73 selfUpdate,
121 targetVersion,74 targetVersion,
122 label = "Update server",75 label = "Update",
123}: {76}: {
124 readonly environmentId: EnvironmentId;77 readonly environmentId: EnvironmentId;
125 readonly serverLabel: string;78 readonly serverLabel: string;
apps/web/src/components/settings/ConnectionsSettings.tsx+25-25
...@@ -4,7 +4,6 @@ import {...@@ -4,7 +4,6 @@ import {
4 QrCodeIcon,4 QrCodeIcon,
5 RefreshCwIcon,5 RefreshCwIcon,
6 TerminalIcon,6 TerminalIcon,
7 TriangleAlertIcon,
8} from "lucide-react";7} from "lucide-react";
9import { useAtomValue } from "@effect/atom-react";8import { useAtomValue } from "@effect/atom-react";
10import { type ReactNode, memo, useCallback, useId, useMemo, useState } from "react";9import { type ReactNode, memo, useCallback, useId, useMemo, useState } from "react";
...@@ -1426,17 +1425,20 @@ function SavedBackendListRow({...@@ -1426,17 +1425,20 @@ function SavedBackendListRow({
1426 ) : null}1425 ) : null}
1427 {serverUpdateState.status !== "idle" ? (1426 {serverUpdateState.status !== "idle" ? (
1428 <div className="max-w-md">1427 <div className="max-w-md">
1429 <ServerUpdateProgress1428 <ServerUpdateProgress state={serverUpdateState} />
1430 fromVersion={serverUpdateState.fromVersion}
1431 state={serverUpdateState}
1432 />
1433 </div>1429 </div>
1434 ) : versionMismatch ? (1430 ) : versionMismatch ? (
1435 <p className="flex items-center gap-1 text-warning text-xs">1431 <Tooltip>
1436 <TriangleAlertIcon className="size-3.5 shrink-0" />1432 <TooltipTrigger
1437 Version drift: client {versionMismatch.clientVersion}, server{" "}1433 render={
1438 {versionMismatch.serverVersion}.1434 <p className="w-fit text-muted-foreground text-xs">Server update available</p>
1439 </p>1435 }
1436 />
1437 <TooltipPopup side="top">
1438 {versionMismatch.serverVersion} <span aria-hidden="true">→</span>{" "}
1439 {versionMismatch.clientVersion}
1440 </TooltipPopup>
1441 </Tooltip>
1440 ) : null}1442 ) : null}
1441 {environment.connection.error && !resumingServerUpdate ? (1443 {environment.connection.error && !resumingServerUpdate ? (
1442 <p className="flex min-w-0 items-center gap-2 text-destructive text-xs">1444 <p className="flex min-w-0 items-center gap-2 text-destructive text-xs">
...@@ -1461,7 +1463,7 @@ function SavedBackendListRow({...@@ -1461,7 +1463,7 @@ function SavedBackendListRow({
1461 serverLabel={`${environment.label} server`}1463 serverLabel={`${environment.label} server`}
1462 selfUpdate={resolveServerSelfUpdateCapability(environment.serverConfig)}1464 selfUpdate={resolveServerSelfUpdateCapability(environment.serverConfig)}
1463 targetVersion={versionMismatch.clientVersion}1465 targetVersion={versionMismatch.clientVersion}
1464 label={serverUpdateState.status === "failed" ? "Retry update" : "Update server"}1466 label={serverUpdateState.status === "failed" ? "Retry" : "Update"}
1465 />1467 />
1466 ) : null}1468 ) : null}
1467 {isWslEnvironment ? (1469 {isWslEnvironment ? (
...@@ -2998,21 +3000,21 @@ export function ConnectionsSettings() {...@@ -2998,21 +3000,21 @@ export function ConnectionsSettings() {
2998 ? "Update failed"3000 ? "Update failed"
2999 : primaryServerUpdateState.status === "running"3001 : primaryServerUpdateState.status === "running"
3000 ? "Updating server"3002 ? "Updating server"
3001 : "Version drift"3003 : "Server update available"
3002 }3004 }
3003 description={3005 description={
3004 primaryServerUpdateState.status !== "idle" ? (3006 primaryServerUpdateState.status !== "idle" ? (
3005 <ServerUpdateProgress3007 <ServerUpdateProgress state={primaryServerUpdateState} />
3006 fromVersion={primaryServerUpdateState.fromVersion}
3007 state={primaryServerUpdateState}
3008 />
3009 ) : primaryVersionMismatch ? (3008 ) : primaryVersionMismatch ? (
3010 <span className="flex items-center gap-1 text-warning">3009 <Tooltip>
3011 <TriangleAlertIcon className="size-3.5 shrink-0" />3010 <TooltipTrigger
3012 Client {primaryVersionMismatch.clientVersion}, server{" "}3011 render={<span className="w-fit">Update to match this client.</span>}
3013 {primaryVersionMismatch.serverVersion}. Sync them if RPC calls or reconnects3012 />
3014 fail.3013 <TooltipPopup side="top">
3015 </span>3014 {primaryVersionMismatch.serverVersion} <span aria-hidden="true">→</span>{" "}
3015 {primaryVersionMismatch.clientVersion}
3016 </TooltipPopup>
3017 </Tooltip>
3016 ) : null3018 ) : null
3017 }3019 }
3018 control={3020 control={
...@@ -3024,9 +3026,7 @@ export function ConnectionsSettings() {...@@ -3024,9 +3026,7 @@ export function ConnectionsSettings() {
3024 serverLabel={primaryEnvironment?.label ?? "this server"}3026 serverLabel={primaryEnvironment?.label ?? "this server"}
3025 selfUpdate={resolveServerSelfUpdateCapability(primaryServerConfig)}3027 selfUpdate={resolveServerSelfUpdateCapability(primaryServerConfig)}
3026 targetVersion={primaryVersionMismatch.clientVersion}3028 targetVersion={primaryVersionMismatch.clientVersion}
3027 {...(primaryServerUpdateState.status === "failed"3029 label={primaryServerUpdateState.status === "failed" ? "Retry" : "Update"}
3028 ? { label: "Retry update" }
3029 : {})}
3030 />3030 />
3031 ) : undefined3031 ) : undefined
3032 }3032 }
docs/user/updating.md+4-4
...@@ -35,10 +35,10 @@ An older background-service launcher may ask you to run the exact...@@ -35,10 +35,10 @@ An older background-service launcher may ask you to run the exact
35`npx t3@<version> service update` command on the server machine. That one local update installs the35`npx t3@<version> service update` command on the server machine. That one local update installs the
36rollback support needed for later remote updates, including versions that change the database.36rollback support needed for later remote updates, including versions that change the database.
3737
38After selecting **Update server**, the warning becomes a three-step progress rail:38After selecting **Update**, the notice becomes a live status line: **Downloading…** while the new
39**Download**, **Install**, and **Resume**. The same progress appears in the conversation and in39version is fetched and verified, then **Restarting…** while the server restarts into it. The same
40Connections, so navigating between them does not lose the update. A failed step remains visible40status appears in the conversation and in Connections, so navigating between them does not lose the
41with its error and an option to retry.41update. A failure remains visible with its error and an option to retry.
4242
43**Copy update command** gives you `npx t3@<client-version>`, which relaunches the server directly43**Copy update command** gives you `npx t3@<client-version>`, which relaunches the server directly
44at the matching version. Add whatever startup options you normally use.44at the matching version. Add whatever startup options you normally use.