authorgravatar for 62337003+Bil0000@users.noreply.github.comBilal Bakr <62337003+Bil0000@users.noreply.github.com> 2026-09-02 07:12:07+03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-01 21:12:07-07:00
loge9db39ce05c706122bb747fd5e7e70e1aac78935
treed5dbca717364b4a89f842f5fb9de98b978724649
parent941acb4f919bcef337d4d9e1623101bdc30d3616
signature Signed by PGP key B5690EEEBB952194

fix(web): align composer notices and stash (#8890)

Co-authored-by: Julius Marminge <julius0216@outlook.com>

8 files changed, 82 insertions(+), 69 deletions(-)

apps/web/src/components/ChatView.tsx+16-25
...@@ -25,10 +25,7 @@ import {...@@ -25,10 +25,7 @@ import {
25 RuntimeMode,25 RuntimeMode,
26 TerminalOpenInput,26 TerminalOpenInput,
27} from "@t3tools/contracts";27} from "@t3tools/contracts";
28import {28import { type EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection";
29 connectionStatusTitle,
30 type EnvironmentConnectionPresentation,
31} from "@t3tools/client-runtime/connection";
32import { wasBootstrapThreadDeleted } from "@t3tools/client-runtime/errors";29import { wasBootstrapThreadDeleted } from "@t3tools/client-runtime/errors";
33import { type CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates";30import { type CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates";
34import { effectiveSnoozed, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled";31import { effectiveSnoozed, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled";
...@@ -2267,21 +2264,20 @@ function ChatViewContent(props: ChatViewProps) {...@@ -2267,21 +2264,20 @@ function ChatViewContent(props: ChatViewProps) {
2267 />2264 />
2268 ),2265 ),
2269 title: `${unavailableConnection.phase === "connecting" ? "Connecting" : "Reconnecting"} to ${activeEnvironmentUnavailableState.label}`,2266 title: `${unavailableConnection.phase === "connecting" ? "Connecting" : "Reconnecting"} to ${activeEnvironmentUnavailableState.label}`,
2270 description: "It may be finishing an update. One moment.",2267 description: "Finishing an update",
2271 });2268 });
2272 } else {2269 } else {
2273 items.push({2270 items.push({
2274 id: `environment-unavailable:${activeEnvironmentUnavailableState.environmentId}`,2271 id: `environment-unavailable:${activeEnvironmentUnavailableState.environmentId}`,
2275 variant: unavailableConnection.phase === "error" ? "error" : "warning",2272 variant: unavailableConnection.phase === "error" ? "error" : "warning",
2276 icon: <WifiOffIcon />,2273 icon: <WifiOffIcon />,
2277 title: `${activeEnvironmentUnavailableState.label}: ${connectionStatusTitle(unavailableConnection)}`,2274 title: `${activeEnvironmentUnavailableState.label} is ${environmentReconnecting ? "reconnecting" : "offline"}`,
2278 description:2275 description: environmentReconnecting ? "Trying again" : "Reconnect to continue",
2279 unavailableConnection.error ??
2280 "Reconnect this environment before sending messages or running actions.",
2281 actions: (2276 actions: (
2282 <>2277 <>
2283 <Button2278 <Button
2284 size="xs"2279 size="xs"
2280 variant="ghost"
2285 disabled={environmentReconnecting}2281 disabled={environmentReconnecting}
2286 onClick={() =>2282 onClick={() =>
2287 void handleReconnectActiveEnvironment(2283 void handleReconnectActiveEnvironment(
...@@ -2293,7 +2289,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -2293,7 +2289,7 @@ function ChatViewContent(props: ChatViewProps) {
2293 </Button>2289 </Button>
2294 <Button2290 <Button
2295 size="xs"2291 size="xs"
2296 variant="outline"2292 variant="ghost"
2297 onClick={() => void navigate({ to: "/settings/connections" })}2293 onClick={() => void navigate({ to: "/settings/connections" })}
2298 >2294 >
2299 Connections2295 Connections
...@@ -2344,13 +2340,10 @@ function ChatViewContent(props: ChatViewProps) {...@@ -2344,13 +2340,10 @@ function ChatViewContent(props: ChatViewProps) {
2344 description:2340 description:
2345 !updateInProgress &&2341 !updateInProgress &&
2346 !updateFailed &&2342 !updateFailed &&
2347 versionMismatchSelfUpdate === "desktop-managed" &&2343 versionMismatchSelfUpdate !== null &&
2348 !versionMismatchDesktopAppUpdate2344 (versionMismatchSelfUpdate !== "desktop-managed" || !versionMismatchDesktopAppUpdate)
2349 ? serverUpdateGuidance(versionMismatchSelfUpdate, versionMismatchServerLabel)2345 ? serverUpdateGuidance(versionMismatchSelfUpdate)
2350 : undefined,2346 : undefined,
2351 // The desktop-managed guidance is already the description; the action
2352 // slot would only repeat it. When the desktop app accepts remote
2353 // update requests, the action button takes over instead.
2354 actions:2347 actions:
2355 updateInProgress ||2348 updateInProgress ||
2356 !versionMismatch ||2349 !versionMismatch ||
...@@ -2363,6 +2356,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -2363,6 +2356,7 @@ function ChatViewContent(props: ChatViewProps) {
2363 desktopAppUpdate={versionMismatchDesktopAppUpdate}2356 desktopAppUpdate={versionMismatchDesktopAppUpdate}
2364 targetVersion={versionMismatch.clientVersion}2357 targetVersion={versionMismatch.clientVersion}
2365 label={updateFailed ? "Retry" : "Update"}2358 label={updateFailed ? "Retry" : "Update"}
2359 variant="ghost"
2366 />2360 />
2367 ),2361 ),
2368 ...(updateInProgress || (!updateFailed && !versionMismatchDismissKey)2362 ...(updateInProgress || (!updateFailed && !versionMismatchDismissKey)
...@@ -5000,8 +4994,8 @@ function ChatViewContent(props: ChatViewProps) {...@@ -5000,8 +4994,8 @@ function ChatViewContent(props: ChatViewProps) {
5000 id: `thread-woke:${activeThread?.id ?? "unknown"}`,4994 id: `thread-woke:${activeThread?.id ?? "unknown"}`,
5001 variant: "info",4995 variant: "info",
5002 icon: <AlarmClockIcon />,4996 icon: <AlarmClockIcon />,
5003 title: "This thread woke from snooze",4997 title: "Thread woke from snooze",
5004 description: "Dismiss to clear the Woke indicator, or send a message to keep going.",4998 description: "Send a message to continue",
5005 dismissLabel: "Dismiss Woke notification",4999 dismissLabel: "Dismiss Woke notification",
5006 onDismiss: acknowledgeActiveThreadWoke,5000 onDismiss: acknowledgeActiveThreadWoke,
5007 };5001 };
...@@ -5016,13 +5010,11 @@ function ChatViewContent(props: ChatViewProps) {...@@ -5016,13 +5010,11 @@ function ChatViewContent(props: ChatViewProps) {
5016 variant: "info",5010 variant: "info",
5017 icon: isSnoozed ? <AlarmClockIcon /> : <CheckCircle2Icon />,5011 icon: isSnoozed ? <AlarmClockIcon /> : <CheckCircle2Icon />,
5018 title: `This thread is ${isSnoozed ? "snoozed" : "settled"}`,5012 title: `This thread is ${isSnoozed ? "snoozed" : "settled"}`,
5019 description: isSnoozed5013 description: `Send a message to ${isSnoozed ? "wake" : "unsettle"}`,
5020 ? "Sending a message wakes it and moves it back to Active in the sidebar."
5021 : "Sending a message moves it back to Active in the sidebar.",
5022 actions: (5014 actions: (
5023 <Button5015 <Button
5024 size="xs"5016 size="xs"
5025 variant="outline"5017 variant="ghost"
5026 disabled={isSnoozed ? isUnsnoozing : isUnsettling}5018 disabled={isSnoozed ? isUnsnoozing : isUnsettling}
5027 onClick={() =>5019 onClick={() =>
5028 void (isSnoozed ? handleUnsnoozeActiveThread() : handleUnsettleActiveThread())5020 void (isSnoozed ? handleUnsnoozeActiveThread() : handleUnsettleActiveThread())
...@@ -5106,7 +5098,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -5106,7 +5098,7 @@ function ChatViewContent(props: ChatViewProps) {
5106 const compactAction = (5098 const compactAction = (
5107 <Button5099 <Button
5108 size="xs"5100 size="xs"
5109 variant="outline"5101 variant="ghost"
5110 disabled={compactDisabled}5102 disabled={compactDisabled}
5111 onClick={() => {5103 onClick={() => {
5112 if (compactDisabled) return;5104 if (compactDisabled) return;
...@@ -5121,7 +5113,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -5121,7 +5113,7 @@ function ChatViewContent(props: ChatViewProps) {
5121 variant: "info",5113 variant: "info",
5122 icon: <Minimize2Icon />,5114 icon: <Minimize2Icon />,
5123 title: "Resume with less context",5115 title: "Resume with less context",
5124 description: `${formatContextWindowTokens(activeContextWindow.usedTokens)} tokens from an older session`,5116 description: `${formatContextWindowTokens(activeContextWindow.usedTokens)} tokens from earlier`,
5125 actions: compactDisabledReason ? (5117 actions: compactDisabledReason ? (
5126 <Tooltip>5118 <Tooltip>
5127 <TooltipTrigger render={<span className="inline-flex">{compactAction}</span>} />5119 <TooltipTrigger render={<span className="inline-flex">{compactAction}</span>} />
...@@ -5198,7 +5190,6 @@ function ChatViewContent(props: ChatViewProps) {...@@ -5198,7 +5190,6 @@ function ChatViewContent(props: ChatViewProps) {
5198 </Tooltip>5190 </Tooltip>
5199 </span>5191 </span>
5200 ),5192 ),
5201 className: "dark:shadow-none",
5202 actions: (5193 actions: (
5203 <Button5194 <Button
5204 size="xs"5195 size="xs"
apps/web/src/components/ServerUpdateAction.tsx+5-2
...@@ -4,6 +4,7 @@ import {...@@ -4,6 +4,7 @@ import {
4 isAtomCommandInterrupted,4 isAtomCommandInterrupted,
5 squashAtomCommandFailure,5 squashAtomCommandFailure,
6} from "@t3tools/client-runtime/state/runtime";6} from "@t3tools/client-runtime/state/runtime";
7import type { ComponentProps } from "react";
78
8import { requestConfirmDialog } from "~/confirmDialog";9import { requestConfirmDialog } from "~/confirmDialog";
9import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";10import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
...@@ -79,6 +80,7 @@ export function ServerUpdateAction({...@@ -79,6 +80,7 @@ export function ServerUpdateAction({
79 desktopAppUpdate = false,80 desktopAppUpdate = false,
80 targetVersion,81 targetVersion,
81 label = "Update",82 label = "Update",
83 variant = "outline",
82}: {84}: {
83 readonly environmentId: EnvironmentId;85 readonly environmentId: EnvironmentId;
84 readonly serverLabel: string;86 readonly serverLabel: string;
...@@ -88,6 +90,7 @@ export function ServerUpdateAction({...@@ -88,6 +90,7 @@ export function ServerUpdateAction({
88 readonly desktopAppUpdate?: boolean;90 readonly desktopAppUpdate?: boolean;
89 readonly targetVersion: string;91 readonly targetVersion: string;
90 readonly label?: string;92 readonly label?: string;
93 readonly variant?: ComponentProps<typeof Button>["variant"];
91}) {94}) {
92 const isDesktopAppUpdate = selfUpdate === "desktop-managed";95 const isDesktopAppUpdate = selfUpdate === "desktop-managed";
93 const updateServer = useAtomCommand(serverEnvironment.updateServer, {96 const updateServer = useAtomCommand(serverEnvironment.updateServer, {
...@@ -170,14 +173,14 @@ export function ServerUpdateAction({...@@ -170,14 +173,14 @@ export function ServerUpdateAction({
170 if (selfUpdate === null) {173 if (selfUpdate === null) {
171 const command = manualServerUpdateCommand(targetVersion);174 const command = manualServerUpdateCommand(targetVersion);
172 return (175 return (
173 <Button size="xs" variant="outline" onClick={() => copyToClipboard(command, { command })}>176 <Button size="xs" variant={variant} onClick={() => copyToClipboard(command, { command })}>
174 Copy update command177 Copy update command
175 </Button>178 </Button>
176 );179 );
177 }180 }
178181
179 return (182 return (
180 <Button size="xs" variant="outline" onClick={() => void handleUpdate()}>183 <Button size="xs" variant={variant} onClick={() => void handleUpdate()}>
181 {label}184 {label}
182 </Button>185 </Button>
183 );186 );
apps/web/src/components/chat/ComposerBanner.tsx+6-2
...@@ -143,11 +143,13 @@ function Column({ className, ...props }: ComponentProps<"div">) {...@@ -143,11 +143,13 @@ function Column({ className, ...props }: ComponentProps<"div">) {
143143
144function Root({144function Root({
145 className,145 className,
146 density = "default",
146 placement = "attached",147 placement = "attached",
147 variant = "default",148 variant = "default",
148 width = "fill",149 width = "fill",
149 ...props150 ...props
150}: ComponentProps<"div"> & {151}: ComponentProps<"div"> & {
152 density?: "default" | "comfortable";
151 placement?: "attached" | "floating";153 placement?: "attached" | "floating";
152 variant?: ComposerBannerVariant;154 variant?: ComposerBannerVariant;
153 width?: "fill" | "content";155 width?: "fill" | "content";
...@@ -155,7 +157,8 @@ function Root({...@@ -155,7 +157,8 @@ function Root({
155 return (157 return (
156 <Surface158 <Surface
157 className={cn(159 className={cn(
158 "min-w-0 p-1 pb-[calc(var(--chat-composer-attachment-overlap)+(--spacing(1)))] text-xs/4 [--composer-banner-icon-column:--spacing(7)] sm:[--composer-banner-icon-column:--spacing(6)]",160 "min-w-0 px-1 pt-(--composer-banner-padding-block) pb-[calc(var(--chat-composer-attachment-overlap)+var(--composer-banner-padding-block))] text-xs/4 [--composer-banner-icon-column:--spacing(7)] [--composer-banner-padding-block:--spacing(1)] sm:[--composer-banner-icon-column:--spacing(6)]",
161 density === "comfortable" && "[--composer-banner-padding-block:--spacing(1.25)]",
159 width === "content" ? "w-fit max-w-full flex-none" : "@container",162 width === "content" ? "w-fit max-w-full flex-none" : "@container",
160 className,163 className,
161 )}164 )}
...@@ -183,7 +186,7 @@ function Row({...@@ -183,7 +186,7 @@ function Row({
183 "not-has-[>[data-slot=composer-banner-actions]]:grid-cols-[var(--composer-banner-icon-column)_minmax(0,1fr)]",186 "not-has-[>[data-slot=composer-banner-actions]]:grid-cols-[var(--composer-banner-icon-column)_minmax(0,1fr)]",
184 "[&:is(button)]:cursor-pointer [&:is(button)]:rounded-[0.5rem] [&:is(button)]:focus-visible:outline-2 [&:is(button)]:focus-visible:-outline-offset-2 [&:is(button)]:focus-visible:outline-ring",187 "[&:is(button)]:cursor-pointer [&:is(button)]:rounded-[0.5rem] [&:is(button)]:focus-visible:outline-2 [&:is(button)]:focus-visible:-outline-offset-2 [&:is(button)]:focus-visible:outline-ring",
185 layout === "wrap-actions" &&188 layout === "wrap-actions" &&
186 "@max-[400px]:flex @max-[400px]:flex-wrap @max-[400px]:gap-y-1 @max-[400px]:*:data-[slot=composer-banner-actions]:ms-auto @max-[400px]:*:data-[slot=composer-banner-actions]:max-w-full @max-[400px]:has-[>[data-slot=composer-banner-icon]]:*:data-[slot=composer-banner-actions]:max-w-[calc(100%-var(--composer-banner-icon-column)-(--spacing(1)))] @max-[400px]:*:data-[slot=composer-banner-content]:min-h-(--composer-banner-icon-column) @max-[400px]:*:data-[slot=composer-banner-content]:flex-[1_1_10rem]",189 "@max-[400px]:*:data-[slot=composer-banner-content]:min-h-(--composer-banner-icon-column)",
187 className,190 className,
188 ),191 ),
189 "data-composer-banner-row": "true",192 "data-composer-banner-row": "true",
...@@ -243,6 +246,7 @@ function Actions({ className, ...props }: ComponentProps<"span">) {...@@ -243,6 +246,7 @@ function Actions({ className, ...props }: ComponentProps<"span">) {
243 data-slot="composer-banner-actions"246 data-slot="composer-banner-actions"
244 className={cn(247 className={cn(
245 "col-start-3 row-start-1 flex flex-wrap items-center justify-end gap-1",248 "col-start-3 row-start-1 flex flex-wrap items-center justify-end gap-1",
249 "@max-[400px]:group-data-[composer-banner-layout=wrap-actions]/banner-row:has-[>:nth-child(2)]:col-start-2 @max-[400px]:group-data-[composer-banner-layout=wrap-actions]/banner-row:has-[>:nth-child(2)]:col-end-4 @max-[400px]:group-data-[composer-banner-layout=wrap-actions]/banner-row:has-[>:nth-child(2)]:row-start-2 @max-[400px]:group-data-[composer-banner-layout=wrap-actions]/banner-row:has-[>:nth-child(2)]:-ms-2 @max-[400px]:group-data-[composer-banner-layout=wrap-actions]/banner-row:has-[>:nth-child(2)]:justify-start",
246 className,250 className,
247 )}251 )}
248 {...props}252 {...props}
apps/web/src/components/chat/ComposerBannerStack.tsx+42-14
...@@ -1,6 +1,9 @@...@@ -1,6 +1,9 @@
1import { InfoIcon } from "lucide-react";
1import { useEffect, useId, useLayoutEffect, useRef, useState, type ReactNode } from "react";2import { useEffect, useId, useLayoutEffect, useRef, useState, type ReactNode } from "react";
23
3import { cn } from "~/lib/utils";4import { cn } from "~/lib/utils";
5import { Button } from "../ui/button";
6import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
4import { ComposerBanner, type ComposerBannerVariant } from "./ComposerBanner";7import { ComposerBanner, type ComposerBannerVariant } from "./ComposerBanner";
58
6// Match the duration-220 exit transition before removing a dismissed notice.9// Match the duration-220 exit transition before removing a dismissed notice.
...@@ -15,14 +18,13 @@ export interface ComposerBannerStackItem {...@@ -15,14 +18,13 @@ export interface ComposerBannerStackItem {
15 readonly description?: ReactNode;18 readonly description?: ReactNode;
16 readonly children?: ReactNode;19 readonly children?: ReactNode;
17 readonly actions?: ReactNode;20 readonly actions?: ReactNode;
18 readonly className?: string;
19 readonly dismissLabel?: string;21 readonly dismissLabel?: string;
20 readonly onDismiss?: () => void;22 readonly onDismiss?: () => void;
21}23}
2224
23export type ComposerBannerStackContent = Pick<25export type ComposerBannerStackContent = Pick<
24 ComposerBannerStackItem,26 ComposerBannerStackItem,
25 "id" | "variant" | "priority" | "className"27 "id" | "variant" | "priority"
26> & { readonly content: ReactNode };28> & { readonly content: ReactNode };
2729
28type ComposerBannerStackEntry = ComposerBannerStackItem | ComposerBannerStackContent;30type ComposerBannerStackEntry = ComposerBannerStackItem | ComposerBannerStackContent;
...@@ -252,37 +254,63 @@ function ComposerBannerStackAlert({...@@ -252,37 +254,63 @@ function ComposerBannerStackAlert({
252 if ("content" in item) {254 if ("content" in item) {
253 return (255 return (
254 <ComposerBanner.Root256 <ComposerBanner.Root
257 density="comfortable"
255 placement={attached ? "attached" : "floating"}258 placement={attached ? "attached" : "floating"}
256 variant={item.variant}259 variant={item.variant}
257 className={item.className}
258 >260 >
259 {item.content}261 {item.content}
260 </ComposerBanner.Root>262 </ComposerBanner.Root>
261 );263 );
262 }264 }
263
264 return (265 return (
265 <ComposerBanner.Root266 <ComposerBanner.Root
266 role="alert"267 role="alert"
267 placement={attached ? "attached" : "floating"}268 placement={attached ? "attached" : "floating"}
268 variant={item.variant}269 variant={item.variant}
269 className={item.className}270 density="comfortable"
270 >271 >
271 <ComposerBanner.Row layout="wrap-actions">272 <ComposerBanner.Row layout="wrap-actions">
272 <ComposerBanner.Icon className={item.description ? "min-h-4 self-start" : undefined}>273 <ComposerBanner.Icon className="h-(--composer-banner-icon-column) self-start">
273 {item.icon}274 {item.icon}
274 </ComposerBanner.Icon>275 </ComposerBanner.Icon>
275 <ComposerBanner.Content276 <ComposerBanner.Content className="whitespace-nowrap">
276 className={item.description ? "flex-col items-start gap-0" : "font-medium"}277 <span
277 >278 className={cn(
279 "min-w-0 font-medium leading-7 sm:leading-6",
280 typeof item.title === "string" && "truncate",
281 )}
282 >
283 {item.title}
284 </span>
278 {item.description ? (285 {item.description ? (
279 <>286 <>
280 <span className="font-medium">{item.title}</span>287 <span className="min-w-0 shrink-[9999] truncate text-muted-foreground @max-[400px]:sr-only">
281 <span className="text-muted-foreground">{item.description}</span>288 {item.description}
289 </span>
290 <Popover>
291 <PopoverTrigger
292 openOnHover
293 render={
294 <Button
295 size="icon-xs"
296 variant="ghost"
297 aria-label="Show notice details"
298 className="hidden flex-none text-muted-foreground hover:text-foreground @max-[400px]:inline-flex"
299 />
300 }
301 >
302 <InfoIcon className="size-3.5" />
303 </PopoverTrigger>
304 <PopoverPopup
305 tooltipStyle
306 side="top"
307 className="max-w-72 whitespace-normal text-pretty"
308 >
309 {item.description}
310 </PopoverPopup>
311 </Popover>
282 </>312 </>
283 ) : (313 ) : null}
284 item.title
285 )}
286 </ComposerBanner.Content>314 </ComposerBanner.Content>
287 {item.actions || item.onDismiss ? (315 {item.actions || item.onDismiss ? (
288 <ComposerBanner.Actions>316 <ComposerBanner.Actions>
apps/web/src/components/chat/ComposerStashBadge.tsx+6-1
...@@ -34,7 +34,12 @@ export const ComposerStashBadge = memo(function ComposerStashBadge(props: {...@@ -34,7 +34,12 @@ export const ComposerStashBadge = memo(function ComposerStashBadge(props: {
34 );34 );
3535
36 return (36 return (
37 <ComposerBanner.Root width="content" data-composer-shoulder-tab className="ml-auto">37 <ComposerBanner.Root
38 density="comfortable"
39 width="content"
40 data-composer-shoulder-tab
41 className="ml-auto"
42 >
38 <ComposerBanner.Row43 <ComposerBanner.Row
39 render={<button type="button" />}44 render={<button type="button" />}
40 data-prompt-stash-badge="true"45 data-prompt-stash-badge="true"
apps/web/src/components/chat/ComposerTasksBadge.tsx+3-1
...@@ -123,7 +123,9 @@ export const ComposerTasksBadge = memo(function ComposerTasksBadge({...@@ -123,7 +123,9 @@ export const ComposerTasksBadge = memo(function ComposerTasksBadge({
123 return placement === "inline" ? (123 return placement === "inline" ? (
124 row124 row
125 ) : (125 ) : (
126 <ComposerBanner.Root data-composer-shoulder-tab>{row}</ComposerBanner.Root>126 <ComposerBanner.Root density="comfortable" data-composer-shoulder-tab>
127 {row}
128 </ComposerBanner.Root>
127 );129 );
128});130});
129131
apps/web/src/versionSkew.test.ts+2-9
...@@ -215,14 +215,7 @@ describe("versionSkew", () => {...@@ -215,14 +215,7 @@ describe("versionSkew", () => {
215 });215 });
216216
217 it("matches version-drift guidance to the advertised update path", () => {217 it("matches version-drift guidance to the advertised update path", () => {
218 expect(serverUpdateGuidance("respawn", "Remote server")).toBe(218 expect(serverUpdateGuidance("respawn")).toBe("Update to stay in sync");
219 "Update the Remote server so they stay in sync.",219 expect(serverUpdateGuidance("desktop-managed")).toBe("Update the desktop app");
220 );
221 expect(serverUpdateGuidance("desktop-managed", "Desktop server")).toBe(
222 "Update the desktop app that runs the Desktop server.",
223 );
224 expect(serverUpdateGuidance(null, "Local server")).toBe(
225 "Relaunch the Local server with the copied command to sync them.",
226 );
227 });220 });
228});221});
apps/web/src/versionSkew.ts+2-15
...@@ -111,21 +111,8 @@ export function manualServerUpdateCommand(targetVersion: string): string {...@@ -111,21 +111,8 @@ export function manualServerUpdateCommand(targetVersion: string): string {
111 return `npx t3@${targetVersion}`;111 return `npx t3@${targetVersion}`;
112}112}
113113
114/** One sentence telling the user how to resolve version skew for a server,114export function serverUpdateGuidance(capability: ServerSelfUpdateCapability): string {
115 matched to the update path it offers. */115 return capability === "desktop-managed" ? "Update the desktop app" : "Update to stay in sync";
116export function serverUpdateGuidance(
117 capability: ServerSelfUpdateCapability | null,
118 serverLabel: string,
119): string {
120 switch (capability) {
121 case "boot-service":
122 case "respawn":
123 return `Update the ${serverLabel} so they stay in sync.`;
124 case "desktop-managed":
125 return `Update the desktop app that runs the ${serverLabel}.`;
126 default:
127 return `Relaunch the ${serverLabel} with the copied command to sync them.`;
128 }
129}116}
130117
131export function buildVersionMismatchDismissalKey(118export function buildVersionMismatchDismissalKey(