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 {
4242 ComboboxStatus,
4343 ComboboxTrigger,
4444} from "./ui/combobox";
45import { toastManager } from "./ui/toast";
45import { stackedThreadToast, toastManager } from "./ui/toast";
4646
4747interface BranchToolbarBranchSelectorProps {
4848 environmentId: EnvironmentId;
......@@ -351,11 +351,13 @@ export function BranchToolbarBranchSelector({
351351 setThreadBranch(nextBranchName, selectionTarget.nextWorktreePath);
352352 } catch (error) {
353353 setOptimisticBranch(previousBranch);
354 toastManager.add({
355 type: "error",
356 title: "Failed to checkout branch.",
357 description: toBranchActionErrorMessage(error),
358 });
354 toastManager.add(
355 stackedThreadToast({
356 type: "error",
357 title: "Failed to checkout branch.",
358 description: toBranchActionErrorMessage(error),
359 }),
360 );
359361 }
360362 });
361363 };
......@@ -381,11 +383,13 @@ export function BranchToolbarBranchSelector({
381383 setThreadBranch(createBranchResult.branch, activeWorktreePath);
382384 } catch (error) {
383385 setOptimisticBranch(previousBranch);
384 toastManager.add({
385 type: "error",
386 title: "Failed to create and checkout branch.",
387 description: toBranchActionErrorMessage(error),
388 });
386 toastManager.add(
387 stackedThreadToast({
388 type: "error",
389 title: "Failed to create and checkout branch.",
390 description: toBranchActionErrorMessage(error),
391 }),
392 );
389393 }
390394 });
391395 };
apps/web/src/components/ChatMarkdown.tsx+22-16
......@@ -20,7 +20,7 @@ import { defaultUrlTransform } from "react-markdown";
2020import remarkGfm from "remark-gfm";
2121import { VscodeEntryIcon } from "./chat/VscodeEntryIcon";
2222import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
23import { toastManager } from "./ui/toast";
23import { stackedThreadToast, toastManager } from "./ui/toast";
2424import { openInPreferredEditor } from "../editorPreferences";
2525import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering";
2626import { fnv1a32 } from "../lib/diffRendering";
......@@ -351,21 +351,25 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
351351 }
352352
353353 void openInPreferredEditor(api, targetPath).catch((error) => {
354 toastManager.add({
355 type: "error",
356 title: "Unable to open file",
357 description: error instanceof Error ? error.message : "An error occurred.",
358 });
354 toastManager.add(
355 stackedThreadToast({
356 type: "error",
357 title: "Unable to open file",
358 description: error instanceof Error ? error.message : "An error occurred.",
359 }),
360 );
359361 });
360362 }, [targetPath]);
361363
362364 const handleCopy = useCallback((value: string, title: string) => {
363365 if (typeof window === "undefined" || !navigator.clipboard?.writeText) {
364 toastManager.add({
365 type: "error",
366 title: `Failed to copy ${title.toLowerCase()}`,
367 description: "Clipboard API unavailable.",
368 });
366 toastManager.add(
367 stackedThreadToast({
368 type: "error",
369 title: `Failed to copy ${title.toLowerCase()}`,
370 description: "Clipboard API unavailable.",
371 }),
372 );
369373 return;
370374 }
371375
......@@ -378,11 +382,13 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
378382 });
379383 },
380384 (error) => {
381 toastManager.add({
382 type: "error",
383 title: `Failed to copy ${title.toLowerCase()}`,
384 description: error instanceof Error ? error.message : "An error occurred.",
385 });
385 toastManager.add(
386 stackedThreadToast({
387 type: "error",
388 title: `Failed to copy ${title.toLowerCase()}`,
389 description: error instanceof Error ? error.message : "An error occurred.",
390 }),
391 );
386392 },
387393 );
388394 }, []);
apps/web/src/components/ChatView.tsx+32-22
......@@ -101,7 +101,7 @@ import PlanSidebar from "./PlanSidebar";
101101import ThreadTerminalDrawer from "./ThreadTerminalDrawer";
102102import { ChevronDownIcon } from "lucide-react";
103103import { cn, randomUUID } from "~/lib/utils";
104import { toastManager } from "./ui/toast";
104import { stackedThreadToast, toastManager } from "./ui/toast";
105105import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings";
106106import { type NewProjectScriptInput } from "./ProjectScriptsControl";
107107import {
......@@ -1855,11 +1855,13 @@ export default function ChatView(props: ChatViewProps) {
18551855 title: `Deleted action "${deletedName ?? "Unknown"}"`,
18561856 });
18571857 } catch (error) {
1858 toastManager.add({
1859 type: "error",
1860 title: "Could not delete action",
1861 description: error instanceof Error ? error.message : "An unexpected error occurred.",
1862 });
1858 toastManager.add(
1859 stackedThreadToast({
1860 type: "error",
1861 title: "Could not delete action",
1862 description: error instanceof Error ? error.message : "An unexpected error occurred.",
1863 }),
1864 );
18631865 }
18641866 },
18651867 [activeProject, persistProjectScripts],
......@@ -2426,11 +2428,13 @@ export default function ChatView(props: ChatViewProps) {
24262428 expiredTerminalContextCount,
24272429 "empty",
24282430 );
2429 toastManager.add({
2430 type: "warning",
2431 title: toastCopy.title,
2432 description: toastCopy.description,
2433 });
2431 toastManager.add(
2432 stackedThreadToast({
2433 type: "warning",
2434 title: toastCopy.title,
2435 description: toastCopy.description,
2436 }),
2437 );
24342438 }
24352439 return;
24362440 }
......@@ -2512,11 +2516,13 @@ export default function ChatView(props: ChatViewProps) {
25122516 expiredTerminalContextCount,
25132517 "omitted",
25142518 );
2515 toastManager.add({
2516 type: "warning",
2517 title: toastCopy.title,
2518 description: toastCopy.description,
2519 });
2519 toastManager.add(
2520 stackedThreadToast({
2521 type: "warning",
2522 title: toastCopy.title,
2523 description: toastCopy.description,
2524 }),
2525 );
25202526 }
25212527 promptRef.current = "";
25222528 clearComposerDraftContent(composerDraftTarget);
......@@ -3082,12 +3088,16 @@ export default function ChatView(props: ChatViewProps) {
30823088 threadId: nextThreadId,
30833089 })
30843090 .catch(() => undefined);
3085 toastManager.add({
3086 type: "error",
3087 title: "Could not start implementation thread",
3088 description:
3089 err instanceof Error ? err.message : "An error occurred while creating the new thread.",
3090 });
3091 toastManager.add(
3092 stackedThreadToast({
3093 type: "error",
3094 title: "Could not start implementation thread",
3095 description:
3096 err instanceof Error
3097 ? err.message
3098 : "An error occurred while creating the new thread.",
3099 }),
3100 );
30913101 })
30923102 .then(finish, finish);
30933103 }, [
apps/web/src/components/CommandPalette.tsx+36-26
......@@ -102,7 +102,7 @@ import {
102102} from "./ui/command";
103103import { Button } from "./ui/button";
104104import { Kbd, KbdGroup } from "./ui/kbd";
105import { toastManager } from "./ui/toast";
105import { stackedThreadToast, toastManager } from "./ui/toast";
106106import { ComposerHandleContext, useComposerHandleContext } from "../composerHandleContext";
107107import type { ChatComposerHandle } from "./chat/ChatComposer";
108108
......@@ -605,11 +605,13 @@ function OpenCommandPaletteDialog() {
605605
606606 const environmentId = defaultAddProjectEnvironmentId;
607607 if (!environmentId) {
608 toastManager.add({
609 type: "error",
610 title: "Unable to browse projects",
611 description: "No environment is available.",
612 });
608 toastManager.add(
609 stackedThreadToast({
610 type: "error",
611 title: "Unable to browse projects",
612 description: "No environment is available.",
613 }),
614 );
613615 return;
614616 }
615617
......@@ -724,20 +726,24 @@ function OpenCommandPaletteDialog() {
724726 if (!api) return;
725727
726728 if (isUnsupportedWindowsProjectPath(rawCwd.trim(), browseEnvironmentPlatform)) {
727 toastManager.add({
728 type: "error",
729 title: "Failed to add project",
730 description: "Windows-style paths are only supported on Windows.",
731 });
729 toastManager.add(
730 stackedThreadToast({
731 type: "error",
732 title: "Failed to add project",
733 description: "Windows-style paths are only supported on Windows.",
734 }),
735 );
732736 return;
733737 }
734738
735739 if (isExplicitRelativeProjectPath(rawCwd.trim()) && !currentProjectCwdForBrowse) {
736 toastManager.add({
737 type: "error",
738 title: "Failed to add project",
739 description: "Relative paths require an active project.",
740 });
740 toastManager.add(
741 stackedThreadToast({
742 type: "error",
743 title: "Failed to add project",
744 description: "Relative paths require an active project.",
745 }),
746 );
741747 return;
742748 }
743749
......@@ -790,11 +796,13 @@ function OpenCommandPaletteDialog() {
790796 }).catch(() => undefined);
791797 setOpen(false);
792798 } catch (error) {
793 toastManager.add({
794 type: "error",
795 title: "Failed to add project",
796 description: error instanceof Error ? error.message : "An error occurred.",
797 });
799 toastManager.add(
800 stackedThreadToast({
801 type: "error",
802 title: "Failed to add project",
803 description: error instanceof Error ? error.message : "An error occurred.",
804 }),
805 );
798806 }
799807 },
800808 [
......@@ -934,11 +942,13 @@ function OpenCommandPaletteDialog() {
934942 }
935943
936944 void item.run().catch((error: unknown) => {
937 toastManager.add({
938 type: "error",
939 title: "Unable to run command",
940 description: error instanceof Error ? error.message : "An unexpected error occurred.",
941 });
945 toastManager.add(
946 stackedThreadToast({
947 type: "error",
948 title: "Unable to run command",
949 description: error instanceof Error ? error.message : "An unexpected error occurred.",
950 }),
951 );
942952 });
943953 }
944954
apps/web/src/components/GitActionsControl.browser.tsx+1
......@@ -90,6 +90,7 @@ vi.mock("~/components/ui/toast", () => ({
9090 promise: toastPromiseSpy,
9191 update: toastUpdateSpy,
9292 },
93 stackedThreadToast: vi.fn((options: unknown) => options),
9394}));
9495
9596vi.mock("~/editorPreferences", () => ({
apps/web/src/components/GitActionsControl.tsx+52-34
......@@ -38,7 +38,7 @@ import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu";
3838import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover";
3939import { ScrollArea } from "~/components/ui/scroll-area";
4040import { Textarea } from "~/components/ui/textarea";
41import { toastManager, type ThreadToastData } from "~/components/ui/toast";
41import { stackedThreadToast, toastManager, type ThreadToastData } from "~/components/ui/toast";
4242import { openInPreferredEditor } from "~/editorPreferences";
4343import {
4444 gitInitMutationOptions,
......@@ -474,12 +474,14 @@ export default function GitActionsControl({
474474 return;
475475 }
476476 void api.shell.openExternal(prUrl).catch((err: unknown) => {
477 toastManager.add({
478 type: "error",
479 title: "Unable to open PR link",
480 description: err instanceof Error ? err.message : "An error occurred.",
481 data: threadToastData,
482 });
477 toastManager.add(
478 stackedThreadToast({
479 type: "error",
480 title: "Unable to open PR link",
481 description: err instanceof Error ? err.message : "An error occurred.",
482 ...(threadToastData !== undefined ? { data: threadToastData } : {}),
483 }),
484 );
483485 });
484486 }, [gitStatusForActions, threadToastData]);
485487
......@@ -670,33 +672,44 @@ export default function GitActionsControl({
670672 };
671673 }
672674
673 const successToastBase = {
674 type: "success",
675 title: result.toast.title,
676 description: result.toast.description,
677 timeout: 0,
678 data: {
679 ...scopedToastData,
680 dismissAfterVisibleMs: 10_000,
681 },
682 } as const;
675 const successToastData = {
676 ...scopedToastData,
677 dismissAfterVisibleMs: 10_000,
678 };
683679
684680 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 {
685694 toastManager.update(resolvedProgressToastId, {
686 ...successToastBase,
687 actionProps: toastActionProps,
695 type: "success",
696 title: result.toast.title,
697 description: result.toast.description,
698 timeout: 0,
699 data: successToastData,
688700 });
689 } else {
690 toastManager.update(resolvedProgressToastId, successToastBase);
691701 }
692702 } catch (err) {
693703 activeGitActionProgressRef.current = null;
694 toastManager.update(resolvedProgressToastId, {
695 type: "error",
696 title: "Action failed",
697 description: err instanceof Error ? err.message : "An error occurred.",
698 data: scopedToastData,
699 });
704 toastManager.update(
705 resolvedProgressToastId,
706 stackedThreadToast({
707 type: "error",
708 title: "Action failed",
709 description: err instanceof Error ? err.message : "An error occurred.",
710 ...(scopedToastData !== undefined ? { data: scopedToastData } : {}),
711 }),
712 );
700713 }
701714 },
702715 );
......@@ -753,7 +766,10 @@ export default function GitActionsControl({
753766 }
754767 if (quickAction.kind === "run_pull") {
755768 const promise = pullMutation.mutateAsync();
756 toastManager.promise(promise, {
769 void toastManager.promise<
770 Awaited<ReturnType<typeof pullMutation.mutateAsync>>,
771 ThreadToastData
772 >(promise, {
757773 loading: { title: "Pulling...", data: threadToastData },
758774 success: (result) => ({
759775 title: result.status === "pulled" ? "Pulled" : "Already up to date",
......@@ -832,12 +848,14 @@ export default function GitActionsControl({
832848 }
833849 const target = resolvePathLinkTarget(filePath, gitCwd);
834850 void openInPreferredEditor(api, target).catch((error) => {
835 toastManager.add({
836 type: "error",
837 title: "Unable to open file",
838 description: error instanceof Error ? error.message : "An error occurred.",
839 data: threadToastData,
840 });
851 toastManager.add(
852 stackedThreadToast({
853 type: "error",
854 title: "Unable to open file",
855 description: error instanceof Error ? error.message : "An error occurred.",
856 ...(threadToastData !== undefined ? { data: threadToastData } : {}),
857 }),
858 );
841859 });
842860 },
843861 [gitCwd, threadToastData],
apps/web/src/components/PlanSidebar.tsx+8-6
......@@ -26,7 +26,7 @@ import {
2626} from "../proposedPlan";
2727import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu";
2828import { readEnvironmentApi } from "~/environmentApi";
29import { toastManager } from "./ui/toast";
29import { stackedThreadToast, toastManager } from "./ui/toast";
3030import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
3131
3232function stepStatusIcon(status: string): React.ReactNode {
......@@ -112,11 +112,13 @@ const PlanSidebar = memo(function PlanSidebar({
112112 });
113113 })
114114 .catch((error) => {
115 toastManager.add({
116 type: "error",
117 title: "Could not save plan",
118 description: error instanceof Error ? error.message : "An error occurred.",
119 });
115 toastManager.add(
116 stackedThreadToast({
117 type: "error",
118 title: "Could not save plan",
119 description: error instanceof Error ? error.message : "An error occurred.",
120 }),
121 );
120122 })
121123 .then(
122124 () => setIsSavingToWorkspace(false),
apps/web/src/components/Sidebar.tsx+155-128
......@@ -94,7 +94,7 @@ import {
9494 resolveThreadRouteRef,
9595 resolveThreadRouteTarget,
9696} from "../threadRoutes";
97import { toastManager } from "./ui/toast";
97import { stackedThreadToast, toastManager } from "./ui/toast";
9898import { formatRelativeTimeLabel } from "../timestampFormat";
9999import { SettingsSidebarNav } from "./settings/SettingsSidebarNav";
100100import { Kbd } from "./ui/kbd";
......@@ -950,11 +950,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
950950 });
951951 },
952952 onError: (error) => {
953 toastManager.add({
954 type: "error",
955 title: "Failed to copy thread ID",
956 description: error instanceof Error ? error.message : "An error occurred.",
957 });
953 toastManager.add(
954 stackedThreadToast({
955 type: "error",
956 title: "Failed to copy thread ID",
957 description: error instanceof Error ? error.message : "An error occurred.",
958 }),
959 );
958960 },
959961 });
960962 const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{
......@@ -968,11 +970,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
968970 });
969971 },
970972 onError: (error) => {
971 toastManager.add({
972 type: "error",
973 title: "Failed to copy path",
974 description: error instanceof Error ? error.message : "An error occurred.",
975 });
973 toastManager.add(
974 stackedThreadToast({
975 type: "error",
976 title: "Failed to copy path",
977 description: error instanceof Error ? error.message : "An error occurred.",
978 }),
979 );
976980 },
977981 });
978982 const openPrLink = useCallback((event: React.MouseEvent<HTMLElement>, prUrl: string) => {
......@@ -989,11 +993,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
989993 }
990994
991995 void api.shell.openExternal(prUrl).catch((error) => {
992 toastManager.add({
993 type: "error",
994 title: "Unable to open PR link",
995 description: error instanceof Error ? error.message : "An error occurred.",
996 });
996 toastManager.add(
997 stackedThreadToast({
998 type: "error",
999 title: "Unable to open PR link",
1000 description: error instanceof Error ? error.message : "An error occurred.",
1001 }),
1002 );
9971003 });
9981004 }, []);
9991005 const sidebarThreads = useStore(
......@@ -1299,72 +1305,73 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
12991305 const memberProjectRef = scopeProjectRef(member.environmentId, member.id);
13001306 const memberThreadCount = memberThreadCountByPhysicalKey.get(member.physicalProjectKey) ?? 0;
13011307 if (memberThreadCount > 0) {
1302 const warningToastId = toastManager.add({
1303 type: "warning",
1304 title: "Project is not empty",
1305 description: "Delete all threads in this project before removing it.",
1306 data: {
1307 actionLayout: "stacked-end",
1308 const warningToastId = toastManager.add(
1309 stackedThreadToast({
1310 type: "warning",
1311 title: "Project is not empty",
1312 description: "Delete all threads in this project before removing it.",
13081313 actionVariant: "destructive",
1309 },
1310 actionProps: {
1311 children: "Delete anyway",
1312 onClick: () => {
1313 void (async () => {
1314 toastManager.close(warningToastId);
1315 await new Promise<void>((resolve) => {
1316 window.setTimeout(resolve, 180);
1317 });
1318
1319 const latestProjectThreads = selectSidebarThreadsForProjectRefs(
1320 useStore.getState(),
1321 [memberProjectRef],
1322 );
1323 const confirmed = await api.dialogs.confirm(
1324 latestProjectThreads.length > 0
1325 ? [
1326 `Remove project "${member.name}" and delete its ${latestProjectThreads.length} thread${
1327 latestProjectThreads.length === 1 ? "" : "s"
1328 }?`,
1329 `Path: ${member.cwd}`,
1330 ...(member.environmentLabel
1331 ? [`Environment: ${member.environmentLabel}`]
1332 : []),
1333 "This permanently clears conversation history for those threads.",
1334 "This removes only this project entry.",
1335 "This action cannot be undone.",
1336 ].join("\n")
1337 : [
1338 `Remove project "${member.name}"?`,
1339 `Path: ${member.cwd}`,
1340 ...(member.environmentLabel
1341 ? [`Environment: ${member.environmentLabel}`]
1342 : []),
1343 "This removes only this project entry.",
1344 ].join("\n"),
1345 );
1346 if (!confirmed) {
1347 return;
1348 }
1314 actionProps: {
1315 children: "Delete anyway",
1316 onClick: () => {
1317 void (async () => {
1318 toastManager.close(warningToastId);
1319 await new Promise<void>((resolve) => {
1320 window.setTimeout(resolve, 180);
1321 });
1322
1323 const latestProjectThreads = selectSidebarThreadsForProjectRefs(
1324 useStore.getState(),
1325 [memberProjectRef],
1326 );
1327 const confirmed = await api.dialogs.confirm(
1328 latestProjectThreads.length > 0
1329 ? [
1330 `Remove project "${member.name}" and delete its ${latestProjectThreads.length} thread${
1331 latestProjectThreads.length === 1 ? "" : "s"
1332 }?`,
1333 `Path: ${member.cwd}`,
1334 ...(member.environmentLabel
1335 ? [`Environment: ${member.environmentLabel}`]
1336 : []),
1337 "This permanently clears conversation history for those threads.",
1338 "This removes only this project entry.",
1339 "This action cannot be undone.",
1340 ].join("\n")
1341 : [
1342 `Remove project "${member.name}"?`,
1343 `Path: ${member.cwd}`,
1344 ...(member.environmentLabel
1345 ? [`Environment: ${member.environmentLabel}`]
1346 : []),
1347 "This removes only this project entry.",
1348 ].join("\n"),
1349 );
1350 if (!confirmed) {
1351 return;
1352 }
13491353
1350 await removeProject(member, { force: true });
1351 })().catch((error) => {
1352 const message =
1353 error instanceof Error ? error.message : "Unknown error removing project.";
1354 console.error("Failed to remove project", {
1355 projectId: member.id,
1356 environmentId: member.environmentId,
1357 error,
1358 });
1359 toastManager.add({
1360 type: "error",
1361 title: `Failed to remove "${member.name}"`,
1362 description: message,
1354 await removeProject(member, { force: true });
1355 })().catch((error) => {
1356 const message =
1357 error instanceof Error ? error.message : "Unknown error removing project.";
1358 console.error("Failed to remove project", {
1359 projectId: member.id,
1360 environmentId: member.environmentId,
1361 error,
1362 });
1363 toastManager.add(
1364 stackedThreadToast({
1365 type: "error",
1366 title: `Failed to remove "${member.name}"`,
1367 description: message,
1368 }),
1369 );
13631370 });
1364 });
1371 },
13651372 },
1366 },
1367 });
1373 }),
1374 );
13681375 return;
13691376 }
13701377
......@@ -1388,11 +1395,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
13881395 environmentId: member.environmentId,
13891396 error,
13901397 });
1391 toastManager.add({
1392 type: "error",
1393 title: `Failed to remove "${member.name}"`,
1394 description: message,
1395 });
1398 toastManager.add(
1399 stackedThreadToast({
1400 type: "error",
1401 title: `Failed to remove "${member.name}"`,
1402 description: message,
1403 }),
1404 );
13961405 }
13971406 },
13981407 [memberThreadCountByPhysicalKey, removeProject],
......@@ -1705,11 +1714,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
17051714 try {
17061715 await archiveThread(threadRef);
17071716 } catch (error) {
1708 toastManager.add({
1709 type: "error",
1710 title: "Failed to archive thread",
1711 description: error instanceof Error ? error.message : "An error occurred.",
1712 });
1717 toastManager.add(
1718 stackedThreadToast({
1719 type: "error",
1720 title: "Failed to archive thread",
1721 description: error instanceof Error ? error.message : "An error occurred.",
1722 }),
1723 );
17131724 }
17141725 },
17151726 [archiveThread],
......@@ -1757,11 +1768,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
17571768 title: trimmed,
17581769 });
17591770 } catch (error) {
1760 toastManager.add({
1761 type: "error",
1762 title: "Failed to rename thread",
1763 description: error instanceof Error ? error.message : "An error occurred.",
1764 });
1771 toastManager.add(
1772 stackedThreadToast({
1773 type: "error",
1774 title: "Failed to rename thread",
1775 description: error instanceof Error ? error.message : "An error occurred.",
1776 }),
1777 );
17651778 }
17661779 finishRename();
17671780 },
......@@ -1794,11 +1807,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
17941807
17951808 const api = readEnvironmentApi(projectRenameTarget.environmentId);
17961809 if (!api) {
1797 toastManager.add({
1798 type: "error",
1799 title: "Failed to rename project",
1800 description: "Project API unavailable.",
1801 });
1810 toastManager.add(
1811 stackedThreadToast({
1812 type: "error",
1813 title: "Failed to rename project",
1814 description: "Project API unavailable.",
1815 }),
1816 );
18021817 return;
18031818 }
18041819
......@@ -1811,11 +1826,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
18111826 });
18121827 closeProjectRenameDialog();
18131828 } catch (error) {
1814 toastManager.add({
1815 type: "error",
1816 title: "Failed to rename project",
1817 description: error instanceof Error ? error.message : "An error occurred.",
1818 });
1829 toastManager.add(
1830 stackedThreadToast({
1831 type: "error",
1832 title: "Failed to rename project",
1833 description: error instanceof Error ? error.message : "An error occurred.",
1834 }),
1835 );
18191836 }
18201837 }, [closeProjectRenameDialog, projectRenameTarget, projectRenameTitle]);
18211838
......@@ -1885,11 +1902,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
18851902 }
18861903 if (clicked === "copy-path") {
18871904 if (!threadWorkspacePath) {
1888 toastManager.add({
1889 type: "error",
1890 title: "Path unavailable",
1891 description: "This thread does not have a workspace path to copy.",
1892 });
1905 toastManager.add(
1906 stackedThreadToast({
1907 type: "error",
1908 title: "Path unavailable",
1909 description: "This thread does not have a workspace path to copy.",
1910 }),
1911 );
18931912 return;
18941913 }
18951914 copyPathToClipboard(threadWorkspacePath, { path: threadWorkspacePath });
......@@ -3220,18 +3239,22 @@ export default function Sidebar() {
32203239 if (!shouldToastDesktopUpdateActionResult(result)) return;
32213240 const actionError = getDesktopUpdateActionError(result);
32223241 if (!actionError) return;
3223 toastManager.add({
3224 type: "error",
3225 title: "Could not download update",
3226 description: actionError,
3227 });
3242 toastManager.add(
3243 stackedThreadToast({
3244 type: "error",
3245 title: "Could not download update",
3246 description: actionError,
3247 }),
3248 );
32283249 })
32293250 .catch((error) => {
3230 toastManager.add({
3231 type: "error",
3232 title: "Could not start update download",
3233 description: error instanceof Error ? error.message : "An unexpected error occurred.",
3234 });
3251 toastManager.add(
3252 stackedThreadToast({
3253 type: "error",
3254 title: "Could not start update download",
3255 description: error instanceof Error ? error.message : "An unexpected error occurred.",
3256 }),
3257 );
32353258 });
32363259 return;
32373260 }
......@@ -3247,18 +3270,22 @@ export default function Sidebar() {
32473270 if (!shouldToastDesktopUpdateActionResult(result)) return;
32483271 const actionError = getDesktopUpdateActionError(result);
32493272 if (!actionError) return;
3250 toastManager.add({
3251 type: "error",
3252 title: "Could not install update",
3253 description: actionError,
3254 });
3273 toastManager.add(
3274 stackedThreadToast({
3275 type: "error",
3276 title: "Could not install update",
3277 description: actionError,
3278 }),
3279 );
32553280 })
32563281 .catch((error) => {
3257 toastManager.add({
3258 type: "error",
3259 title: "Could not install update",
3260 description: error instanceof Error ? error.message : "An unexpected error occurred.",
3261 });
3282 toastManager.add(
3283 stackedThreadToast({
3284 type: "error",
3285 title: "Could not install update",
3286 description: error instanceof Error ? error.message : "An unexpected error occurred.",
3287 }),
3288 );
32623289 });
32633290 }
32643291 }, [desktopUpdateButtonAction, desktopUpdateButtonDisabled, desktopUpdateState]);
apps/web/src/components/WebSocketConnectionSurface.tsx+58-29
......@@ -10,7 +10,7 @@ import {
1010 useWsConnectionStatus,
1111 WS_RECONNECT_MAX_ATTEMPTS,
1212} from "../rpc/wsConnectionState";
13import { toastManager } from "./ui/toast";
13import { stackedThreadToast, toastManager } from "./ui/toast";
1414import { getPrimaryEnvironmentConnection } from "../environments/runtime";
1515
1616const FORCED_WS_RECONNECT_DEBOUNCE_MS = 5_000;
......@@ -75,13 +75,34 @@ function describeRecoveredToast(
7575 return "Connection restored.";
7676}
7777
78function describeSlowRpcAckToast(requests: ReadonlyArray<SlowRpcAckRequest>): ReactNode {
78function describeSlowRpcAckToast(requests: ReadonlyArray<SlowRpcAckRequest>): string {
7979 const count = requests.length;
8080 const thresholdSeconds = Math.round((requests[0]?.thresholdMs ?? 0) / 1000);
8181
8282 return `${count} request${count === 1 ? "" : "s"} waiting longer than ${thresholdSeconds}s.`;
8383}
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
85106export function shouldAutoReconnect(
86107 status: WsConnectionStatus,
87108 trigger: WsAutoReconnectTrigger,
......@@ -138,15 +159,18 @@ export function WebSocketConnectionCoordinator() {
138159 console.warn("Automatic WebSocket reconnect failed", { error });
139160 return;
140161 }
141 toastManager.add({
142 type: "error",
143 title: "Reconnect failed",
144 description: error instanceof Error ? error.message : "Unable to restart the WebSocket.",
145 data: {
146 dismissAfterVisibleMs: 8_000,
147 hideCopyButton: true,
148 },
149 });
162 toastManager.add(
163 stackedThreadToast({
164 type: "error",
165 title: "Reconnect failed",
166 description:
167 error instanceof Error ? error.message : "Unable to restart the WebSocket.",
168 data: {
169 dismissAfterVisibleMs: 8_000,
170 hideCopyButton: true,
171 },
172 }),
173 );
150174 });
151175 });
152176 const syncBrowserOnlineStatus = useEffectEvent(() => {
......@@ -253,45 +277,45 @@ export function WebSocketConnectionCoordinator() {
253277
254278 if (shouldShowReconnectToast || shouldShowOfflineToast || shouldShowExhaustedToast) {
255279 const toastPayload = shouldShowOfflineToast
256 ? {
257 description: describeOfflineToast(),
258 timeout: 0,
259 title: "Offline",
260 type: "warning" as const,
280 ? stackedThreadToast({
261281 data: {
262282 hideCopyButton: true,
263283 },
264 }
284 description: describeOfflineToast(),
285 timeout: 0,
286 title: "Offline",
287 type: "warning",
288 })
265289 : shouldShowExhaustedToast
266 ? {
290 ? stackedThreadToast({
267291 actionProps: {
268292 children: "Retry",
269293 onClick: triggerManualReconnect,
270294 },
271 description: describeExhaustedToast(),
272 timeout: 0,
273 title: "Disconnected from T3 Server",
274 type: "error" as const,
275295 data: {
276296 hideCopyButton: true,
277297 },
278 }
279 : {
298 description: describeExhaustedToast(),
299 timeout: 0,
300 title: "Disconnected from T3 Server",
301 type: "error",
302 })
303 : stackedThreadToast({
280304 actionProps: {
281305 children: "Retry now",
282306 onClick: triggerManualReconnect,
283307 },
308 data: {
309 hideCopyButton: true,
310 },
284311 description:
285312 status.nextRetryAt === null
286313 ? `Reconnecting... ${formatReconnectAttemptLabel(status)}`
287314 : `Reconnecting in ${formatRetryCountdown(status.nextRetryAt, nowMs)}... ${formatReconnectAttemptLabel(status)}`,
288315 timeout: 0,
289316 title: buildReconnectTitle(status),
290 type: "loading" as const,
291 data: {
292 hideCopyButton: true,
293 },
294 };
317 type: "loading",
318 });
295319
296320 if (toastIdRef.current) {
297321 toastManager.update(toastIdRef.current, toastPayload);
......@@ -369,6 +393,11 @@ export function SlowRpcAckToastCoordinator() {
369393 }
370394
371395 const nextToast = {
396 data: {
397 expandableContent: <SlowRpcAckRequestDetails requests={slowRequests} />,
398 expandableDescriptionTrigger: true,
399 expandableLabels: { collapse: "Hide requests", expand: "Show requests" },
400 },
372401 description: describeSlowRpcAckToast(slowRequests),
373402 timeout: 0,
374403 title: "Some requests are slow",
apps/web/src/components/chat/ProposedPlanCard.tsx+22-16
......@@ -24,7 +24,7 @@ import {
2424 DialogPopup,
2525 DialogTitle,
2626} from "../ui/dialog";
27import { toastManager } from "../ui/toast";
27import { stackedThreadToast, toastManager } from "../ui/toast";
2828import { readEnvironmentApi } from "~/environmentApi";
2929import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
3030
......@@ -45,11 +45,13 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({
4545 const [isSavingToWorkspace, setIsSavingToWorkspace] = useState(false);
4646 const { copyToClipboard, isCopied } = useCopyToClipboard({
4747 onError: (error) => {
48 toastManager.add({
49 type: "error",
50 title: "Could not copy plan",
51 description: error instanceof Error ? error.message : "An error occurred while copying.",
52 });
48 toastManager.add(
49 stackedThreadToast({
50 type: "error",
51 title: "Could not copy plan",
52 description: error instanceof Error ? error.message : "An error occurred while copying.",
53 }),
54 );
5355 },
5456 });
5557 const savePathInputId = useId();
......@@ -73,11 +75,13 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({
7375
7476 const openSaveDialog = () => {
7577 if (!workspaceRoot) {
76 toastManager.add({
77 type: "error",
78 title: "Workspace path is unavailable",
79 description: "This thread does not have a workspace path to save into.",
80 });
78 toastManager.add(
79 stackedThreadToast({
80 type: "error",
81 title: "Workspace path is unavailable",
82 description: "This thread does not have a workspace path to save into.",
83 }),
84 );
8185 return;
8286 }
8387 setSavePath((existing) => (existing.length > 0 ? existing : downloadFilename));
......@@ -114,11 +118,13 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({
114118 });
115119 })
116120 .catch((error) => {
117 toastManager.add({
118 type: "error",
119 title: "Could not save plan",
120 description: error instanceof Error ? error.message : "An error occurred while saving.",
121 });
121 toastManager.add(
122 stackedThreadToast({
123 type: "error",
124 title: "Could not save plan",
125 description: error instanceof Error ? error.message : "An error occurred while saving.",
126 }),
127 );
122128 })
123129 .then(
124130 () => {
apps/web/src/components/settings/ConnectionsSettings.tsx+64-46
......@@ -41,7 +41,7 @@ import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
4141import { QRCodeSvg } from "../ui/qr-code";
4242import { Spinner } from "../ui/spinner";
4343import { Switch } from "../ui/switch";
44import { toastManager } from "../ui/toast";
44import { stackedThreadToast, toastManager } from "../ui/toast";
4545import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
4646import { Button } from "../ui/button";
4747import { Textarea } from "../ui/textarea";
......@@ -302,11 +302,13 @@ const PairingLinkListRow = memo(function PairingLinkListRow({
302302 },
303303 onError: (error) => {
304304 setIsRevealDialogOpen(true);
305 toastManager.add({
306 type: "error",
307 title: canCopyToClipboard ? "Could not copy pairing URL" : "Clipboard copy unavailable",
308 description: canCopyToClipboard ? error.message : "Showing the full value instead.",
309 });
305 toastManager.add(
306 stackedThreadToast({
307 type: "error",
308 title: canCopyToClipboard ? "Could not copy pairing URL" : "Clipboard copy unavailable",
309 description: canCopyToClipboard ? error.message : "Showing the full value instead.",
310 }),
311 );
310312 },
311313 });
312314
......@@ -535,11 +537,13 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio
535537 setDialogOpen(false);
536538 } catch (error) {
537539 const message = error instanceof Error ? error.message : "Failed to create pairing URL.";
538 toastManager.add({
539 type: "error",
540 title: "Could not create pairing URL",
541 description: message,
542 });
540 toastManager.add(
541 stackedThreadToast({
542 type: "error",
543 title: "Could not create pairing URL",
544 description: message,
545 }),
546 );
543547 } finally {
544548 setIsCreatingPairingLink(false);
545549 }
......@@ -828,11 +832,13 @@ export function ConnectionsSettings() {
828832 error instanceof Error ? error.message : "Failed to update network exposure.";
829833 setPendingDesktopServerExposureMode(null);
830834 setDesktopServerExposureError(message);
831 toastManager.add({
832 type: "error",
833 title: "Could not update network access",
834 description: message,
835 });
835 toastManager.add(
836 stackedThreadToast({
837 type: "error",
838 title: "Could not update network access",
839 description: message,
840 }),
841 );
836842 setIsUpdatingDesktopServerExposure(false);
837843 }
838844 },
......@@ -853,11 +859,13 @@ export function ConnectionsSettings() {
853859 } catch (error) {
854860 const message = error instanceof Error ? error.message : "Failed to revoke pairing link.";
855861 setDesktopAccessManagementError(message);
856 toastManager.add({
857 type: "error",
858 title: "Could not revoke pairing link",
859 description: message,
860 });
862 toastManager.add(
863 stackedThreadToast({
864 type: "error",
865 title: "Could not revoke pairing link",
866 description: message,
867 }),
868 );
861869 } finally {
862870 setRevokingDesktopPairingLinkId(null);
863871 }
......@@ -872,11 +880,13 @@ export function ConnectionsSettings() {
872880 } catch (error) {
873881 const message = error instanceof Error ? error.message : "Failed to revoke client access.";
874882 setDesktopAccessManagementError(message);
875 toastManager.add({
876 type: "error",
877 title: "Could not revoke client access",
878 description: message,
879 });
883 toastManager.add(
884 stackedThreadToast({
885 type: "error",
886 title: "Could not revoke client access",
887 description: message,
888 }),
889 );
880890 } finally {
881891 setRevokingDesktopClientSessionId(null);
882892 }
......@@ -897,11 +907,13 @@ export function ConnectionsSettings() {
897907 } catch (error) {
898908 const message = error instanceof Error ? error.message : "Failed to revoke other clients.";
899909 setDesktopAccessManagementError(message);
900 toastManager.add({
901 type: "error",
902 title: "Could not revoke other clients",
903 description: message,
904 });
910 toastManager.add(
911 stackedThreadToast({
912 type: "error",
913 title: "Could not revoke other clients",
914 description: message,
915 }),
916 );
905917 } finally {
906918 setIsRevokingOtherDesktopClients(false);
907919 }
......@@ -933,11 +945,13 @@ export function ConnectionsSettings() {
933945 } catch (error) {
934946 const message = error instanceof Error ? error.message : "Failed to add backend.";
935947 setSavedBackendError(message);
936 toastManager.add({
937 type: "error",
938 title: "Could not add backend",
939 description: message,
940 });
948 toastManager.add(
949 stackedThreadToast({
950 type: "error",
951 title: "Could not add backend",
952 description: message,
953 }),
954 );
941955 } finally {
942956 setIsAddingSavedBackend(false);
943957 }
......@@ -957,11 +971,13 @@ export function ConnectionsSettings() {
957971 } catch (error) {
958972 const message = error instanceof Error ? error.message : "Failed to reconnect backend.";
959973 setSavedBackendError(message);
960 toastManager.add({
961 type: "error",
962 title: "Could not reconnect backend",
963 description: message,
964 });
974 toastManager.add(
975 stackedThreadToast({
976 type: "error",
977 title: "Could not reconnect backend",
978 description: message,
979 }),
980 );
965981 } finally {
966982 setReconnectingSavedEnvironmentId(null);
967983 }
......@@ -975,11 +991,13 @@ export function ConnectionsSettings() {
975991 } catch (error) {
976992 const message = error instanceof Error ? error.message : "Failed to remove backend.";
977993 setSavedBackendError(message);
978 toastManager.add({
979 type: "error",
980 title: "Could not remove backend",
981 description: message,
982 });
994 toastManager.add(
995 stackedThreadToast({
996 type: "error",
997 title: "Could not remove backend",
998 description: message,
999 }),
1000 );
9831001 } finally {
9841002 setRemovingSavedEnvironmentId(null);
9851003 }
apps/web/src/components/settings/SettingsPanels.tsx+52-38
......@@ -63,7 +63,7 @@ import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "..
6363import { Input } from "../ui/input";
6464import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
6565import { Switch } from "../ui/switch";
66import { toastManager } from "../ui/toast";
66import { stackedThreadToast, toastManager } from "../ui/toast";
6767import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
6868import {
6969 SettingResetButton,
......@@ -282,11 +282,13 @@ function AboutVersionSection() {
282282 setDesktopUpdateStateQueryData(queryClient, state);
283283 })
284284 .catch((error: unknown) => {
285 toastManager.add({
286 type: "error",
287 title: "Could not change update track",
288 description: error instanceof Error ? error.message : "Update track change failed.",
289 });
285 toastManager.add(
286 stackedThreadToast({
287 type: "error",
288 title: "Could not change update track",
289 description: error instanceof Error ? error.message : "Update track change failed.",
290 }),
291 );
290292 })
291293 .finally(() => {
292294 setIsChangingUpdateChannel(false);
......@@ -308,11 +310,13 @@ function AboutVersionSection() {
308310 setDesktopUpdateStateQueryData(queryClient, result.state);
309311 })
310312 .catch((error: unknown) => {
311 toastManager.add({
312 type: "error",
313 title: "Could not download update",
314 description: error instanceof Error ? error.message : "Download failed.",
315 });
313 toastManager.add(
314 stackedThreadToast({
315 type: "error",
316 title: "Could not download update",
317 description: error instanceof Error ? error.message : "Download failed.",
318 }),
319 );
316320 });
317321 return;
318322 }
......@@ -330,11 +334,13 @@ function AboutVersionSection() {
330334 setDesktopUpdateStateQueryData(queryClient, result.state);
331335 })
332336 .catch((error: unknown) => {
333 toastManager.add({
334 type: "error",
335 title: "Could not install update",
336 description: error instanceof Error ? error.message : "Install failed.",
337 });
337 toastManager.add(
338 stackedThreadToast({
339 type: "error",
340 title: "Could not install update",
341 description: error instanceof Error ? error.message : "Install failed.",
342 }),
343 );
338344 });
339345 return;
340346 }
......@@ -345,20 +351,24 @@ function AboutVersionSection() {
345351 .then((result) => {
346352 setDesktopUpdateStateQueryData(queryClient, result.state);
347353 if (!result.checked) {
348 toastManager.add({
349 type: "error",
350 title: "Could not check for updates",
351 description:
352 result.state.message ?? "Automatic updates are not available in this build.",
353 });
354 toastManager.add(
355 stackedThreadToast({
356 type: "error",
357 title: "Could not check for updates",
358 description:
359 result.state.message ?? "Automatic updates are not available in this build.",
360 }),
361 );
354362 }
355363 })
356364 .catch((error: unknown) => {
357 toastManager.add({
358 type: "error",
359 title: "Could not check for updates",
360 description: error instanceof Error ? error.message : "Update check failed.",
361 });
365 toastManager.add(
366 stackedThreadToast({
367 type: "error",
368 title: "Could not check for updates",
369 description: error instanceof Error ? error.message : "Update check failed.",
370 }),
371 );
362372 });
363373 }, [queryClient, updateState]);
364374
......@@ -1688,11 +1698,13 @@ export function ArchivedThreadsPanel() {
16881698 try {
16891699 await unarchiveThread(threadRef);
16901700 } catch (error) {
1691 toastManager.add({
1692 type: "error",
1693 title: "Failed to unarchive thread",
1694 description: error instanceof Error ? error.message : "An error occurred.",
1695 });
1701 toastManager.add(
1702 stackedThreadToast({
1703 type: "error",
1704 title: "Failed to unarchive thread",
1705 description: error instanceof Error ? error.message : "An error occurred.",
1706 }),
1707 );
16961708 }
16971709 return;
16981710 }
......@@ -1756,12 +1768,14 @@ export function ArchivedThreadsPanel() {
17561768 onClick={() =>
17571769 void unarchiveThread(scopeThreadRef(thread.environmentId, thread.id)).catch(
17581770 (error) => {
1759 toastManager.add({
1760 type: "error",
1761 title: "Failed to unarchive thread",
1762 description:
1763 error instanceof Error ? error.message : "An error occurred.",
1764 });
1771 toastManager.add(
1772 stackedThreadToast({
1773 type: "error",
1774 title: "Failed to unarchive thread",
1775 description:
1776 error instanceof Error ? error.message : "An error occurred.",
1777 }),
1778 );
17651779 },
17661780 )
17671781 }
apps/web/src/components/sidebar/SidebarUpdatePill.tsx+29-21
......@@ -6,7 +6,7 @@ import {
66 setDesktopUpdateStateQueryData,
77 useDesktopUpdateState,
88} from "../../lib/desktopUpdateReactQuery";
9import { toastManager } from "../ui/toast";
9import { stackedThreadToast, toastManager } from "../ui/toast";
1010import {
1111 getArm64IntelBuildWarningDescription,
1212 getDesktopUpdateActionError,
......@@ -55,18 +55,22 @@ export function SidebarUpdatePill() {
5555 if (!shouldToastDesktopUpdateActionResult(result)) return;
5656 const actionError = getDesktopUpdateActionError(result);
5757 if (!actionError) return;
58 toastManager.add({
59 type: "error",
60 title: "Could not download update",
61 description: actionError,
62 });
58 toastManager.add(
59 stackedThreadToast({
60 type: "error",
61 title: "Could not download update",
62 description: actionError,
63 }),
64 );
6365 })
6466 .catch((error) => {
65 toastManager.add({
66 type: "error",
67 title: "Could not start update download",
68 description: error instanceof Error ? error.message : "An unexpected error occurred.",
69 });
67 toastManager.add(
68 stackedThreadToast({
69 type: "error",
70 title: "Could not start update download",
71 description: error instanceof Error ? error.message : "An unexpected error occurred.",
72 }),
73 );
7074 });
7175 return;
7276 }
......@@ -81,18 +85,22 @@ export function SidebarUpdatePill() {
8185 if (!shouldToastDesktopUpdateActionResult(result)) return;
8286 const actionError = getDesktopUpdateActionError(result);
8387 if (!actionError) return;
84 toastManager.add({
85 type: "error",
86 title: "Could not install update",
87 description: actionError,
88 });
88 toastManager.add(
89 stackedThreadToast({
90 type: "error",
91 title: "Could not install update",
92 description: actionError,
93 }),
94 );
8995 })
9096 .catch((error) => {
91 toastManager.add({
92 type: "error",
93 title: "Could not install update",
94 description: error instanceof Error ? error.message : "An unexpected error occurred.",
95 });
97 toastManager.add(
98 stackedThreadToast({
99 type: "error",
100 title: "Could not install update",
101 description: error instanceof Error ? error.message : "An unexpected error occurred.",
102 }),
103 );
96104 });
97105 }
98106 }, [action, disabled, queryClient, state]);
apps/web/src/components/ui/toast.logic.test.ts+56
......@@ -45,6 +45,62 @@ describe("buildVisibleToastLayout", () => {
4545 );
4646 });
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
48104 it("treats missing heights as zero", () => {
49105 const layout = buildVisibleToastLayout([
50106 { id: "a" },
apps/web/src/components/ui/toast.logic.ts+52-10
......@@ -14,6 +14,12 @@ type ToastWithHeight = {
1414 height?: number | null | undefined;
1515};
1616
17type ToastWithTransitionStatus = {
18 transitionStatus?: "starting" | "ending" | undefined;
19};
20
21type ToastWithLayoutProps = ToastWithHeight & ToastWithTransitionStatus;
22
1723type VisibleToastLayoutItem<TToast extends object> = {
1824 toast: TToast;
1925 visibleIndex: number;
......@@ -21,25 +27,61 @@ type VisibleToastLayoutItem<TToast extends object> = {
2127};
2228
2329export function buildVisibleToastLayout<TToast extends object>(
24 visibleToasts: readonly (TToast & ToastWithHeight)[],
30 visibleToasts: readonly (TToast & ToastWithLayoutProps)[],
2531): {
2632 frontmostHeight: number;
27 items: VisibleToastLayoutItem<TToast & ToastWithHeight>[];
33 items: VisibleToastLayoutItem<TToast & ToastWithLayoutProps>[];
2834} {
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) => {
3464 const item = {
3565 toast,
36 visibleIndex,
37 offsetY,
66 visibleIndex: liveIndex,
67 offsetY: liveOffsetY,
3868 };
3969
40 offsetY += normalizeToastHeight(toast.height);
70 fullOffsetY += height;
71 fullIndex += 1;
72 liveOffsetY += height;
73 liveIndex += 1;
4174 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,
4385 };
4486}
4587
apps/web/src/components/ui/toast.tsx+340-113
......@@ -1,17 +1,27 @@
11"use client";
22
33import { 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";
512import { useParams } from "@tanstack/react-router";
613import { type ScopedThreadRef, type ThreadId } from "@t3tools/contracts";
714import {
815 CheckIcon,
16 ChevronDownIcon,
17 ChevronUpIcon,
918 CircleAlertIcon,
1019 CircleCheckIcon,
1120 CopyIcon,
1221 InfoIcon,
1322 LoaderCircleIcon,
1423 TriangleAlertIcon,
24 XIcon,
1525} from "lucide-react";
1626
1727import { cn } from "~/lib/utils";
......@@ -31,6 +41,11 @@ export type ThreadToastData = {
3141 tooltipStyle?: boolean;
3242 dismissAfterVisibleMs?: number;
3343 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;
3449 actionLayout?: "inline" | "stacked-end";
3550 actionVariant?:
3651 | "default"
......@@ -55,25 +70,265 @@ const TOAST_ICONS = {
5570 warning: TriangleAlertIcon,
5671} 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
5893function CopyErrorButton({ text }: { text: string }) {
5994 const { copyToClipboard, isCopied } = useCopyToClipboard();
6095
6196 return (
6297 <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"
6499 onClick={() => copyToClipboard(text)}
65100 title="Copy error"
66101 type="button"
67102 >
68 {isCopied ? (
69 <CheckIcon className="size-3.5 text-success" />
70 ) : (
71 <CopyIcon className="size-3.5" />
72 )}
103 {isCopied ? <CheckIcon className="size-3 text-success" /> : <CopyIcon className="size-3" />}
73104 </button>
74105 );
75106}
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
77332type ToastPosition =
78333 | "top-left"
79334 | "top-center"
......@@ -236,19 +491,17 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) {
236491 }
237492 >
238493 {visibleToastLayout.items.map(({ toast, visibleIndex, offsetY }) => {
239 const Icon = toast.type ? TOAST_ICONS[toast.type as keyof typeof TOAST_ICONS] : null;
240494 const hideCollapsedContent = shouldHideCollapsedToastContent(
241495 visibleIndex,
242496 visibleToastLayout.items.length,
243497 );
244 const stackedActionLayout =
245 toast.actionProps !== undefined && toast.data?.actionLayout === "stacked-end";
246 const actionVariant = toast.data?.actionVariant ?? "default";
498 const bodyDescriptor = deriveToastBodyDescriptor(toast);
499 const { stackedActionLayout, inlineContentEndPad } = bodyDescriptor;
247500
248501 return (
249502 <Toast.Root
250503 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%)]",
252505 // Base positioning using data-position
253506 "data-[position*=right]:right-0 data-[position*=right]:left-auto",
254507 "data-[position*=left]:right-auto data-[position*=left]:left-0",
......@@ -259,11 +512,19 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) {
259512 "after:absolute after:left-0 after:h-[calc(var(--toast-gap)+1px)] after:w-full",
260513 "data-[position*=top]:after:top-full",
261514 "data-[position*=bottom]:after:bottom-full",
262 // Define some variables
263 // Base UI exposes a shared front-most height for the collapsed stack.
264 // If that shared measurement is briefly stale, long content can render
265 // outside the card until hover expands the toast and swaps to its own 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))]",
515 // `--toast-calc-height`: behind + collapsed = peek height only (content `opacity-0`);
516 // max(front, own) there produced a tall empty shell for long bodies.
517 visibleIndex > 0
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))]"
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",
267528 // Define offset-y variable
268529 "data-[position*=top]:[--toast-calc-offset-y:calc(var(--toast-offset-y)+var(--toast-index)*var(--toast-gap)+var(--toast-swipe-movement-y))]",
269530 "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 }) {
272533 "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))]",
273534 // Limited state
274535 "data-limited:opacity-0",
275 // Expanded state
276 "data-expanded:h-(--toast-height)",
536 // Expanded stack
277537 "data-position:data-expanded:transform-[translateX(var(--toast-swipe-movement-x))_translateY(var(--toast-calc-offset-y))]",
278538 // Starting and ending animations
279539 "data-[position*=top]:data-starting-style:transform-[translateY(calc(-100%-var(--toast-inset)))]",
......@@ -314,54 +574,36 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) {
314574 dismissAfterVisibleMs={toast.data?.dismissAfterVisibleMs}
315575 toastId={toast.id}
316576 />
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>
317588 <Toast.Content
318589 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",
320593 stackedActionLayout
321 ? "flex flex-col gap-2 py-2.5"
322 : "flex items-center justify-between gap-1.5 py-3",
594 ? "flex flex-col gap-2 py-2.5 pr-3.5"
595 : cn("py-3", "flex items-center justify-between gap-1.5", inlineContentEndPad),
323596 hideCollapsedContent &&
324597 "not-data-expanded:pointer-events-none not-data-expanded:opacity-0",
325598 )}
326599 >
327 <div className="flex min-w-0 flex-1 gap-2">
328 {Icon && (
329 <div
330 className="[&>svg]:h-lh [&>svg]:w-4 [&_svg]:pointer-events-none [&_svg]:shrink-0"
331 data-slot="toast-icon"
332 >
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" />
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 )}
600 <ToastBodyContent
601 {...bodyDescriptor}
602 actionProps={toast.actionProps}
603 toastData={toast.data}
604 toastDescription={toast.description}
605 toastType={toast.type}
606 />
365607 </Toast.Content>
366608 </Toast.Root>
367609 );
......@@ -390,12 +632,10 @@ function AnchoredToasts() {
390632 {toasts
391633 .filter((toast) => shouldRenderThreadScopedToast(toast.data, activeThreadRef))
392634 .map((toast) => {
393 const Icon = toast.type ? TOAST_ICONS[toast.type as keyof typeof TOAST_ICONS] : null;
394635 const tooltipStyle = toast.data?.tooltipStyle ?? false;
395636 const positionerProps = toast.positionerProps;
396 const stackedActionLayout =
397 toast.actionProps !== undefined && toast.data?.actionLayout === "stacked-end";
398 const actionVariant = toast.data?.actionVariant ?? "default";
637 const bodyDescriptor = deriveToastBodyDescriptor(toast);
638 const { stackedActionLayout, inlineContentEndPad } = bodyDescriptor;
399639
400640 if (!positionerProps?.anchor) {
401641 return null;
......@@ -411,7 +651,7 @@ function AnchoredToasts() {
411651 >
412652 <Toast.Root
413653 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%)]",
415655 tooltipStyle
416656 ? "rounded-md shadow-md/5 before:rounded-[calc(var(--radius-md)-1px)]"
417657 : "rounded-lg shadow-lg/5 before:rounded-[calc(var(--radius-lg)-1px)]",
......@@ -424,55 +664,39 @@ function AnchoredToasts() {
424664 <Toast.Title data-slot="toast-title" />
425665 </Toast.Content>
426666 ) : (
427 <Toast.Content
428 className={cn(
429 "pointer-events-auto overflow-hidden px-3.5 text-sm",
430 stackedActionLayout
431 ? "flex flex-col gap-2 py-2.5"
432 : "flex items-center justify-between gap-1.5 py-3",
433 )}
434 >
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"
667 <>
668 <div className={toastCornerDismissClass}>
669 <button
670 aria-label="Dismiss notification"
671 className={toastCornerOrbClass}
672 data-slot="toast-close"
673 onClick={() => anchoredToastManager.close(toast.id)}
674 type="button"
471675 >
472 {toast.actionProps.children}
473 </Toast.Action>
474 )}
475 </Toast.Content>
676 <XIcon className="size-3" strokeWidth={2.25} />
677 </button>
678 </div>
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 </>
476700 )}
477701 </Toast.Root>
478702 </Toast.Positioner>
......@@ -483,6 +707,9 @@ function AnchoredToasts() {
483707 );
484708}
485709
710export { stackedThreadToast } from "./toastHelpers";
711export type { StackedThreadToastOptions } from "./toastHelpers";
712
486713export {
487714 ToastProvider,
488715 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 {
2020import { useTerminalStateStore } from "../terminalStateStore";
2121import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes";
2222import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";
23import { toastManager } from "../components/ui/toast";
23import { stackedThreadToast, toastManager } from "../components/ui/toast";
2424import { useSettings } from "./useSettings";
2525
2626export function useThreadActions() {
......@@ -225,11 +225,13 @@ export function useThreadActions() {
225225 worktreePath: orphanedWorktreePath,
226226 error,
227227 });
228 toastManager.add({
229 type: "error",
230 title: "Thread deleted, but worktree removal failed",
231 description: `Could not remove ${displayWorktreePath ?? orphanedWorktreePath}. ${message}`,
232 });
228 toastManager.add(
229 stackedThreadToast({
230 type: "error",
231 title: "Thread deleted, but worktree removal failed",
232 description: `Could not remove ${displayWorktreePath ?? orphanedWorktreePath}. ${message}`,
233 }),
234 );
233235 }
234236 },
235237 [
apps/web/src/routes/__root.tsx+40-30
......@@ -19,7 +19,12 @@ import {
1919 WebSocketConnectionSurface,
2020} from "../components/WebSocketConnectionSurface";
2121import { 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";
2328import { resolveAndPersistPreferredEditor } from "../editorPreferences";
2429import { readLocalApi } from "../localApi";
2530import { useSettings } from "../hooks/useSettings";
......@@ -290,37 +295,42 @@ function EventRouter() {
290295 return;
291296 }
292297
293 toastManager.add({
294 type: "warning",
295 title: "Invalid keybindings configuration",
296 description: issue.message,
297 actionProps: {
298 children: "Open keybindings.json",
299 onClick: () => {
300 const api = readLocalApi();
301 if (!api) {
302 return;
303 }
304
305 void Promise.resolve(serverConfig ?? api.server.getConfig())
306 .then((config) => {
307 const editor = resolveAndPersistPreferredEditor(config.availableEditors);
308 if (!editor) {
309 throw new Error("No available editors found.");
310 }
311 return api.shell.openInEditor(config.keybindingsConfigPath, editor);
312 })
313 .catch((error) => {
314 toastManager.add({
315 type: "error",
316 title: "Unable to open keybindings file",
317 description:
318 error instanceof Error ? error.message : "Unknown error opening file.",
298 toastManager.add(
299 stackedThreadToast({
300 type: "warning",
301 title: "Invalid keybindings configuration",
302 description: issue.message,
303 actionVariant: "outline",
304 actionProps: {
305 children: "Open keybindings.json",
306 onClick: () => {
307 const api = readLocalApi();
308 if (!api) {
309 return;
310 }
311
312 void Promise.resolve(serverConfig ?? api.server.getConfig())
313 .then((config) => {
314 const editor = resolveAndPersistPreferredEditor(config.availableEditors);
315 if (!editor) {
316 throw new Error("No available editors found.");
317 }
318 return api.shell.openInEditor(config.keybindingsConfigPath, editor);
319 })
320 .catch((error) => {
321 toastManager.add(
322 stackedThreadToast({
323 type: "error",
324 title: "Unable to open keybindings file",
325 description:
326 error instanceof Error ? error.message : "Unknown error opening file.",
327 }),
328 );
319329 });
320 });
330 },
321331 },
322 },
323 });
332 }),
333 );
324334 },
325335 );
326336