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.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", () => ({ diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 6d2312e4aac..3f39b129acc 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", @@ -832,12 +848,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..e0bb560980a 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,18 @@ 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, + hideCopyButton: true, + }, + }), + ); }); }); const syncBrowserOnlineStatus = useEffectEvent(() => { @@ -253,45 +277,45 @@ export function WebSocketConnectionCoordinator() { if (shouldShowReconnectToast || shouldShowOfflineToast || shouldShowExhaustedToast) { const toastPayload = shouldShowOfflineToast - ? { - description: describeOfflineToast(), - timeout: 0, - title: "Offline", - type: "warning" as const, + ? stackedThreadToast({ data: { hideCopyButton: true, }, - } + description: describeOfflineToast(), + timeout: 0, + title: "Offline", + type: "warning", + }) : shouldShowExhaustedToast - ? { + ? stackedThreadToast({ actionProps: { children: "Retry", onClick: triggerManualReconnect, }, - description: describeExhaustedToast(), - timeout: 0, - title: "Disconnected from T3 Server", - type: "error" as const, data: { hideCopyButton: true, }, - } - : { + description: describeExhaustedToast(), + timeout: 0, + title: "Disconnected from T3 Server", + type: "error", + }) + : stackedThreadToast({ actionProps: { children: "Retry now", onClick: triggerManualReconnect, }, + data: { + hideCopyButton: true, + }, description: status.nextRetryAt === null ? `Reconnecting... ${formatReconnectAttemptLabel(status)}` : `Reconnecting in ${formatRetryCountdown(status.nextRetryAt, nowMs)}... ${formatReconnectAttemptLabel(status)}`, timeout: 0, title: buildReconnectTitle(status), - type: "loading" as const, - data: { - hideCopyButton: true, - }, - }; + type: "loading", + }); if (toastIdRef.current) { toastManager.update(toastIdRef.current, toastPayload); @@ -369,6 +393,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.logic.test.ts b/apps/web/src/components/ui/toast.logic.test.ts index e634d7d9463..fefb61196bb 100644 --- a/apps/web/src/components/ui/toast.logic.test.ts +++ b/apps/web/src/components/ui/toast.logic.test.ts @@ -45,6 +45,62 @@ describe("buildVisibleToastLayout", () => { ); }); + 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 }, + { 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 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 }, + { id: "c", visibleIndex: 1, offsetY: 72 }, + ], + ); + }); + + 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 09905a7a670..80f23970cee 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,61 @@ type VisibleToastLayoutItem = { }; export function buildVisibleToastLayout( - visibleToasts: readonly (TToast & ToastWithHeight)[], + visibleToasts: readonly (TToast & ToastWithLayoutProps)[], ): { frontmostHeight: number; - items: VisibleToastLayoutItem[]; + items: VisibleToastLayoutItem[]; } { - let offsetY = 0; + // 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") { + const item = { + toast, + visibleIndex: fullIndex, + offsetY: fullOffsetY, + }; + fullOffsetY += height; + fullIndex += 1; + return item; + } - return { - frontmostHeight: normalizeToastHeight(visibleToasts[0]?.height), - items: visibleToasts.map((toast, visibleIndex) => { const item = { toast, - visibleIndex, - offsetY, + visibleIndex: liveIndex, + offsetY: liveOffsetY, }; - offsetY += normalizeToastHeight(toast.height); + fullOffsetY += height; + fullIndex += 1; + liveOffsetY += 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, }; } diff --git a/apps/web/src/components/ui/toast.tsx b/apps/web/src/components/ui/toast.tsx index 083ca480079..dd0eba432b7 100644 --- a/apps/web/src/components/ui/toast.tsx +++ b/apps/web/src/components/ui/toast.tsx @@ -1,17 +1,27 @@ "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, InfoIcon, LoaderCircleIcon, TriangleAlertIcon, + XIcon, } from "lucide-react"; import { cn } from "~/lib/utils"; @@ -31,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" @@ -55,25 +70,265 @@ 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 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" @@ -236,19 +491,17 @@ 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 bodyDescriptor = deriveToastBodyDescriptor(toast); + const { stackedActionLayout, inlineContentEndPad } = bodyDescriptor; 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))]", @@ -272,8 +533,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)))]", @@ -314,54 +574,36 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) { dismissAfterVisibleMs={toast.data?.dismissAfterVisibleMs} toastId={toast.id} /> +
+ +
-
- {Icon && ( -
- -
- )} - -
-
- - {toast.type === "error" && - typeof toast.description === "string" && - !toast.data?.hideCopyButton && } -
- -
-
- {toast.actionProps && ( - - {toast.actionProps.children} - - )} +
); @@ -390,12 +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 bodyDescriptor = deriveToastBodyDescriptor(toast); + const { stackedActionLayout, inlineContentEndPad } = bodyDescriptor; if (!positionerProps?.anchor) { return null; @@ -411,7 +651,7 @@ function AnchoredToasts() { > ) : ( - -
- {Icon && ( -
- -
- )} - -
-
- - {toast.type === "error" && - typeof toast.description === "string" && - !toast.data?.hideCopyButton && ( - - )} -
- -
-
- {toast.actionProps && ( - +
+ +
+ + + + )}
@@ -483,6 +707,9 @@ function AnchoredToasts() { ); } +export { stackedThreadToast } from "./toastHelpers"; +export 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..4ec5d14106c --- /dev/null +++ b/apps/web/src/components/ui/toastHelpers.ts @@ -0,0 +1,58 @@ +"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" | "loading"; + title: ReactNode; + description?: ReactNode; + timeout?: number; + priority?: "low" | "high"; + actionProps?: ComponentPropsWithoutRef<"button">; + /** Merged into `data`; `actionLayout` is always forced to `"stacked-end"` by the helper. */ + actionVariant?: ThreadToastData["actionVariant"]; + data?: Omit; +}; + +/** + * 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; + + // 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 = { + ...(data !== undefined ? data : {}), + actionLayout: "stacked-end", + }; + 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/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.", + }), + ); }); - }); + }, }, - }, - }); + }), + ); }, );