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 {
2525 RuntimeMode,
2626 TerminalOpenInput,
2727} from "@t3tools/contracts";
28import {
29 connectionStatusTitle,
30 type EnvironmentConnectionPresentation,
31} from "@t3tools/client-runtime/connection";
28import { type EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection";
3229import { wasBootstrapThreadDeleted } from "@t3tools/client-runtime/errors";
3330import { type CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates";
3431import { effectiveSnoozed, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled";
......@@ -2267,21 +2264,20 @@ function ChatViewContent(props: ChatViewProps) {
22672264 />
22682265 ),
22692266 title: `${unavailableConnection.phase === "connecting" ? "Connecting" : "Reconnecting"} to ${activeEnvironmentUnavailableState.label}`,
2270 description: "It may be finishing an update. One moment.",
2267 description: "Finishing an update",
22712268 });
22722269 } else {
22732270 items.push({
22742271 id: `environment-unavailable:${activeEnvironmentUnavailableState.environmentId}`,
22752272 variant: unavailableConnection.phase === "error" ? "error" : "warning",
22762273 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",
22812276 actions: (
22822277 <>
22832278 <Button
22842279 size="xs"
2280 variant="ghost"
22852281 disabled={environmentReconnecting}
22862282 onClick={() =>
22872283 void handleReconnectActiveEnvironment(
......@@ -2293,7 +2289,7 @@ function ChatViewContent(props: ChatViewProps) {
22932289 </Button>
22942290 <Button
22952291 size="xs"
2296 variant="outline"
2292 variant="ghost"
22972293 onClick={() => void navigate({ to: "/settings/connections" })}
22982294 >
22992295 Connections
......@@ -2344,13 +2340,10 @@ function ChatViewContent(props: ChatViewProps) {
23442340 description:
23452341 !updateInProgress &&
23462342 !updateFailed &&
2347 versionMismatchSelfUpdate === "desktop-managed" &&
2348 !versionMismatchDesktopAppUpdate
2349 ? serverUpdateGuidance(versionMismatchSelfUpdate, versionMismatchServerLabel)
2343 versionMismatchSelfUpdate !== null &&
2344 (versionMismatchSelfUpdate !== "desktop-managed" || !versionMismatchDesktopAppUpdate)
2345 ? serverUpdateGuidance(versionMismatchSelfUpdate)
23502346 : 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.
23542347 actions:
23552348 updateInProgress ||
23562349 !versionMismatch ||
......@@ -2363,6 +2356,7 @@ function ChatViewContent(props: ChatViewProps) {
23632356 desktopAppUpdate={versionMismatchDesktopAppUpdate}
23642357 targetVersion={versionMismatch.clientVersion}
23652358 label={updateFailed ? "Retry" : "Update"}
2359 variant="ghost"
23662360 />
23672361 ),
23682362 ...(updateInProgress || (!updateFailed && !versionMismatchDismissKey)
......@@ -5000,8 +4994,8 @@ function ChatViewContent(props: ChatViewProps) {
50004994 id: `thread-woke:${activeThread?.id ?? "unknown"}`,
50014995 variant: "info",
50024996 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",
50054999 dismissLabel: "Dismiss Woke notification",
50065000 onDismiss: acknowledgeActiveThreadWoke,
50075001 };
......@@ -5016,13 +5010,11 @@ function ChatViewContent(props: ChatViewProps) {
50165010 variant: "info",
50175011 icon: isSnoozed ? <AlarmClockIcon /> : <CheckCircle2Icon />,
50185012 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"}`,
50225014 actions: (
50235015 <Button
50245016 size="xs"
5025 variant="outline"
5017 variant="ghost"
50265018 disabled={isSnoozed ? isUnsnoozing : isUnsettling}
50275019 onClick={() =>
50285020 void (isSnoozed ? handleUnsnoozeActiveThread() : handleUnsettleActiveThread())
......@@ -5106,7 +5098,7 @@ function ChatViewContent(props: ChatViewProps) {
51065098 const compactAction = (
51075099 <Button
51085100 size="xs"
5109 variant="outline"
5101 variant="ghost"
51105102 disabled={compactDisabled}
51115103 onClick={() => {
51125104 if (compactDisabled) return;
......@@ -5121,7 +5113,7 @@ function ChatViewContent(props: ChatViewProps) {
51215113 variant: "info",
51225114 icon: <Minimize2Icon />,
51235115 title: "Resume with less context",
5124 description: `${formatContextWindowTokens(activeContextWindow.usedTokens)} tokens from an older session`,
5116 description: `${formatContextWindowTokens(activeContextWindow.usedTokens)} tokens from earlier`,
51255117 actions: compactDisabledReason ? (
51265118 <Tooltip>
51275119 <TooltipTrigger render={<span className="inline-flex">{compactAction}</span>} />
......@@ -5198,7 +5190,6 @@ function ChatViewContent(props: ChatViewProps) {
51985190 </Tooltip>
51995191 </span>
52005192 ),
5201 className: "dark:shadow-none",
52025193 actions: (
52035194 <Button
52045195 size="xs"
apps/web/src/components/ServerUpdateAction.tsx+5-2
......@@ -4,6 +4,7 @@ import {
44 isAtomCommandInterrupted,
55 squashAtomCommandFailure,
66} from "@t3tools/client-runtime/state/runtime";
7import type { ComponentProps } from "react";
78
89import { requestConfirmDialog } from "~/confirmDialog";
910import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
......@@ -79,6 +80,7 @@ export function ServerUpdateAction({
7980 desktopAppUpdate = false,
8081 targetVersion,
8182 label = "Update",
83 variant = "outline",
8284}: {
8385 readonly environmentId: EnvironmentId;
8486 readonly serverLabel: string;
......@@ -88,6 +90,7 @@ export function ServerUpdateAction({
8890 readonly desktopAppUpdate?: boolean;
8991 readonly targetVersion: string;
9092 readonly label?: string;
93 readonly variant?: ComponentProps<typeof Button>["variant"];
9194}) {
9295 const isDesktopAppUpdate = selfUpdate === "desktop-managed";
9396 const updateServer = useAtomCommand(serverEnvironment.updateServer, {
......@@ -170,14 +173,14 @@ export function ServerUpdateAction({
170173 if (selfUpdate === null) {
171174 const command = manualServerUpdateCommand(targetVersion);
172175 return (
173 <Button size="xs" variant="outline" onClick={() => copyToClipboard(command, { command })}>
176 <Button size="xs" variant={variant} onClick={() => copyToClipboard(command, { command })}>
174177 Copy update command
175178 </Button>
176179 );
177180 }
178181
179182 return (
180 <Button size="xs" variant="outline" onClick={() => void handleUpdate()}>
183 <Button size="xs" variant={variant} onClick={() => void handleUpdate()}>
181184 {label}
182185 </Button>
183186 );
apps/web/src/components/chat/ComposerBanner.tsx+6-2
......@@ -143,11 +143,13 @@ function Column({ className, ...props }: ComponentProps<"div">) {
143143
144144function Root({
145145 className,
146 density = "default",
146147 placement = "attached",
147148 variant = "default",
148149 width = "fill",
149150 ...props
150151}: ComponentProps<"div"> & {
152 density?: "default" | "comfortable";
151153 placement?: "attached" | "floating";
152154 variant?: ComposerBannerVariant;
153155 width?: "fill" | "content";
......@@ -155,7 +157,8 @@ function Root({
155157 return (
156158 <Surface
157159 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)]",
159162 width === "content" ? "w-fit max-w-full flex-none" : "@container",
160163 className,
161164 )}
......@@ -183,7 +186,7 @@ function Row({
183186 "not-has-[>[data-slot=composer-banner-actions]]:grid-cols-[var(--composer-banner-icon-column)_minmax(0,1fr)]",
184187 "[&: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",
185188 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)",
187190 className,
188191 ),
189192 "data-composer-banner-row": "true",
......@@ -243,6 +246,7 @@ function Actions({ className, ...props }: ComponentProps<"span">) {
243246 data-slot="composer-banner-actions"
244247 className={cn(
245248 "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",
246250 className,
247251 )}
248252 {...props}
apps/web/src/components/chat/ComposerBannerStack.tsx+42-14
......@@ -1,6 +1,9 @@
1import { InfoIcon } from "lucide-react";
12import { useEffect, useId, useLayoutEffect, useRef, useState, type ReactNode } from "react";
23
34import { cn } from "~/lib/utils";
5import { Button } from "../ui/button";
6import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
47import { ComposerBanner, type ComposerBannerVariant } from "./ComposerBanner";
58
69// Match the duration-220 exit transition before removing a dismissed notice.
......@@ -15,14 +18,13 @@ export interface ComposerBannerStackItem {
1518 readonly description?: ReactNode;
1619 readonly children?: ReactNode;
1720 readonly actions?: ReactNode;
18 readonly className?: string;
1921 readonly dismissLabel?: string;
2022 readonly onDismiss?: () => void;
2123}
2224
2325export type ComposerBannerStackContent = Pick<
2426 ComposerBannerStackItem,
25 "id" | "variant" | "priority" | "className"
27 "id" | "variant" | "priority"
2628> & { readonly content: ReactNode };
2729
2830type ComposerBannerStackEntry = ComposerBannerStackItem | ComposerBannerStackContent;
......@@ -252,37 +254,63 @@ function ComposerBannerStackAlert({
252254 if ("content" in item) {
253255 return (
254256 <ComposerBanner.Root
257 density="comfortable"
255258 placement={attached ? "attached" : "floating"}
256259 variant={item.variant}
257 className={item.className}
258260 >
259261 {item.content}
260262 </ComposerBanner.Root>
261263 );
262264 }
263
264265 return (
265266 <ComposerBanner.Root
266267 role="alert"
267268 placement={attached ? "attached" : "floating"}
268269 variant={item.variant}
269 className={item.className}
270 density="comfortable"
270271 >
271272 <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">
273274 {item.icon}
274275 </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>
278285 {item.description ? (
279286 <>
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>
282312 </>
283 ) : (
284 item.title
285 )}
313 ) : null}
286314 </ComposerBanner.Content>
287315 {item.actions || item.onDismiss ? (
288316 <ComposerBanner.Actions>
apps/web/src/components/chat/ComposerStashBadge.tsx+6-1
......@@ -34,7 +34,12 @@ export const ComposerStashBadge = memo(function ComposerStashBadge(props: {
3434 );
3535
3636 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 >
3843 <ComposerBanner.Row
3944 render={<button type="button" />}
4045 data-prompt-stash-badge="true"
apps/web/src/components/chat/ComposerTasksBadge.tsx+3-1
......@@ -123,7 +123,9 @@ export const ComposerTasksBadge = memo(function ComposerTasksBadge({
123123 return placement === "inline" ? (
124124 row
125125 ) : (
126 <ComposerBanner.Root data-composer-shoulder-tab>{row}</ComposerBanner.Root>
126 <ComposerBanner.Root density="comfortable" data-composer-shoulder-tab>
127 {row}
128 </ComposerBanner.Root>
127129 );
128130});
129131
apps/web/src/versionSkew.test.ts+2-9
......@@ -215,14 +215,7 @@ describe("versionSkew", () => {
215215 });
216216
217217 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");
227220 });
228221});
apps/web/src/versionSkew.ts+2-15
......@@ -111,21 +111,8 @@ export function manualServerUpdateCommand(targetVersion: string): string {
111111 return `npx t3@${targetVersion}`;
112112}
113113
114/** One sentence telling the user how to resolve version skew for a server,
115 matched to the update path it offers. */
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 }
114export function serverUpdateGuidance(capability: ServerSelfUpdateCapability): string {
115 return capability === "desktop-managed" ? "Update the desktop app" : "Update to stay in sync";
129116}
130117
131118export function buildVersionMismatchDismissalKey(