authorgravatar for 59626436+noxire-dev@users.noreply.github.comNoxire <59626436+noxire-dev@users.noreply.github.com> 2026-04-21 18:32:08+03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-21 08:32:08-07:00
log3a1daa87ac103da0c176426e82eb576d87046bdf
tree4c26309b1ca4f5455334f65d13e2b2d48c64ea8c
parent055897f0eafd8c2ba981f9d0ced1c21052035f5b
signature Signed by PGP key B5690EEEBB952194

Add close buttons to toasts (#2023)

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

19 files changed, 1100 insertions(+), 552 deletions(-)

apps/web/src/components/BranchToolbarBranchSelector.tsx+15-11
...@@ -42,7 +42,7 @@ import {...@@ -42,7 +42,7 @@ import {
42 ComboboxStatus,42 ComboboxStatus,
43 ComboboxTrigger,43 ComboboxTrigger,
44} from "./ui/combobox";44} from "./ui/combobox";
45import { toastManager } from "./ui/toast";45import { stackedThreadToast, toastManager } from "./ui/toast";
4646
47interface BranchToolbarBranchSelectorProps {47interface BranchToolbarBranchSelectorProps {
48 environmentId: EnvironmentId;48 environmentId: EnvironmentId;
...@@ -351,11 +351,13 @@ export function BranchToolbarBranchSelector({...@@ -351,11 +351,13 @@ export function BranchToolbarBranchSelector({
351 setThreadBranch(nextBranchName, selectionTarget.nextWorktreePath);351 setThreadBranch(nextBranchName, selectionTarget.nextWorktreePath);
352 } catch (error) {352 } catch (error) {
353 setOptimisticBranch(previousBranch);353 setOptimisticBranch(previousBranch);
354 toastManager.add({354 toastManager.add(
355 type: "error",355 stackedThreadToast({
356 title: "Failed to checkout branch.",356 type: "error",
357 description: toBranchActionErrorMessage(error),357 title: "Failed to checkout branch.",
358 });358 description: toBranchActionErrorMessage(error),
359 }),
360 );
359 }361 }
360 });362 });
361 };363 };
...@@ -381,11 +383,13 @@ export function BranchToolbarBranchSelector({...@@ -381,11 +383,13 @@ export function BranchToolbarBranchSelector({
381 setThreadBranch(createBranchResult.branch, activeWorktreePath);383 setThreadBranch(createBranchResult.branch, activeWorktreePath);
382 } catch (error) {384 } catch (error) {
383 setOptimisticBranch(previousBranch);385 setOptimisticBranch(previousBranch);
384 toastManager.add({386 toastManager.add(
385 type: "error",387 stackedThreadToast({
386 title: "Failed to create and checkout branch.",388 type: "error",
387 description: toBranchActionErrorMessage(error),389 title: "Failed to create and checkout branch.",
388 });390 description: toBranchActionErrorMessage(error),
391 }),
392 );
389 }393 }
390 });394 });
391 };395 };
apps/web/src/components/ChatMarkdown.tsx+22-16
...@@ -20,7 +20,7 @@ import { defaultUrlTransform } from "react-markdown";...@@ -20,7 +20,7 @@ import { defaultUrlTransform } from "react-markdown";
20import remarkGfm from "remark-gfm";20import remarkGfm from "remark-gfm";
21import { VscodeEntryIcon } from "./chat/VscodeEntryIcon";21import { VscodeEntryIcon } from "./chat/VscodeEntryIcon";
22import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";22import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
23import { toastManager } from "./ui/toast";23import { stackedThreadToast, toastManager } from "./ui/toast";
24import { openInPreferredEditor } from "../editorPreferences";24import { openInPreferredEditor } from "../editorPreferences";
25import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering";25import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering";
26import { fnv1a32 } from "../lib/diffRendering";26import { fnv1a32 } from "../lib/diffRendering";
...@@ -351,21 +351,25 @@ const MarkdownFileLink = memo(function MarkdownFileLink({...@@ -351,21 +351,25 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
351 }351 }
352352
353 void openInPreferredEditor(api, targetPath).catch((error) => {353 void openInPreferredEditor(api, targetPath).catch((error) => {
354 toastManager.add({354 toastManager.add(
355 type: "error",355 stackedThreadToast({
356 title: "Unable to open file",356 type: "error",
357 description: error instanceof Error ? error.message : "An error occurred.",357 title: "Unable to open file",
358 });358 description: error instanceof Error ? error.message : "An error occurred.",
359 }),
360 );
359 });361 });
360 }, [targetPath]);362 }, [targetPath]);
361363
362 const handleCopy = useCallback((value: string, title: string) => {364 const handleCopy = useCallback((value: string, title: string) => {
363 if (typeof window === "undefined" || !navigator.clipboard?.writeText) {365 if (typeof window === "undefined" || !navigator.clipboard?.writeText) {
364 toastManager.add({366 toastManager.add(
365 type: "error",367 stackedThreadToast({
366 title: `Failed to copy ${title.toLowerCase()}`,368 type: "error",
367 description: "Clipboard API unavailable.",369 title: `Failed to copy ${title.toLowerCase()}`,
368 });370 description: "Clipboard API unavailable.",
371 }),
372 );
369 return;373 return;
370 }374 }
371375
...@@ -378,11 +382,13 @@ const MarkdownFileLink = memo(function MarkdownFileLink({...@@ -378,11 +382,13 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
378 });382 });
379 },383 },
380 (error) => {384 (error) => {
381 toastManager.add({385 toastManager.add(
382 type: "error",386 stackedThreadToast({
383 title: `Failed to copy ${title.toLowerCase()}`,387 type: "error",
384 description: error instanceof Error ? error.message : "An error occurred.",388 title: `Failed to copy ${title.toLowerCase()}`,
385 });389 description: error instanceof Error ? error.message : "An error occurred.",
390 }),
391 );
386 },392 },
387 );393 );
388 }, []);394 }, []);
apps/web/src/components/ChatView.tsx+32-22
...@@ -101,7 +101,7 @@ import PlanSidebar from "./PlanSidebar";...@@ -101,7 +101,7 @@ import PlanSidebar from "./PlanSidebar";
101import ThreadTerminalDrawer from "./ThreadTerminalDrawer";101import ThreadTerminalDrawer from "./ThreadTerminalDrawer";
102import { ChevronDownIcon } from "lucide-react";102import { ChevronDownIcon } from "lucide-react";
103import { cn, randomUUID } from "~/lib/utils";103import { cn, randomUUID } from "~/lib/utils";
104import { toastManager } from "./ui/toast";104import { stackedThreadToast, toastManager } from "./ui/toast";
105import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings";105import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings";
106import { type NewProjectScriptInput } from "./ProjectScriptsControl";106import { type NewProjectScriptInput } from "./ProjectScriptsControl";
107import {107import {
...@@ -1855,11 +1855,13 @@ export default function ChatView(props: ChatViewProps) {...@@ -1855,11 +1855,13 @@ export default function ChatView(props: ChatViewProps) {
1855 title: `Deleted action "${deletedName ?? "Unknown"}"`,1855 title: `Deleted action "${deletedName ?? "Unknown"}"`,
1856 });1856 });
1857 } catch (error) {1857 } catch (error) {
1858 toastManager.add({1858 toastManager.add(
1859 type: "error",1859 stackedThreadToast({
1860 title: "Could not delete action",1860 type: "error",
1861 description: error instanceof Error ? error.message : "An unexpected error occurred.",1861 title: "Could not delete action",
1862 });1862 description: error instanceof Error ? error.message : "An unexpected error occurred.",
1863 }),
1864 );
1863 }1865 }
1864 },1866 },
1865 [activeProject, persistProjectScripts],1867 [activeProject, persistProjectScripts],
...@@ -2426,11 +2428,13 @@ export default function ChatView(props: ChatViewProps) {...@@ -2426,11 +2428,13 @@ export default function ChatView(props: ChatViewProps) {
2426 expiredTerminalContextCount,2428 expiredTerminalContextCount,
2427 "empty",2429 "empty",
2428 );2430 );
2429 toastManager.add({2431 toastManager.add(
2430 type: "warning",2432 stackedThreadToast({
2431 title: toastCopy.title,2433 type: "warning",
2432 description: toastCopy.description,2434 title: toastCopy.title,
2433 });2435 description: toastCopy.description,
2436 }),
2437 );
2434 }2438 }
2435 return;2439 return;
2436 }2440 }
...@@ -2512,11 +2516,13 @@ export default function ChatView(props: ChatViewProps) {...@@ -2512,11 +2516,13 @@ export default function ChatView(props: ChatViewProps) {
2512 expiredTerminalContextCount,2516 expiredTerminalContextCount,
2513 "omitted",2517 "omitted",
2514 );2518 );
2515 toastManager.add({2519 toastManager.add(
2516 type: "warning",2520 stackedThreadToast({
2517 title: toastCopy.title,2521 type: "warning",
2518 description: toastCopy.description,2522 title: toastCopy.title,
2519 });2523 description: toastCopy.description,
2524 }),
2525 );
2520 }2526 }
2521 promptRef.current = "";2527 promptRef.current = "";
2522 clearComposerDraftContent(composerDraftTarget);2528 clearComposerDraftContent(composerDraftTarget);
...@@ -3082,12 +3088,16 @@ export default function ChatView(props: ChatViewProps) {...@@ -3082,12 +3088,16 @@ export default function ChatView(props: ChatViewProps) {
3082 threadId: nextThreadId,3088 threadId: nextThreadId,
3083 })3089 })
3084 .catch(() => undefined);3090 .catch(() => undefined);
3085 toastManager.add({3091 toastManager.add(
3086 type: "error",3092 stackedThreadToast({
3087 title: "Could not start implementation thread",3093 type: "error",
3088 description:3094 title: "Could not start implementation thread",
3089 err instanceof Error ? err.message : "An error occurred while creating the new thread.",3095 description:
3090 });3096 err instanceof Error
3097 ? err.message
3098 : "An error occurred while creating the new thread.",
3099 }),
3100 );
3091 })3101 })
3092 .then(finish, finish);3102 .then(finish, finish);
3093 }, [3103 }, [
apps/web/src/components/CommandPalette.tsx+36-26
...@@ -102,7 +102,7 @@ import {...@@ -102,7 +102,7 @@ import {
102} from "./ui/command";102} from "./ui/command";
103import { Button } from "./ui/button";103import { Button } from "./ui/button";
104import { Kbd, KbdGroup } from "./ui/kbd";104import { Kbd, KbdGroup } from "./ui/kbd";
105import { toastManager } from "./ui/toast";105import { stackedThreadToast, toastManager } from "./ui/toast";
106import { ComposerHandleContext, useComposerHandleContext } from "../composerHandleContext";106import { ComposerHandleContext, useComposerHandleContext } from "../composerHandleContext";
107import type { ChatComposerHandle } from "./chat/ChatComposer";107import type { ChatComposerHandle } from "./chat/ChatComposer";
108108
...@@ -605,11 +605,13 @@ function OpenCommandPaletteDialog() {...@@ -605,11 +605,13 @@ function OpenCommandPaletteDialog() {
605605
606 const environmentId = defaultAddProjectEnvironmentId;606 const environmentId = defaultAddProjectEnvironmentId;
607 if (!environmentId) {607 if (!environmentId) {
608 toastManager.add({608 toastManager.add(
609 type: "error",609 stackedThreadToast({
610 title: "Unable to browse projects",610 type: "error",
611 description: "No environment is available.",611 title: "Unable to browse projects",
612 });612 description: "No environment is available.",
613 }),
614 );
613 return;615 return;
614 }616 }
615617
...@@ -724,20 +726,24 @@ function OpenCommandPaletteDialog() {...@@ -724,20 +726,24 @@ function OpenCommandPaletteDialog() {
724 if (!api) return;726 if (!api) return;
725727
726 if (isUnsupportedWindowsProjectPath(rawCwd.trim(), browseEnvironmentPlatform)) {728 if (isUnsupportedWindowsProjectPath(rawCwd.trim(), browseEnvironmentPlatform)) {
727 toastManager.add({729 toastManager.add(
728 type: "error",730 stackedThreadToast({
729 title: "Failed to add project",731 type: "error",
730 description: "Windows-style paths are only supported on Windows.",732 title: "Failed to add project",
731 });733 description: "Windows-style paths are only supported on Windows.",
734 }),
735 );
732 return;736 return;
733 }737 }
734738
735 if (isExplicitRelativeProjectPath(rawCwd.trim()) && !currentProjectCwdForBrowse) {739 if (isExplicitRelativeProjectPath(rawCwd.trim()) && !currentProjectCwdForBrowse) {
736 toastManager.add({740 toastManager.add(
737 type: "error",741 stackedThreadToast({
738 title: "Failed to add project",742 type: "error",
739 description: "Relative paths require an active project.",743 title: "Failed to add project",
740 });744 description: "Relative paths require an active project.",
745 }),
746 );
741 return;747 return;
742 }748 }
743749
...@@ -790,11 +796,13 @@ function OpenCommandPaletteDialog() {...@@ -790,11 +796,13 @@ function OpenCommandPaletteDialog() {
790 }).catch(() => undefined);796 }).catch(() => undefined);
791 setOpen(false);797 setOpen(false);
792 } catch (error) {798 } catch (error) {
793 toastManager.add({799 toastManager.add(
794 type: "error",800 stackedThreadToast({
795 title: "Failed to add project",801 type: "error",
796 description: error instanceof Error ? error.message : "An error occurred.",802 title: "Failed to add project",
797 });803 description: error instanceof Error ? error.message : "An error occurred.",
804 }),
805 );
798 }806 }
799 },807 },
800 [808 [
...@@ -934,11 +942,13 @@ function OpenCommandPaletteDialog() {...@@ -934,11 +942,13 @@ function OpenCommandPaletteDialog() {
934 }942 }
935943
936 void item.run().catch((error: unknown) => {944 void item.run().catch((error: unknown) => {
937 toastManager.add({945 toastManager.add(
938 type: "error",946 stackedThreadToast({
939 title: "Unable to run command",947 type: "error",
940 description: error instanceof Error ? error.message : "An unexpected error occurred.",948 title: "Unable to run command",
941 });949 description: error instanceof Error ? error.message : "An unexpected error occurred.",
950 }),
951 );
942 });952 });
943 }953 }
944954
apps/web/src/components/GitActionsControl.browser.tsx+1
...@@ -90,6 +90,7 @@ vi.mock("~/components/ui/toast", () => ({...@@ -90,6 +90,7 @@ vi.mock("~/components/ui/toast", () => ({
90 promise: toastPromiseSpy,90 promise: toastPromiseSpy,
91 update: toastUpdateSpy,91 update: toastUpdateSpy,
92 },92 },
93 stackedThreadToast: vi.fn((options: unknown) => options),
93}));94}));
9495
95vi.mock("~/editorPreferences", () => ({96vi.mock("~/editorPreferences", () => ({
apps/web/src/components/GitActionsControl.tsx+52-34
...@@ -38,7 +38,7 @@ import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu";...@@ -38,7 +38,7 @@ import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu";
38import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover";38import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover";
39import { ScrollArea } from "~/components/ui/scroll-area";39import { ScrollArea } from "~/components/ui/scroll-area";
40import { Textarea } from "~/components/ui/textarea";40import { Textarea } from "~/components/ui/textarea";
41import { toastManager, type ThreadToastData } from "~/components/ui/toast";41import { stackedThreadToast, toastManager, type ThreadToastData } from "~/components/ui/toast";
42import { openInPreferredEditor } from "~/editorPreferences";42import { openInPreferredEditor } from "~/editorPreferences";
43import {43import {
44 gitInitMutationOptions,44 gitInitMutationOptions,
...@@ -474,12 +474,14 @@ export default function GitActionsControl({...@@ -474,12 +474,14 @@ export default function GitActionsControl({
474 return;474 return;
475 }475 }
476 void api.shell.openExternal(prUrl).catch((err: unknown) => {476 void api.shell.openExternal(prUrl).catch((err: unknown) => {
477 toastManager.add({477 toastManager.add(
478 type: "error",478 stackedThreadToast({
479 title: "Unable to open PR link",479 type: "error",
480 description: err instanceof Error ? err.message : "An error occurred.",480 title: "Unable to open PR link",
481 data: threadToastData,481 description: err instanceof Error ? err.message : "An error occurred.",
482 });482 ...(threadToastData !== undefined ? { data: threadToastData } : {}),
483 }),
484 );
483 });485 });
484 }, [gitStatusForActions, threadToastData]);486 }, [gitStatusForActions, threadToastData]);
485487
...@@ -670,33 +672,44 @@ export default function GitActionsControl({...@@ -670,33 +672,44 @@ export default function GitActionsControl({
670 };672 };
671 }673 }
672674
673 const successToastBase = {675 const successToastData = {
674 type: "success",676 ...scopedToastData,
675 title: result.toast.title,677 dismissAfterVisibleMs: 10_000,
676 description: result.toast.description,678 };
677 timeout: 0,
678 data: {
679 ...scopedToastData,
680 dismissAfterVisibleMs: 10_000,
681 },
682 } as const;
683679
684 if (toastActionProps) {680 if (toastActionProps) {
681 toastManager.update(
682 resolvedProgressToastId,
683 stackedThreadToast({
684 type: "success",
685 title: result.toast.title,
686 description: result.toast.description,
687 timeout: 0,
688 actionProps: toastActionProps,
689 actionVariant: "outline",
690 data: successToastData,
691 }),
692 );
693 } else {
685 toastManager.update(resolvedProgressToastId, {694 toastManager.update(resolvedProgressToastId, {
686 ...successToastBase,695 type: "success",
687 actionProps: toastActionProps,696 title: result.toast.title,
697 description: result.toast.description,
698 timeout: 0,
699 data: successToastData,
688 });700 });
689 } else {
690 toastManager.update(resolvedProgressToastId, successToastBase);
691 }701 }
692 } catch (err) {702 } catch (err) {
693 activeGitActionProgressRef.current = null;703 activeGitActionProgressRef.current = null;
694 toastManager.update(resolvedProgressToastId, {704 toastManager.update(
695 type: "error",705 resolvedProgressToastId,
696 title: "Action failed",706 stackedThreadToast({
697 description: err instanceof Error ? err.message : "An error occurred.",707 type: "error",
698 data: scopedToastData,708 title: "Action failed",
699 });709 description: err instanceof Error ? err.message : "An error occurred.",
710 ...(scopedToastData !== undefined ? { data: scopedToastData } : {}),
711 }),
712 );
700 }713 }
701 },714 },
702 );715 );
...@@ -753,7 +766,10 @@ export default function GitActionsControl({...@@ -753,7 +766,10 @@ export default function GitActionsControl({
753 }766 }
754 if (quickAction.kind === "run_pull") {767 if (quickAction.kind === "run_pull") {
755 const promise = pullMutation.mutateAsync();768 const promise = pullMutation.mutateAsync();
756 toastManager.promise(promise, {769 void toastManager.promise<
770 Awaited<ReturnType<typeof pullMutation.mutateAsync>>,
771 ThreadToastData
772 >(promise, {
757 loading: { title: "Pulling...", data: threadToastData },773 loading: { title: "Pulling...", data: threadToastData },
758 success: (result) => ({774 success: (result) => ({
759 title: result.status === "pulled" ? "Pulled" : "Already up to date",775 title: result.status === "pulled" ? "Pulled" : "Already up to date",
...@@ -832,12 +848,14 @@ export default function GitActionsControl({...@@ -832,12 +848,14 @@ export default function GitActionsControl({
832 }848 }
833 const target = resolvePathLinkTarget(filePath, gitCwd);849 const target = resolvePathLinkTarget(filePath, gitCwd);
834 void openInPreferredEditor(api, target).catch((error) => {850 void openInPreferredEditor(api, target).catch((error) => {
835 toastManager.add({851 toastManager.add(
836 type: "error",852 stackedThreadToast({
837 title: "Unable to open file",853 type: "error",
838 description: error instanceof Error ? error.message : "An error occurred.",854 title: "Unable to open file",
839 data: threadToastData,855 description: error instanceof Error ? error.message : "An error occurred.",
840 });856 ...(threadToastData !== undefined ? { data: threadToastData } : {}),
857 }),
858 );
841 });859 });
842 },860 },
843 [gitCwd, threadToastData],861 [gitCwd, threadToastData],
apps/web/src/components/PlanSidebar.tsx+8-6
...@@ -26,7 +26,7 @@ import {...@@ -26,7 +26,7 @@ import {
26} from "../proposedPlan";26} from "../proposedPlan";
27import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu";27import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu";
28import { readEnvironmentApi } from "~/environmentApi";28import { readEnvironmentApi } from "~/environmentApi";
29import { toastManager } from "./ui/toast";29import { stackedThreadToast, toastManager } from "./ui/toast";
30import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";30import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
3131
32function stepStatusIcon(status: string): React.ReactNode {32function stepStatusIcon(status: string): React.ReactNode {
...@@ -112,11 +112,13 @@ const PlanSidebar = memo(function PlanSidebar({...@@ -112,11 +112,13 @@ const PlanSidebar = memo(function PlanSidebar({
112 });112 });
113 })113 })
114 .catch((error) => {114 .catch((error) => {
115 toastManager.add({115 toastManager.add(
116 type: "error",116 stackedThreadToast({
117 title: "Could not save plan",117 type: "error",
118 description: error instanceof Error ? error.message : "An error occurred.",118 title: "Could not save plan",
119 });119 description: error instanceof Error ? error.message : "An error occurred.",
120 }),
121 );
120 })122 })
121 .then(123 .then(
122 () => setIsSavingToWorkspace(false),124 () => setIsSavingToWorkspace(false),
apps/web/src/components/Sidebar.tsx+155-128
...@@ -94,7 +94,7 @@ import {...@@ -94,7 +94,7 @@ import {
94 resolveThreadRouteRef,94 resolveThreadRouteRef,
95 resolveThreadRouteTarget,95 resolveThreadRouteTarget,
96} from "../threadRoutes";96} from "../threadRoutes";
97import { toastManager } from "./ui/toast";97import { stackedThreadToast, toastManager } from "./ui/toast";
98import { formatRelativeTimeLabel } from "../timestampFormat";98import { formatRelativeTimeLabel } from "../timestampFormat";
99import { SettingsSidebarNav } from "./settings/SettingsSidebarNav";99import { SettingsSidebarNav } from "./settings/SettingsSidebarNav";
100import { Kbd } from "./ui/kbd";100import { Kbd } from "./ui/kbd";
...@@ -950,11 +950,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -950,11 +950,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
950 });950 });
951 },951 },
952 onError: (error) => {952 onError: (error) => {
953 toastManager.add({953 toastManager.add(
954 type: "error",954 stackedThreadToast({
955 title: "Failed to copy thread ID",955 type: "error",
956 description: error instanceof Error ? error.message : "An error occurred.",956 title: "Failed to copy thread ID",
957 });957 description: error instanceof Error ? error.message : "An error occurred.",
958 }),
959 );
958 },960 },
959 });961 });
960 const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{962 const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{
...@@ -968,11 +970,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -968,11 +970,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
968 });970 });
969 },971 },
970 onError: (error) => {972 onError: (error) => {
971 toastManager.add({973 toastManager.add(
972 type: "error",974 stackedThreadToast({
973 title: "Failed to copy path",975 type: "error",
974 description: error instanceof Error ? error.message : "An error occurred.",976 title: "Failed to copy path",
975 });977 description: error instanceof Error ? error.message : "An error occurred.",
978 }),
979 );
976 },980 },
977 });981 });
978 const openPrLink = useCallback((event: React.MouseEvent<HTMLElement>, prUrl: string) => {982 const openPrLink = useCallback((event: React.MouseEvent<HTMLElement>, prUrl: string) => {
...@@ -989,11 +993,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -989,11 +993,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
989 }993 }
990994
991 void api.shell.openExternal(prUrl).catch((error) => {995 void api.shell.openExternal(prUrl).catch((error) => {
992 toastManager.add({996 toastManager.add(
993 type: "error",997 stackedThreadToast({
994 title: "Unable to open PR link",998 type: "error",
995 description: error instanceof Error ? error.message : "An error occurred.",999 title: "Unable to open PR link",
996 });1000 description: error instanceof Error ? error.message : "An error occurred.",
1001 }),
1002 );
997 });1003 });
998 }, []);1004 }, []);
999 const sidebarThreads = useStore(1005 const sidebarThreads = useStore(
...@@ -1299,72 +1305,73 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -1299,72 +1305,73 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
1299 const memberProjectRef = scopeProjectRef(member.environmentId, member.id);1305 const memberProjectRef = scopeProjectRef(member.environmentId, member.id);
1300 const memberThreadCount = memberThreadCountByPhysicalKey.get(member.physicalProjectKey) ?? 0;1306 const memberThreadCount = memberThreadCountByPhysicalKey.get(member.physicalProjectKey) ?? 0;
1301 if (memberThreadCount > 0) {1307 if (memberThreadCount > 0) {
1302 const warningToastId = toastManager.add({1308 const warningToastId = toastManager.add(
1303 type: "warning",1309 stackedThreadToast({
1304 title: "Project is not empty",1310 type: "warning",
1305 description: "Delete all threads in this project before removing it.",1311 title: "Project is not empty",
1306 data: {1312 description: "Delete all threads in this project before removing it.",
1307 actionLayout: "stacked-end",
1308 actionVariant: "destructive",1313 actionVariant: "destructive",
1309 },1314 actionProps: {
1310 actionProps: {1315 children: "Delete anyway",
1311 children: "Delete anyway",1316 onClick: () => {
1312 onClick: () => {1317 void (async () => {
1313 void (async () => {1318 toastManager.close(warningToastId);
1314 toastManager.close(warningToastId);1319 await new Promise<void>((resolve) => {
1315 await new Promise<void>((resolve) => {1320 window.setTimeout(resolve, 180);
1316 window.setTimeout(resolve, 180);1321 });
1317 });1322
13181323 const latestProjectThreads = selectSidebarThreadsForProjectRefs(
1319 const latestProjectThreads = selectSidebarThreadsForProjectRefs(1324 useStore.getState(),
1320 useStore.getState(),1325 [memberProjectRef],
1321 [memberProjectRef],1326 );
1322 );1327 const confirmed = await api.dialogs.confirm(
1323 const confirmed = await api.dialogs.confirm(1328 latestProjectThreads.length > 0
1324 latestProjectThreads.length > 01329 ? [
1325 ? [1330 `Remove project "${member.name}" and delete its ${latestProjectThreads.length} thread${
1326 `Remove project "${member.name}" and delete its ${latestProjectThreads.length} thread${1331 latestProjectThreads.length === 1 ? "" : "s"
1327 latestProjectThreads.length === 1 ? "" : "s"1332 }?`,
1328 }?`,1333 `Path: ${member.cwd}`,
1329 `Path: ${member.cwd}`,1334 ...(member.environmentLabel
1330 ...(member.environmentLabel1335 ? [`Environment: ${member.environmentLabel}`]
1331 ? [`Environment: ${member.environmentLabel}`]1336 : []),
1332 : []),1337 "This permanently clears conversation history for those threads.",
1333 "This permanently clears conversation history for those threads.",1338 "This removes only this project entry.",
1334 "This removes only this project entry.",1339 "This action cannot be undone.",
1335 "This action cannot be undone.",1340 ].join("\n")
1336 ].join("\n")1341 : [
1337 : [1342 `Remove project "${member.name}"?`,
1338 `Remove project "${member.name}"?`,1343 `Path: ${member.cwd}`,
1339 `Path: ${member.cwd}`,1344 ...(member.environmentLabel
1340 ...(member.environmentLabel1345 ? [`Environment: ${member.environmentLabel}`]
1341 ? [`Environment: ${member.environmentLabel}`]1346 : []),
1342 : []),1347 "This removes only this project entry.",
1343 "This removes only this project entry.",1348 ].join("\n"),
1344 ].join("\n"),1349 );
1345 );1350 if (!confirmed) {
1346 if (!confirmed) {1351 return;
1347 return;1352 }
1348 }
13491353
1350 await removeProject(member, { force: true });1354 await removeProject(member, { force: true });
1351 })().catch((error) => {1355 })().catch((error) => {
1352 const message =1356 const message =
1353 error instanceof Error ? error.message : "Unknown error removing project.";1357 error instanceof Error ? error.message : "Unknown error removing project.";
1354 console.error("Failed to remove project", {1358 console.error("Failed to remove project", {
1355 projectId: member.id,1359 projectId: member.id,
1356 environmentId: member.environmentId,1360 environmentId: member.environmentId,
1357 error,1361 error,
1358 });1362 });
1359 toastManager.add({1363 toastManager.add(
1360 type: "error",1364 stackedThreadToast({
1361 title: `Failed to remove "${member.name}"`,1365 type: "error",
1362 description: message,1366 title: `Failed to remove "${member.name}"`,
1367 description: message,
1368 }),
1369 );
1363 });1370 });
1364 });1371 },
1365 },1372 },
1366 },1373 }),
1367 });1374 );
1368 return;1375 return;
1369 }1376 }
13701377
...@@ -1388,11 +1395,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -1388,11 +1395,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
1388 environmentId: member.environmentId,1395 environmentId: member.environmentId,
1389 error,1396 error,
1390 });1397 });
1391 toastManager.add({1398 toastManager.add(
1392 type: "error",1399 stackedThreadToast({
1393 title: `Failed to remove "${member.name}"`,1400 type: "error",
1394 description: message,1401 title: `Failed to remove "${member.name}"`,
1395 });1402 description: message,
1403 }),
1404 );
1396 }1405 }
1397 },1406 },
1398 [memberThreadCountByPhysicalKey, removeProject],1407 [memberThreadCountByPhysicalKey, removeProject],
...@@ -1705,11 +1714,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -1705,11 +1714,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
1705 try {1714 try {
1706 await archiveThread(threadRef);1715 await archiveThread(threadRef);
1707 } catch (error) {1716 } catch (error) {
1708 toastManager.add({1717 toastManager.add(
1709 type: "error",1718 stackedThreadToast({
1710 title: "Failed to archive thread",1719 type: "error",
1711 description: error instanceof Error ? error.message : "An error occurred.",1720 title: "Failed to archive thread",
1712 });1721 description: error instanceof Error ? error.message : "An error occurred.",
1722 }),
1723 );
1713 }1724 }
1714 },1725 },
1715 [archiveThread],1726 [archiveThread],
...@@ -1757,11 +1768,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -1757,11 +1768,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
1757 title: trimmed,1768 title: trimmed,
1758 });1769 });
1759 } catch (error) {1770 } catch (error) {
1760 toastManager.add({1771 toastManager.add(
1761 type: "error",1772 stackedThreadToast({
1762 title: "Failed to rename thread",1773 type: "error",
1763 description: error instanceof Error ? error.message : "An error occurred.",1774 title: "Failed to rename thread",
1764 });1775 description: error instanceof Error ? error.message : "An error occurred.",
1776 }),
1777 );
1765 }1778 }
1766 finishRename();1779 finishRename();
1767 },1780 },
...@@ -1794,11 +1807,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -1794,11 +1807,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
17941807
1795 const api = readEnvironmentApi(projectRenameTarget.environmentId);1808 const api = readEnvironmentApi(projectRenameTarget.environmentId);
1796 if (!api) {1809 if (!api) {
1797 toastManager.add({1810 toastManager.add(
1798 type: "error",1811 stackedThreadToast({
1799 title: "Failed to rename project",1812 type: "error",
1800 description: "Project API unavailable.",1813 title: "Failed to rename project",
1801 });1814 description: "Project API unavailable.",
1815 }),
1816 );
1802 return;1817 return;
1803 }1818 }
18041819
...@@ -1811,11 +1826,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -1811,11 +1826,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
1811 });1826 });
1812 closeProjectRenameDialog();1827 closeProjectRenameDialog();
1813 } catch (error) {1828 } catch (error) {
1814 toastManager.add({1829 toastManager.add(
1815 type: "error",1830 stackedThreadToast({
1816 title: "Failed to rename project",1831 type: "error",
1817 description: error instanceof Error ? error.message : "An error occurred.",1832 title: "Failed to rename project",
1818 });1833 description: error instanceof Error ? error.message : "An error occurred.",
1834 }),
1835 );
1819 }1836 }
1820 }, [closeProjectRenameDialog, projectRenameTarget, projectRenameTitle]);1837 }, [closeProjectRenameDialog, projectRenameTarget, projectRenameTitle]);
18211838
...@@ -1885,11 +1902,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -1885,11 +1902,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
1885 }1902 }
1886 if (clicked === "copy-path") {1903 if (clicked === "copy-path") {
1887 if (!threadWorkspacePath) {1904 if (!threadWorkspacePath) {
1888 toastManager.add({1905 toastManager.add(
1889 type: "error",1906 stackedThreadToast({
1890 title: "Path unavailable",1907 type: "error",
1891 description: "This thread does not have a workspace path to copy.",1908 title: "Path unavailable",
1892 });1909 description: "This thread does not have a workspace path to copy.",
1910 }),
1911 );
1893 return;1912 return;
1894 }1913 }
1895 copyPathToClipboard(threadWorkspacePath, { path: threadWorkspacePath });1914 copyPathToClipboard(threadWorkspacePath, { path: threadWorkspacePath });
...@@ -3220,18 +3239,22 @@ export default function Sidebar() {...@@ -3220,18 +3239,22 @@ export default function Sidebar() {
3220 if (!shouldToastDesktopUpdateActionResult(result)) return;3239 if (!shouldToastDesktopUpdateActionResult(result)) return;
3221 const actionError = getDesktopUpdateActionError(result);3240 const actionError = getDesktopUpdateActionError(result);
3222 if (!actionError) return;3241 if (!actionError) return;
3223 toastManager.add({3242 toastManager.add(
3224 type: "error",3243 stackedThreadToast({
3225 title: "Could not download update",3244 type: "error",
3226 description: actionError,3245 title: "Could not download update",
3227 });3246 description: actionError,
3247 }),
3248 );
3228 })3249 })
3229 .catch((error) => {3250 .catch((error) => {
3230 toastManager.add({3251 toastManager.add(
3231 type: "error",3252 stackedThreadToast({
3232 title: "Could not start update download",3253 type: "error",
3233 description: error instanceof Error ? error.message : "An unexpected error occurred.",3254 title: "Could not start update download",
3234 });3255 description: error instanceof Error ? error.message : "An unexpected error occurred.",
3256 }),
3257 );
3235 });3258 });
3236 return;3259 return;
3237 }3260 }
...@@ -3247,18 +3270,22 @@ export default function Sidebar() {...@@ -3247,18 +3270,22 @@ export default function Sidebar() {
3247 if (!shouldToastDesktopUpdateActionResult(result)) return;3270 if (!shouldToastDesktopUpdateActionResult(result)) return;
3248 const actionError = getDesktopUpdateActionError(result);3271 const actionError = getDesktopUpdateActionError(result);
3249 if (!actionError) return;3272 if (!actionError) return;
3250 toastManager.add({3273 toastManager.add(
3251 type: "error",3274 stackedThreadToast({
3252 title: "Could not install update",3275 type: "error",
3253 description: actionError,3276 title: "Could not install update",
3254 });3277 description: actionError,
3278 }),
3279 );
3255 })3280 })
3256 .catch((error) => {3281 .catch((error) => {
3257 toastManager.add({3282 toastManager.add(
3258 type: "error",3283 stackedThreadToast({
3259 title: "Could not install update",3284 type: "error",
3260 description: error instanceof Error ? error.message : "An unexpected error occurred.",3285 title: "Could not install update",
3261 });3286 description: error instanceof Error ? error.message : "An unexpected error occurred.",
3287 }),
3288 );
3262 });3289 });
3263 }3290 }
3264 }, [desktopUpdateButtonAction, desktopUpdateButtonDisabled, desktopUpdateState]);3291 }, [desktopUpdateButtonAction, desktopUpdateButtonDisabled, desktopUpdateState]);
apps/web/src/components/WebSocketConnectionSurface.tsx+58-29
...@@ -10,7 +10,7 @@ import {...@@ -10,7 +10,7 @@ import {
10 useWsConnectionStatus,10 useWsConnectionStatus,
11 WS_RECONNECT_MAX_ATTEMPTS,11 WS_RECONNECT_MAX_ATTEMPTS,
12} from "../rpc/wsConnectionState";12} from "../rpc/wsConnectionState";
13import { toastManager } from "./ui/toast";13import { stackedThreadToast, toastManager } from "./ui/toast";
14import { getPrimaryEnvironmentConnection } from "../environments/runtime";14import { getPrimaryEnvironmentConnection } from "../environments/runtime";
1515
16const FORCED_WS_RECONNECT_DEBOUNCE_MS = 5_000;16const FORCED_WS_RECONNECT_DEBOUNCE_MS = 5_000;
...@@ -75,13 +75,34 @@ function describeRecoveredToast(...@@ -75,13 +75,34 @@ function describeRecoveredToast(
75 return "Connection restored.";75 return "Connection restored.";
76}76}
7777
78function describeSlowRpcAckToast(requests: ReadonlyArray<SlowRpcAckRequest>): ReactNode {78function describeSlowRpcAckToast(requests: ReadonlyArray<SlowRpcAckRequest>): string {
79 const count = requests.length;79 const count = requests.length;
80 const thresholdSeconds = Math.round((requests[0]?.thresholdMs ?? 0) / 1000);80 const thresholdSeconds = Math.round((requests[0]?.thresholdMs ?? 0) / 1000);
8181
82 return `${count} request${count === 1 ? "" : "s"} waiting longer than ${thresholdSeconds}s.`;82 return `${count} request${count === 1 ? "" : "s"} waiting longer than ${thresholdSeconds}s.`;
83}83}
8484
85function SlowRpcAckRequestDetails({ requests }: { requests: ReadonlyArray<SlowRpcAckRequest> }) {
86 return (
87 <ul className="space-y-2.5 text-xs text-muted-foreground">
88 {requests.map((req) => (
89 <li
90 className="min-w-0 border-border/50 border-b pb-2 last:border-b-0 last:pb-0"
91 key={req.requestId}
92 >
93 <div className="wrap-break-word font-medium text-foreground">{req.tag}</div>
94 <div className="mt-0.5 font-mono text-[10px] leading-snug opacity-90">
95 {req.requestId}
96 </div>
97 <div className="mt-0.5 text-[10px] opacity-75">
98 Started {formatConnectionMoment(req.startedAt) ?? req.startedAt}
99 </div>
100 </li>
101 ))}
102 </ul>
103 );
104}
105
85export function shouldAutoReconnect(106export function shouldAutoReconnect(
86 status: WsConnectionStatus,107 status: WsConnectionStatus,
87 trigger: WsAutoReconnectTrigger,108 trigger: WsAutoReconnectTrigger,
...@@ -138,15 +159,18 @@ export function WebSocketConnectionCoordinator() {...@@ -138,15 +159,18 @@ export function WebSocketConnectionCoordinator() {
138 console.warn("Automatic WebSocket reconnect failed", { error });159 console.warn("Automatic WebSocket reconnect failed", { error });
139 return;160 return;
140 }161 }
141 toastManager.add({162 toastManager.add(
142 type: "error",163 stackedThreadToast({
143 title: "Reconnect failed",164 type: "error",
144 description: error instanceof Error ? error.message : "Unable to restart the WebSocket.",165 title: "Reconnect failed",
145 data: {166 description:
146 dismissAfterVisibleMs: 8_000,167 error instanceof Error ? error.message : "Unable to restart the WebSocket.",
147 hideCopyButton: true,168 data: {
148 },169 dismissAfterVisibleMs: 8_000,
149 });170 hideCopyButton: true,
171 },
172 }),
173 );
150 });174 });
151 });175 });
152 const syncBrowserOnlineStatus = useEffectEvent(() => {176 const syncBrowserOnlineStatus = useEffectEvent(() => {
...@@ -253,45 +277,45 @@ export function WebSocketConnectionCoordinator() {...@@ -253,45 +277,45 @@ export function WebSocketConnectionCoordinator() {
253277
254 if (shouldShowReconnectToast || shouldShowOfflineToast || shouldShowExhaustedToast) {278 if (shouldShowReconnectToast || shouldShowOfflineToast || shouldShowExhaustedToast) {
255 const toastPayload = shouldShowOfflineToast279 const toastPayload = shouldShowOfflineToast
256 ? {280 ? stackedThreadToast({
257 description: describeOfflineToast(),
258 timeout: 0,
259 title: "Offline",
260 type: "warning" as const,
261 data: {281 data: {
262 hideCopyButton: true,282 hideCopyButton: true,
263 },283 },
264 }284 description: describeOfflineToast(),
285 timeout: 0,
286 title: "Offline",
287 type: "warning",
288 })
265 : shouldShowExhaustedToast289 : shouldShowExhaustedToast
266 ? {290 ? stackedThreadToast({
267 actionProps: {291 actionProps: {
268 children: "Retry",292 children: "Retry",
269 onClick: triggerManualReconnect,293 onClick: triggerManualReconnect,
270 },294 },
271 description: describeExhaustedToast(),
272 timeout: 0,
273 title: "Disconnected from T3 Server",
274 type: "error" as const,
275 data: {295 data: {
276 hideCopyButton: true,296 hideCopyButton: true,
277 },297 },
278 }298 description: describeExhaustedToast(),
279 : {299 timeout: 0,
300 title: "Disconnected from T3 Server",
301 type: "error",
302 })
303 : stackedThreadToast({
280 actionProps: {304 actionProps: {
281 children: "Retry now",305 children: "Retry now",
282 onClick: triggerManualReconnect,306 onClick: triggerManualReconnect,
283 },307 },
308 data: {
309 hideCopyButton: true,
310 },
284 description:311 description:
285 status.nextRetryAt === null312 status.nextRetryAt === null
286 ? `Reconnecting... ${formatReconnectAttemptLabel(status)}`313 ? `Reconnecting... ${formatReconnectAttemptLabel(status)}`
287 : `Reconnecting in ${formatRetryCountdown(status.nextRetryAt, nowMs)}... ${formatReconnectAttemptLabel(status)}`,314 : `Reconnecting in ${formatRetryCountdown(status.nextRetryAt, nowMs)}... ${formatReconnectAttemptLabel(status)}`,
288 timeout: 0,315 timeout: 0,
289 title: buildReconnectTitle(status),316 title: buildReconnectTitle(status),
290 type: "loading" as const,317 type: "loading",
291 data: {318 });
292 hideCopyButton: true,
293 },
294 };
295319
296 if (toastIdRef.current) {320 if (toastIdRef.current) {
297 toastManager.update(toastIdRef.current, toastPayload);321 toastManager.update(toastIdRef.current, toastPayload);
...@@ -369,6 +393,11 @@ export function SlowRpcAckToastCoordinator() {...@@ -369,6 +393,11 @@ export function SlowRpcAckToastCoordinator() {
369 }393 }
370394
371 const nextToast = {395 const nextToast = {
396 data: {
397 expandableContent: <SlowRpcAckRequestDetails requests={slowRequests} />,
398 expandableDescriptionTrigger: true,
399 expandableLabels: { collapse: "Hide requests", expand: "Show requests" },
400 },
372 description: describeSlowRpcAckToast(slowRequests),401 description: describeSlowRpcAckToast(slowRequests),
373 timeout: 0,402 timeout: 0,
374 title: "Some requests are slow",403 title: "Some requests are slow",
apps/web/src/components/chat/ProposedPlanCard.tsx+22-16
...@@ -24,7 +24,7 @@ import {...@@ -24,7 +24,7 @@ import {
24 DialogPopup,24 DialogPopup,
25 DialogTitle,25 DialogTitle,
26} from "../ui/dialog";26} from "../ui/dialog";
27import { toastManager } from "../ui/toast";27import { stackedThreadToast, toastManager } from "../ui/toast";
28import { readEnvironmentApi } from "~/environmentApi";28import { readEnvironmentApi } from "~/environmentApi";
29import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";29import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
3030
...@@ -45,11 +45,13 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({...@@ -45,11 +45,13 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({
45 const [isSavingToWorkspace, setIsSavingToWorkspace] = useState(false);45 const [isSavingToWorkspace, setIsSavingToWorkspace] = useState(false);
46 const { copyToClipboard, isCopied } = useCopyToClipboard({46 const { copyToClipboard, isCopied } = useCopyToClipboard({
47 onError: (error) => {47 onError: (error) => {
48 toastManager.add({48 toastManager.add(
49 type: "error",49 stackedThreadToast({
50 title: "Could not copy plan",50 type: "error",
51 description: error instanceof Error ? error.message : "An error occurred while copying.",51 title: "Could not copy plan",
52 });52 description: error instanceof Error ? error.message : "An error occurred while copying.",
53 }),
54 );
53 },55 },
54 });56 });
55 const savePathInputId = useId();57 const savePathInputId = useId();
...@@ -73,11 +75,13 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({...@@ -73,11 +75,13 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({
7375
74 const openSaveDialog = () => {76 const openSaveDialog = () => {
75 if (!workspaceRoot) {77 if (!workspaceRoot) {
76 toastManager.add({78 toastManager.add(
77 type: "error",79 stackedThreadToast({
78 title: "Workspace path is unavailable",80 type: "error",
79 description: "This thread does not have a workspace path to save into.",81 title: "Workspace path is unavailable",
80 });82 description: "This thread does not have a workspace path to save into.",
83 }),
84 );
81 return;85 return;
82 }86 }
83 setSavePath((existing) => (existing.length > 0 ? existing : downloadFilename));87 setSavePath((existing) => (existing.length > 0 ? existing : downloadFilename));
...@@ -114,11 +118,13 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({...@@ -114,11 +118,13 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({
114 });118 });
115 })119 })
116 .catch((error) => {120 .catch((error) => {
117 toastManager.add({121 toastManager.add(
118 type: "error",122 stackedThreadToast({
119 title: "Could not save plan",123 type: "error",
120 description: error instanceof Error ? error.message : "An error occurred while saving.",124 title: "Could not save plan",
121 });125 description: error instanceof Error ? error.message : "An error occurred while saving.",
126 }),
127 );
122 })128 })
123 .then(129 .then(
124 () => {130 () => {
apps/web/src/components/settings/ConnectionsSettings.tsx+64-46
...@@ -41,7 +41,7 @@ import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";...@@ -41,7 +41,7 @@ import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
41import { QRCodeSvg } from "../ui/qr-code";41import { QRCodeSvg } from "../ui/qr-code";
42import { Spinner } from "../ui/spinner";42import { Spinner } from "../ui/spinner";
43import { Switch } from "../ui/switch";43import { Switch } from "../ui/switch";
44import { toastManager } from "../ui/toast";44import { stackedThreadToast, toastManager } from "../ui/toast";
45import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";45import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
46import { Button } from "../ui/button";46import { Button } from "../ui/button";
47import { Textarea } from "../ui/textarea";47import { Textarea } from "../ui/textarea";
...@@ -302,11 +302,13 @@ const PairingLinkListRow = memo(function PairingLinkListRow({...@@ -302,11 +302,13 @@ const PairingLinkListRow = memo(function PairingLinkListRow({
302 },302 },
303 onError: (error) => {303 onError: (error) => {
304 setIsRevealDialogOpen(true);304 setIsRevealDialogOpen(true);
305 toastManager.add({305 toastManager.add(
306 type: "error",306 stackedThreadToast({
307 title: canCopyToClipboard ? "Could not copy pairing URL" : "Clipboard copy unavailable",307 type: "error",
308 description: canCopyToClipboard ? error.message : "Showing the full value instead.",308 title: canCopyToClipboard ? "Could not copy pairing URL" : "Clipboard copy unavailable",
309 });309 description: canCopyToClipboard ? error.message : "Showing the full value instead.",
310 }),
311 );
310 },312 },
311 });313 });
312314
...@@ -535,11 +537,13 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio...@@ -535,11 +537,13 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio
535 setDialogOpen(false);537 setDialogOpen(false);
536 } catch (error) {538 } catch (error) {
537 const message = error instanceof Error ? error.message : "Failed to create pairing URL.";539 const message = error instanceof Error ? error.message : "Failed to create pairing URL.";
538 toastManager.add({540 toastManager.add(
539 type: "error",541 stackedThreadToast({
540 title: "Could not create pairing URL",542 type: "error",
541 description: message,543 title: "Could not create pairing URL",
542 });544 description: message,
545 }),
546 );
543 } finally {547 } finally {
544 setIsCreatingPairingLink(false);548 setIsCreatingPairingLink(false);
545 }549 }
...@@ -828,11 +832,13 @@ export function ConnectionsSettings() {...@@ -828,11 +832,13 @@ export function ConnectionsSettings() {
828 error instanceof Error ? error.message : "Failed to update network exposure.";832 error instanceof Error ? error.message : "Failed to update network exposure.";
829 setPendingDesktopServerExposureMode(null);833 setPendingDesktopServerExposureMode(null);
830 setDesktopServerExposureError(message);834 setDesktopServerExposureError(message);
831 toastManager.add({835 toastManager.add(
832 type: "error",836 stackedThreadToast({
833 title: "Could not update network access",837 type: "error",
834 description: message,838 title: "Could not update network access",
835 });839 description: message,
840 }),
841 );
836 setIsUpdatingDesktopServerExposure(false);842 setIsUpdatingDesktopServerExposure(false);
837 }843 }
838 },844 },
...@@ -853,11 +859,13 @@ export function ConnectionsSettings() {...@@ -853,11 +859,13 @@ export function ConnectionsSettings() {
853 } catch (error) {859 } catch (error) {
854 const message = error instanceof Error ? error.message : "Failed to revoke pairing link.";860 const message = error instanceof Error ? error.message : "Failed to revoke pairing link.";
855 setDesktopAccessManagementError(message);861 setDesktopAccessManagementError(message);
856 toastManager.add({862 toastManager.add(
857 type: "error",863 stackedThreadToast({
858 title: "Could not revoke pairing link",864 type: "error",
859 description: message,865 title: "Could not revoke pairing link",
860 });866 description: message,
867 }),
868 );
861 } finally {869 } finally {
862 setRevokingDesktopPairingLinkId(null);870 setRevokingDesktopPairingLinkId(null);
863 }871 }
...@@ -872,11 +880,13 @@ export function ConnectionsSettings() {...@@ -872,11 +880,13 @@ export function ConnectionsSettings() {
872 } catch (error) {880 } catch (error) {
873 const message = error instanceof Error ? error.message : "Failed to revoke client access.";881 const message = error instanceof Error ? error.message : "Failed to revoke client access.";
874 setDesktopAccessManagementError(message);882 setDesktopAccessManagementError(message);
875 toastManager.add({883 toastManager.add(
876 type: "error",884 stackedThreadToast({
877 title: "Could not revoke client access",885 type: "error",
878 description: message,886 title: "Could not revoke client access",
879 });887 description: message,
888 }),
889 );
880 } finally {890 } finally {
881 setRevokingDesktopClientSessionId(null);891 setRevokingDesktopClientSessionId(null);
882 }892 }
...@@ -897,11 +907,13 @@ export function ConnectionsSettings() {...@@ -897,11 +907,13 @@ export function ConnectionsSettings() {
897 } catch (error) {907 } catch (error) {
898 const message = error instanceof Error ? error.message : "Failed to revoke other clients.";908 const message = error instanceof Error ? error.message : "Failed to revoke other clients.";
899 setDesktopAccessManagementError(message);909 setDesktopAccessManagementError(message);
900 toastManager.add({910 toastManager.add(
901 type: "error",911 stackedThreadToast({
902 title: "Could not revoke other clients",912 type: "error",
903 description: message,913 title: "Could not revoke other clients",
904 });914 description: message,
915 }),
916 );
905 } finally {917 } finally {
906 setIsRevokingOtherDesktopClients(false);918 setIsRevokingOtherDesktopClients(false);
907 }919 }
...@@ -933,11 +945,13 @@ export function ConnectionsSettings() {...@@ -933,11 +945,13 @@ export function ConnectionsSettings() {
933 } catch (error) {945 } catch (error) {
934 const message = error instanceof Error ? error.message : "Failed to add backend.";946 const message = error instanceof Error ? error.message : "Failed to add backend.";
935 setSavedBackendError(message);947 setSavedBackendError(message);
936 toastManager.add({948 toastManager.add(
937 type: "error",949 stackedThreadToast({
938 title: "Could not add backend",950 type: "error",
939 description: message,951 title: "Could not add backend",
940 });952 description: message,
953 }),
954 );
941 } finally {955 } finally {
942 setIsAddingSavedBackend(false);956 setIsAddingSavedBackend(false);
943 }957 }
...@@ -957,11 +971,13 @@ export function ConnectionsSettings() {...@@ -957,11 +971,13 @@ export function ConnectionsSettings() {
957 } catch (error) {971 } catch (error) {
958 const message = error instanceof Error ? error.message : "Failed to reconnect backend.";972 const message = error instanceof Error ? error.message : "Failed to reconnect backend.";
959 setSavedBackendError(message);973 setSavedBackendError(message);
960 toastManager.add({974 toastManager.add(
961 type: "error",975 stackedThreadToast({
962 title: "Could not reconnect backend",976 type: "error",
963 description: message,977 title: "Could not reconnect backend",
964 });978 description: message,
979 }),
980 );
965 } finally {981 } finally {
966 setReconnectingSavedEnvironmentId(null);982 setReconnectingSavedEnvironmentId(null);
967 }983 }
...@@ -975,11 +991,13 @@ export function ConnectionsSettings() {...@@ -975,11 +991,13 @@ export function ConnectionsSettings() {
975 } catch (error) {991 } catch (error) {
976 const message = error instanceof Error ? error.message : "Failed to remove backend.";992 const message = error instanceof Error ? error.message : "Failed to remove backend.";
977 setSavedBackendError(message);993 setSavedBackendError(message);
978 toastManager.add({994 toastManager.add(
979 type: "error",995 stackedThreadToast({
980 title: "Could not remove backend",996 type: "error",
981 description: message,997 title: "Could not remove backend",
982 });998 description: message,
999 }),
1000 );
983 } finally {1001 } finally {
984 setRemovingSavedEnvironmentId(null);1002 setRemovingSavedEnvironmentId(null);
985 }1003 }
apps/web/src/components/settings/SettingsPanels.tsx+52-38
...@@ -63,7 +63,7 @@ import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from ".....@@ -63,7 +63,7 @@ import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "..
63import { Input } from "../ui/input";63import { Input } from "../ui/input";
64import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";64import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
65import { Switch } from "../ui/switch";65import { Switch } from "../ui/switch";
66import { toastManager } from "../ui/toast";66import { stackedThreadToast, toastManager } from "../ui/toast";
67import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";67import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
68import {68import {
69 SettingResetButton,69 SettingResetButton,
...@@ -282,11 +282,13 @@ function AboutVersionSection() {...@@ -282,11 +282,13 @@ function AboutVersionSection() {
282 setDesktopUpdateStateQueryData(queryClient, state);282 setDesktopUpdateStateQueryData(queryClient, state);
283 })283 })
284 .catch((error: unknown) => {284 .catch((error: unknown) => {
285 toastManager.add({285 toastManager.add(
286 type: "error",286 stackedThreadToast({
287 title: "Could not change update track",287 type: "error",
288 description: error instanceof Error ? error.message : "Update track change failed.",288 title: "Could not change update track",
289 });289 description: error instanceof Error ? error.message : "Update track change failed.",
290 }),
291 );
290 })292 })
291 .finally(() => {293 .finally(() => {
292 setIsChangingUpdateChannel(false);294 setIsChangingUpdateChannel(false);
...@@ -308,11 +310,13 @@ function AboutVersionSection() {...@@ -308,11 +310,13 @@ function AboutVersionSection() {
308 setDesktopUpdateStateQueryData(queryClient, result.state);310 setDesktopUpdateStateQueryData(queryClient, result.state);
309 })311 })
310 .catch((error: unknown) => {312 .catch((error: unknown) => {
311 toastManager.add({313 toastManager.add(
312 type: "error",314 stackedThreadToast({
313 title: "Could not download update",315 type: "error",
314 description: error instanceof Error ? error.message : "Download failed.",316 title: "Could not download update",
315 });317 description: error instanceof Error ? error.message : "Download failed.",
318 }),
319 );
316 });320 });
317 return;321 return;
318 }322 }
...@@ -330,11 +334,13 @@ function AboutVersionSection() {...@@ -330,11 +334,13 @@ function AboutVersionSection() {
330 setDesktopUpdateStateQueryData(queryClient, result.state);334 setDesktopUpdateStateQueryData(queryClient, result.state);
331 })335 })
332 .catch((error: unknown) => {336 .catch((error: unknown) => {
333 toastManager.add({337 toastManager.add(
334 type: "error",338 stackedThreadToast({
335 title: "Could not install update",339 type: "error",
336 description: error instanceof Error ? error.message : "Install failed.",340 title: "Could not install update",
337 });341 description: error instanceof Error ? error.message : "Install failed.",
342 }),
343 );
338 });344 });
339 return;345 return;
340 }346 }
...@@ -345,20 +351,24 @@ function AboutVersionSection() {...@@ -345,20 +351,24 @@ function AboutVersionSection() {
345 .then((result) => {351 .then((result) => {
346 setDesktopUpdateStateQueryData(queryClient, result.state);352 setDesktopUpdateStateQueryData(queryClient, result.state);
347 if (!result.checked) {353 if (!result.checked) {
348 toastManager.add({354 toastManager.add(
349 type: "error",355 stackedThreadToast({
350 title: "Could not check for updates",356 type: "error",
351 description:357 title: "Could not check for updates",
352 result.state.message ?? "Automatic updates are not available in this build.",358 description:
353 });359 result.state.message ?? "Automatic updates are not available in this build.",
360 }),
361 );
354 }362 }
355 })363 })
356 .catch((error: unknown) => {364 .catch((error: unknown) => {
357 toastManager.add({365 toastManager.add(
358 type: "error",366 stackedThreadToast({
359 title: "Could not check for updates",367 type: "error",
360 description: error instanceof Error ? error.message : "Update check failed.",368 title: "Could not check for updates",
361 });369 description: error instanceof Error ? error.message : "Update check failed.",
370 }),
371 );
362 });372 });
363 }, [queryClient, updateState]);373 }, [queryClient, updateState]);
364374
...@@ -1688,11 +1698,13 @@ export function ArchivedThreadsPanel() {...@@ -1688,11 +1698,13 @@ export function ArchivedThreadsPanel() {
1688 try {1698 try {
1689 await unarchiveThread(threadRef);1699 await unarchiveThread(threadRef);
1690 } catch (error) {1700 } catch (error) {
1691 toastManager.add({1701 toastManager.add(
1692 type: "error",1702 stackedThreadToast({
1693 title: "Failed to unarchive thread",1703 type: "error",
1694 description: error instanceof Error ? error.message : "An error occurred.",1704 title: "Failed to unarchive thread",
1695 });1705 description: error instanceof Error ? error.message : "An error occurred.",
1706 }),
1707 );
1696 }1708 }
1697 return;1709 return;
1698 }1710 }
...@@ -1756,12 +1768,14 @@ export function ArchivedThreadsPanel() {...@@ -1756,12 +1768,14 @@ export function ArchivedThreadsPanel() {
1756 onClick={() =>1768 onClick={() =>
1757 void unarchiveThread(scopeThreadRef(thread.environmentId, thread.id)).catch(1769 void unarchiveThread(scopeThreadRef(thread.environmentId, thread.id)).catch(
1758 (error) => {1770 (error) => {
1759 toastManager.add({1771 toastManager.add(
1760 type: "error",1772 stackedThreadToast({
1761 title: "Failed to unarchive thread",1773 type: "error",
1762 description:1774 title: "Failed to unarchive thread",
1763 error instanceof Error ? error.message : "An error occurred.",1775 description:
1764 });1776 error instanceof Error ? error.message : "An error occurred.",
1777 }),
1778 );
1765 },1779 },
1766 )1780 )
1767 }1781 }
apps/web/src/components/sidebar/SidebarUpdatePill.tsx+29-21
...@@ -6,7 +6,7 @@ import {...@@ -6,7 +6,7 @@ import {
6 setDesktopUpdateStateQueryData,6 setDesktopUpdateStateQueryData,
7 useDesktopUpdateState,7 useDesktopUpdateState,
8} from "../../lib/desktopUpdateReactQuery";8} from "../../lib/desktopUpdateReactQuery";
9import { toastManager } from "../ui/toast";9import { stackedThreadToast, toastManager } from "../ui/toast";
10import {10import {
11 getArm64IntelBuildWarningDescription,11 getArm64IntelBuildWarningDescription,
12 getDesktopUpdateActionError,12 getDesktopUpdateActionError,
...@@ -55,18 +55,22 @@ export function SidebarUpdatePill() {...@@ -55,18 +55,22 @@ export function SidebarUpdatePill() {
55 if (!shouldToastDesktopUpdateActionResult(result)) return;55 if (!shouldToastDesktopUpdateActionResult(result)) return;
56 const actionError = getDesktopUpdateActionError(result);56 const actionError = getDesktopUpdateActionError(result);
57 if (!actionError) return;57 if (!actionError) return;
58 toastManager.add({58 toastManager.add(
59 type: "error",59 stackedThreadToast({
60 title: "Could not download update",60 type: "error",
61 description: actionError,61 title: "Could not download update",
62 });62 description: actionError,
63 }),
64 );
63 })65 })
64 .catch((error) => {66 .catch((error) => {
65 toastManager.add({67 toastManager.add(
66 type: "error",68 stackedThreadToast({
67 title: "Could not start update download",69 type: "error",
68 description: error instanceof Error ? error.message : "An unexpected error occurred.",70 title: "Could not start update download",
69 });71 description: error instanceof Error ? error.message : "An unexpected error occurred.",
72 }),
73 );
70 });74 });
71 return;75 return;
72 }76 }
...@@ -81,18 +85,22 @@ export function SidebarUpdatePill() {...@@ -81,18 +85,22 @@ export function SidebarUpdatePill() {
81 if (!shouldToastDesktopUpdateActionResult(result)) return;85 if (!shouldToastDesktopUpdateActionResult(result)) return;
82 const actionError = getDesktopUpdateActionError(result);86 const actionError = getDesktopUpdateActionError(result);
83 if (!actionError) return;87 if (!actionError) return;
84 toastManager.add({88 toastManager.add(
85 type: "error",89 stackedThreadToast({
86 title: "Could not install update",90 type: "error",
87 description: actionError,91 title: "Could not install update",
88 });92 description: actionError,
93 }),
94 );
89 })95 })
90 .catch((error) => {96 .catch((error) => {
91 toastManager.add({97 toastManager.add(
92 type: "error",98 stackedThreadToast({
93 title: "Could not install update",99 type: "error",
94 description: error instanceof Error ? error.message : "An unexpected error occurred.",100 title: "Could not install update",
95 });101 description: error instanceof Error ? error.message : "An unexpected error occurred.",
102 }),
103 );
96 });104 });
97 }105 }
98 }, [action, disabled, queryClient, state]);106 }, [action, disabled, queryClient, state]);
apps/web/src/components/ui/toast.logic.test.ts+56
...@@ -45,6 +45,62 @@ describe("buildVisibleToastLayout", () => {...@@ -45,6 +45,62 @@ describe("buildVisibleToastLayout", () => {
45 );45 );
46 });46 });
4747
48 it("reflows live toasts forward when the front toast is dismissed", () => {
49 const visibleToasts = [
50 { id: "a", height: 48, transitionStatus: "ending" as const },
51 { id: "b", height: 72 },
52 { id: "c", height: 24 },
53 ];
54
55 const layout = buildVisibleToastLayout(visibleToasts);
56
57 // frontmost height should be the first live toast, not the ending one
58 assert.equal(layout.frontmostHeight, 72);
59 assert.deepEqual(
60 layout.items.map(({ toast, visibleIndex, offsetY }) => ({
61 id: toast.id,
62 visibleIndex,
63 offsetY,
64 })),
65 [
66 // Ending toast stays at its front slot; data-ending-style drives its exit
67 { id: "a", visibleIndex: 0, offsetY: 0 },
68 // Live toasts get fresh indices starting at 0 so they move up in sync
69 { id: "b", visibleIndex: 0, offsetY: 0 },
70 { id: "c", visibleIndex: 1, offsetY: 72 },
71 ],
72 );
73 });
74
75 it("keeps a non-front ending toast at its current slot so it exits straight", () => {
76 const visibleToasts = [
77 { id: "a", height: 48 },
78 { id: "b", height: 72, transitionStatus: "ending" as const },
79 { id: "c", height: 24 },
80 ];
81
82 const layout = buildVisibleToastLayout(visibleToasts);
83
84 // front toast stays, so frontmost height is unchanged
85 assert.equal(layout.frontmostHeight, 48);
86 assert.deepEqual(
87 layout.items.map(({ toast, visibleIndex, offsetY }) => ({
88 id: toast.id,
89 visibleIndex,
90 offsetY,
91 })),
92 [
93 // Front live toast — unaffected
94 { id: "a", visibleIndex: 0, offsetY: 0 },
95 // Ending toast keeps its pre-dismissal slot so its horizontal exit
96 // originates from where the user saw it (not from Y=0).
97 { id: "b", visibleIndex: 1, offsetY: 48 },
98 // Live toast behind "b" slides forward into the vacated slot.
99 { id: "c", visibleIndex: 1, offsetY: 48 },
100 ],
101 );
102 });
103
48 it("treats missing heights as zero", () => {104 it("treats missing heights as zero", () => {
49 const layout = buildVisibleToastLayout([105 const layout = buildVisibleToastLayout([
50 { id: "a" },106 { id: "a" },
apps/web/src/components/ui/toast.logic.ts+52-10
...@@ -14,6 +14,12 @@ type ToastWithHeight = {...@@ -14,6 +14,12 @@ type ToastWithHeight = {
14 height?: number | null | undefined;14 height?: number | null | undefined;
15};15};
1616
17type ToastWithTransitionStatus = {
18 transitionStatus?: "starting" | "ending" | undefined;
19};
20
21type ToastWithLayoutProps = ToastWithHeight & ToastWithTransitionStatus;
22
17type VisibleToastLayoutItem<TToast extends object> = {23type VisibleToastLayoutItem<TToast extends object> = {
18 toast: TToast;24 toast: TToast;
19 visibleIndex: number;25 visibleIndex: number;
...@@ -21,25 +27,61 @@ type VisibleToastLayoutItem<TToast extends object> = {...@@ -21,25 +27,61 @@ type VisibleToastLayoutItem<TToast extends object> = {
21};27};
2228
23export function buildVisibleToastLayout<TToast extends object>(29export function buildVisibleToastLayout<TToast extends object>(
24 visibleToasts: readonly (TToast & ToastWithHeight)[],30 visibleToasts: readonly (TToast & ToastWithLayoutProps)[],
25): {31): {
26 frontmostHeight: number;32 frontmostHeight: number;
27 items: VisibleToastLayoutItem<TToast & ToastWithHeight>[];33 items: VisibleToastLayoutItem<TToast & ToastWithLayoutProps>[];
28} {34} {
29 let offsetY = 0;35 // Two parallel cursors:
36 // - `full*` advances on every toast, so an ending toast keeps the slot it
37 // occupied before dismissal and its data-ending-style exit transform
38 // originates from the correct position (critical for dismissing a
39 // non-front toast in the expanded stack — otherwise it would snap to
40 // Y=0 and slide off diagonally).
41 // - `live*` advances only on non-ending toasts, so live toasts reflow
42 // past the vacated slot in parallel with the exit animation instead of
43 // waiting for it to finish (which caused a visible "stop and bump").
44 let fullIndex = 0;
45 let fullOffsetY = 0;
46 let liveIndex = 0;
47 let liveOffsetY = 0;
48
49 const items: VisibleToastLayoutItem<TToast & ToastWithLayoutProps>[] = visibleToasts.map(
50 (toast) => {
51 const height = normalizeToastHeight(toast.height);
52
53 if (toast.transitionStatus === "ending") {
54 const item = {
55 toast,
56 visibleIndex: fullIndex,
57 offsetY: fullOffsetY,
58 };
59 fullOffsetY += height;
60 fullIndex += 1;
61 return item;
62 }
3063
31 return {
32 frontmostHeight: normalizeToastHeight(visibleToasts[0]?.height),
33 items: visibleToasts.map((toast, visibleIndex) => {
34 const item = {64 const item = {
35 toast,65 toast,
36 visibleIndex,66 visibleIndex: liveIndex,
37 offsetY,67 offsetY: liveOffsetY,
38 };68 };
3969
40 offsetY += normalizeToastHeight(toast.height);70 fullOffsetY += height;
71 fullIndex += 1;
72 liveOffsetY += height;
73 liveIndex += 1;
41 return item;74 return item;
42 }),75 },
76 );
77
78 // Frontmost height should reflect the first non-ending (live) toast so the
79 // stack sizes to what's actually staying on screen.
80 const frontmostLiveToast = visibleToasts.find((toast) => toast.transitionStatus !== "ending");
81
82 return {
83 frontmostHeight: normalizeToastHeight(frontmostLiveToast?.height),
84 items,
43 };85 };
44}86}
4587
apps/web/src/components/ui/toast.tsx+340-113
...@@ -1,17 +1,27 @@...@@ -1,17 +1,27 @@
1"use client";1"use client";
22
3import { Toast } from "@base-ui/react/toast";3import { Toast } from "@base-ui/react/toast";
4import { useEffect, useMemo, type CSSProperties } from "react";4import {
5 useEffect,
6 useMemo,
7 useState,
8 type CSSProperties,
9 type KeyboardEvent,
10 type ReactNode,
11} from "react";
5import { useParams } from "@tanstack/react-router";12import { useParams } from "@tanstack/react-router";
6import { type ScopedThreadRef, type ThreadId } from "@t3tools/contracts";13import { type ScopedThreadRef, type ThreadId } from "@t3tools/contracts";
7import {14import {
8 CheckIcon,15 CheckIcon,
16 ChevronDownIcon,
17 ChevronUpIcon,
9 CircleAlertIcon,18 CircleAlertIcon,
10 CircleCheckIcon,19 CircleCheckIcon,
11 CopyIcon,20 CopyIcon,
12 InfoIcon,21 InfoIcon,
13 LoaderCircleIcon,22 LoaderCircleIcon,
14 TriangleAlertIcon,23 TriangleAlertIcon,
24 XIcon,
15} from "lucide-react";25} from "lucide-react";
1626
17import { cn } from "~/lib/utils";27import { cn } from "~/lib/utils";
...@@ -31,6 +41,11 @@ export type ThreadToastData = {...@@ -31,6 +41,11 @@ export type ThreadToastData = {
31 tooltipStyle?: boolean;41 tooltipStyle?: boolean;
32 dismissAfterVisibleMs?: number;42 dismissAfterVisibleMs?: number;
33 hideCopyButton?: boolean;43 hideCopyButton?: boolean;
44 /** Optional extra body shown after toggling “Show details” (e.g. a list of pending RPCs). */
45 expandableContent?: ReactNode;
46 expandableLabels?: { expand?: string; collapse?: string };
47 /** When set with `expandableContent`, the summary + label act as one text disclosure (no separate chevron row). */
48 expandableDescriptionTrigger?: boolean;
34 actionLayout?: "inline" | "stacked-end";49 actionLayout?: "inline" | "stacked-end";
35 actionVariant?:50 actionVariant?:
36 | "default"51 | "default"
...@@ -55,25 +70,265 @@ const TOAST_ICONS = {...@@ -55,25 +70,265 @@ const TOAST_ICONS = {
55 warning: TriangleAlertIcon,70 warning: TriangleAlertIcon,
56} as const;71} as const;
5772
73/** Visually shorten long error bodies; clipboard copy still uses the full `description` string. */
74const ERROR_DESCRIPTION_CLAMP_MIN_CHARS = 180;
75function errorDescriptionClampClass(type: unknown, description: unknown): string | undefined {
76 if (type !== "error" || typeof description !== "string") {
77 return undefined;
78 }
79 if (description.length < ERROR_DESCRIPTION_CLAMP_MIN_CHARS) {
80 return undefined;
81 }
82 return "line-clamp-4";
83}
84
85/** Dismiss-only: circular control overlapping the card corner (iOS notification–style). */
86const toastCornerDismissClass = "absolute z-20 -top-1.5 -right-1.5";
87const toastCornerOrbClass = cn(
88 "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",
89 "transition-[color,background-color,box-shadow] hover:bg-popover hover:text-foreground",
90 "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
91);
92
58function CopyErrorButton({ text }: { text: string }) {93function CopyErrorButton({ text }: { text: string }) {
59 const { copyToClipboard, isCopied } = useCopyToClipboard();94 const { copyToClipboard, isCopied } = useCopyToClipboard();
6095
61 return (96 return (
62 <button97 <button
63 className="shrink-0 cursor-pointer rounded-md p-1 text-muted-foreground opacity-60 transition-opacity hover:opacity-100"98 className="inline-flex size-5 shrink-0 cursor-pointer items-center justify-center rounded-md p-0 text-muted-foreground/80 transition-colors hover:text-muted-foreground"
64 onClick={() => copyToClipboard(text)}99 onClick={() => copyToClipboard(text)}
65 title="Copy error"100 title="Copy error"
66 type="button"101 type="button"
67 >102 >
68 {isCopied ? (103 {isCopied ? <CheckIcon className="size-3 text-success" /> : <CopyIcon className="size-3" />}
69 <CheckIcon className="size-3.5 text-success" />
70 ) : (
71 <CopyIcon className="size-3.5" />
72 )}
73 </button>104 </button>
74 );105 );
75}106}
76107
108/** Scrollable cap for long expandable lists (~10rem); keeps the toast from growing without bound. */
109const toastExpandablePanelClassName =
110 "mt-2 max-h-40 min-h-0 overflow-y-auto overscroll-contain pr-0.5 select-text";
111
112function ToastExpandableSection({
113 children,
114 labels,
115}: {
116 children: ReactNode;
117 labels: { expand?: string; collapse?: string };
118}) {
119 const [open, setOpen] = useState(false);
120 const expandLabel = labels.expand ?? "Show details";
121 const collapseLabel = labels.collapse ?? "Hide details";
122
123 return (
124 <div className="min-w-0">
125 <button
126 aria-expanded={open}
127 className="inline-flex cursor-pointer items-center gap-1 rounded-md py-0.5 text-left text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
128 onClick={() => setOpen((prev) => !prev)}
129 type="button"
130 >
131 {open ? (
132 <ChevronUpIcon className="size-3.5 shrink-0 opacity-80" strokeWidth={2.25} />
133 ) : (
134 <ChevronDownIcon className="size-3.5 shrink-0 opacity-80" strokeWidth={2.25} />
135 )}
136 {open ? collapseLabel : expandLabel}
137 </button>
138 {open ? <div className={toastExpandablePanelClassName}>{children}</div> : null}
139 </div>
140 );
141}
142
143function ToastDescriptionAndExpandable({
144 toastData,
145 toastDescription,
146 toastType,
147}: {
148 toastData: ThreadToastData | undefined;
149 toastDescription: unknown;
150 toastType: unknown;
151}) {
152 const expandableContent = toastData?.expandableContent;
153 const labels = toastData?.expandableLabels ?? {};
154 const descriptionTrigger = toastData?.expandableDescriptionTrigger ?? false;
155 const descriptionClassName = cn(
156 "min-w-0 select-text wrap-break-word text-muted-foreground",
157 errorDescriptionClampClass(toastType, toastDescription),
158 );
159 const [open, setOpen] = useState(false);
160
161 if (!expandableContent) {
162 return <Toast.Description className={descriptionClassName} data-slot="toast-description" />;
163 }
164
165 if (!descriptionTrigger) {
166 return (
167 <>
168 <Toast.Description className={descriptionClassName} data-slot="toast-description" />
169 <ToastExpandableSection labels={labels}>{expandableContent}</ToastExpandableSection>
170 </>
171 );
172 }
173
174 const expandLabel = labels.expand ?? "Show details";
175 const collapseLabel = labels.collapse ?? "Hide details";
176
177 const toggle = () => setOpen((v) => !v);
178 const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
179 if (event.key === "Enter" || event.key === " ") {
180 event.preventDefault();
181 toggle();
182 }
183 };
184
185 return (
186 <>
187 <div
188 aria-expanded={open}
189 className={cn(
190 "group flex min-w-0 w-full cursor-pointer select-none items-start gap-1.5 rounded-sm text-left outline-none ring-offset-background",
191 "transition-colors hover:bg-muted/40",
192 "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
193 )}
194 onClick={toggle}
195 onKeyDown={onKeyDown}
196 role="button"
197 tabIndex={0}
198 title={open ? collapseLabel : expandLabel}
199 >
200 <div className="min-w-0 flex-1">
201 <Toast.Description
202 className={cn(
203 "min-w-0 select-none wrap-break-word text-muted-foreground",
204 errorDescriptionClampClass(toastType, toastDescription),
205 "underline-offset-2 decoration-muted-foreground/60 group-hover:underline",
206 )}
207 data-slot="toast-description"
208 />
209 </div>
210 {open ? (
211 <ChevronUpIcon
212 aria-hidden
213 className="mt-0.5 size-3.5 shrink-0 text-muted-foreground opacity-80"
214 strokeWidth={2.25}
215 />
216 ) : (
217 <ChevronDownIcon
218 aria-hidden
219 className="mt-0.5 size-3.5 shrink-0 text-muted-foreground opacity-80"
220 strokeWidth={2.25}
221 />
222 )}
223 </div>
224 {open ? <div className={toastExpandablePanelClassName}>{expandableContent}</div> : null}
225 </>
226 );
227}
228
229type ToastIconComponent = (typeof TOAST_ICONS)[keyof typeof TOAST_ICONS];
230
231interface ToastBodyDescriptor {
232 readonly Icon: ToastIconComponent | null | undefined;
233 readonly stackedActionLayout: boolean;
234 readonly actionVariant: NonNullable<ThreadToastData["actionVariant"]>;
235 readonly copyErrorText: string | null;
236 readonly hasTrailingControls: boolean;
237 readonly inlineContentEndPad: string;
238}
239
240function deriveToastBodyDescriptor(toast: {
241 readonly type?: string | undefined;
242 readonly description?: unknown;
243 readonly actionProps?: unknown;
244 readonly data?: ThreadToastData | undefined;
245}): ToastBodyDescriptor {
246 const Icon = toast.type ? TOAST_ICONS[toast.type as keyof typeof TOAST_ICONS] : null;
247 const stackedActionLayout =
248 toast.actionProps !== undefined && toast.data?.actionLayout === "stacked-end";
249 const actionVariant: NonNullable<ThreadToastData["actionVariant"]> =
250 toast.data?.actionVariant ?? "default";
251 const copyErrorText =
252 toast.type === "error" && typeof toast.description === "string" && !toast.data?.hideCopyButton
253 ? toast.description
254 : null;
255 const hasTrailingControls = copyErrorText !== null || toast.actionProps !== undefined;
256 const inlineContentEndPad = hasTrailingControls ? "pr-6" : "pr-10";
257 return {
258 Icon,
259 stackedActionLayout,
260 actionVariant,
261 copyErrorText,
262 hasTrailingControls,
263 inlineContentEndPad,
264 };
265}
266
267interface ToastBodyContentProps extends ToastBodyDescriptor {
268 readonly actionProps: { readonly children?: ReactNode } | undefined;
269 readonly toastData: ThreadToastData | undefined;
270 readonly toastDescription: unknown;
271 readonly toastType: unknown;
272}
273
274function ToastBodyContent({
275 stackedActionLayout,
276 Icon,
277 copyErrorText,
278 actionProps,
279 actionVariant,
280 hasTrailingControls,
281 toastData,
282 toastDescription,
283 toastType,
284}: ToastBodyContentProps) {
285 return (
286 <>
287 <div className={cn("flex min-w-0 gap-2", !stackedActionLayout && "flex-1")}>
288 {Icon && (
289 <div
290 className="[&>svg]:h-lh [&>svg]:w-4 [&_svg]:pointer-events-none [&_svg]:shrink-0"
291 data-slot="toast-icon"
292 >
293 <Icon className="in-data-[type=loading]:animate-spin in-data-[type=error]:text-destructive in-data-[type=info]:text-info in-data-[type=success]:text-success in-data-[type=warning]:text-warning in-data-[type=loading]:opacity-80" />
294 </div>
295 )}
296 <div
297 className={cn(
298 "flex min-h-0 min-w-0 flex-1 flex-col gap-0.5",
299 stackedActionLayout && "pr-5",
300 )}
301 >
302 <Toast.Title className="min-w-0 wrap-break-word font-medium" data-slot="toast-title" />
303 <ToastDescriptionAndExpandable
304 toastData={toastData}
305 toastDescription={toastDescription}
306 toastType={toastType}
307 />
308 </div>
309 </div>
310 {hasTrailingControls ? (
311 <div
312 className={cn(
313 "flex items-center gap-1.5",
314 stackedActionLayout ? "w-full justify-end" : "shrink-0",
315 )}
316 >
317 {copyErrorText !== null ? <CopyErrorButton text={copyErrorText} /> : null}
318 {actionProps ? (
319 <Toast.Action
320 className={cn(buttonVariants({ size: "xs", variant: actionVariant }), "shrink-0")}
321 data-slot="toast-action"
322 >
323 {actionProps.children}
324 </Toast.Action>
325 ) : null}
326 </div>
327 ) : null}
328 </>
329 );
330}
331
77type ToastPosition =332type ToastPosition =
78 | "top-left"333 | "top-left"
79 | "top-center"334 | "top-center"
...@@ -236,19 +491,17 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) {...@@ -236,19 +491,17 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) {
236 }491 }
237 >492 >
238 {visibleToastLayout.items.map(({ toast, visibleIndex, offsetY }) => {493 {visibleToastLayout.items.map(({ toast, visibleIndex, offsetY }) => {
239 const Icon = toast.type ? TOAST_ICONS[toast.type as keyof typeof TOAST_ICONS] : null;
240 const hideCollapsedContent = shouldHideCollapsedToastContent(494 const hideCollapsedContent = shouldHideCollapsedToastContent(
241 visibleIndex,495 visibleIndex,
242 visibleToastLayout.items.length,496 visibleToastLayout.items.length,
243 );497 );
244 const stackedActionLayout =498 const bodyDescriptor = deriveToastBodyDescriptor(toast);
245 toast.actionProps !== undefined && toast.data?.actionLayout === "stacked-end";499 const { stackedActionLayout, inlineContentEndPad } = bodyDescriptor;
246 const actionVariant = toast.data?.actionVariant ?? "default";
247500
248 return (501 return (
249 <Toast.Root502 <Toast.Root
250 className={cn(503 className={cn(
251 "absolute z-[calc(9999-var(--toast-index))] h-(--toast-calc-height) w-full select-none rounded-lg border bg-popover not-dark:bg-clip-padding text-popover-foreground shadow-lg/5 [transition:transform_.5s_cubic-bezier(.22,1,.36,1),opacity_.5s,height_.15s] before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] before:shadow-[0_1px_--theme(--color-black/4%)] dark:before:shadow-[0_-1px_--theme(--color-white/6%)]",504 "absolute z-[calc(9999-var(--toast-index))] w-full overflow-visible select-none rounded-lg border bg-popover not-dark:bg-clip-padding text-popover-foreground shadow-lg/5 [transition:transform_.5s_cubic-bezier(.22,1,.36,1),opacity_.5s,height_.15s] before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] before:shadow-[0_1px_--theme(--color-black/4%)] dark:before:shadow-[0_-1px_--theme(--color-white/6%)]",
252 // Base positioning using data-position505 // Base positioning using data-position
253 "data-[position*=right]:right-0 data-[position*=right]:left-auto",506 "data-[position*=right]:right-0 data-[position*=right]:left-auto",
254 "data-[position*=left]:right-auto data-[position*=left]:left-0",507 "data-[position*=left]:right-auto data-[position*=left]:left-0",
...@@ -259,11 +512,19 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) {...@@ -259,11 +512,19 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) {
259 "after:absolute after:left-0 after:h-[calc(var(--toast-gap)+1px)] after:w-full",512 "after:absolute after:left-0 after:h-[calc(var(--toast-gap)+1px)] after:w-full",
260 "data-[position*=top]:after:top-full",513 "data-[position*=top]:after:top-full",
261 "data-[position*=bottom]:after:bottom-full",514 "data-[position*=bottom]:after:bottom-full",
262 // Define some variables515 // `--toast-calc-height`: behind + collapsed = peek height only (content `opacity-0`);
263 // Base UI exposes a shared front-most height for the collapsed stack.516 // max(front, own) there produced a tall empty shell for long bodies.
264 // If that shared measurement is briefly stale, long content can render517 visibleIndex > 0
265 // outside the card until hover expands the toast and swaps to its own height.518 ? "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))]"
266 "[--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))]",519 : "[--toast-calc-height:max(var(--toast-frontmost-height,var(--toast-height)),var(--toast-height))]",
520 "[--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))]",
521 // Root height: never `min-h-(--toast-height)` — Base UI measures height by briefly forcing
522 // `height: auto` on this node; an old `min-height` from `--toast-height` blocks shrinking,
523 // so `recalculateHeight` keeps the inflated value after an expandable closes.
524 // Behind + collapsed: fixed peek. Otherwise natural height (expand/collapse, hover stack).
525 visibleIndex > 0
526 ? "not-data-expanded:h-(--toast-calc-height) data-expanded:h-auto"
527 : "h-auto",
267 // Define offset-y variable528 // Define offset-y variable
268 "data-[position*=top]:[--toast-calc-offset-y:calc(var(--toast-offset-y)+var(--toast-index)*var(--toast-gap)+var(--toast-swipe-movement-y))]",529 "data-[position*=top]:[--toast-calc-offset-y:calc(var(--toast-offset-y)+var(--toast-index)*var(--toast-gap)+var(--toast-swipe-movement-y))]",
269 "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))]",530 "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 }) {...@@ -272,8 +533,7 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) {
272 "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))]",533 "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))]",
273 // Limited state534 // Limited state
274 "data-limited:opacity-0",535 "data-limited:opacity-0",
275 // Expanded state536 // Expanded stack
276 "data-expanded:h-(--toast-height)",
277 "data-position:data-expanded:transform-[translateX(var(--toast-swipe-movement-x))_translateY(var(--toast-calc-offset-y))]",537 "data-position:data-expanded:transform-[translateX(var(--toast-swipe-movement-x))_translateY(var(--toast-calc-offset-y))]",
278 // Starting and ending animations538 // Starting and ending animations
279 "data-[position*=top]:data-starting-style:transform-[translateY(calc(-100%-var(--toast-inset)))]",539 "data-[position*=top]:data-starting-style:transform-[translateY(calc(-100%-var(--toast-inset)))]",
...@@ -314,54 +574,36 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) {...@@ -314,54 +574,36 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) {
314 dismissAfterVisibleMs={toast.data?.dismissAfterVisibleMs}574 dismissAfterVisibleMs={toast.data?.dismissAfterVisibleMs}
315 toastId={toast.id}575 toastId={toast.id}
316 />576 />
577 <div className={toastCornerDismissClass}>
578 <button
579 aria-label="Dismiss notification"
580 className={toastCornerOrbClass}
581 data-slot="toast-close"
582 onClick={() => toastManager.close(toast.id)}
583 type="button"
584 >
585 <XIcon className="size-3" strokeWidth={2.25} />
586 </button>
587 </div>
317 <Toast.Content588 <Toast.Content
318 className={cn(589 className={cn(
319 "pointer-events-auto overflow-hidden px-3.5 text-sm transition-opacity duration-250 data-expanded:opacity-100",590 // `overflow-x: clip` avoids the CSS quirk where pairing `hidden` + `y: visible`
591 // forces `y` to `auto`. Expandable detail panels can extend below without being cut off.
592 "pointer-events-auto min-h-0 overflow-y-visible pl-3.5 text-sm transition-opacity duration-250 [overflow-x:clip] data-expanded:opacity-100",
320 stackedActionLayout593 stackedActionLayout
321 ? "flex flex-col gap-2 py-2.5"594 ? "flex flex-col gap-2 py-2.5 pr-3.5"
322 : "flex items-center justify-between gap-1.5 py-3",595 : cn("py-3", "flex items-center justify-between gap-1.5", inlineContentEndPad),
323 hideCollapsedContent &&596 hideCollapsedContent &&
324 "not-data-expanded:pointer-events-none not-data-expanded:opacity-0",597 "not-data-expanded:pointer-events-none not-data-expanded:opacity-0",
325 )}598 )}
326 >599 >
327 <div className="flex min-w-0 flex-1 gap-2">600 <ToastBodyContent
328 {Icon && (601 {...bodyDescriptor}
329 <div602 actionProps={toast.actionProps}
330 className="[&>svg]:h-lh [&>svg]:w-4 [&_svg]:pointer-events-none [&_svg]:shrink-0"603 toastData={toast.data}
331 data-slot="toast-icon"604 toastDescription={toast.description}
332 >605 toastType={toast.type}
333 <Icon className="in-data-[type=loading]:animate-spin in-data-[type=error]:text-destructive in-data-[type=info]:text-info in-data-[type=success]:text-success in-data-[type=warning]:text-warning in-data-[type=loading]:opacity-80" />606 />
334 </div>
335 )}
336
337 <div className="flex min-w-0 flex-1 flex-col gap-0.5">
338 <div className="flex items-center justify-between gap-1">
339 <Toast.Title
340 className="min-w-0 wrap-break-word font-medium"
341 data-slot="toast-title"
342 />
343 {toast.type === "error" &&
344 typeof toast.description === "string" &&
345 !toast.data?.hideCopyButton && <CopyErrorButton text={toast.description} />}
346 </div>
347 <Toast.Description
348 className="min-w-0 select-text wrap-break-word text-muted-foreground"
349 data-slot="toast-description"
350 />
351 </div>
352 </div>
353 {toast.actionProps && (
354 <Toast.Action
355 className={cn(
356 buttonVariants({ size: "xs", variant: actionVariant }),
357 "shrink-0",
358 stackedActionLayout && "self-end",
359 )}
360 data-slot="toast-action"
361 >
362 {toast.actionProps.children}
363 </Toast.Action>
364 )}
365 </Toast.Content>607 </Toast.Content>
366 </Toast.Root>608 </Toast.Root>
367 );609 );
...@@ -390,12 +632,10 @@ function AnchoredToasts() {...@@ -390,12 +632,10 @@ function AnchoredToasts() {
390 {toasts632 {toasts
391 .filter((toast) => shouldRenderThreadScopedToast(toast.data, activeThreadRef))633 .filter((toast) => shouldRenderThreadScopedToast(toast.data, activeThreadRef))
392 .map((toast) => {634 .map((toast) => {
393 const Icon = toast.type ? TOAST_ICONS[toast.type as keyof typeof TOAST_ICONS] : null;
394 const tooltipStyle = toast.data?.tooltipStyle ?? false;635 const tooltipStyle = toast.data?.tooltipStyle ?? false;
395 const positionerProps = toast.positionerProps;636 const positionerProps = toast.positionerProps;
396 const stackedActionLayout =637 const bodyDescriptor = deriveToastBodyDescriptor(toast);
397 toast.actionProps !== undefined && toast.data?.actionLayout === "stacked-end";638 const { stackedActionLayout, inlineContentEndPad } = bodyDescriptor;
398 const actionVariant = toast.data?.actionVariant ?? "default";
399639
400 if (!positionerProps?.anchor) {640 if (!positionerProps?.anchor) {
401 return null;641 return null;
...@@ -411,7 +651,7 @@ function AnchoredToasts() {...@@ -411,7 +651,7 @@ function AnchoredToasts() {
411 >651 >
412 <Toast.Root652 <Toast.Root
413 className={cn(653 className={cn(
414 "relative text-balance border bg-popover not-dark:bg-clip-padding text-popover-foreground text-xs transition-[scale,opacity] before:pointer-events-none before:absolute before:inset-0 before:shadow-[0_1px_--theme(--color-black/4%)] data-ending-style:scale-98 data-starting-style:scale-98 data-ending-style:opacity-0 data-starting-style:opacity-0 dark:before:shadow-[0_-1px_--theme(--color-white/6%)]",654 "relative overflow-visible text-balance border bg-popover not-dark:bg-clip-padding text-popover-foreground text-xs transition-[scale,opacity] before:pointer-events-none before:absolute before:inset-0 before:shadow-[0_1px_--theme(--color-black/4%)] data-ending-style:scale-98 data-starting-style:scale-98 data-ending-style:opacity-0 data-starting-style:opacity-0 dark:before:shadow-[0_-1px_--theme(--color-white/6%)]",
415 tooltipStyle655 tooltipStyle
416 ? "rounded-md shadow-md/5 before:rounded-[calc(var(--radius-md)-1px)]"656 ? "rounded-md shadow-md/5 before:rounded-[calc(var(--radius-md)-1px)]"
417 : "rounded-lg shadow-lg/5 before:rounded-[calc(var(--radius-lg)-1px)]",657 : "rounded-lg shadow-lg/5 before:rounded-[calc(var(--radius-lg)-1px)]",
...@@ -424,55 +664,39 @@ function AnchoredToasts() {...@@ -424,55 +664,39 @@ function AnchoredToasts() {
424 <Toast.Title data-slot="toast-title" />664 <Toast.Title data-slot="toast-title" />
425 </Toast.Content>665 </Toast.Content>
426 ) : (666 ) : (
427 <Toast.Content667 <>
428 className={cn(668 <div className={toastCornerDismissClass}>
429 "pointer-events-auto overflow-hidden px-3.5 text-sm",669 <button
430 stackedActionLayout670 aria-label="Dismiss notification"
431 ? "flex flex-col gap-2 py-2.5"671 className={toastCornerOrbClass}
432 : "flex items-center justify-between gap-1.5 py-3",672 data-slot="toast-close"
433 )}673 onClick={() => anchoredToastManager.close(toast.id)}
434 >674 type="button"
435 <div className="flex min-w-0 flex-1 gap-2">
436 {Icon && (
437 <div
438 className="[&>svg]:h-lh [&>svg]:w-4 [&_svg]:pointer-events-none [&_svg]:shrink-0"
439 data-slot="toast-icon"
440 >
441 <Icon className="in-data-[type=loading]:animate-spin in-data-[type=error]:text-destructive in-data-[type=info]:text-info in-data-[type=success]:text-success in-data-[type=warning]:text-warning in-data-[type=loading]:opacity-80" />
442 </div>
443 )}
444
445 <div className="flex min-w-0 flex-1 flex-col gap-0.5">
446 <div className="flex items-center gap-1">
447 <Toast.Title
448 className="min-w-0 wrap-break-word font-medium"
449 data-slot="toast-title"
450 />
451 {toast.type === "error" &&
452 typeof toast.description === "string" &&
453 !toast.data?.hideCopyButton && (
454 <CopyErrorButton text={toast.description} />
455 )}
456 </div>
457 <Toast.Description
458 className="min-w-0 select-text wrap-break-word text-muted-foreground"
459 data-slot="toast-description"
460 />
461 </div>
462 </div>
463 {toast.actionProps && (
464 <Toast.Action
465 className={cn(
466 buttonVariants({ size: "xs", variant: actionVariant }),
467 "shrink-0",
468 stackedActionLayout && "self-end",
469 )}
470 data-slot="toast-action"
471 >675 >
472 {toast.actionProps.children}676 <XIcon className="size-3" strokeWidth={2.25} />
473 </Toast.Action>677 </button>
474 )}678 </div>
475 </Toast.Content>679 <Toast.Content
680 className={cn(
681 "pointer-events-auto min-h-0 overflow-y-visible pl-3.5 text-sm [overflow-x:clip]",
682 stackedActionLayout
683 ? "flex flex-col gap-2 py-2.5 pr-3.5"
684 : cn(
685 "py-3",
686 "flex items-center justify-between gap-1.5",
687 inlineContentEndPad,
688 ),
689 )}
690 >
691 <ToastBodyContent
692 {...bodyDescriptor}
693 actionProps={toast.actionProps}
694 toastData={toast.data}
695 toastDescription={toast.description}
696 toastType={toast.type}
697 />
698 </Toast.Content>
699 </>
476 )}700 )}
477 </Toast.Root>701 </Toast.Root>
478 </Toast.Positioner>702 </Toast.Positioner>
...@@ -483,6 +707,9 @@ function AnchoredToasts() {...@@ -483,6 +707,9 @@ function AnchoredToasts() {
483 );707 );
484}708}
485709
710export { stackedThreadToast } from "./toastHelpers";
711export type { StackedThreadToastOptions } from "./toastHelpers";
712
486export {713export {
487 ToastProvider,714 ToastProvider,
488 type ToastPosition,715 type ToastPosition,
apps/web/src/components/ui/toastHelpers.ts created+58
...@@ -0,0 +1,58 @@
1"use client";
2
3import type { ToastManagerAddOptions } from "@base-ui/react/toast";
4import type { ComponentPropsWithoutRef, ReactNode } from "react";
5
6import type { ThreadToastData } from "./toast";
7
8export type StackedThreadToastOptions = {
9 type: "error" | "warning" | "success" | "info" | "loading";
10 title: ReactNode;
11 description?: ReactNode;
12 timeout?: number;
13 priority?: "low" | "high";
14 actionProps?: ComponentPropsWithoutRef<"button">;
15 /** Merged into `data`; `actionLayout` is always forced to `"stacked-end"` by the helper. */
16 actionVariant?: ThreadToastData["actionVariant"];
17 data?: Omit<ThreadToastData, "actionLayout">;
18};
19
20/**
21 * Thread toast using the stacked body + bottom action row (copy for errors, CTA on its own row).
22 */
23export function stackedThreadToast(
24 options: StackedThreadToastOptions,
25): ToastManagerAddOptions<ThreadToastData> {
26 const { type, title, description, timeout, priority, actionProps, actionVariant, data } = options;
27
28 // Helper-owned `actionLayout` must win over any caller-provided `data`, so spread
29 // the caller's data first and apply `actionLayout: "stacked-end"` last.
30 const mergedData: ThreadToastData = {
31 ...(data !== undefined ? data : {}),
32 actionLayout: "stacked-end",
33 };
34 if (actionVariant !== undefined) {
35 mergedData.actionVariant = actionVariant;
36 }
37
38 const payload: ToastManagerAddOptions<ThreadToastData> = {
39 type,
40 title,
41 data: mergedData,
42 };
43
44 if (description !== undefined) {
45 payload.description = description;
46 }
47 if (timeout !== undefined) {
48 payload.timeout = timeout;
49 }
50 if (priority !== undefined) {
51 payload.priority = priority;
52 }
53 if (actionProps !== undefined) {
54 payload.actionProps = actionProps;
55 }
56
57 return payload;
58}
apps/web/src/hooks/useThreadActions.ts+8-6
...@@ -20,7 +20,7 @@ import {...@@ -20,7 +20,7 @@ import {
20import { useTerminalStateStore } from "../terminalStateStore";20import { useTerminalStateStore } from "../terminalStateStore";
21import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes";21import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes";
22import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";22import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";
23import { toastManager } from "../components/ui/toast";23import { stackedThreadToast, toastManager } from "../components/ui/toast";
24import { useSettings } from "./useSettings";24import { useSettings } from "./useSettings";
2525
26export function useThreadActions() {26export function useThreadActions() {
...@@ -225,11 +225,13 @@ export function useThreadActions() {...@@ -225,11 +225,13 @@ export function useThreadActions() {
225 worktreePath: orphanedWorktreePath,225 worktreePath: orphanedWorktreePath,
226 error,226 error,
227 });227 });
228 toastManager.add({228 toastManager.add(
229 type: "error",229 stackedThreadToast({
230 title: "Thread deleted, but worktree removal failed",230 type: "error",
231 description: `Could not remove ${displayWorktreePath ?? orphanedWorktreePath}. ${message}`,231 title: "Thread deleted, but worktree removal failed",
232 });232 description: `Could not remove ${displayWorktreePath ?? orphanedWorktreePath}. ${message}`,
233 }),
234 );
233 }235 }
234 },236 },
235 [237 [
apps/web/src/routes/__root.tsx+40-30
...@@ -19,7 +19,12 @@ import {...@@ -19,7 +19,12 @@ import {
19 WebSocketConnectionSurface,19 WebSocketConnectionSurface,
20} from "../components/WebSocketConnectionSurface";20} from "../components/WebSocketConnectionSurface";
21import { Button } from "../components/ui/button";21import { Button } from "../components/ui/button";
22import { AnchoredToastProvider, ToastProvider, toastManager } from "../components/ui/toast";22import {
23 AnchoredToastProvider,
24 stackedThreadToast,
25 ToastProvider,
26 toastManager,
27} from "../components/ui/toast";
23import { resolveAndPersistPreferredEditor } from "../editorPreferences";28import { resolveAndPersistPreferredEditor } from "../editorPreferences";
24import { readLocalApi } from "../localApi";29import { readLocalApi } from "../localApi";
25import { useSettings } from "../hooks/useSettings";30import { useSettings } from "../hooks/useSettings";
...@@ -290,37 +295,42 @@ function EventRouter() {...@@ -290,37 +295,42 @@ function EventRouter() {
290 return;295 return;
291 }296 }
292297
293 toastManager.add({298 toastManager.add(
294 type: "warning",299 stackedThreadToast({
295 title: "Invalid keybindings configuration",300 type: "warning",
296 description: issue.message,301 title: "Invalid keybindings configuration",
297 actionProps: {302 description: issue.message,
298 children: "Open keybindings.json",303 actionVariant: "outline",
299 onClick: () => {304 actionProps: {
300 const api = readLocalApi();305 children: "Open keybindings.json",
301 if (!api) {306 onClick: () => {
302 return;307 const api = readLocalApi();
303 }308 if (!api) {
304309 return;
305 void Promise.resolve(serverConfig ?? api.server.getConfig())310 }
306 .then((config) => {311
307 const editor = resolveAndPersistPreferredEditor(config.availableEditors);312 void Promise.resolve(serverConfig ?? api.server.getConfig())
308 if (!editor) {313 .then((config) => {
309 throw new Error("No available editors found.");314 const editor = resolveAndPersistPreferredEditor(config.availableEditors);
310 }315 if (!editor) {
311 return api.shell.openInEditor(config.keybindingsConfigPath, editor);316 throw new Error("No available editors found.");
312 })317 }
313 .catch((error) => {318 return api.shell.openInEditor(config.keybindingsConfigPath, editor);
314 toastManager.add({319 })
315 type: "error",320 .catch((error) => {
316 title: "Unable to open keybindings file",321 toastManager.add(
317 description:322 stackedThreadToast({
318 error instanceof Error ? error.message : "Unknown error opening file.",323 type: "error",
324 title: "Unable to open keybindings file",
325 description:
326 error instanceof Error ? error.message : "Unknown error opening file.",
327 }),
328 );
319 });329 });
320 });330 },
321 },331 },
322 },332 }),
323 });333 );
324 },334 },
325 );335 );
326336