| author | |
| committer | |
| log | e9db39ce05c706122bb747fd5e7e70e1aac78935 |
| tree | d5dbca717364b4a89f842f5fb9de98b978724649 |
| parent | 941acb4f919bcef337d4d9e1623101bdc30d3616 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 25 | RuntimeMode, |
| 26 | 26 | TerminalOpenInput, |
| 27 | 27 | } from "@t3tools/contracts"; |
| 28 | import { | |
| 29 | connectionStatusTitle, | |
| 30 | type EnvironmentConnectionPresentation, | |
| 31 | } from "@t3tools/client-runtime/connection"; | |
| 28 | import { type EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection"; | |
| 32 | 29 | import { wasBootstrapThreadDeleted } from "@t3tools/client-runtime/errors"; |
| 33 | 30 | import { type CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates"; |
| 34 | 31 | import { effectiveSnoozed, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled"; |
| ... | ... | @@ -2267,21 +2264,20 @@ function ChatViewContent(props: ChatViewProps) { |
| 2267 | 2264 | /> |
| 2268 | 2265 | ), |
| 2269 | 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 | 2269 | } else { |
| 2273 | 2270 | items.push({ |
| 2274 | 2271 | id: `environment-unavailable:${activeEnvironmentUnavailableState.environmentId}`, |
| 2275 | 2272 | variant: unavailableConnection.phase === "error" ? "error" : "warning", |
| 2276 | 2273 | icon: <WifiOffIcon />, |
| 2277 | title: `${activeEnvironmentUnavailableState.label}: ${connectionStatusTitle(unavailableConnection)}`, | |
| 2278 | description: | |
| 2279 | unavailableConnection.error ?? | |
| 2280 | "Reconnect this environment before sending messages or running actions.", | |
| 2274 | title: `${activeEnvironmentUnavailableState.label} is ${environmentReconnecting ? "reconnecting" : "offline"}`, | |
| 2275 | description: environmentReconnecting ? "Trying again" : "Reconnect to continue", | |
| 2281 | 2276 | actions: ( |
| 2282 | 2277 | <> |
| 2283 | 2278 | <Button |
| 2284 | 2279 | size="xs" |
| 2280 | variant="ghost" | |
| 2285 | 2281 | disabled={environmentReconnecting} |
| 2286 | 2282 | onClick={() => |
| 2287 | 2283 | void handleReconnectActiveEnvironment( |
| ... | ... | @@ -2293,7 +2289,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 2293 | 2289 | </Button> |
| 2294 | 2290 | <Button |
| 2295 | 2291 | size="xs" |
| 2296 | variant="outline" | |
| 2292 | variant="ghost" | |
| 2297 | 2293 | onClick={() => void navigate({ to: "/settings/connections" })} |
| 2298 | 2294 | > |
| 2299 | 2295 | Connections |
| ... | ... | @@ -2344,13 +2340,10 @@ function ChatViewContent(props: ChatViewProps) { |
| 2344 | 2340 | description: |
| 2345 | 2341 | !updateInProgress && |
| 2346 | 2342 | !updateFailed && |
| 2347 | versionMismatchSelfUpdate === "desktop-managed" && | |
| 2348 | !versionMismatchDesktopAppUpdate | |
| 2349 | ? serverUpdateGuidance(versionMismatchSelfUpdate, versionMismatchServerLabel) | |
| 2343 | versionMismatchSelfUpdate !== null && | |
| 2344 | (versionMismatchSelfUpdate !== "desktop-managed" || !versionMismatchDesktopAppUpdate) | |
| 2345 | ? serverUpdateGuidance(versionMismatchSelfUpdate) | |
| 2350 | 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 | 2347 | actions: |
| 2355 | 2348 | updateInProgress || |
| 2356 | 2349 | !versionMismatch || |
| ... | ... | @@ -2363,6 +2356,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 2363 | 2356 | desktopAppUpdate={versionMismatchDesktopAppUpdate} |
| 2364 | 2357 | targetVersion={versionMismatch.clientVersion} |
| 2365 | 2358 | label={updateFailed ? "Retry" : "Update"} |
| 2359 | variant="ghost" | |
| 2366 | 2360 | /> |
| 2367 | 2361 | ), |
| 2368 | 2362 | ...(updateInProgress || (!updateFailed && !versionMismatchDismissKey) |
| ... | ... | @@ -5000,8 +4994,8 @@ function ChatViewContent(props: ChatViewProps) { |
| 5000 | 4994 | id: `thread-woke:${activeThread?.id ?? "unknown"}`, |
| 5001 | 4995 | variant: "info", |
| 5002 | 4996 | icon: <AlarmClockIcon />, |
| 5003 | title: "This thread woke from snooze", | |
| 5004 | description: "Dismiss to clear the Woke indicator, or send a message to keep going.", | |
| 4997 | title: "Thread woke from snooze", | |
| 4998 | description: "Send a message to continue", | |
| 5005 | 4999 | dismissLabel: "Dismiss Woke notification", |
| 5006 | 5000 | onDismiss: acknowledgeActiveThreadWoke, |
| 5007 | 5001 | }; |
| ... | ... | @@ -5016,13 +5010,11 @@ function ChatViewContent(props: ChatViewProps) { |
| 5016 | 5010 | variant: "info", |
| 5017 | 5011 | icon: isSnoozed ? <AlarmClockIcon /> : <CheckCircle2Icon />, |
| 5018 | 5012 | title: `This thread is ${isSnoozed ? "snoozed" : "settled"}`, |
| 5019 | description: isSnoozed | |
| 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.", | |
| 5013 | description: `Send a message to ${isSnoozed ? "wake" : "unsettle"}`, | |
| 5022 | 5014 | actions: ( |
| 5023 | 5015 | <Button |
| 5024 | 5016 | size="xs" |
| 5025 | variant="outline" | |
| 5017 | variant="ghost" | |
| 5026 | 5018 | disabled={isSnoozed ? isUnsnoozing : isUnsettling} |
| 5027 | 5019 | onClick={() => |
| 5028 | 5020 | void (isSnoozed ? handleUnsnoozeActiveThread() : handleUnsettleActiveThread()) |
| ... | ... | @@ -5106,7 +5098,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 5106 | 5098 | const compactAction = ( |
| 5107 | 5099 | <Button |
| 5108 | 5100 | size="xs" |
| 5109 | variant="outline" | |
| 5101 | variant="ghost" | |
| 5110 | 5102 | disabled={compactDisabled} |
| 5111 | 5103 | onClick={() => { |
| 5112 | 5104 | if (compactDisabled) return; |
| ... | ... | @@ -5121,7 +5113,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 5121 | 5113 | variant: "info", |
| 5122 | 5114 | icon: <Minimize2Icon />, |
| 5123 | 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 | 5117 | actions: compactDisabledReason ? ( |
| 5126 | 5118 | <Tooltip> |
| 5127 | 5119 | <TooltipTrigger render={<span className="inline-flex">{compactAction}</span>} /> |
| ... | ... | @@ -5198,7 +5190,6 @@ function ChatViewContent(props: ChatViewProps) { |
| 5198 | 5190 | </Tooltip> |
| 5199 | 5191 | </span> |
| 5200 | 5192 | ), |
| 5201 | className: "dark:shadow-none", | |
| 5202 | 5193 | actions: ( |
| 5203 | 5194 | <Button |
| 5204 | 5195 | size="xs" |
apps/web/src/components/ServerUpdateAction.tsx+5-2| ... | ... | @@ -4,6 +4,7 @@ import { |
| 4 | 4 | isAtomCommandInterrupted, |
| 5 | 5 | squashAtomCommandFailure, |
| 6 | 6 | } from "@t3tools/client-runtime/state/runtime"; |
| 7 | import type { ComponentProps } from "react"; | |
| 7 | 8 | |
| 8 | 9 | import { requestConfirmDialog } from "~/confirmDialog"; |
| 9 | 10 | import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; |
| ... | ... | @@ -79,6 +80,7 @@ export function ServerUpdateAction({ |
| 79 | 80 | desktopAppUpdate = false, |
| 80 | 81 | targetVersion, |
| 81 | 82 | label = "Update", |
| 83 | variant = "outline", | |
| 82 | 84 | }: { |
| 83 | 85 | readonly environmentId: EnvironmentId; |
| 84 | 86 | readonly serverLabel: string; |
| ... | ... | @@ -88,6 +90,7 @@ export function ServerUpdateAction({ |
| 88 | 90 | readonly desktopAppUpdate?: boolean; |
| 89 | 91 | readonly targetVersion: string; |
| 90 | 92 | readonly label?: string; |
| 93 | readonly variant?: ComponentProps<typeof Button>["variant"]; | |
| 91 | 94 | }) { |
| 92 | 95 | const isDesktopAppUpdate = selfUpdate === "desktop-managed"; |
| 93 | 96 | const updateServer = useAtomCommand(serverEnvironment.updateServer, { |
| ... | ... | @@ -170,14 +173,14 @@ export function ServerUpdateAction({ |
| 170 | 173 | if (selfUpdate === null) { |
| 171 | 174 | const command = manualServerUpdateCommand(targetVersion); |
| 172 | 175 | return ( |
| 173 | <Button size="xs" variant="outline" onClick={() => copyToClipboard(command, { command })}> | |
| 176 | <Button size="xs" variant={variant} onClick={() => copyToClipboard(command, { command })}> | |
| 174 | 177 | Copy update command |
| 175 | 178 | </Button> |
| 176 | 179 | ); |
| 177 | 180 | } |
| 178 | 181 | |
| 179 | 182 | return ( |
| 180 | <Button size="xs" variant="outline" onClick={() => void handleUpdate()}> | |
| 183 | <Button size="xs" variant={variant} onClick={() => void handleUpdate()}> | |
| 181 | 184 | {label} |
| 182 | 185 | </Button> |
| 183 | 186 | ); |
apps/web/src/components/chat/ComposerBanner.tsx+6-2| ... | ... | @@ -143,11 +143,13 @@ function Column({ className, ...props }: ComponentProps<"div">) { |
| 143 | 143 | |
| 144 | 144 | function Root({ |
| 145 | 145 | className, |
| 146 | density = "default", | |
| 146 | 147 | placement = "attached", |
| 147 | 148 | variant = "default", |
| 148 | 149 | width = "fill", |
| 149 | 150 | ...props |
| 150 | 151 | }: ComponentProps<"div"> & { |
| 152 | density?: "default" | "comfortable"; | |
| 151 | 153 | placement?: "attached" | "floating"; |
| 152 | 154 | variant?: ComposerBannerVariant; |
| 153 | 155 | width?: "fill" | "content"; |
| ... | ... | @@ -155,7 +157,8 @@ function Root({ |
| 155 | 157 | return ( |
| 156 | 158 | <Surface |
| 157 | 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 | 162 | width === "content" ? "w-fit max-w-full flex-none" : "@container", |
| 160 | 163 | className, |
| 161 | 164 | )} |
| ... | ... | @@ -183,7 +186,7 @@ function Row({ |
| 183 | 186 | "not-has-[>[data-slot=composer-banner-actions]]:grid-cols-[var(--composer-banner-icon-column)_minmax(0,1fr)]", |
| 184 | 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 | 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 | 190 | className, |
| 188 | 191 | ), |
| 189 | 192 | "data-composer-banner-row": "true", |
| ... | ... | @@ -243,6 +246,7 @@ function Actions({ className, ...props }: ComponentProps<"span">) { |
| 243 | 246 | data-slot="composer-banner-actions" |
| 244 | 247 | className={cn( |
| 245 | 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 | 250 | className, |
| 247 | 251 | )} |
| 248 | 252 | {...props} |
apps/web/src/components/chat/ComposerBannerStack.tsx+42-14| ... | ... | @@ -1,6 +1,9 @@ |
| 1 | import { InfoIcon } from "lucide-react"; | |
| 1 | 2 | import { useEffect, useId, useLayoutEffect, useRef, useState, type ReactNode } from "react"; |
| 2 | 3 | |
| 3 | 4 | import { cn } from "~/lib/utils"; |
| 5 | import { Button } from "../ui/button"; | |
| 6 | import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; | |
| 4 | 7 | import { ComposerBanner, type ComposerBannerVariant } from "./ComposerBanner"; |
| 5 | 8 | |
| 6 | 9 | // Match the duration-220 exit transition before removing a dismissed notice. |
| ... | ... | @@ -15,14 +18,13 @@ export interface ComposerBannerStackItem { |
| 15 | 18 | readonly description?: ReactNode; |
| 16 | 19 | readonly children?: ReactNode; |
| 17 | 20 | readonly actions?: ReactNode; |
| 18 | readonly className?: string; | |
| 19 | 21 | readonly dismissLabel?: string; |
| 20 | 22 | readonly onDismiss?: () => void; |
| 21 | 23 | } |
| 22 | 24 | |
| 23 | 25 | export type ComposerBannerStackContent = Pick< |
| 24 | 26 | ComposerBannerStackItem, |
| 25 | "id" | "variant" | "priority" | "className" | |
| 27 | "id" | "variant" | "priority" | |
| 26 | 28 | > & { readonly content: ReactNode }; |
| 27 | 29 | |
| 28 | 30 | type ComposerBannerStackEntry = ComposerBannerStackItem | ComposerBannerStackContent; |
| ... | ... | @@ -252,37 +254,63 @@ function ComposerBannerStackAlert({ |
| 252 | 254 | if ("content" in item) { |
| 253 | 255 | return ( |
| 254 | 256 | <ComposerBanner.Root |
| 257 | density="comfortable" | |
| 255 | 258 | placement={attached ? "attached" : "floating"} |
| 256 | 259 | variant={item.variant} |
| 257 | className={item.className} | |
| 258 | 260 | > |
| 259 | 261 | {item.content} |
| 260 | 262 | </ComposerBanner.Root> |
| 261 | 263 | ); |
| 262 | 264 | } |
| 263 | ||
| 264 | 265 | return ( |
| 265 | 266 | <ComposerBanner.Root |
| 266 | 267 | role="alert" |
| 267 | 268 | placement={attached ? "attached" : "floating"} |
| 268 | 269 | variant={item.variant} |
| 269 | className={item.className} | |
| 270 | density="comfortable" | |
| 270 | 271 | > |
| 271 | 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 | 274 | {item.icon} |
| 274 | 275 | </ComposerBanner.Icon> |
| 275 | <ComposerBanner.Content | |
| 276 | className={item.description ? "flex-col items-start gap-0" : "font-medium"} | |
| 277 | > | |
| 276 | <ComposerBanner.Content className="whitespace-nowrap"> | |
| 277 | <span | |
| 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 | 285 | {item.description ? ( |
| 279 | 286 | <> |
| 280 | <span className="font-medium">{item.title}</span> | |
| 281 | <span className="text-muted-foreground">{item.description}</span> | |
| 287 | <span className="min-w-0 shrink-[9999] truncate text-muted-foreground @max-[400px]:sr-only"> | |
| 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 | ) : ( | |
| 284 | item.title | |
| 285 | )} | |
| 313 | ) : null} | |
| 286 | 314 | </ComposerBanner.Content> |
| 287 | 315 | {item.actions || item.onDismiss ? ( |
| 288 | 316 | <ComposerBanner.Actions> |
apps/web/src/components/chat/ComposerStashBadge.tsx+6-1| ... | ... | @@ -34,7 +34,12 @@ export const ComposerStashBadge = memo(function ComposerStashBadge(props: { |
| 34 | 34 | ); |
| 35 | 35 | |
| 36 | 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 | 43 | <ComposerBanner.Row |
| 39 | 44 | render={<button type="button" />} |
| 40 | 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 | 123 | return placement === "inline" ? ( |
| 124 | 124 | 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 | }); |
| 129 | 131 |
apps/web/src/versionSkew.test.ts+2-9| ... | ... | @@ -215,14 +215,7 @@ describe("versionSkew", () => { |
| 215 | 215 | }); |
| 216 | 216 | |
| 217 | 217 | it("matches version-drift guidance to the advertised update path", () => { |
| 218 | expect(serverUpdateGuidance("respawn", "Remote server")).toBe( | |
| 219 | "Update the Remote server so they stay in sync.", | |
| 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 | ); | |
| 218 | expect(serverUpdateGuidance("respawn")).toBe("Update to stay in sync"); | |
| 219 | expect(serverUpdateGuidance("desktop-managed")).toBe("Update the desktop app"); | |
| 227 | 220 | }); |
| 228 | 221 | }); |
apps/web/src/versionSkew.ts+2-15| ... | ... | @@ -111,21 +111,8 @@ export function manualServerUpdateCommand(targetVersion: string): string { |
| 111 | 111 | return `npx t3@${targetVersion}`; |
| 112 | 112 | } |
| 113 | 113 | |
| 114 | /** One sentence telling the user how to resolve version skew for a server, | |
| 115 | matched to the update path it offers. */ | |
| 116 | export 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 | } | |
| 114 | export function serverUpdateGuidance(capability: ServerSelfUpdateCapability): string { | |
| 115 | return capability === "desktop-managed" ? "Update the desktop app" : "Update to stay in sync"; | |
| 129 | 116 | } |
| 130 | 117 | |
| 131 | 118 | export function buildVersionMismatchDismissalKey( |