From 4857e288e44c58920edabb057a48971fca93d8c6 Mon Sep 17 00:00:00 2001 From: noxire-dev Date: Wed, 15 Apr 2026 01:01:41 +0300 Subject: [PATCH 01/12] Add close buttons to toasts - Add a dismiss control to standard toasts - Add the same close affordance to anchored toasts --- apps/web/src/components/ui/toast.tsx | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/apps/web/src/components/ui/toast.tsx b/apps/web/src/components/ui/toast.tsx index 916f1c5e970..b4c8e3982f8 100644 --- a/apps/web/src/components/ui/toast.tsx +++ b/apps/web/src/components/ui/toast.tsx @@ -12,6 +12,7 @@ import { InfoIcon, LoaderCircleIcon, TriangleAlertIcon, + XIcon, } from "lucide-react"; import { cn } from "~/lib/utils"; @@ -343,6 +344,15 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) { {toast.actionProps.children} )} + ); @@ -439,6 +449,15 @@ function AnchoredToasts() { {toast.actionProps.children} )} + )} From b54ce34ce58be6416a8ed732100527af38a5730a Mon Sep 17 00:00:00 2001 From: noxire-dev Date: Thu, 16 Apr 2026 12:27:22 +0300 Subject: [PATCH 02/12] Position dismiss button top-right per reviewer feedback Move the close button from inline (vertically centered) to absolute top-right positioning matching the t3 design language. Add pr-8 padding to toast content to prevent text overlap with the dismiss button. Addresses review feedback from juliusmarminge referencing #2040 styling. --- apps/web/src/components/ui/toast.tsx | 110 ++++++++++++++------------- 1 file changed, 59 insertions(+), 51 deletions(-) diff --git a/apps/web/src/components/ui/toast.tsx b/apps/web/src/components/ui/toast.tsx index b4c8e3982f8..603442df799 100644 --- a/apps/web/src/components/ui/toast.tsx +++ b/apps/web/src/components/ui/toast.tsx @@ -303,9 +303,21 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) { dismissAfterVisibleMs={toast.data?.dismissAfterVisibleMs} toastId={toast.id} /> + )} - ); @@ -412,53 +415,58 @@ function AnchoredToasts() { ) : ( - -
- {Icon && ( -
- -
- )} - -
-
- - {toast.type === "error" && - typeof toast.description === "string" && - !toast.data?.hideCopyButton && ( - - )} -
- -
-
- {toast.actionProps && ( - - {toast.actionProps.children} - - )} + <> -
+ +
+ {Icon && ( +
+ +
+ )} + +
+
+ + {toast.type === "error" && + typeof toast.description === "string" && + !toast.data?.hideCopyButton && ( + + )} +
+ +
+
+ {toast.actionProps && ( + + {toast.actionProps.children} + + )} +
+ )} From d5838a4a19390e19fc2b650a922145e62b67e429 Mon Sep 17 00:00:00 2001 From: noxire-dev Date: Fri, 17 Apr 2026 23:50:38 +0300 Subject: [PATCH 03/12] Reflow toast stack immediately when a toast dismisses Exclude toasts in the 'ending' transitionStatus from live index/offset calculations so the remaining toasts slide forward in parallel with the exit animation, instead of waiting for it to finish (which produced a visible stop-and-bump). The ending toast keeps its previous front slot so data-ending-style drives its motion, and frontmost height tracks the first live toast so the viewport sizes to what stays on screen. --- .../web/src/components/ui/toast.logic.test.ts | 27 ++++++++++ apps/web/src/components/ui/toast.logic.ts | 51 +++++++++++++++---- 2 files changed, 68 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/ui/toast.logic.test.ts b/apps/web/src/components/ui/toast.logic.test.ts index e634d7d9463..3f0d9d84e83 100644 --- a/apps/web/src/components/ui/toast.logic.test.ts +++ b/apps/web/src/components/ui/toast.logic.test.ts @@ -45,6 +45,33 @@ describe("buildVisibleToastLayout", () => { ); }); + it("skips ending toasts so remaining toasts reflow immediately on dismiss", () => { + const visibleToasts = [ + { id: "a", height: 48, transitionStatus: "ending" as const }, + { id: "b", height: 72 }, + { id: "c", height: 24 }, + ]; + + const layout = buildVisibleToastLayout(visibleToasts); + + // frontmost height should be the first live toast, not the ending one + assert.equal(layout.frontmostHeight, 72); + assert.deepEqual( + layout.items.map(({ toast, visibleIndex, offsetY }) => ({ + id: toast.id, + visibleIndex, + offsetY, + })), + [ + // Ending toast collapses to front slot; data-ending-style drives its exit + { id: "a", visibleIndex: 0, offsetY: 0 }, + // Live toasts get fresh indices starting at 0 so they move up in sync + { id: "b", visibleIndex: 0, offsetY: 0 }, + { id: "c", visibleIndex: 1, offsetY: 72 }, + ], + ); + }); + it("treats missing heights as zero", () => { const layout = buildVisibleToastLayout([ { id: "a" }, diff --git a/apps/web/src/components/ui/toast.logic.ts b/apps/web/src/components/ui/toast.logic.ts index 09905a7a670..a3a35e621af 100644 --- a/apps/web/src/components/ui/toast.logic.ts +++ b/apps/web/src/components/ui/toast.logic.ts @@ -14,6 +14,12 @@ type ToastWithHeight = { height?: number | null | undefined; }; +type ToastWithTransitionStatus = { + transitionStatus?: "starting" | "ending" | undefined; +}; + +type ToastWithLayoutProps = ToastWithHeight & ToastWithTransitionStatus; + type VisibleToastLayoutItem = { toast: TToast; visibleIndex: number; @@ -21,25 +27,50 @@ type VisibleToastLayoutItem = { }; export function buildVisibleToastLayout( - visibleToasts: readonly (TToast & ToastWithHeight)[], + visibleToasts: readonly (TToast & ToastWithLayoutProps)[], ): { frontmostHeight: number; - items: VisibleToastLayoutItem[]; + items: VisibleToastLayoutItem[]; } { - let offsetY = 0; + // Ending toasts are excluded from live index/offset calculations so the + // remaining toasts can reflow in parallel with the dismiss animation + // (otherwise the second toast only starts moving forward after the top + // toast's exit animation completes, causing a visible "stop and bump"). + let liveIndex = 0; + let liveOffsetY = 0; + + const items: VisibleToastLayoutItem[] = visibleToasts.map( + (toast) => { + if (toast.transitionStatus === "ending") { + // Keep ending toasts at their previous front position so their exit + // animation originates from the correct spot. The data-ending-style + // transform takes over their actual motion. + return { + toast, + visibleIndex: 0, + offsetY: 0, + }; + } - return { - frontmostHeight: normalizeToastHeight(visibleToasts[0]?.height), - items: visibleToasts.map((toast, visibleIndex) => { const item = { toast, - visibleIndex, - offsetY, + visibleIndex: liveIndex, + offsetY: liveOffsetY, }; - offsetY += normalizeToastHeight(toast.height); + liveOffsetY += normalizeToastHeight(toast.height); + liveIndex += 1; return item; - }), + }, + ); + + // Frontmost height should reflect the first non-ending (live) toast so the + // stack sizes to what's actually staying on screen. + const frontmostLiveToast = visibleToasts.find((toast) => toast.transitionStatus !== "ending"); + + return { + frontmostHeight: normalizeToastHeight(frontmostLiveToast?.height), + items, }; } From f1444385fc99dd6d3a92e54ff0f950189b0420dd Mon Sep 17 00:00:00 2001 From: noxire-dev Date: Sat, 18 Apr 2026 00:05:33 +0300 Subject: [PATCH 04/12] Preserve ending toast's original slot so non-front dismissals exit straight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous change hardcoded ending toasts to (visibleIndex: 0, offsetY: 0), which assumed only the front toast could dismiss. With the new close buttons, any toast in the expanded stack can be dismissed — and snapping its CSS variables to 0 made data-ending-style animate it diagonally from its old position toward Y=0 on the way off-screen. Track two cursors: 'full' advances on every toast (so an ending toast keeps the slot it occupied pre-dismissal and its horizontal exit originates from the right spot), and 'live' advances only on non-ending toasts (so the remaining toasts still reflow in parallel with the exit animation, preserving the earlier stop-and-bump fix). --- .../web/src/components/ui/toast.logic.test.ts | 33 +++++++++++++++++-- apps/web/src/components/ui/toast.logic.ts | 33 ++++++++++++------- 2 files changed, 53 insertions(+), 13 deletions(-) diff --git a/apps/web/src/components/ui/toast.logic.test.ts b/apps/web/src/components/ui/toast.logic.test.ts index 3f0d9d84e83..fefb61196bb 100644 --- a/apps/web/src/components/ui/toast.logic.test.ts +++ b/apps/web/src/components/ui/toast.logic.test.ts @@ -45,7 +45,7 @@ describe("buildVisibleToastLayout", () => { ); }); - it("skips ending toasts so remaining toasts reflow immediately on dismiss", () => { + it("reflows live toasts forward when the front toast is dismissed", () => { const visibleToasts = [ { id: "a", height: 48, transitionStatus: "ending" as const }, { id: "b", height: 72 }, @@ -63,7 +63,7 @@ describe("buildVisibleToastLayout", () => { offsetY, })), [ - // Ending toast collapses to front slot; data-ending-style drives its exit + // Ending toast stays at its front slot; data-ending-style drives its exit { id: "a", visibleIndex: 0, offsetY: 0 }, // Live toasts get fresh indices starting at 0 so they move up in sync { id: "b", visibleIndex: 0, offsetY: 0 }, @@ -72,6 +72,35 @@ describe("buildVisibleToastLayout", () => { ); }); + it("keeps a non-front ending toast at its current slot so it exits straight", () => { + const visibleToasts = [ + { id: "a", height: 48 }, + { id: "b", height: 72, transitionStatus: "ending" as const }, + { id: "c", height: 24 }, + ]; + + const layout = buildVisibleToastLayout(visibleToasts); + + // front toast stays, so frontmost height is unchanged + assert.equal(layout.frontmostHeight, 48); + assert.deepEqual( + layout.items.map(({ toast, visibleIndex, offsetY }) => ({ + id: toast.id, + visibleIndex, + offsetY, + })), + [ + // Front live toast — unaffected + { id: "a", visibleIndex: 0, offsetY: 0 }, + // Ending toast keeps its pre-dismissal slot so its horizontal exit + // originates from where the user saw it (not from Y=0). + { id: "b", visibleIndex: 1, offsetY: 48 }, + // Live toast behind "b" slides forward into the vacated slot. + { id: "c", visibleIndex: 1, offsetY: 48 }, + ], + ); + }); + it("treats missing heights as zero", () => { const layout = buildVisibleToastLayout([ { id: "a" }, diff --git a/apps/web/src/components/ui/toast.logic.ts b/apps/web/src/components/ui/toast.logic.ts index a3a35e621af..80f23970cee 100644 --- a/apps/web/src/components/ui/toast.logic.ts +++ b/apps/web/src/components/ui/toast.logic.ts @@ -32,24 +32,33 @@ export function buildVisibleToastLayout( frontmostHeight: number; items: VisibleToastLayoutItem[]; } { - // Ending toasts are excluded from live index/offset calculations so the - // remaining toasts can reflow in parallel with the dismiss animation - // (otherwise the second toast only starts moving forward after the top - // toast's exit animation completes, causing a visible "stop and bump"). + // Two parallel cursors: + // - `full*` advances on every toast, so an ending toast keeps the slot it + // occupied before dismissal and its data-ending-style exit transform + // originates from the correct position (critical for dismissing a + // non-front toast in the expanded stack — otherwise it would snap to + // Y=0 and slide off diagonally). + // - `live*` advances only on non-ending toasts, so live toasts reflow + // past the vacated slot in parallel with the exit animation instead of + // waiting for it to finish (which caused a visible "stop and bump"). + let fullIndex = 0; + let fullOffsetY = 0; let liveIndex = 0; let liveOffsetY = 0; const items: VisibleToastLayoutItem[] = visibleToasts.map( (toast) => { + const height = normalizeToastHeight(toast.height); + if (toast.transitionStatus === "ending") { - // Keep ending toasts at their previous front position so their exit - // animation originates from the correct spot. The data-ending-style - // transform takes over their actual motion. - return { + const item = { toast, - visibleIndex: 0, - offsetY: 0, + visibleIndex: fullIndex, + offsetY: fullOffsetY, }; + fullOffsetY += height; + fullIndex += 1; + return item; } const item = { @@ -58,7 +67,9 @@ export function buildVisibleToastLayout( offsetY: liveOffsetY, }; - liveOffsetY += normalizeToastHeight(toast.height); + fullOffsetY += height; + fullIndex += 1; + liveOffsetY += height; liveIndex += 1; return item; }, From ed7988ae59f4d27594dc542b8c5cc48633bf39ba Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 20 Apr 2026 09:08:11 -0700 Subject: [PATCH 05/12] Fix server bin entrypoint path - Correct the packaged `t3` binary target to point at the built entry file --- apps/server/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/package.json b/apps/server/package.json index 14dbe35bcba..6f2776c3ea8 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -8,7 +8,7 @@ "directory": "apps/server" }, "bin": { - "t3": "./dist/bin.mjs" + "t3": "./dist/bin.mj" }, "files": [ "dist" From 1d2fa3a7d6563b2ca460435b7673b32f9fd62be9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 20 Apr 2026 10:55:43 -0700 Subject: [PATCH 06/12] Add dismiss support to thread-scoped toasts - Add a toast helper for stacked thread toasts - Surface dismiss controls and richer slow-RPC details - Update toast call sites to use the new behavior --- apps/server/package.json | 2 +- .../BranchToolbarBranchSelector.tsx | 26 +- apps/web/src/components/ChatMarkdown.tsx | 38 +- apps/web/src/components/ChatView.tsx | 54 +- apps/web/src/components/CommandPalette.tsx | 62 ++- apps/web/src/components/GitActionsControl.tsx | 98 ++-- apps/web/src/components/PlanSidebar.tsx | 14 +- apps/web/src/components/Sidebar.tsx | 283 +++++----- .../components/WebSocketConnectionSurface.tsx | 74 ++- .../src/components/chat/ProposedPlanCard.tsx | 38 +- .../settings/ConnectionsSettings.tsx | 110 ++-- .../components/settings/SettingsPanels.tsx | 90 ++-- .../components/sidebar/SidebarUpdatePill.tsx | 50 +- apps/web/src/components/ui/toast.tsx | 508 ++++++++++++++---- apps/web/src/components/ui/toastHelpers.ts | 56 ++ apps/web/src/hooks/useThreadActions.ts | 14 +- apps/web/src/routeTree.gen.ts | 21 + apps/web/src/routes/__root.tsx | 70 +-- apps/web/src/routes/dev.toast.tsx | 475 ++++++++++++++++ apps/web/src/rpc/requestLatencyState.ts | 2 +- 20 files changed, 1534 insertions(+), 551 deletions(-) create mode 100644 apps/web/src/components/ui/toastHelpers.ts create mode 100644 apps/web/src/routes/dev.toast.tsx diff --git a/apps/server/package.json b/apps/server/package.json index 6f2776c3ea8..14dbe35bcba 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -8,7 +8,7 @@ "directory": "apps/server" }, "bin": { - "t3": "./dist/bin.mj" + "t3": "./dist/bin.mjs" }, "files": [ "dist" diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 996fca4b391..828e6e122ba 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -42,7 +42,7 @@ import { ComboboxStatus, ComboboxTrigger, } from "./ui/combobox"; -import { toastManager } from "./ui/toast"; +import { stackedThreadToast, toastManager } from "./ui/toast"; interface BranchToolbarBranchSelectorProps { environmentId: EnvironmentId; @@ -351,11 +351,13 @@ export function BranchToolbarBranchSelector({ setThreadBranch(nextBranchName, selectionTarget.nextWorktreePath); } catch (error) { setOptimisticBranch(previousBranch); - toastManager.add({ - type: "error", - title: "Failed to checkout branch.", - description: toBranchActionErrorMessage(error), - }); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to checkout branch.", + description: toBranchActionErrorMessage(error), + }), + ); } }); }; @@ -381,11 +383,13 @@ export function BranchToolbarBranchSelector({ setThreadBranch(createBranchResult.branch, activeWorktreePath); } catch (error) { setOptimisticBranch(previousBranch); - toastManager.add({ - type: "error", - title: "Failed to create and checkout branch.", - description: toBranchActionErrorMessage(error), - }); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to create and checkout branch.", + description: toBranchActionErrorMessage(error), + }), + ); } }); }; diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index ba1c944cc87..4b53f8534ff 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -20,7 +20,7 @@ import { defaultUrlTransform } from "react-markdown"; import remarkGfm from "remark-gfm"; import { VscodeEntryIcon } from "./chat/VscodeEntryIcon"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; -import { toastManager } from "./ui/toast"; +import { stackedThreadToast, toastManager } from "./ui/toast"; import { openInPreferredEditor } from "../editorPreferences"; import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; import { fnv1a32 } from "../lib/diffRendering"; @@ -351,21 +351,25 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ } void openInPreferredEditor(api, targetPath).catch((error) => { - toastManager.add({ - type: "error", - title: "Unable to open file", - description: error instanceof Error ? error.message : "An error occurred.", - }); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to open file", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); }); }, [targetPath]); const handleCopy = useCallback((value: string, title: string) => { if (typeof window === "undefined" || !navigator.clipboard?.writeText) { - toastManager.add({ - type: "error", - title: `Failed to copy ${title.toLowerCase()}`, - description: "Clipboard API unavailable.", - }); + toastManager.add( + stackedThreadToast({ + type: "error", + title: `Failed to copy ${title.toLowerCase()}`, + description: "Clipboard API unavailable.", + }), + ); return; } @@ -378,11 +382,13 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ }); }, (error) => { - toastManager.add({ - type: "error", - title: `Failed to copy ${title.toLowerCase()}`, - description: error instanceof Error ? error.message : "An error occurred.", - }); + toastManager.add( + stackedThreadToast({ + type: "error", + title: `Failed to copy ${title.toLowerCase()}`, + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); }, ); }, []); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 535c0d9fcae..0c76059b6a8 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -101,7 +101,7 @@ import PlanSidebar from "./PlanSidebar"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; import { ChevronDownIcon } from "lucide-react"; import { cn, randomUUID } from "~/lib/utils"; -import { toastManager } from "./ui/toast"; +import { stackedThreadToast, toastManager } from "./ui/toast"; import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; import { type NewProjectScriptInput } from "./ProjectScriptsControl"; import { @@ -1855,11 +1855,13 @@ export default function ChatView(props: ChatViewProps) { title: `Deleted action "${deletedName ?? "Unknown"}"`, }); } catch (error) { - toastManager.add({ - type: "error", - title: "Could not delete action", - description: error instanceof Error ? error.message : "An unexpected error occurred.", - }); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not delete action", + description: error instanceof Error ? error.message : "An unexpected error occurred.", + }), + ); } }, [activeProject, persistProjectScripts], @@ -2426,11 +2428,13 @@ export default function ChatView(props: ChatViewProps) { expiredTerminalContextCount, "empty", ); - toastManager.add({ - type: "warning", - title: toastCopy.title, - description: toastCopy.description, - }); + toastManager.add( + stackedThreadToast({ + type: "warning", + title: toastCopy.title, + description: toastCopy.description, + }), + ); } return; } @@ -2512,11 +2516,13 @@ export default function ChatView(props: ChatViewProps) { expiredTerminalContextCount, "omitted", ); - toastManager.add({ - type: "warning", - title: toastCopy.title, - description: toastCopy.description, - }); + toastManager.add( + stackedThreadToast({ + type: "warning", + title: toastCopy.title, + description: toastCopy.description, + }), + ); } promptRef.current = ""; clearComposerDraftContent(composerDraftTarget); @@ -3082,12 +3088,16 @@ export default function ChatView(props: ChatViewProps) { threadId: nextThreadId, }) .catch(() => undefined); - toastManager.add({ - type: "error", - title: "Could not start implementation thread", - description: - err instanceof Error ? err.message : "An error occurred while creating the new thread.", - }); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not start implementation thread", + description: + err instanceof Error + ? err.message + : "An error occurred while creating the new thread.", + }), + ); }) .then(finish, finish); }, [ diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 929a9f87e9c..2b587afc510 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -102,7 +102,7 @@ import { } from "./ui/command"; import { Button } from "./ui/button"; import { Kbd, KbdGroup } from "./ui/kbd"; -import { toastManager } from "./ui/toast"; +import { stackedThreadToast, toastManager } from "./ui/toast"; import { ComposerHandleContext, useComposerHandleContext } from "../composerHandleContext"; import type { ChatComposerHandle } from "./chat/ChatComposer"; @@ -605,11 +605,13 @@ function OpenCommandPaletteDialog() { const environmentId = defaultAddProjectEnvironmentId; if (!environmentId) { - toastManager.add({ - type: "error", - title: "Unable to browse projects", - description: "No environment is available.", - }); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to browse projects", + description: "No environment is available.", + }), + ); return; } @@ -724,20 +726,24 @@ function OpenCommandPaletteDialog() { if (!api) return; if (isUnsupportedWindowsProjectPath(rawCwd.trim(), browseEnvironmentPlatform)) { - toastManager.add({ - type: "error", - title: "Failed to add project", - description: "Windows-style paths are only supported on Windows.", - }); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to add project", + description: "Windows-style paths are only supported on Windows.", + }), + ); return; } if (isExplicitRelativeProjectPath(rawCwd.trim()) && !currentProjectCwdForBrowse) { - toastManager.add({ - type: "error", - title: "Failed to add project", - description: "Relative paths require an active project.", - }); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to add project", + description: "Relative paths require an active project.", + }), + ); return; } @@ -790,11 +796,13 @@ function OpenCommandPaletteDialog() { }).catch(() => undefined); setOpen(false); } catch (error) { - toastManager.add({ - type: "error", - title: "Failed to add project", - description: error instanceof Error ? error.message : "An error occurred.", - }); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to add project", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); } }, [ @@ -934,11 +942,13 @@ function OpenCommandPaletteDialog() { } void item.run().catch((error: unknown) => { - toastManager.add({ - type: "error", - title: "Unable to run command", - description: error instanceof Error ? error.message : "An unexpected error occurred.", - }); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to run command", + description: error instanceof Error ? error.message : "An unexpected error occurred.", + }), + ); }); } diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 6d2312e4aac..091007590be 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -38,7 +38,7 @@ import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu"; import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; import { ScrollArea } from "~/components/ui/scroll-area"; import { Textarea } from "~/components/ui/textarea"; -import { toastManager, type ThreadToastData } from "~/components/ui/toast"; +import { stackedThreadToast, toastManager, type ThreadToastData } from "~/components/ui/toast"; import { openInPreferredEditor } from "~/editorPreferences"; import { gitInitMutationOptions, @@ -474,12 +474,14 @@ export default function GitActionsControl({ return; } void api.shell.openExternal(prUrl).catch((err: unknown) => { - toastManager.add({ - type: "error", - title: "Unable to open PR link", - description: err instanceof Error ? err.message : "An error occurred.", - data: threadToastData, - }); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to open PR link", + description: err instanceof Error ? err.message : "An error occurred.", + ...(threadToastData !== undefined ? { data: threadToastData } : {}), + }), + ); }); }, [gitStatusForActions, threadToastData]); @@ -670,33 +672,44 @@ export default function GitActionsControl({ }; } - const successToastBase = { - type: "success", - title: result.toast.title, - description: result.toast.description, - timeout: 0, - data: { - ...scopedToastData, - dismissAfterVisibleMs: 10_000, - }, - } as const; + const successToastData = { + ...scopedToastData, + dismissAfterVisibleMs: 10_000, + }; if (toastActionProps) { + toastManager.update( + resolvedProgressToastId, + stackedThreadToast({ + type: "success", + title: result.toast.title, + description: result.toast.description, + timeout: 0, + actionProps: toastActionProps, + actionVariant: "outline", + data: successToastData, + }), + ); + } else { toastManager.update(resolvedProgressToastId, { - ...successToastBase, - actionProps: toastActionProps, + type: "success", + title: result.toast.title, + description: result.toast.description, + timeout: 0, + data: successToastData, }); - } else { - toastManager.update(resolvedProgressToastId, successToastBase); } } catch (err) { activeGitActionProgressRef.current = null; - toastManager.update(resolvedProgressToastId, { - type: "error", - title: "Action failed", - description: err instanceof Error ? err.message : "An error occurred.", - data: scopedToastData, - }); + toastManager.update( + resolvedProgressToastId, + stackedThreadToast({ + type: "error", + title: "Action failed", + description: err instanceof Error ? err.message : "An error occurred.", + ...(scopedToastData !== undefined ? { data: scopedToastData } : {}), + }), + ); } }, ); @@ -753,7 +766,10 @@ export default function GitActionsControl({ } if (quickAction.kind === "run_pull") { const promise = pullMutation.mutateAsync(); - toastManager.promise(promise, { + void toastManager.promise< + Awaited>, + ThreadToastData + >(promise, { loading: { title: "Pulling...", data: threadToastData }, success: (result) => ({ title: result.status === "pulled" ? "Pulled" : "Already up to date", @@ -763,11 +779,13 @@ export default function GitActionsControl({ : `${result.branch} is already synchronized.`, data: threadToastData, }), - error: (err) => ({ - title: "Pull failed", - description: err instanceof Error ? err.message : "An error occurred.", - data: threadToastData, - }), + error: (err) => + stackedThreadToast({ + type: "error", + title: "Pull failed", + description: err instanceof Error ? err.message : "An error occurred.", + ...(threadToastData !== undefined ? { data: threadToastData } : {}), + }), }); void promise.catch(() => undefined); return; @@ -832,12 +850,14 @@ export default function GitActionsControl({ } const target = resolvePathLinkTarget(filePath, gitCwd); void openInPreferredEditor(api, target).catch((error) => { - toastManager.add({ - type: "error", - title: "Unable to open file", - description: error instanceof Error ? error.message : "An error occurred.", - data: threadToastData, - }); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to open file", + description: error instanceof Error ? error.message : "An error occurred.", + ...(threadToastData !== undefined ? { data: threadToastData } : {}), + }), + ); }); }, [gitCwd, threadToastData], diff --git a/apps/web/src/components/PlanSidebar.tsx b/apps/web/src/components/PlanSidebar.tsx index 00b9da2b0c8..afd4bb2e0bc 100644 --- a/apps/web/src/components/PlanSidebar.tsx +++ b/apps/web/src/components/PlanSidebar.tsx @@ -26,7 +26,7 @@ import { } from "../proposedPlan"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; import { readEnvironmentApi } from "~/environmentApi"; -import { toastManager } from "./ui/toast"; +import { stackedThreadToast, toastManager } from "./ui/toast"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; function stepStatusIcon(status: string): React.ReactNode { @@ -112,11 +112,13 @@ const PlanSidebar = memo(function PlanSidebar({ }); }) .catch((error) => { - toastManager.add({ - type: "error", - title: "Could not save plan", - description: error instanceof Error ? error.message : "An error occurred.", - }); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not save plan", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); }) .then( () => setIsSavingToWorkspace(false), diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index d25f930c0d5..5d778eec36e 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -94,7 +94,7 @@ import { resolveThreadRouteRef, resolveThreadRouteTarget, } from "../threadRoutes"; -import { toastManager } from "./ui/toast"; +import { stackedThreadToast, toastManager } from "./ui/toast"; import { formatRelativeTimeLabel } from "../timestampFormat"; import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; import { Kbd } from "./ui/kbd"; @@ -950,11 +950,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec }); }, onError: (error) => { - toastManager.add({ - type: "error", - title: "Failed to copy thread ID", - description: error instanceof Error ? error.message : "An error occurred.", - }); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to copy thread ID", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); }, }); const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ @@ -968,11 +970,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec }); }, onError: (error) => { - toastManager.add({ - type: "error", - title: "Failed to copy path", - description: error instanceof Error ? error.message : "An error occurred.", - }); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to copy path", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); }, }); const openPrLink = useCallback((event: React.MouseEvent, prUrl: string) => { @@ -989,11 +993,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec } void api.shell.openExternal(prUrl).catch((error) => { - toastManager.add({ - type: "error", - title: "Unable to open PR link", - description: error instanceof Error ? error.message : "An error occurred.", - }); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to open PR link", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); }); }, []); const sidebarThreads = useStore( @@ -1299,72 +1305,73 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const memberProjectRef = scopeProjectRef(member.environmentId, member.id); const memberThreadCount = memberThreadCountByPhysicalKey.get(member.physicalProjectKey) ?? 0; if (memberThreadCount > 0) { - const warningToastId = toastManager.add({ - type: "warning", - title: "Project is not empty", - description: "Delete all threads in this project before removing it.", - data: { - actionLayout: "stacked-end", + const warningToastId = toastManager.add( + stackedThreadToast({ + type: "warning", + title: "Project is not empty", + description: "Delete all threads in this project before removing it.", actionVariant: "destructive", - }, - actionProps: { - children: "Delete anyway", - onClick: () => { - void (async () => { - toastManager.close(warningToastId); - await new Promise((resolve) => { - window.setTimeout(resolve, 180); - }); - - const latestProjectThreads = selectSidebarThreadsForProjectRefs( - useStore.getState(), - [memberProjectRef], - ); - const confirmed = await api.dialogs.confirm( - latestProjectThreads.length > 0 - ? [ - `Remove project "${member.name}" and delete its ${latestProjectThreads.length} thread${ - latestProjectThreads.length === 1 ? "" : "s" - }?`, - `Path: ${member.cwd}`, - ...(member.environmentLabel - ? [`Environment: ${member.environmentLabel}`] - : []), - "This permanently clears conversation history for those threads.", - "This removes only this project entry.", - "This action cannot be undone.", - ].join("\n") - : [ - `Remove project "${member.name}"?`, - `Path: ${member.cwd}`, - ...(member.environmentLabel - ? [`Environment: ${member.environmentLabel}`] - : []), - "This removes only this project entry.", - ].join("\n"), - ); - if (!confirmed) { - return; - } + actionProps: { + children: "Delete anyway", + onClick: () => { + void (async () => { + toastManager.close(warningToastId); + await new Promise((resolve) => { + window.setTimeout(resolve, 180); + }); + + const latestProjectThreads = selectSidebarThreadsForProjectRefs( + useStore.getState(), + [memberProjectRef], + ); + const confirmed = await api.dialogs.confirm( + latestProjectThreads.length > 0 + ? [ + `Remove project "${member.name}" and delete its ${latestProjectThreads.length} thread${ + latestProjectThreads.length === 1 ? "" : "s" + }?`, + `Path: ${member.cwd}`, + ...(member.environmentLabel + ? [`Environment: ${member.environmentLabel}`] + : []), + "This permanently clears conversation history for those threads.", + "This removes only this project entry.", + "This action cannot be undone.", + ].join("\n") + : [ + `Remove project "${member.name}"?`, + `Path: ${member.cwd}`, + ...(member.environmentLabel + ? [`Environment: ${member.environmentLabel}`] + : []), + "This removes only this project entry.", + ].join("\n"), + ); + if (!confirmed) { + return; + } - await removeProject(member, { force: true }); - })().catch((error) => { - const message = - error instanceof Error ? error.message : "Unknown error removing project."; - console.error("Failed to remove project", { - projectId: member.id, - environmentId: member.environmentId, - error, - }); - toastManager.add({ - type: "error", - title: `Failed to remove "${member.name}"`, - description: message, + await removeProject(member, { force: true }); + })().catch((error) => { + const message = + error instanceof Error ? error.message : "Unknown error removing project."; + console.error("Failed to remove project", { + projectId: member.id, + environmentId: member.environmentId, + error, + }); + toastManager.add( + stackedThreadToast({ + type: "error", + title: `Failed to remove "${member.name}"`, + description: message, + }), + ); }); - }); + }, }, - }, - }); + }), + ); return; } @@ -1388,11 +1395,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec environmentId: member.environmentId, error, }); - toastManager.add({ - type: "error", - title: `Failed to remove "${member.name}"`, - description: message, - }); + toastManager.add( + stackedThreadToast({ + type: "error", + title: `Failed to remove "${member.name}"`, + description: message, + }), + ); } }, [memberThreadCountByPhysicalKey, removeProject], @@ -1705,11 +1714,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec try { await archiveThread(threadRef); } catch (error) { - toastManager.add({ - type: "error", - title: "Failed to archive thread", - description: error instanceof Error ? error.message : "An error occurred.", - }); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to archive thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); } }, [archiveThread], @@ -1757,11 +1768,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec title: trimmed, }); } catch (error) { - toastManager.add({ - type: "error", - title: "Failed to rename thread", - description: error instanceof Error ? error.message : "An error occurred.", - }); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to rename thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); } finishRename(); }, @@ -1794,11 +1807,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const api = readEnvironmentApi(projectRenameTarget.environmentId); if (!api) { - toastManager.add({ - type: "error", - title: "Failed to rename project", - description: "Project API unavailable.", - }); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to rename project", + description: "Project API unavailable.", + }), + ); return; } @@ -1811,11 +1826,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec }); closeProjectRenameDialog(); } catch (error) { - toastManager.add({ - type: "error", - title: "Failed to rename project", - description: error instanceof Error ? error.message : "An error occurred.", - }); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to rename project", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); } }, [closeProjectRenameDialog, projectRenameTarget, projectRenameTitle]); @@ -1885,11 +1902,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec } if (clicked === "copy-path") { if (!threadWorkspacePath) { - toastManager.add({ - type: "error", - title: "Path unavailable", - description: "This thread does not have a workspace path to copy.", - }); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Path unavailable", + description: "This thread does not have a workspace path to copy.", + }), + ); return; } copyPathToClipboard(threadWorkspacePath, { path: threadWorkspacePath }); @@ -3220,18 +3239,22 @@ export default function Sidebar() { if (!shouldToastDesktopUpdateActionResult(result)) return; const actionError = getDesktopUpdateActionError(result); if (!actionError) return; - toastManager.add({ - type: "error", - title: "Could not download update", - description: actionError, - }); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not download update", + description: actionError, + }), + ); }) .catch((error) => { - toastManager.add({ - type: "error", - title: "Could not start update download", - description: error instanceof Error ? error.message : "An unexpected error occurred.", - }); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not start update download", + description: error instanceof Error ? error.message : "An unexpected error occurred.", + }), + ); }); return; } @@ -3247,18 +3270,22 @@ export default function Sidebar() { if (!shouldToastDesktopUpdateActionResult(result)) return; const actionError = getDesktopUpdateActionError(result); if (!actionError) return; - toastManager.add({ - type: "error", - title: "Could not install update", - description: actionError, - }); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not install update", + description: actionError, + }), + ); }) .catch((error) => { - toastManager.add({ - type: "error", - title: "Could not install update", - description: error instanceof Error ? error.message : "An unexpected error occurred.", - }); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not install update", + description: error instanceof Error ? error.message : "An unexpected error occurred.", + }), + ); }); } }, [desktopUpdateButtonAction, desktopUpdateButtonDisabled, desktopUpdateState]); diff --git a/apps/web/src/components/WebSocketConnectionSurface.tsx b/apps/web/src/components/WebSocketConnectionSurface.tsx index 7b350e98496..6e656143e19 100644 --- a/apps/web/src/components/WebSocketConnectionSurface.tsx +++ b/apps/web/src/components/WebSocketConnectionSurface.tsx @@ -10,7 +10,7 @@ import { useWsConnectionStatus, WS_RECONNECT_MAX_ATTEMPTS, } from "../rpc/wsConnectionState"; -import { toastManager } from "./ui/toast"; +import { stackedThreadToast, toastManager } from "./ui/toast"; import { getPrimaryEnvironmentConnection } from "../environments/runtime"; const FORCED_WS_RECONNECT_DEBOUNCE_MS = 5_000; @@ -75,13 +75,34 @@ function describeRecoveredToast( return "Connection restored."; } -function describeSlowRpcAckToast(requests: ReadonlyArray): ReactNode { +function describeSlowRpcAckToast(requests: ReadonlyArray): string { const count = requests.length; const thresholdSeconds = Math.round((requests[0]?.thresholdMs ?? 0) / 1000); return `${count} request${count === 1 ? "" : "s"} waiting longer than ${thresholdSeconds}s.`; } +function SlowRpcAckRequestDetails({ requests }: { requests: ReadonlyArray }) { + return ( +
    + {requests.map((req) => ( +
  • +
    {req.tag}
    +
    + {req.requestId} +
    +
    + Started {formatConnectionMoment(req.startedAt) ?? req.startedAt} +
    +
  • + ))} +
+ ); +} + export function shouldAutoReconnect( status: WsConnectionStatus, trigger: WsAutoReconnectTrigger, @@ -138,15 +159,17 @@ export function WebSocketConnectionCoordinator() { console.warn("Automatic WebSocket reconnect failed", { error }); return; } - toastManager.add({ - type: "error", - title: "Reconnect failed", - description: error instanceof Error ? error.message : "Unable to restart the WebSocket.", - data: { - dismissAfterVisibleMs: 8_000, - hideCopyButton: true, - }, - }); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Reconnect failed", + description: + error instanceof Error ? error.message : "Unable to restart the WebSocket.", + data: { + dismissAfterVisibleMs: 8_000, + }, + }), + ); }); }); const syncBrowserOnlineStatus = useEffectEvent(() => { @@ -253,17 +276,14 @@ export function WebSocketConnectionCoordinator() { if (shouldShowReconnectToast || shouldShowOfflineToast || shouldShowExhaustedToast) { const toastPayload = shouldShowOfflineToast - ? { + ? stackedThreadToast({ description: describeOfflineToast(), timeout: 0, title: "Offline", - type: "warning" as const, - data: { - hideCopyButton: true, - }, - } + type: "warning", + }) : shouldShowExhaustedToast - ? { + ? stackedThreadToast({ actionProps: { children: "Retry", onClick: triggerManualReconnect, @@ -271,16 +291,16 @@ export function WebSocketConnectionCoordinator() { description: describeExhaustedToast(), timeout: 0, title: "Disconnected from T3 Server", - type: "error" as const, - data: { - hideCopyButton: true, - }, - } + type: "error", + }) : { actionProps: { children: "Retry now", onClick: triggerManualReconnect, }, + data: { + hideCopyButton: true, + }, description: status.nextRetryAt === null ? `Reconnecting... ${formatReconnectAttemptLabel(status)}` @@ -288,9 +308,6 @@ export function WebSocketConnectionCoordinator() { timeout: 0, title: buildReconnectTitle(status), type: "loading" as const, - data: { - hideCopyButton: true, - }, }; if (toastIdRef.current) { @@ -369,6 +386,11 @@ export function SlowRpcAckToastCoordinator() { } const nextToast = { + data: { + expandableContent: , + expandableDescriptionTrigger: true, + expandableLabels: { collapse: "Hide requests", expand: "Show requests" }, + }, description: describeSlowRpcAckToast(slowRequests), timeout: 0, title: "Some requests are slow", diff --git a/apps/web/src/components/chat/ProposedPlanCard.tsx b/apps/web/src/components/chat/ProposedPlanCard.tsx index a36cb097cbe..e53ee93b913 100644 --- a/apps/web/src/components/chat/ProposedPlanCard.tsx +++ b/apps/web/src/components/chat/ProposedPlanCard.tsx @@ -24,7 +24,7 @@ import { DialogPopup, DialogTitle, } from "../ui/dialog"; -import { toastManager } from "../ui/toast"; +import { stackedThreadToast, toastManager } from "../ui/toast"; import { readEnvironmentApi } from "~/environmentApi"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; @@ -45,11 +45,13 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({ const [isSavingToWorkspace, setIsSavingToWorkspace] = useState(false); const { copyToClipboard, isCopied } = useCopyToClipboard({ onError: (error) => { - toastManager.add({ - type: "error", - title: "Could not copy plan", - description: error instanceof Error ? error.message : "An error occurred while copying.", - }); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not copy plan", + description: error instanceof Error ? error.message : "An error occurred while copying.", + }), + ); }, }); const savePathInputId = useId(); @@ -73,11 +75,13 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({ const openSaveDialog = () => { if (!workspaceRoot) { - toastManager.add({ - type: "error", - title: "Workspace path is unavailable", - description: "This thread does not have a workspace path to save into.", - }); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Workspace path is unavailable", + description: "This thread does not have a workspace path to save into.", + }), + ); return; } setSavePath((existing) => (existing.length > 0 ? existing : downloadFilename)); @@ -114,11 +118,13 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({ }); }) .catch((error) => { - toastManager.add({ - type: "error", - title: "Could not save plan", - description: error instanceof Error ? error.message : "An error occurred while saving.", - }); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not save plan", + description: error instanceof Error ? error.message : "An error occurred while saving.", + }), + ); }) .then( () => { diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index ab31fe7e173..880c4376e2c 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -41,7 +41,7 @@ import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { QRCodeSvg } from "../ui/qr-code"; import { Spinner } from "../ui/spinner"; import { Switch } from "../ui/switch"; -import { toastManager } from "../ui/toast"; +import { stackedThreadToast, toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { Button } from "../ui/button"; import { Textarea } from "../ui/textarea"; @@ -302,11 +302,13 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ }, onError: (error) => { setIsRevealDialogOpen(true); - toastManager.add({ - type: "error", - title: canCopyToClipboard ? "Could not copy pairing URL" : "Clipboard copy unavailable", - description: canCopyToClipboard ? error.message : "Showing the full value instead.", - }); + toastManager.add( + stackedThreadToast({ + type: "error", + title: canCopyToClipboard ? "Could not copy pairing URL" : "Clipboard copy unavailable", + description: canCopyToClipboard ? error.message : "Showing the full value instead.", + }), + ); }, }); @@ -535,11 +537,13 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio setDialogOpen(false); } catch (error) { const message = error instanceof Error ? error.message : "Failed to create pairing URL."; - toastManager.add({ - type: "error", - title: "Could not create pairing URL", - description: message, - }); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not create pairing URL", + description: message, + }), + ); } finally { setIsCreatingPairingLink(false); } @@ -828,11 +832,13 @@ export function ConnectionsSettings() { error instanceof Error ? error.message : "Failed to update network exposure."; setPendingDesktopServerExposureMode(null); setDesktopServerExposureError(message); - toastManager.add({ - type: "error", - title: "Could not update network access", - description: message, - }); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not update network access", + description: message, + }), + ); setIsUpdatingDesktopServerExposure(false); } }, @@ -853,11 +859,13 @@ export function ConnectionsSettings() { } catch (error) { const message = error instanceof Error ? error.message : "Failed to revoke pairing link."; setDesktopAccessManagementError(message); - toastManager.add({ - type: "error", - title: "Could not revoke pairing link", - description: message, - }); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not revoke pairing link", + description: message, + }), + ); } finally { setRevokingDesktopPairingLinkId(null); } @@ -872,11 +880,13 @@ export function ConnectionsSettings() { } catch (error) { const message = error instanceof Error ? error.message : "Failed to revoke client access."; setDesktopAccessManagementError(message); - toastManager.add({ - type: "error", - title: "Could not revoke client access", - description: message, - }); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not revoke client access", + description: message, + }), + ); } finally { setRevokingDesktopClientSessionId(null); } @@ -897,11 +907,13 @@ export function ConnectionsSettings() { } catch (error) { const message = error instanceof Error ? error.message : "Failed to revoke other clients."; setDesktopAccessManagementError(message); - toastManager.add({ - type: "error", - title: "Could not revoke other clients", - description: message, - }); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not revoke other clients", + description: message, + }), + ); } finally { setIsRevokingOtherDesktopClients(false); } @@ -933,11 +945,13 @@ export function ConnectionsSettings() { } catch (error) { const message = error instanceof Error ? error.message : "Failed to add backend."; setSavedBackendError(message); - toastManager.add({ - type: "error", - title: "Could not add backend", - description: message, - }); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not add backend", + description: message, + }), + ); } finally { setIsAddingSavedBackend(false); } @@ -957,11 +971,13 @@ export function ConnectionsSettings() { } catch (error) { const message = error instanceof Error ? error.message : "Failed to reconnect backend."; setSavedBackendError(message); - toastManager.add({ - type: "error", - title: "Could not reconnect backend", - description: message, - }); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not reconnect backend", + description: message, + }), + ); } finally { setReconnectingSavedEnvironmentId(null); } @@ -975,11 +991,13 @@ export function ConnectionsSettings() { } catch (error) { const message = error instanceof Error ? error.message : "Failed to remove backend."; setSavedBackendError(message); - toastManager.add({ - type: "error", - title: "Could not remove backend", - description: message, - }); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not remove backend", + description: message, + }), + ); } finally { setRemovingSavedEnvironmentId(null); } diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 230b0a9965d..b1e7cf81f6f 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -63,7 +63,7 @@ import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from ".. import { Input } from "../ui/input"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { Switch } from "../ui/switch"; -import { toastManager } from "../ui/toast"; +import { stackedThreadToast, toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { SettingResetButton, @@ -282,11 +282,13 @@ function AboutVersionSection() { setDesktopUpdateStateQueryData(queryClient, state); }) .catch((error: unknown) => { - toastManager.add({ - type: "error", - title: "Could not change update track", - description: error instanceof Error ? error.message : "Update track change failed.", - }); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not change update track", + description: error instanceof Error ? error.message : "Update track change failed.", + }), + ); }) .finally(() => { setIsChangingUpdateChannel(false); @@ -308,11 +310,13 @@ function AboutVersionSection() { setDesktopUpdateStateQueryData(queryClient, result.state); }) .catch((error: unknown) => { - toastManager.add({ - type: "error", - title: "Could not download update", - description: error instanceof Error ? error.message : "Download failed.", - }); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not download update", + description: error instanceof Error ? error.message : "Download failed.", + }), + ); }); return; } @@ -330,11 +334,13 @@ function AboutVersionSection() { setDesktopUpdateStateQueryData(queryClient, result.state); }) .catch((error: unknown) => { - toastManager.add({ - type: "error", - title: "Could not install update", - description: error instanceof Error ? error.message : "Install failed.", - }); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not install update", + description: error instanceof Error ? error.message : "Install failed.", + }), + ); }); return; } @@ -345,20 +351,24 @@ function AboutVersionSection() { .then((result) => { setDesktopUpdateStateQueryData(queryClient, result.state); if (!result.checked) { - toastManager.add({ - type: "error", - title: "Could not check for updates", - description: - result.state.message ?? "Automatic updates are not available in this build.", - }); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not check for updates", + description: + result.state.message ?? "Automatic updates are not available in this build.", + }), + ); } }) .catch((error: unknown) => { - toastManager.add({ - type: "error", - title: "Could not check for updates", - description: error instanceof Error ? error.message : "Update check failed.", - }); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not check for updates", + description: error instanceof Error ? error.message : "Update check failed.", + }), + ); }); }, [queryClient, updateState]); @@ -1688,11 +1698,13 @@ export function ArchivedThreadsPanel() { try { await unarchiveThread(threadRef); } catch (error) { - toastManager.add({ - type: "error", - title: "Failed to unarchive thread", - description: error instanceof Error ? error.message : "An error occurred.", - }); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to unarchive thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); } return; } @@ -1756,12 +1768,14 @@ export function ArchivedThreadsPanel() { onClick={() => void unarchiveThread(scopeThreadRef(thread.environmentId, thread.id)).catch( (error) => { - toastManager.add({ - type: "error", - title: "Failed to unarchive thread", - description: - error instanceof Error ? error.message : "An error occurred.", - }); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to unarchive thread", + description: + error instanceof Error ? error.message : "An error occurred.", + }), + ); }, ) } diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx index 2f9aec112a9..d7e5b74d42d 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx @@ -6,7 +6,7 @@ import { setDesktopUpdateStateQueryData, useDesktopUpdateState, } from "../../lib/desktopUpdateReactQuery"; -import { toastManager } from "../ui/toast"; +import { stackedThreadToast, toastManager } from "../ui/toast"; import { getArm64IntelBuildWarningDescription, getDesktopUpdateActionError, @@ -55,18 +55,22 @@ export function SidebarUpdatePill() { if (!shouldToastDesktopUpdateActionResult(result)) return; const actionError = getDesktopUpdateActionError(result); if (!actionError) return; - toastManager.add({ - type: "error", - title: "Could not download update", - description: actionError, - }); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not download update", + description: actionError, + }), + ); }) .catch((error) => { - toastManager.add({ - type: "error", - title: "Could not start update download", - description: error instanceof Error ? error.message : "An unexpected error occurred.", - }); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not start update download", + description: error instanceof Error ? error.message : "An unexpected error occurred.", + }), + ); }); return; } @@ -81,18 +85,22 @@ export function SidebarUpdatePill() { if (!shouldToastDesktopUpdateActionResult(result)) return; const actionError = getDesktopUpdateActionError(result); if (!actionError) return; - toastManager.add({ - type: "error", - title: "Could not install update", - description: actionError, - }); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not install update", + description: actionError, + }), + ); }) .catch((error) => { - toastManager.add({ - type: "error", - title: "Could not install update", - description: error instanceof Error ? error.message : "An unexpected error occurred.", - }); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not install update", + description: error instanceof Error ? error.message : "An unexpected error occurred.", + }), + ); }); } }, [action, disabled, queryClient, state]); diff --git a/apps/web/src/components/ui/toast.tsx b/apps/web/src/components/ui/toast.tsx index 1ff4d5590ff..bb39b3ca4c2 100644 --- a/apps/web/src/components/ui/toast.tsx +++ b/apps/web/src/components/ui/toast.tsx @@ -1,11 +1,20 @@ "use client"; import { Toast } from "@base-ui/react/toast"; -import { useEffect, useMemo, type CSSProperties } from "react"; +import { + useEffect, + useMemo, + useState, + type CSSProperties, + type KeyboardEvent, + type ReactNode, +} from "react"; import { useParams } from "@tanstack/react-router"; import { type ScopedThreadRef, type ThreadId } from "@t3tools/contracts"; import { CheckIcon, + ChevronDownIcon, + ChevronUpIcon, CircleAlertIcon, CircleCheckIcon, CopyIcon, @@ -32,6 +41,11 @@ export type ThreadToastData = { tooltipStyle?: boolean; dismissAfterVisibleMs?: number; hideCopyButton?: boolean; + /** Optional extra body shown after toggling “Show details” (e.g. a list of pending RPCs). */ + expandableContent?: ReactNode; + expandableLabels?: { expand?: string; collapse?: string }; + /** When set with `expandableContent`, the summary + label act as one text disclosure (no separate chevron row). */ + expandableDescriptionTrigger?: boolean; actionLayout?: "inline" | "stacked-end"; actionVariant?: | "default" @@ -56,25 +70,162 @@ const TOAST_ICONS = { warning: TriangleAlertIcon, } as const; +/** Visually shorten long error bodies; clipboard copy still uses the full `description` string. */ +const ERROR_DESCRIPTION_CLAMP_MIN_CHARS = 180; +function errorDescriptionClampClass(type: unknown, description: unknown): string | undefined { + if (type !== "error" || typeof description !== "string") { + return undefined; + } + if (description.length < ERROR_DESCRIPTION_CLAMP_MIN_CHARS) { + return undefined; + } + return "line-clamp-4"; +} + +/** Dismiss-only: circular control overlapping the card corner (iOS notification–style). */ +const toastCornerDismissClass = "absolute z-20 -top-1.5 -right-1.5"; +const toastCornerOrbClass = cn( + "inline-flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-full border border-border/60 bg-popover/92 text-muted-foreground shadow-sm outline-none backdrop-blur-sm", + "transition-[color,background-color,box-shadow] hover:bg-popover hover:text-foreground", + "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background", +); + function CopyErrorButton({ text }: { text: string }) { const { copyToClipboard, isCopied } = useCopyToClipboard(); return ( ); } +/** Scrollable cap for long expandable lists (~10rem); keeps the toast from growing without bound. */ +const toastExpandablePanelClassName = + "mt-2 max-h-40 min-h-0 overflow-y-auto overscroll-contain pr-0.5 select-text"; + +function ToastExpandableSection({ + children, + labels, +}: { + children: ReactNode; + labels: { expand?: string; collapse?: string }; +}) { + const [open, setOpen] = useState(false); + const expandLabel = labels.expand ?? "Show details"; + const collapseLabel = labels.collapse ?? "Hide details"; + + return ( +
+ + {open ?
{children}
: null} +
+ ); +} + +function ToastDescriptionAndExpandable({ + toastData, + toastDescription, + toastType, +}: { + toastData: ThreadToastData | undefined; + toastDescription: unknown; + toastType: unknown; +}) { + const expandableContent = toastData?.expandableContent; + const labels = toastData?.expandableLabels ?? {}; + const descriptionTrigger = toastData?.expandableDescriptionTrigger ?? false; + const descriptionClassName = cn( + "min-w-0 select-text wrap-break-word text-muted-foreground", + errorDescriptionClampClass(toastType, toastDescription), + ); + const [open, setOpen] = useState(false); + + if (!expandableContent) { + return ; + } + + if (!descriptionTrigger) { + return ( + <> + + {expandableContent} + + ); + } + + const expandLabel = labels.expand ?? "Show details"; + const collapseLabel = labels.collapse ?? "Hide details"; + + const toggle = () => setOpen((v) => !v); + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + toggle(); + } + }; + + return ( + <> +
+
+ +
+ {open ? ( + + ) : ( + + )} +
+ {open ?
{expandableContent}
: null} + + ); +} + type ToastPosition = | "top-left" | "top-center" @@ -245,11 +396,19 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) { const stackedActionLayout = toast.actionProps !== undefined && toast.data?.actionLayout === "stacked-end"; const actionVariant = toast.data?.actionVariant ?? "default"; + const copyErrorText = + toast.type === "error" && + typeof toast.description === "string" && + !toast.data?.hideCopyButton + ? toast.description + : null; + const hasTrailingControls = copyErrorText !== null || toast.actionProps !== undefined; + const inlineContentEndPad = hasTrailingControls ? "pr-6" : "pr-10"; return ( 0 + ? "not-data-expanded:[--toast-calc-height:var(--toast-frontmost-height)] data-expanded:[--toast-calc-height:max(var(--toast-frontmost-height,var(--toast-height)),var(--toast-height))]" + : "[--toast-calc-height:max(var(--toast-frontmost-height,var(--toast-height)),var(--toast-height))]", + "[--toast-gap:--spacing(3)] [--toast-peek:--spacing(3)] [--toast-scale:calc(max(0,1-(var(--toast-index)*.1)))] [--toast-shrink:calc(1-var(--toast-scale))]", + // Root height: never `min-h-(--toast-height)` — Base UI measures height by briefly forcing + // `height: auto` on this node; an old `min-height` from `--toast-height` blocks shrinking, + // so `recalculateHeight` keeps the inflated value after an expandable closes. + // Behind + collapsed: fixed peek. Otherwise natural height (expand/collapse, hover stack). + visibleIndex > 0 + ? "not-data-expanded:h-(--toast-calc-height) data-expanded:h-auto" + : "h-auto", // Define offset-y variable "data-[position*=top]:[--toast-calc-offset-y:calc(var(--toast-offset-y)+var(--toast-index)*var(--toast-gap)+var(--toast-swipe-movement-y))]", "data-[position*=bottom]:[--toast-calc-offset-y:calc(var(--toast-offset-y)*-1+var(--toast-index)*var(--toast-gap)*-1+var(--toast-swipe-movement-y))]", @@ -273,8 +440,7 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) { "data-[position*=bottom]:transform-[translateX(var(--toast-swipe-movement-x))_translateY(calc(var(--toast-swipe-movement-y)-(var(--toast-index)*var(--toast-peek))-(var(--toast-shrink)*var(--toast-calc-height))))_scale(var(--toast-scale))]", // Limited state "data-limited:opacity-0", - // Expanded state - "data-expanded:h-(--toast-height)", + // Expanded stack "data-position:data-expanded:transform-[translateX(var(--toast-swipe-movement-x))_translateY(var(--toast-calc-offset-y))]", // Starting and ending animations "data-[position*=top]:data-starting-style:transform-[translateY(calc(-100%-var(--toast-inset)))]", @@ -315,65 +481,111 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) { dismissAfterVisibleMs={toast.data?.dismissAfterVisibleMs} toastId={toast.id} /> - +
+ +
-
- {Icon && ( -
- + {stackedActionLayout ? ( + <> +
+ {Icon && ( +
+ +
+ )} + +
+ + +
- )} - -
-
- - {toast.type === "error" && - typeof toast.description === "string" && - !toast.data?.hideCopyButton && } + {hasTrailingControls ? ( +
+ {copyErrorText !== null ? : null} + {toast.actionProps ? ( + + {toast.actionProps.children} + + ) : null} +
+ ) : null} + + ) : ( + <> +
+ {Icon && ( +
+ +
+ )} + +
+ + +
- -
-
- {toast.actionProps && ( - - {toast.actionProps.children} - + {hasTrailingControls ? ( +
+ {copyErrorText !== null ? : null} + {toast.actionProps ? ( + + {toast.actionProps.children} + + ) : null} +
+ ) : null} + )} @@ -409,6 +621,14 @@ function AnchoredToasts() { const stackedActionLayout = toast.actionProps !== undefined && toast.data?.actionLayout === "stacked-end"; const actionVariant = toast.data?.actionVariant ?? "default"; + const copyErrorText = + toast.type === "error" && + typeof toast.description === "string" && + !toast.data?.hideCopyButton + ? toast.description + : null; + const hasTrailingControls = copyErrorText !== null || toast.actionProps !== undefined; + const inlineContentEndPad = hasTrailingControls ? "pr-6" : "pr-10"; if (!positionerProps?.anchor) { return null; @@ -424,7 +644,7 @@ function AnchoredToasts() { > ) : ( <> - +
+ +
-
- {Icon && ( -
- + {stackedActionLayout ? ( + <> +
+ {Icon && ( +
+ +
+ )} + +
+ + +
- )} - -
-
- - {toast.type === "error" && - typeof toast.description === "string" && - !toast.data?.hideCopyButton && ( - - )} + {hasTrailingControls ? ( +
+ {copyErrorText !== null ? ( + + ) : null} + {toast.actionProps ? ( + + {toast.actionProps.children} + + ) : null} +
+ ) : null} + + ) : ( + <> +
+ {Icon && ( +
+ +
+ )} + +
+ + +
- -
-
- {toast.actionProps && ( - - {toast.actionProps.children} - + {hasTrailingControls ? ( +
+ {copyErrorText !== null ? ( + + ) : null} + {toast.actionProps ? ( + + {toast.actionProps.children} + + ) : null} +
+ ) : null} + )} @@ -510,6 +780,8 @@ function AnchoredToasts() { ); } +export { stackedThreadToast, type StackedThreadToastOptions } from "./toastHelpers"; + export { ToastProvider, type ToastPosition, diff --git a/apps/web/src/components/ui/toastHelpers.ts b/apps/web/src/components/ui/toastHelpers.ts new file mode 100644 index 00000000000..4c39141b703 --- /dev/null +++ b/apps/web/src/components/ui/toastHelpers.ts @@ -0,0 +1,56 @@ +"use client"; + +import type { ToastManagerAddOptions } from "@base-ui/react/toast"; +import type { ComponentPropsWithoutRef, ReactNode } from "react"; + +import type { ThreadToastData } from "./toast"; + +export type StackedThreadToastOptions = { + type: "error" | "warning" | "success" | "info"; + title: ReactNode; + description?: ReactNode; + timeout?: number; + priority?: "low" | "high"; + actionProps?: ComponentPropsWithoutRef<"button">; + /** Merged into `data` after `actionLayout: "stacked-end"`. */ + actionVariant?: ThreadToastData["actionVariant"]; + data?: ThreadToastData; +}; + +/** + * Thread toast using the stacked body + bottom action row (copy for errors, CTA on its own row). + */ +export function stackedThreadToast( + options: StackedThreadToastOptions, +): ToastManagerAddOptions { + const { type, title, description, timeout, priority, actionProps, actionVariant, data } = options; + + const mergedData: ThreadToastData = { + actionLayout: "stacked-end", + ...(data !== undefined ? data : {}), + }; + if (actionVariant !== undefined) { + mergedData.actionVariant = actionVariant; + } + + const payload: ToastManagerAddOptions = { + type, + title, + data: mergedData, + }; + + if (description !== undefined) { + payload.description = description; + } + if (timeout !== undefined) { + payload.timeout = timeout; + } + if (priority !== undefined) { + payload.priority = priority; + } + if (actionProps !== undefined) { + payload.actionProps = actionProps; + } + + return payload; +} diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 60b1be85a70..de60ffb18dc 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -20,7 +20,7 @@ import { import { useTerminalStateStore } from "../terminalStateStore"; import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; -import { toastManager } from "../components/ui/toast"; +import { stackedThreadToast, toastManager } from "../components/ui/toast"; import { useSettings } from "./useSettings"; export function useThreadActions() { @@ -225,11 +225,13 @@ export function useThreadActions() { worktreePath: orphanedWorktreePath, error, }); - toastManager.add({ - type: "error", - title: "Thread deleted, but worktree removal failed", - description: `Could not remove ${displayWorktreePath ?? orphanedWorktreePath}. ${message}`, - }); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Thread deleted, but worktree removal failed", + description: `Could not remove ${displayWorktreePath ?? orphanedWorktreePath}. ${message}`, + }), + ); } }, [ diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 6494ecdb25f..3d1aec9fbec 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -16,6 +16,7 @@ import { Route as ChatIndexRouteImport } from './routes/_chat.index' import { Route as SettingsGeneralRouteImport } from './routes/settings.general' import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections' import { Route as SettingsArchivedRouteImport } from './routes/settings.archived' +import { Route as DevToastRouteImport } from './routes/dev.toast' import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId' import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId' @@ -53,6 +54,11 @@ const SettingsArchivedRoute = SettingsArchivedRouteImport.update({ path: '/archived', getParentRoute: () => SettingsRoute, } as any) +const DevToastRoute = DevToastRouteImport.update({ + id: '/dev/toast', + path: '/dev/toast', + getParentRoute: () => rootRouteImport, +} as any) const ChatDraftDraftIdRoute = ChatDraftDraftIdRouteImport.update({ id: '/draft/$draftId', path: '/draft/$draftId', @@ -69,6 +75,7 @@ export interface FileRoutesByFullPath { '/': typeof ChatIndexRoute '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren + '/dev/toast': typeof DevToastRoute '/settings/archived': typeof SettingsArchivedRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/general': typeof SettingsGeneralRoute @@ -78,6 +85,7 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren + '/dev/toast': typeof DevToastRoute '/settings/archived': typeof SettingsArchivedRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/general': typeof SettingsGeneralRoute @@ -90,6 +98,7 @@ export interface FileRoutesById { '/_chat': typeof ChatRouteWithChildren '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren + '/dev/toast': typeof DevToastRoute '/settings/archived': typeof SettingsArchivedRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/general': typeof SettingsGeneralRoute @@ -103,6 +112,7 @@ export interface FileRouteTypes { | '/' | '/pair' | '/settings' + | '/dev/toast' | '/settings/archived' | '/settings/connections' | '/settings/general' @@ -112,6 +122,7 @@ export interface FileRouteTypes { to: | '/pair' | '/settings' + | '/dev/toast' | '/settings/archived' | '/settings/connections' | '/settings/general' @@ -123,6 +134,7 @@ export interface FileRouteTypes { | '/_chat' | '/pair' | '/settings' + | '/dev/toast' | '/settings/archived' | '/settings/connections' | '/settings/general' @@ -135,6 +147,7 @@ export interface RootRouteChildren { ChatRoute: typeof ChatRouteWithChildren PairRoute: typeof PairRoute SettingsRoute: typeof SettingsRouteWithChildren + DevToastRoute: typeof DevToastRoute } declare module '@tanstack/react-router' { @@ -188,6 +201,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsArchivedRouteImport parentRoute: typeof SettingsRoute } + '/dev/toast': { + id: '/dev/toast' + path: '/dev/toast' + fullPath: '/dev/toast' + preLoaderRoute: typeof DevToastRouteImport + parentRoute: typeof rootRouteImport + } '/_chat/draft/$draftId': { id: '/_chat/draft/$draftId' path: '/draft/$draftId' @@ -239,6 +259,7 @@ const rootRouteChildren: RootRouteChildren = { ChatRoute: ChatRouteWithChildren, PairRoute: PairRoute, SettingsRoute: SettingsRouteWithChildren, + DevToastRoute: DevToastRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index b0c0713fe3a..87e8667901d 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -19,7 +19,12 @@ import { WebSocketConnectionSurface, } from "../components/WebSocketConnectionSurface"; import { Button } from "../components/ui/button"; -import { AnchoredToastProvider, ToastProvider, toastManager } from "../components/ui/toast"; +import { + AnchoredToastProvider, + stackedThreadToast, + ToastProvider, + toastManager, +} from "../components/ui/toast"; import { resolveAndPersistPreferredEditor } from "../editorPreferences"; import { readLocalApi } from "../localApi"; import { useSettings } from "../hooks/useSettings"; @@ -290,37 +295,42 @@ function EventRouter() { return; } - toastManager.add({ - type: "warning", - title: "Invalid keybindings configuration", - description: issue.message, - actionProps: { - children: "Open keybindings.json", - onClick: () => { - const api = readLocalApi(); - if (!api) { - return; - } - - void Promise.resolve(serverConfig ?? api.server.getConfig()) - .then((config) => { - const editor = resolveAndPersistPreferredEditor(config.availableEditors); - if (!editor) { - throw new Error("No available editors found."); - } - return api.shell.openInEditor(config.keybindingsConfigPath, editor); - }) - .catch((error) => { - toastManager.add({ - type: "error", - title: "Unable to open keybindings file", - description: - error instanceof Error ? error.message : "Unknown error opening file.", + toastManager.add( + stackedThreadToast({ + type: "warning", + title: "Invalid keybindings configuration", + description: issue.message, + actionVariant: "outline", + actionProps: { + children: "Open keybindings.json", + onClick: () => { + const api = readLocalApi(); + if (!api) { + return; + } + + void Promise.resolve(serverConfig ?? api.server.getConfig()) + .then((config) => { + const editor = resolveAndPersistPreferredEditor(config.availableEditors); + if (!editor) { + throw new Error("No available editors found."); + } + return api.shell.openInEditor(config.keybindingsConfigPath, editor); + }) + .catch((error) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to open keybindings file", + description: + error instanceof Error ? error.message : "Unknown error opening file.", + }), + ); }); - }); + }, }, - }, - }); + }), + ); }, ); diff --git a/apps/web/src/routes/dev.toast.tsx b/apps/web/src/routes/dev.toast.tsx new file mode 100644 index 00000000000..fb78dd90463 --- /dev/null +++ b/apps/web/src/routes/dev.toast.tsx @@ -0,0 +1,475 @@ +import { Toast } from "@base-ui/react/toast"; +import { createFileRoute } from "@tanstack/react-router"; +import { useRef } from "react"; + +import { + anchoredToastManager, + stackedThreadToast, + toastManager, + type ThreadToastData, +} from "~/components/ui/toast"; +import { Button } from "~/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card"; +import { cn } from "~/lib/utils"; + +export const Route = createFileRoute("/dev/toast")({ + component: DevToastPlaygroundRoute, +}); + +const LONG_COPY = + "This description is intentionally long so you can confirm wrapping, collapsed-stack peek height, and expanded hover behavior without leaving this page."; + +const MEGA_ERROR_BODY = [ + "Typecheck failed across 38 packages after incremental rebuild. The compiler stopped after the first 200 diagnostics; full output is ~18k lines on disk.", + "", + "packages/web/src/components/ChatView.tsx(3084,11): error TS2322: Type 'DispatchCommandResult | undefined' is not assignable to type 'DispatchCommandResult'.", + "packages/web/src/components/GitActionsControl.tsx(639,22): error TS18048: 'progress' is possibly 'undefined'.", + "packages/contracts/src/orchestration.ts(112,3): error TS4104: The type 'readonly string[]' is 'readonly' and cannot be assigned to the mutable type 'string[]'.", + "", + "Caused by: upstream @types/node@22.10.2 conflicting with workspace typescript@5.9.2 — see https://example.invalid/ts-triage for the internal runbook.", + "", + LONG_COPY, + LONG_COPY, + "Retry with T3_VERBOSE_TSC=1 and attach .t3/tsc-full.log when filing an issue.", +].join("\n"); + +function DevToastPlaygroundRoute() { + const anchoredTooltipAnchorRef = useRef(null); + const anchoredPanelAnchorRef = useRef(null); + const anchoredManager = Toast.useToastManager(); + + return ( +
+
+

Toast playground

+

+ Dev-only route at /dev/toast + . Dismiss toasts with the corner control. Anchored toasts can be cleared in bulk below. +

+
+ + + + Viewport toasts + + Types, copy length, actions, and layout flags used by{" "} + toastManager. + + + +
+

Type / icon

+
+ + + + + + + + +
+
+ +
+

Title-only & long body

+
+ + +
+
+ +
+

Actions & layout

+
+ + + + +
+
+ +
+

Kitchen sink

+

+ Long error body (copy),{" "} + stacked-end CTA row, destructive + variant, corner dismiss — plus optional multi-toast stack behind it. +

+
+ + +
+
+ +
+

Auto-dismiss

+
+ +
+
+ +
+

Stacking

+
+ +
+
+
+
+ + + + Anchored toasts + + anchoredToastManager with + a real anchor element (same thread scoping rules as viewport toasts). + + + +
+ + + +
+
+
+
+ ); +} diff --git a/apps/web/src/rpc/requestLatencyState.ts b/apps/web/src/rpc/requestLatencyState.ts index d21e37b5298..ecc3b88275c 100644 --- a/apps/web/src/rpc/requestLatencyState.ts +++ b/apps/web/src/rpc/requestLatencyState.ts @@ -36,7 +36,7 @@ function getSlowRpcAckRequestsValue(): ReadonlyArray { } function shouldTrackRpcAck(tag: string): boolean { - return !tag.startsWith("subscribe"); + return !tag.includes("subscribe"); } export function getSlowRpcAckRequests(): ReadonlyArray { From 0c6b7fcb0417bccd64fd834f0a3031bfedcc5fa8 Mon Sep 17 00:00:00 2001 From: noxire-dev Date: Mon, 20 Apr 2026 22:42:39 +0300 Subject: [PATCH 07/12] Remove accidentally-shipped /dev/toast playground route --- apps/web/src/routeTree.gen.ts | 21 -- apps/web/src/routes/dev.toast.tsx | 475 ------------------------------ 2 files changed, 496 deletions(-) delete mode 100644 apps/web/src/routes/dev.toast.tsx diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 3d1aec9fbec..6494ecdb25f 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -16,7 +16,6 @@ import { Route as ChatIndexRouteImport } from './routes/_chat.index' import { Route as SettingsGeneralRouteImport } from './routes/settings.general' import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections' import { Route as SettingsArchivedRouteImport } from './routes/settings.archived' -import { Route as DevToastRouteImport } from './routes/dev.toast' import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId' import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId' @@ -54,11 +53,6 @@ const SettingsArchivedRoute = SettingsArchivedRouteImport.update({ path: '/archived', getParentRoute: () => SettingsRoute, } as any) -const DevToastRoute = DevToastRouteImport.update({ - id: '/dev/toast', - path: '/dev/toast', - getParentRoute: () => rootRouteImport, -} as any) const ChatDraftDraftIdRoute = ChatDraftDraftIdRouteImport.update({ id: '/draft/$draftId', path: '/draft/$draftId', @@ -75,7 +69,6 @@ export interface FileRoutesByFullPath { '/': typeof ChatIndexRoute '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren - '/dev/toast': typeof DevToastRoute '/settings/archived': typeof SettingsArchivedRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/general': typeof SettingsGeneralRoute @@ -85,7 +78,6 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren - '/dev/toast': typeof DevToastRoute '/settings/archived': typeof SettingsArchivedRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/general': typeof SettingsGeneralRoute @@ -98,7 +90,6 @@ export interface FileRoutesById { '/_chat': typeof ChatRouteWithChildren '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren - '/dev/toast': typeof DevToastRoute '/settings/archived': typeof SettingsArchivedRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/general': typeof SettingsGeneralRoute @@ -112,7 +103,6 @@ export interface FileRouteTypes { | '/' | '/pair' | '/settings' - | '/dev/toast' | '/settings/archived' | '/settings/connections' | '/settings/general' @@ -122,7 +112,6 @@ export interface FileRouteTypes { to: | '/pair' | '/settings' - | '/dev/toast' | '/settings/archived' | '/settings/connections' | '/settings/general' @@ -134,7 +123,6 @@ export interface FileRouteTypes { | '/_chat' | '/pair' | '/settings' - | '/dev/toast' | '/settings/archived' | '/settings/connections' | '/settings/general' @@ -147,7 +135,6 @@ export interface RootRouteChildren { ChatRoute: typeof ChatRouteWithChildren PairRoute: typeof PairRoute SettingsRoute: typeof SettingsRouteWithChildren - DevToastRoute: typeof DevToastRoute } declare module '@tanstack/react-router' { @@ -201,13 +188,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsArchivedRouteImport parentRoute: typeof SettingsRoute } - '/dev/toast': { - id: '/dev/toast' - path: '/dev/toast' - fullPath: '/dev/toast' - preLoaderRoute: typeof DevToastRouteImport - parentRoute: typeof rootRouteImport - } '/_chat/draft/$draftId': { id: '/_chat/draft/$draftId' path: '/draft/$draftId' @@ -259,7 +239,6 @@ const rootRouteChildren: RootRouteChildren = { ChatRoute: ChatRouteWithChildren, PairRoute: PairRoute, SettingsRoute: SettingsRouteWithChildren, - DevToastRoute: DevToastRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/apps/web/src/routes/dev.toast.tsx b/apps/web/src/routes/dev.toast.tsx deleted file mode 100644 index fb78dd90463..00000000000 --- a/apps/web/src/routes/dev.toast.tsx +++ /dev/null @@ -1,475 +0,0 @@ -import { Toast } from "@base-ui/react/toast"; -import { createFileRoute } from "@tanstack/react-router"; -import { useRef } from "react"; - -import { - anchoredToastManager, - stackedThreadToast, - toastManager, - type ThreadToastData, -} from "~/components/ui/toast"; -import { Button } from "~/components/ui/button"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card"; -import { cn } from "~/lib/utils"; - -export const Route = createFileRoute("/dev/toast")({ - component: DevToastPlaygroundRoute, -}); - -const LONG_COPY = - "This description is intentionally long so you can confirm wrapping, collapsed-stack peek height, and expanded hover behavior without leaving this page."; - -const MEGA_ERROR_BODY = [ - "Typecheck failed across 38 packages after incremental rebuild. The compiler stopped after the first 200 diagnostics; full output is ~18k lines on disk.", - "", - "packages/web/src/components/ChatView.tsx(3084,11): error TS2322: Type 'DispatchCommandResult | undefined' is not assignable to type 'DispatchCommandResult'.", - "packages/web/src/components/GitActionsControl.tsx(639,22): error TS18048: 'progress' is possibly 'undefined'.", - "packages/contracts/src/orchestration.ts(112,3): error TS4104: The type 'readonly string[]' is 'readonly' and cannot be assigned to the mutable type 'string[]'.", - "", - "Caused by: upstream @types/node@22.10.2 conflicting with workspace typescript@5.9.2 — see https://example.invalid/ts-triage for the internal runbook.", - "", - LONG_COPY, - LONG_COPY, - "Retry with T3_VERBOSE_TSC=1 and attach .t3/tsc-full.log when filing an issue.", -].join("\n"); - -function DevToastPlaygroundRoute() { - const anchoredTooltipAnchorRef = useRef(null); - const anchoredPanelAnchorRef = useRef(null); - const anchoredManager = Toast.useToastManager(); - - return ( -
-
-

Toast playground

-

- Dev-only route at /dev/toast - . Dismiss toasts with the corner control. Anchored toasts can be cleared in bulk below. -

-
- - - - Viewport toasts - - Types, copy length, actions, and layout flags used by{" "} - toastManager. - - - -
-

Type / icon

-
- - - - - - - - -
-
- -
-

Title-only & long body

-
- - -
-
- -
-

Actions & layout

-
- - - - -
-
- -
-

Kitchen sink

-

- Long error body (copy),{" "} - stacked-end CTA row, destructive - variant, corner dismiss — plus optional multi-toast stack behind it. -

-
- - -
-
- -
-

Auto-dismiss

-
- -
-
- -
-

Stacking

-
- -
-
-
-
- - - - Anchored toasts - - anchoredToastManager with - a real anchor element (same thread scoping rules as viewport toasts). - - - -
- - - -
-
-
-
- ); -} From ae2e42fb4ef64d97f90f4344983c3a060f4b51c7 Mon Sep 17 00:00:00 2001 From: noxire-dev Date: Mon, 20 Apr 2026 22:50:28 +0300 Subject: [PATCH 08/12] Fix connection toast layout and suppress copy button on status errors - Wrap the reconnecting (loading) toast in stackedThreadToast() so its Retry action renders with actionLayout: "stacked-end", matching the offline and exhausted branches. - Restore data.hideCopyButton on the exhausted "Disconnected from T3 Server" toast and the "Reconnect failed" toast so connection- status errors don't render an unwanted copy-error button. - Extend StackedThreadToastOptions.type to accept "loading" so the reconnecting toast can go through the same helper. --- apps/web/src/components/WebSocketConnectionSurface.tsx | 10 +++++++--- apps/web/src/components/ui/toastHelpers.ts | 2 +- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/WebSocketConnectionSurface.tsx b/apps/web/src/components/WebSocketConnectionSurface.tsx index 6e656143e19..7dd22b6a7dd 100644 --- a/apps/web/src/components/WebSocketConnectionSurface.tsx +++ b/apps/web/src/components/WebSocketConnectionSurface.tsx @@ -167,6 +167,7 @@ export function WebSocketConnectionCoordinator() { error instanceof Error ? error.message : "Unable to restart the WebSocket.", data: { dismissAfterVisibleMs: 8_000, + hideCopyButton: true, }, }), ); @@ -288,12 +289,15 @@ export function WebSocketConnectionCoordinator() { children: "Retry", onClick: triggerManualReconnect, }, + data: { + hideCopyButton: true, + }, description: describeExhaustedToast(), timeout: 0, title: "Disconnected from T3 Server", type: "error", }) - : { + : stackedThreadToast({ actionProps: { children: "Retry now", onClick: triggerManualReconnect, @@ -307,8 +311,8 @@ export function WebSocketConnectionCoordinator() { : `Reconnecting in ${formatRetryCountdown(status.nextRetryAt, nowMs)}... ${formatReconnectAttemptLabel(status)}`, timeout: 0, title: buildReconnectTitle(status), - type: "loading" as const, - }; + type: "loading", + }); if (toastIdRef.current) { toastManager.update(toastIdRef.current, toastPayload); diff --git a/apps/web/src/components/ui/toastHelpers.ts b/apps/web/src/components/ui/toastHelpers.ts index 4c39141b703..26776dd5049 100644 --- a/apps/web/src/components/ui/toastHelpers.ts +++ b/apps/web/src/components/ui/toastHelpers.ts @@ -6,7 +6,7 @@ import type { ComponentPropsWithoutRef, ReactNode } from "react"; import type { ThreadToastData } from "./toast"; export type StackedThreadToastOptions = { - type: "error" | "warning" | "success" | "info"; + type: "error" | "warning" | "success" | "info" | "loading"; title: ReactNode; description?: ReactNode; timeout?: number; From a0306ed9dfed9bc10671af7647abc28c31cb3a6e Mon Sep 17 00:00:00 2001 From: noxire-dev Date: Mon, 20 Apr 2026 23:09:13 +0300 Subject: [PATCH 09/12] Harden stackedThreadToast helper and cover all connection toasts - Split the value+type re-export in toast.tsx into two statements (`export { stackedThreadToast }` and `export type { ... }`). The single-line mixed form was tripping the browser-test transformer: GitActionsControl.browser.tsx failed to resolve the named export at import time. - Offline "WebSocket disconnected" toast now carries data.hideCopyButton: true, matching the exhausted and reconnecting branches. Warnings don't render a copy button today, but the flag keeps the three status toasts consistent and defends against future rendering changes. - Reverse the spread order in stackedThreadToast so that caller- provided data can't override the helper's `actionLayout: "stacked-end"`. Narrow the `data` option type to Omit so the footgun is also caught at the type level. --- apps/web/src/components/WebSocketConnectionSurface.tsx | 3 +++ apps/web/src/components/ui/toast.tsx | 3 ++- apps/web/src/components/ui/toastHelpers.ts | 8 +++++--- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/WebSocketConnectionSurface.tsx b/apps/web/src/components/WebSocketConnectionSurface.tsx index 7dd22b6a7dd..e0bb560980a 100644 --- a/apps/web/src/components/WebSocketConnectionSurface.tsx +++ b/apps/web/src/components/WebSocketConnectionSurface.tsx @@ -278,6 +278,9 @@ export function WebSocketConnectionCoordinator() { if (shouldShowReconnectToast || shouldShowOfflineToast || shouldShowExhaustedToast) { const toastPayload = shouldShowOfflineToast ? stackedThreadToast({ + data: { + hideCopyButton: true, + }, description: describeOfflineToast(), timeout: 0, title: "Offline", diff --git a/apps/web/src/components/ui/toast.tsx b/apps/web/src/components/ui/toast.tsx index bb39b3ca4c2..0a52dcb20af 100644 --- a/apps/web/src/components/ui/toast.tsx +++ b/apps/web/src/components/ui/toast.tsx @@ -780,7 +780,8 @@ function AnchoredToasts() { ); } -export { stackedThreadToast, type StackedThreadToastOptions } from "./toastHelpers"; +export { stackedThreadToast } from "./toastHelpers"; +export type { StackedThreadToastOptions } from "./toastHelpers"; export { ToastProvider, diff --git a/apps/web/src/components/ui/toastHelpers.ts b/apps/web/src/components/ui/toastHelpers.ts index 26776dd5049..4ec5d14106c 100644 --- a/apps/web/src/components/ui/toastHelpers.ts +++ b/apps/web/src/components/ui/toastHelpers.ts @@ -12,9 +12,9 @@ export type StackedThreadToastOptions = { timeout?: number; priority?: "low" | "high"; actionProps?: ComponentPropsWithoutRef<"button">; - /** Merged into `data` after `actionLayout: "stacked-end"`. */ + /** Merged into `data`; `actionLayout` is always forced to `"stacked-end"` by the helper. */ actionVariant?: ThreadToastData["actionVariant"]; - data?: ThreadToastData; + data?: Omit; }; /** @@ -25,9 +25,11 @@ export function stackedThreadToast( ): ToastManagerAddOptions { const { type, title, description, timeout, priority, actionProps, actionVariant, data } = options; + // Helper-owned `actionLayout` must win over any caller-provided `data`, so spread + // the caller's data first and apply `actionLayout: "stacked-end"` last. const mergedData: ThreadToastData = { - actionLayout: "stacked-end", ...(data !== undefined ? data : {}), + actionLayout: "stacked-end", }; if (actionVariant !== undefined) { mergedData.actionVariant = actionVariant; From a54dd775aa71e10b7a458345d5531ab6690d4070 Mon Sep 17 00:00:00 2001 From: noxire-dev Date: Tue, 21 Apr 2026 00:40:20 +0300 Subject: [PATCH 10/12] Address Bugbot findings on stackedThreadToast hardening - requestLatencyState: revert the slow-RPC ack filter from tag.includes("subscribe") back to tag.startsWith("subscribe"). All long-lived stream tags in wsRpcClient follow the subscribeXxx convention, so prefix-matching is precise; substring-matching would have silently suppressed legitimate slow-request alerts for tags that merely contain "subscribe" (e.g. user.unsubscribe, getSubscriptionStatus). The existing "ignores long-lived subscribe requests" test still exercises the subscribeServerConfig tag. - GitActionsControl: the toastManager.promise error callback for the pull action was returning stackedThreadToast(...) which is ToastManagerAddOptions, but the promise API's error branch expects ToastManagerUpdateOptions; it also diverged from the loading/success branches by injecting actionLayout: "stacked-end" into data despite having no actionProps. Return a plain update-options object so the data payload is consistent across all three branches and the type matches. Made-with: Cursor --- apps/web/src/components/GitActionsControl.tsx | 12 +++++------- apps/web/src/rpc/requestLatencyState.ts | 2 +- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 091007590be..3f39b129acc 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -779,13 +779,11 @@ export default function GitActionsControl({ : `${result.branch} is already synchronized.`, data: threadToastData, }), - error: (err) => - stackedThreadToast({ - type: "error", - title: "Pull failed", - description: err instanceof Error ? err.message : "An error occurred.", - ...(threadToastData !== undefined ? { data: threadToastData } : {}), - }), + error: (err) => ({ + title: "Pull failed", + description: err instanceof Error ? err.message : "An error occurred.", + data: threadToastData, + }), }); void promise.catch(() => undefined); return; diff --git a/apps/web/src/rpc/requestLatencyState.ts b/apps/web/src/rpc/requestLatencyState.ts index ecc3b88275c..d21e37b5298 100644 --- a/apps/web/src/rpc/requestLatencyState.ts +++ b/apps/web/src/rpc/requestLatencyState.ts @@ -36,7 +36,7 @@ function getSlowRpcAckRequestsValue(): ReadonlyArray { } function shouldTrackRpcAck(tag: string): boolean { - return !tag.includes("subscribe"); + return !tag.startsWith("subscribe"); } export function getSlowRpcAckRequests(): ReadonlyArray { From a6ca725a7bcbbf633f8e42b8b6fe4ab7e8832136 Mon Sep 17 00:00:00 2001 From: noxire-dev Date: Tue, 21 Apr 2026 00:40:37 +0300 Subject: [PATCH 11/12] Expose stackedThreadToast in GitActionsControl browser test mock The vi.mock factory for ~/components/ui/toast only returned toastManager, but GitActionsControl.tsx has imported stackedThreadToast from that module since commit 1d2fa3a7 ("Add dismiss support to thread-scoped toasts"). Because vi.mock with a factory fully replaces the module, the import failed at test-load time with "SyntaxError: The requested module '/src/components/ui/toast.tsx' does not provide an export named 'stackedThreadToast'", breaking the apps/web/src/components/GitActionsControl.browser.tsx suite in CI. Add stackedThreadToast as a pass-through vi.fn((options) => options) so the component can be imported and the existing toastManager.update assertions keep inspecting the forwarded payload unchanged. No test assertions were modified. Made-with: Cursor --- apps/web/src/components/GitActionsControl.browser.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/src/components/GitActionsControl.browser.tsx b/apps/web/src/components/GitActionsControl.browser.tsx index 651b383fa08..beeb136a9c1 100644 --- a/apps/web/src/components/GitActionsControl.browser.tsx +++ b/apps/web/src/components/GitActionsControl.browser.tsx @@ -90,6 +90,7 @@ vi.mock("~/components/ui/toast", () => ({ promise: toastPromiseSpy, update: toastUpdateSpy, }, + stackedThreadToast: vi.fn((options: unknown) => options), })); vi.mock("~/editorPreferences", () => ({ From 1250b8a8f8cfacf842bbe35369eb143fb7dad2d7 Mon Sep 17 00:00:00 2001 From: noxire-dev Date: Tue, 21 Apr 2026 01:29:37 +0300 Subject: [PATCH 12/12] Extract shared ToastBodyContent helper in toast.tsx The toast content body (icon + title + ToastDescriptionAndExpandable + trailing CopyErrorButton/Toast.Action) was copy-pasted across four nearly-identical branches: stacked vs inline layout inside Toasts, and the same pair inside AnchoredToasts. The same six derived values (Icon, stackedActionLayout, actionVariant, copyErrorText, hasTrailingControls, inlineContentEndPad) were also computed inline in both sites. Introduce a pure deriveToastBodyDescriptor(toast) helper plus a single ToastBodyContent component that collapses the stacked-vs-inline branch into three conditional cn() fragments. Both Toasts and AnchoredToasts now render bodyDescriptor + once. Outer Toast.Content className differences (Toasts' transition-opacity + hideCollapsedContent, AnchoredToasts' tooltipStyle/Positioner wrapping) are intentional and preserved. Class-equivalence audit verified each conditional cn() produces the same effective Tailwind class set as the original (orderings differ but classes are non-conflicting utilities). Net -73 lines. Typecheck, lint, and oxfmt --check pass. Made-with: Cursor --- apps/web/src/components/ui/toast.tsx | 315 ++++++++++----------------- 1 file changed, 121 insertions(+), 194 deletions(-) diff --git a/apps/web/src/components/ui/toast.tsx b/apps/web/src/components/ui/toast.tsx index 0a52dcb20af..dd0eba432b7 100644 --- a/apps/web/src/components/ui/toast.tsx +++ b/apps/web/src/components/ui/toast.tsx @@ -226,6 +226,109 @@ function ToastDescriptionAndExpandable({ ); } +type ToastIconComponent = (typeof TOAST_ICONS)[keyof typeof TOAST_ICONS]; + +interface ToastBodyDescriptor { + readonly Icon: ToastIconComponent | null | undefined; + readonly stackedActionLayout: boolean; + readonly actionVariant: NonNullable; + readonly copyErrorText: string | null; + readonly hasTrailingControls: boolean; + readonly inlineContentEndPad: string; +} + +function deriveToastBodyDescriptor(toast: { + readonly type?: string | undefined; + readonly description?: unknown; + readonly actionProps?: unknown; + readonly data?: ThreadToastData | undefined; +}): ToastBodyDescriptor { + const Icon = toast.type ? TOAST_ICONS[toast.type as keyof typeof TOAST_ICONS] : null; + const stackedActionLayout = + toast.actionProps !== undefined && toast.data?.actionLayout === "stacked-end"; + const actionVariant: NonNullable = + toast.data?.actionVariant ?? "default"; + const copyErrorText = + toast.type === "error" && typeof toast.description === "string" && !toast.data?.hideCopyButton + ? toast.description + : null; + const hasTrailingControls = copyErrorText !== null || toast.actionProps !== undefined; + const inlineContentEndPad = hasTrailingControls ? "pr-6" : "pr-10"; + return { + Icon, + stackedActionLayout, + actionVariant, + copyErrorText, + hasTrailingControls, + inlineContentEndPad, + }; +} + +interface ToastBodyContentProps extends ToastBodyDescriptor { + readonly actionProps: { readonly children?: ReactNode } | undefined; + readonly toastData: ThreadToastData | undefined; + readonly toastDescription: unknown; + readonly toastType: unknown; +} + +function ToastBodyContent({ + stackedActionLayout, + Icon, + copyErrorText, + actionProps, + actionVariant, + hasTrailingControls, + toastData, + toastDescription, + toastType, +}: ToastBodyContentProps) { + return ( + <> +
+ {Icon && ( +
+ +
+ )} +
+ + +
+
+ {hasTrailingControls ? ( +
+ {copyErrorText !== null ? : null} + {actionProps ? ( + + {actionProps.children} + + ) : null} +
+ ) : null} + + ); +} + type ToastPosition = | "top-left" | "top-center" @@ -388,22 +491,12 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) { } > {visibleToastLayout.items.map(({ toast, visibleIndex, offsetY }) => { - const Icon = toast.type ? TOAST_ICONS[toast.type as keyof typeof TOAST_ICONS] : null; const hideCollapsedContent = shouldHideCollapsedToastContent( visibleIndex, visibleToastLayout.items.length, ); - const stackedActionLayout = - toast.actionProps !== undefined && toast.data?.actionLayout === "stacked-end"; - const actionVariant = toast.data?.actionVariant ?? "default"; - const copyErrorText = - toast.type === "error" && - typeof toast.description === "string" && - !toast.data?.hideCopyButton - ? toast.description - : null; - const hasTrailingControls = copyErrorText !== null || toast.actionProps !== undefined; - const inlineContentEndPad = hasTrailingControls ? "pr-6" : "pr-10"; + const bodyDescriptor = deriveToastBodyDescriptor(toast); + const { stackedActionLayout, inlineContentEndPad } = bodyDescriptor; return ( - {stackedActionLayout ? ( - <> -
- {Icon && ( -
- -
- )} - -
- - -
-
- {hasTrailingControls ? ( -
- {copyErrorText !== null ? : null} - {toast.actionProps ? ( - - {toast.actionProps.children} - - ) : null} -
- ) : null} - - ) : ( - <> -
- {Icon && ( -
- -
- )} - -
- - -
-
- {hasTrailingControls ? ( -
- {copyErrorText !== null ? : null} - {toast.actionProps ? ( - - {toast.actionProps.children} - - ) : null} -
- ) : null} - - )} +
); @@ -615,20 +632,10 @@ function AnchoredToasts() { {toasts .filter((toast) => shouldRenderThreadScopedToast(toast.data, activeThreadRef)) .map((toast) => { - const Icon = toast.type ? TOAST_ICONS[toast.type as keyof typeof TOAST_ICONS] : null; const tooltipStyle = toast.data?.tooltipStyle ?? false; const positionerProps = toast.positionerProps; - const stackedActionLayout = - toast.actionProps !== undefined && toast.data?.actionLayout === "stacked-end"; - const actionVariant = toast.data?.actionVariant ?? "default"; - const copyErrorText = - toast.type === "error" && - typeof toast.description === "string" && - !toast.data?.hideCopyButton - ? toast.description - : null; - const hasTrailingControls = copyErrorText !== null || toast.actionProps !== undefined; - const inlineContentEndPad = hasTrailingControls ? "pr-6" : "pr-10"; + const bodyDescriptor = deriveToastBodyDescriptor(toast); + const { stackedActionLayout, inlineContentEndPad } = bodyDescriptor; if (!positionerProps?.anchor) { return null; @@ -681,93 +688,13 @@ function AnchoredToasts() { ), )} > - {stackedActionLayout ? ( - <> -
- {Icon && ( -
- -
- )} - -
- - -
-
- {hasTrailingControls ? ( -
- {copyErrorText !== null ? ( - - ) : null} - {toast.actionProps ? ( - - {toast.actionProps.children} - - ) : null} -
- ) : null} - - ) : ( - <> -
- {Icon && ( -
- -
- )} - -
- - -
-
- {hasTrailingControls ? ( -
- {copyErrorText !== null ? ( - - ) : null} - {toast.actionProps ? ( - - {toast.actionProps.children} - - ) : null} -
- ) : null} - - )} + )}