| author | |
| committer | |
| log | 80720ad592dc68c29677444fd7b6abb97b82c03e |
| tree | ceecb04a5b4daac2cf9c286705e4a08c3e9f66c1 |
| parent | a483337a02d4ac641db0219517816c300a33be6b |
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 | 160 | CheckCircle2Icon, |
| 161 | 161 | ChevronDownIcon, |
| 162 | 162 | GitBranchIcon, |
| 163 | TriangleAlertIcon, | |
| 164 | 163 | WifiOffIcon, |
| 165 | 164 | } from "lucide-react"; |
| 166 | 165 | import { cn, randomHex } from "~/lib/utils"; |
| ... | ... | @@ -1986,31 +1985,35 @@ function ChatViewContent(props: ChatViewProps) { |
| 1986 | 1985 | const updateFailed = serverUpdateState.status === "failed"; |
| 1987 | 1986 | items.push({ |
| 1988 | 1987 | 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 | ), | |
| 1998 | 1998 | title: |
| 1999 | updateInProgress || updateFailed | |
| 2000 | ? `${updateFailed ? "Could not update" : "Updating"} ${versionMismatchServerLabel}` | |
| 2001 | : "Client and server versions differ", | |
| 2002 | description: | |
| 2003 | 1999 | updateInProgress || updateFailed ? ( |
| 2004 | <ServerUpdateProgress | |
| 2005 | fromVersion={serverUpdateState.fromVersion} | |
| 2006 | state={serverUpdateState} | |
| 2007 | /> | |
| 2000 | `${updateFailed ? "Could not update" : "Updating"} ${versionMismatchServerLabel}` | |
| 2008 | 2001 | ) : 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) | |
| 2014 | 2017 | ) : null, |
| 2015 | 2018 | // The desktop-managed guidance is already the description; the action |
| 2016 | 2019 | // slot would only repeat it. |
| ... | ... | @@ -2023,13 +2026,13 @@ function ChatViewContent(props: ChatViewProps) { |
| 2023 | 2026 | serverLabel={versionMismatchServerLabel} |
| 2024 | 2027 | selfUpdate={versionMismatchSelfUpdate} |
| 2025 | 2028 | targetVersion={versionMismatch.clientVersion} |
| 2026 | {...(updateFailed ? { label: "Retry update" } : {})} | |
| 2029 | label={updateFailed ? "Retry" : "Update"} | |
| 2027 | 2030 | /> |
| 2028 | 2031 | ), |
| 2029 | 2032 | ...(updateInProgress || updateFailed || !versionMismatchDismissKey |
| 2030 | 2033 | ? {} |
| 2031 | 2034 | : { |
| 2032 | dismissLabel: "Dismiss version mismatch warning", | |
| 2035 | dismissLabel: "Dismiss update notice", | |
| 2033 | 2036 | onDismiss: () => { |
| 2034 | 2037 | dismissVersionMismatch(versionMismatchDismissKey); |
| 2035 | 2038 | setDismissedVersionMismatchKey(versionMismatchDismissKey); |
apps/web/src/components/ServerUpdateAction.test.tsx+23-11| ... | ... | @@ -101,10 +101,9 @@ describe("ServerUpdateAction", () => { |
| 101 | 101 | }); |
| 102 | 102 | |
| 103 | 103 | describe("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 | 105 | const markup = renderToStaticMarkup( |
| 106 | 106 | <ServerUpdateProgress |
| 107 | fromVersion="0.0.30" | |
| 108 | 107 | state={{ |
| 109 | 108 | status: "running", |
| 110 | 109 | stage: "resuming", |
| ... | ... | @@ -114,23 +113,36 @@ describe("ServerUpdateProgress", () => { |
| 114 | 113 | />, |
| 115 | 114 | ); |
| 116 | 115 | |
| 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"); | |
| 124 | 121 | expect(markup).not.toContain("text-success"); |
| 125 | 122 | expect(markup).not.toContain("text-primary"); |
| 126 | 123 | expect(markup).toContain("animate-status-pulse"); |
| 127 | 124 | expect(markup).not.toContain("animate-spin"); |
| 128 | 125 | }); |
| 129 | 126 | |
| 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 | 144 | const markup = renderToStaticMarkup( |
| 132 | 145 | <ServerUpdateProgress |
| 133 | fromVersion="0.0.30" | |
| 134 | 146 | state={{ |
| 135 | 147 | status: "failed", |
| 136 | 148 | stage: "installing", |
apps/web/src/components/ServerUpdateAction.tsx+34-81| ... | ... | @@ -1,110 +1,63 @@ |
| 1 | 1 | import type { EnvironmentId, ServerSelfUpdateCapability } from "@t3tools/contracts"; |
| 2 | import type { ServerUpdateState } from "@t3tools/client-runtime/state/server"; | |
| 2 | import type { ServerUpdateStage, ServerUpdateState } from "@t3tools/client-runtime/state/server"; | |
| 3 | 3 | import { |
| 4 | 4 | isAtomCommandInterrupted, |
| 5 | 5 | squashAtomCommandFailure, |
| 6 | 6 | } from "@t3tools/client-runtime/state/runtime"; |
| 7 | import { CheckIcon } from "lucide-react"; | |
| 8 | 7 | |
| 9 | 8 | import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; |
| 10 | import { cn } from "~/lib/utils"; | |
| 11 | 9 | import { serverEnvironment } from "~/state/server"; |
| 12 | 10 | import { useAtomCommand } from "~/state/use-atom-command"; |
| 13 | 11 | import { manualServerUpdateCommand } from "~/versionSkew"; |
| 14 | 12 | import { Button } from "./ui/button"; |
| 15 | 13 | import { toastManager } from "./ui/toast"; |
| 16 | 14 | |
| 17 | const 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. | |
| 18 | const UPDATE_STAGE_LABELS: Record<ServerUpdateStage, string> = { | |
| 19 | downloading: "Downloading…", | |
| 20 | installing: "Downloading…", | |
| 21 | resuming: "Restarting…", | |
| 22 | }; | |
| 22 | 23 | const pendingUpdateEnvironmentIds = new Set<EnvironmentId>(); |
| 23 | 24 | |
| 25 | export function serverUpdateStageLabel(stage: ServerUpdateStage): string { | |
| 26 | return UPDATE_STAGE_LABELS[stage]; | |
| 27 | } | |
| 28 | ||
| 24 | 29 | function updateFailureMessage(error: unknown): string { |
| 25 | 30 | return error instanceof Error ? error.message : "Server update failed."; |
| 26 | 31 | } |
| 27 | 32 | |
| 28 | 33 | /** |
| 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. | |
| 33 | 38 | */ |
| 34 | 39 | export function ServerUpdateProgress({ |
| 35 | fromVersion, | |
| 36 | 40 | state, |
| 37 | 41 | }: { |
| 38 | readonly fromVersion: string; | |
| 39 | 42 | readonly state: Exclude<ServerUpdateState, { status: "idle" }>; |
| 40 | 43 | }) { |
| 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}> | |
| 105 | 49 | {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> | |
| 108 | 61 | </div> |
| 109 | 62 | ); |
| 110 | 63 | } |
| ... | ... | @@ -119,7 +72,7 @@ export function ServerUpdateAction({ |
| 119 | 72 | serverLabel, |
| 120 | 73 | selfUpdate, |
| 121 | 74 | targetVersion, |
| 122 | label = "Update server", | |
| 75 | label = "Update", | |
| 123 | 76 | }: { |
| 124 | 77 | readonly environmentId: EnvironmentId; |
| 125 | 78 | readonly serverLabel: string; |
apps/web/src/components/settings/ConnectionsSettings.tsx+25-25| ... | ... | @@ -4,7 +4,6 @@ import { |
| 4 | 4 | QrCodeIcon, |
| 5 | 5 | RefreshCwIcon, |
| 6 | 6 | TerminalIcon, |
| 7 | TriangleAlertIcon, | |
| 8 | 7 | } from "lucide-react"; |
| 9 | 8 | import { useAtomValue } from "@effect/atom-react"; |
| 10 | 9 | import { type ReactNode, memo, useCallback, useId, useMemo, useState } from "react"; |
| ... | ... | @@ -1426,17 +1425,20 @@ function SavedBackendListRow({ |
| 1426 | 1425 | ) : null} |
| 1427 | 1426 | {serverUpdateState.status !== "idle" ? ( |
| 1428 | 1427 | <div className="max-w-md"> |
| 1429 | <ServerUpdateProgress | |
| 1430 | fromVersion={serverUpdateState.fromVersion} | |
| 1431 | state={serverUpdateState} | |
| 1432 | /> | |
| 1428 | <ServerUpdateProgress state={serverUpdateState} /> | |
| 1433 | 1429 | </div> |
| 1434 | 1430 | ) : 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> | |
| 1440 | 1442 | ) : null} |
| 1441 | 1443 | {environment.connection.error && !resumingServerUpdate ? ( |
| 1442 | 1444 | <p className="flex min-w-0 items-center gap-2 text-destructive text-xs"> |
| ... | ... | @@ -1461,7 +1463,7 @@ function SavedBackendListRow({ |
| 1461 | 1463 | serverLabel={`${environment.label} server`} |
| 1462 | 1464 | selfUpdate={resolveServerSelfUpdateCapability(environment.serverConfig)} |
| 1463 | 1465 | targetVersion={versionMismatch.clientVersion} |
| 1464 | label={serverUpdateState.status === "failed" ? "Retry update" : "Update server"} | |
| 1466 | label={serverUpdateState.status === "failed" ? "Retry" : "Update"} | |
| 1465 | 1467 | /> |
| 1466 | 1468 | ) : null} |
| 1467 | 1469 | {isWslEnvironment ? ( |
| ... | ... | @@ -2998,21 +3000,21 @@ export function ConnectionsSettings() { |
| 2998 | 3000 | ? "Update failed" |
| 2999 | 3001 | : primaryServerUpdateState.status === "running" |
| 3000 | 3002 | ? "Updating server" |
| 3001 | : "Version drift" | |
| 3003 | : "Server update available" | |
| 3002 | 3004 | } |
| 3003 | 3005 | description={ |
| 3004 | 3006 | primaryServerUpdateState.status !== "idle" ? ( |
| 3005 | <ServerUpdateProgress | |
| 3006 | fromVersion={primaryServerUpdateState.fromVersion} | |
| 3007 | state={primaryServerUpdateState} | |
| 3008 | /> | |
| 3007 | <ServerUpdateProgress state={primaryServerUpdateState} /> | |
| 3009 | 3008 | ) : 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> | |
| 3016 | 3018 | ) : null |
| 3017 | 3019 | } |
| 3018 | 3020 | control={ |
| ... | ... | @@ -3024,9 +3026,7 @@ export function ConnectionsSettings() { |
| 3024 | 3026 | serverLabel={primaryEnvironment?.label ?? "this server"} |
| 3025 | 3027 | selfUpdate={resolveServerSelfUpdateCapability(primaryServerConfig)} |
| 3026 | 3028 | targetVersion={primaryVersionMismatch.clientVersion} |
| 3027 | {...(primaryServerUpdateState.status === "failed" | |
| 3028 | ? { label: "Retry update" } | |
| 3029 | : {})} | |
| 3029 | label={primaryServerUpdateState.status === "failed" ? "Retry" : "Update"} | |
| 3030 | 3030 | /> |
| 3031 | 3031 | ) : 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 | 35 | `npx t3@<version> service update` command on the server machine. That one local update installs the |
| 36 | 36 | rollback support needed for later remote updates, including versions that change the database. |
| 37 | 37 | |
| 38 | After 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 | |
| 40 | Connections, so navigating between them does not lose the update. A failed step remains visible | |
| 41 | with its error and an option to retry. | |
| 38 | After selecting **Update**, the notice becomes a live status line: **Downloading…** while the new | |
| 39 | version is fetched and verified, then **Restarting…** while the server restarts into it. The same | |
| 40 | status appears in the conversation and in Connections, so navigating between them does not lose the | |
| 41 | update. A failure remains visible with its error and an option to retry. | |
| 42 | 42 | |
| 43 | 43 | **Copy update command** gives you `npx t3@<client-version>`, which relaunches the server directly |
| 44 | 44 | at the matching version. Add whatever startup options you normally use. |