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 {
160160 CheckCircle2Icon,
161161 ChevronDownIcon,
162162 GitBranchIcon,
163 TriangleAlertIcon,
164163 WifiOffIcon,
165164} from "lucide-react";
166165import { cn, randomHex } from "~/lib/utils";
......@@ -1986,31 +1985,35 @@ function ChatViewContent(props: ChatViewProps) {
19861985 const updateFailed = serverUpdateState.status === "failed";
19871986 items.push({
19881987 id: `server-version:${serverUpdateEnvironmentId}`,
1989 variant: updateFailed ? "error" : updateInProgress ? "default" : "warning",
1990 icon: updateInProgress ? (
1991 <span
1992 className="size-1.5 animate-status-pulse rounded-full bg-foreground"
1993 aria-hidden="true"
1994 />
1995 ) : (
1996 <TriangleAlertIcon />
1997 ),
1988 variant: updateFailed ? "error" : "default",
1989 // In-flight and failed states carry their own status dot inside
1990 // ServerUpdateProgress; only the idle offer needs an icon.
1991 icon:
1992 updateInProgress || updateFailed ? null : (
1993 <span
1994 className="size-1.5 rounded-full border border-muted-foreground/40"
1995 aria-hidden="true"
1996 />
1997 ),
19981998 title:
1999 updateInProgress || updateFailed
2000 ? `${updateFailed ? "Could not update" : "Updating"} ${versionMismatchServerLabel}`
2001 : "Client and server versions differ",
2002 description:
20031999 updateInProgress || updateFailed ? (
2004 <ServerUpdateProgress
2005 fromVersion={serverUpdateState.fromVersion}
2006 state={serverUpdateState}
2007 />
2000 `${updateFailed ? "Could not update" : "Updating"} ${versionMismatchServerLabel}`
20082001 ) : versionMismatch ? (
2009 <>
2010 Client {versionMismatch.clientVersion} is connected to {versionMismatchServerLabel}{" "}
2011 {versionMismatch.serverVersion}.{" "}
2012 {serverUpdateGuidance(versionMismatchSelfUpdate, versionMismatchServerLabel)}
2013 </>
2002 <Tooltip>
2003 <TooltipTrigger render={<span>Server update available</span>} />
2004 <TooltipPopup side="top">
2005 {versionMismatchServerLabel} {versionMismatch.serverVersion}{" "}
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)
20142017 ) : null,
20152018 // The desktop-managed guidance is already the description; the action
20162019 // slot would only repeat it.
......@@ -2023,13 +2026,13 @@ function ChatViewContent(props: ChatViewProps) {
20232026 serverLabel={versionMismatchServerLabel}
20242027 selfUpdate={versionMismatchSelfUpdate}
20252028 targetVersion={versionMismatch.clientVersion}
2026 {...(updateFailed ? { label: "Retry update" } : {})}
2029 label={updateFailed ? "Retry" : "Update"}
20272030 />
20282031 ),
20292032 ...(updateInProgress || updateFailed || !versionMismatchDismissKey
20302033 ? {}
20312034 : {
2032 dismissLabel: "Dismiss version mismatch warning",
2035 dismissLabel: "Dismiss update notice",
20332036 onDismiss: () => {
20342037 dismissVersionMismatch(versionMismatchDismissKey);
20352038 setDismissedVersionMismatchKey(versionMismatchDismissKey);
apps/web/src/components/ServerUpdateAction.test.tsx+23-11
......@@ -101,10 +101,9 @@ describe("ServerUpdateAction", () => {
101101});
102102
103103describe("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", () => {
105105 const markup = renderToStaticMarkup(
106106 <ServerUpdateProgress
107 fromVersion="0.0.30"
108107 state={{
109108 status: "running",
110109 stage: "resuming",
......@@ -114,23 +113,36 @@ describe("ServerUpdateProgress", () => {
114113 />,
115114 );
116115
117 expect(markup).toContain("0.0.30");
118 expect(markup).toContain("0.0.31");
119 expect(markup).toContain("Download");
120 expect(markup).toContain("Install");
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.
116 expect(markup).toContain("Restarting…");
117 // The wait state is monochrome and calm: no versions, no step rail, no
118 // success/warning colors, one duty-cycled pulse on the dot.
119 expect(markup).not.toContain("0.0.30");
120 expect(markup).not.toContain("Resum");
124121 expect(markup).not.toContain("text-success");
125122 expect(markup).not.toContain("text-primary");
126123 expect(markup).toContain("animate-status-pulse");
127124 expect(markup).not.toContain("animate-spin");
128125 });
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", () => {
131144 const markup = renderToStaticMarkup(
132145 <ServerUpdateProgress
133 fromVersion="0.0.30"
134146 state={{
135147 status: "failed",
136148 stage: "installing",
apps/web/src/components/ServerUpdateAction.tsx+34-81
......@@ -1,110 +1,63 @@
11import 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";
33import {
44 isAtomCommandInterrupted,
55 squashAtomCommandFailure,
66} from "@t3tools/client-runtime/state/runtime";
7import { CheckIcon } from "lucide-react";
87
98import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
10import { cn } from "~/lib/utils";
119import { serverEnvironment } from "~/state/server";
1210import { useAtomCommand } from "~/state/use-atom-command";
1311import { manualServerUpdateCommand } from "~/versionSkew";
1412import { Button } from "./ui/button";
1513import { toastManager } from "./ui/toast";
1614
17const UPDATE_STEPS = [
18 { stage: "downloading", label: "Download", activeLabel: "Downloading" },
19 { stage: "installing", label: "Install", activeLabel: "Installing" },
20 { stage: "resuming", label: "Resume", activeLabel: "Resuming" },
21] as const;
15// The wire "installing" stage is a sub-second launcher handoff, so the UI
16// folds it into the download phase; everything after the handoff is the
17// restart the user is actually waiting through.
18const UPDATE_STAGE_LABELS: Record<ServerUpdateStage, string> = {
19 downloading: "Downloading…",
20 installing: "Downloading…",
21 resuming: "Restarting…",
22};
2223const pendingUpdateEnvironmentIds = new Set<EnvironmentId>();
2324
25export function serverUpdateStageLabel(stage: ServerUpdateStage): string {
26 return UPDATE_STAGE_LABELS[stage];
27}
28
2429function updateFailureMessage(error: unknown): string {
2530 return error instanceof Error ? error.message : "Server update failed.";
2631}
2732
2833/**
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.
34 * One-row status for an in-flight server update: "Downloading…" then
35 * "Restarting…". The update is a wait, not a warning: a single pulsing dot
36 * and label, no step rail, no versions. Failure turns the row red with the
37 * rollback reason.
3338 */
3439export function ServerUpdateProgress({
35 fromVersion,
3640 state,
3741}: {
38 readonly fromVersion: string;
3942 readonly state: Exclude<ServerUpdateState, { status: "idle" }>;
4043}) {
41 const currentIndex = UPDATE_STEPS.findIndex(({ stage }) => stage === state.stage);
42
43 return (
44 <div className="mt-1 min-w-0 space-y-2.5">
45 <p className="text-xs text-muted-foreground/70">
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">
44 if (state.status === "failed") {
45 return (
46 <div className="mt-1 flex min-w-0 items-center gap-2 text-xs text-destructive" role="alert">
47 <span className="size-1.5 shrink-0 rounded-full bg-destructive" aria-hidden="true" />
48 <span className="min-w-0 truncate" title={state.message}>
10549 {state.message}
106 </p>
107 ) : null}
50 </span>
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>
10861 </div>
10962 );
11063}
......@@ -119,7 +72,7 @@ export function ServerUpdateAction({
11972 serverLabel,
12073 selfUpdate,
12174 targetVersion,
122 label = "Update server",
75 label = "Update",
12376}: {
12477 readonly environmentId: EnvironmentId;
12578 readonly serverLabel: string;
apps/web/src/components/settings/ConnectionsSettings.tsx+25-25
......@@ -4,7 +4,6 @@ import {
44 QrCodeIcon,
55 RefreshCwIcon,
66 TerminalIcon,
7 TriangleAlertIcon,
87} from "lucide-react";
98import { useAtomValue } from "@effect/atom-react";
109import { type ReactNode, memo, useCallback, useId, useMemo, useState } from "react";
......@@ -1426,17 +1425,20 @@ function SavedBackendListRow({
14261425 ) : null}
14271426 {serverUpdateState.status !== "idle" ? (
14281427 <div className="max-w-md">
1429 <ServerUpdateProgress
1430 fromVersion={serverUpdateState.fromVersion}
1431 state={serverUpdateState}
1432 />
1428 <ServerUpdateProgress state={serverUpdateState} />
14331429 </div>
14341430 ) : versionMismatch ? (
1435 <p className="flex items-center gap-1 text-warning text-xs">
1436 <TriangleAlertIcon className="size-3.5 shrink-0" />
1437 Version drift: client {versionMismatch.clientVersion}, server{" "}
1438 {versionMismatch.serverVersion}.
1439 </p>
1431 <Tooltip>
1432 <TooltipTrigger
1433 render={
1434 <p className="w-fit text-muted-foreground text-xs">Server update available</p>
1435 }
1436 />
1437 <TooltipPopup side="top">
1438 {versionMismatch.serverVersion} <span aria-hidden="true">→</span>{" "}
1439 {versionMismatch.clientVersion}
1440 </TooltipPopup>
1441 </Tooltip>
14401442 ) : null}
14411443 {environment.connection.error && !resumingServerUpdate ? (
14421444 <p className="flex min-w-0 items-center gap-2 text-destructive text-xs">
......@@ -1461,7 +1463,7 @@ function SavedBackendListRow({
14611463 serverLabel={`${environment.label} server`}
14621464 selfUpdate={resolveServerSelfUpdateCapability(environment.serverConfig)}
14631465 targetVersion={versionMismatch.clientVersion}
1464 label={serverUpdateState.status === "failed" ? "Retry update" : "Update server"}
1466 label={serverUpdateState.status === "failed" ? "Retry" : "Update"}
14651467 />
14661468 ) : null}
14671469 {isWslEnvironment ? (
......@@ -2998,21 +3000,21 @@ export function ConnectionsSettings() {
29983000 ? "Update failed"
29993001 : primaryServerUpdateState.status === "running"
30003002 ? "Updating server"
3001 : "Version drift"
3003 : "Server update available"
30023004 }
30033005 description={
30043006 primaryServerUpdateState.status !== "idle" ? (
3005 <ServerUpdateProgress
3006 fromVersion={primaryServerUpdateState.fromVersion}
3007 state={primaryServerUpdateState}
3008 />
3007 <ServerUpdateProgress state={primaryServerUpdateState} />
30093008 ) : primaryVersionMismatch ? (
3010 <span className="flex items-center gap-1 text-warning">
3011 <TriangleAlertIcon className="size-3.5 shrink-0" />
3012 Client {primaryVersionMismatch.clientVersion}, server{" "}
3013 {primaryVersionMismatch.serverVersion}. Sync them if RPC calls or reconnects
3014 fail.
3015 </span>
3009 <Tooltip>
3010 <TooltipTrigger
3011 render={<span className="w-fit">Update to match this client.</span>}
3012 />
3013 <TooltipPopup side="top">
3014 {primaryVersionMismatch.serverVersion} <span aria-hidden="true">→</span>{" "}
3015 {primaryVersionMismatch.clientVersion}
3016 </TooltipPopup>
3017 </Tooltip>
30163018 ) : null
30173019 }
30183020 control={
......@@ -3024,9 +3026,7 @@ export function ConnectionsSettings() {
30243026 serverLabel={primaryEnvironment?.label ?? "this server"}
30253027 selfUpdate={resolveServerSelfUpdateCapability(primaryServerConfig)}
30263028 targetVersion={primaryVersionMismatch.clientVersion}
3027 {...(primaryServerUpdateState.status === "failed"
3028 ? { label: "Retry update" }
3029 : {})}
3029 label={primaryServerUpdateState.status === "failed" ? "Retry" : "Update"}
30303030 />
30313031 ) : undefined
30323032 }
docs/user/updating.md+4-4
......@@ -35,10 +35,10 @@ An older background-service launcher may ask you to run the exact
3535`npx t3@<version> service update` command on the server machine. That one local update installs the
3636rollback 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:
39**Download**, **Install**, and **Resume**. The same progress appears in the conversation and in
40Connections, so navigating between them does not lose the update. A failed step remains visible
41with its error and an option to retry.
38After selecting **Update**, the notice becomes a live status line: **Downloading…** while the new
39version is fetched and verified, then **Restarting…** while the server restarts into it. The same
40status appears in the conversation and in Connections, so navigating between them does not lose the
41update. A failure remains visible with its error and an option to retry.
4242
4343**Copy update command** gives you `npx t3@<client-version>`, which relaunches the server directly
4444at the matching version. Add whatever startup options you normally use.