| author | |
| committer | |
| log | 3a1daa87ac103da0c176426e82eb576d87046bdf |
| tree | 4c26309b1ca4f5455334f65d13e2b2d48c64ea8c |
| parent | 055897f0eafd8c2ba981f9d0ced1c21052035f5b |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 42 | ComboboxStatus, |
| 43 | 43 | ComboboxTrigger, |
| 44 | 44 | } from "./ui/combobox"; |
| 45 | import { toastManager } from "./ui/toast"; | |
| 45 | import { stackedThreadToast, toastManager } from "./ui/toast"; | |
| 46 | 46 | |
| 47 | 47 | interface BranchToolbarBranchSelectorProps { |
| 48 | 48 | environmentId: EnvironmentId; |
| ... | ... | @@ -351,11 +351,13 @@ export function BranchToolbarBranchSelector({ |
| 351 | 351 | setThreadBranch(nextBranchName, selectionTarget.nextWorktreePath); |
| 352 | 352 | } catch (error) { |
| 353 | 353 | 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 | ); | |
| 359 | 361 | } |
| 360 | 362 | }); |
| 361 | 363 | }; |
| ... | ... | @@ -381,11 +383,13 @@ export function BranchToolbarBranchSelector({ |
| 381 | 383 | setThreadBranch(createBranchResult.branch, activeWorktreePath); |
| 382 | 384 | } catch (error) { |
| 383 | 385 | 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 | ); | |
| 389 | 393 | } |
| 390 | 394 | }); |
| 391 | 395 | }; |
apps/web/src/components/ChatMarkdown.tsx+22-16| ... | ... | @@ -20,7 +20,7 @@ import { defaultUrlTransform } from "react-markdown"; |
| 20 | 20 | import remarkGfm from "remark-gfm"; |
| 21 | 21 | import { VscodeEntryIcon } from "./chat/VscodeEntryIcon"; |
| 22 | 22 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; |
| 23 | import { toastManager } from "./ui/toast"; | |
| 23 | import { stackedThreadToast, toastManager } from "./ui/toast"; | |
| 24 | 24 | import { openInPreferredEditor } from "../editorPreferences"; |
| 25 | 25 | import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; |
| 26 | 26 | import { fnv1a32 } from "../lib/diffRendering"; |
| ... | ... | @@ -351,21 +351,25 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ |
| 351 | 351 | } |
| 352 | 352 | |
| 353 | 353 | 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 | ); | |
| 359 | 361 | }); |
| 360 | 362 | }, [targetPath]); |
| 361 | 363 | |
| 362 | 364 | const handleCopy = useCallback((value: string, title: string) => { |
| 363 | 365 | 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 | ); | |
| 369 | 373 | return; |
| 370 | 374 | } |
| 371 | 375 | |
| ... | ... | @@ -378,11 +382,13 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ |
| 378 | 382 | }); |
| 379 | 383 | }, |
| 380 | 384 | (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 | ); | |
| 386 | 392 | }, |
| 387 | 393 | ); |
| 388 | 394 | }, []); |
apps/web/src/components/ChatView.tsx+32-22| ... | ... | @@ -101,7 +101,7 @@ import PlanSidebar from "./PlanSidebar"; |
| 101 | 101 | import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; |
| 102 | 102 | import { ChevronDownIcon } from "lucide-react"; |
| 103 | 103 | import { cn, randomUUID } from "~/lib/utils"; |
| 104 | import { toastManager } from "./ui/toast"; | |
| 104 | import { stackedThreadToast, toastManager } from "./ui/toast"; | |
| 105 | 105 | import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; |
| 106 | 106 | import { type NewProjectScriptInput } from "./ProjectScriptsControl"; |
| 107 | 107 | import { |
| ... | ... | @@ -1855,11 +1855,13 @@ export default function ChatView(props: ChatViewProps) { |
| 1855 | 1855 | title: `Deleted action "${deletedName ?? "Unknown"}"`, |
| 1856 | 1856 | }); |
| 1857 | 1857 | } 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 | ); | |
| 1863 | 1865 | } |
| 1864 | 1866 | }, |
| 1865 | 1867 | [activeProject, persistProjectScripts], |
| ... | ... | @@ -2426,11 +2428,13 @@ export default function ChatView(props: ChatViewProps) { |
| 2426 | 2428 | expiredTerminalContextCount, |
| 2427 | 2429 | "empty", |
| 2428 | 2430 | ); |
| 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 | ); | |
| 2434 | 2438 | } |
| 2435 | 2439 | return; |
| 2436 | 2440 | } |
| ... | ... | @@ -2512,11 +2516,13 @@ export default function ChatView(props: ChatViewProps) { |
| 2512 | 2516 | expiredTerminalContextCount, |
| 2513 | 2517 | "omitted", |
| 2514 | 2518 | ); |
| 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 | ); | |
| 2520 | 2526 | } |
| 2521 | 2527 | promptRef.current = ""; |
| 2522 | 2528 | clearComposerDraftContent(composerDraftTarget); |
| ... | ... | @@ -3082,12 +3088,16 @@ export default function ChatView(props: ChatViewProps) { |
| 3082 | 3088 | threadId: nextThreadId, |
| 3083 | 3089 | }) |
| 3084 | 3090 | .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 | ); | |
| 3091 | 3101 | }) |
| 3092 | 3102 | .then(finish, finish); |
| 3093 | 3103 | }, [ |
apps/web/src/components/CommandPalette.tsx+36-26| ... | ... | @@ -102,7 +102,7 @@ import { |
| 102 | 102 | } from "./ui/command"; |
| 103 | 103 | import { Button } from "./ui/button"; |
| 104 | 104 | import { Kbd, KbdGroup } from "./ui/kbd"; |
| 105 | import { toastManager } from "./ui/toast"; | |
| 105 | import { stackedThreadToast, toastManager } from "./ui/toast"; | |
| 106 | 106 | import { ComposerHandleContext, useComposerHandleContext } from "../composerHandleContext"; |
| 107 | 107 | import type { ChatComposerHandle } from "./chat/ChatComposer"; |
| 108 | 108 | |
| ... | ... | @@ -605,11 +605,13 @@ function OpenCommandPaletteDialog() { |
| 605 | 605 | |
| 606 | 606 | const environmentId = defaultAddProjectEnvironmentId; |
| 607 | 607 | 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 | ); | |
| 613 | 615 | return; |
| 614 | 616 | } |
| 615 | 617 | |
| ... | ... | @@ -724,20 +726,24 @@ function OpenCommandPaletteDialog() { |
| 724 | 726 | if (!api) return; |
| 725 | 727 | |
| 726 | 728 | 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 | ); | |
| 732 | 736 | return; |
| 733 | 737 | } |
| 734 | 738 | |
| 735 | 739 | 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 | ); | |
| 741 | 747 | return; |
| 742 | 748 | } |
| 743 | 749 | |
| ... | ... | @@ -790,11 +796,13 @@ function OpenCommandPaletteDialog() { |
| 790 | 796 | }).catch(() => undefined); |
| 791 | 797 | setOpen(false); |
| 792 | 798 | } 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 | ); | |
| 798 | 806 | } |
| 799 | 807 | }, |
| 800 | 808 | [ |
| ... | ... | @@ -934,11 +942,13 @@ function OpenCommandPaletteDialog() { |
| 934 | 942 | } |
| 935 | 943 | |
| 936 | 944 | 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 | ); | |
| 942 | 952 | }); |
| 943 | 953 | } |
| 944 | 954 |
apps/web/src/components/GitActionsControl.browser.tsx+1| ... | ... | @@ -90,6 +90,7 @@ vi.mock("~/components/ui/toast", () => ({ |
| 90 | 90 | promise: toastPromiseSpy, |
| 91 | 91 | update: toastUpdateSpy, |
| 92 | 92 | }, |
| 93 | stackedThreadToast: vi.fn((options: unknown) => options), | |
| 93 | 94 | })); |
| 94 | 95 | |
| 95 | 96 | vi.mock("~/editorPreferences", () => ({ |
apps/web/src/components/GitActionsControl.tsx+52-34| ... | ... | @@ -38,7 +38,7 @@ import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu"; |
| 38 | 38 | import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; |
| 39 | 39 | import { ScrollArea } from "~/components/ui/scroll-area"; |
| 40 | 40 | import { Textarea } from "~/components/ui/textarea"; |
| 41 | import { toastManager, type ThreadToastData } from "~/components/ui/toast"; | |
| 41 | import { stackedThreadToast, toastManager, type ThreadToastData } from "~/components/ui/toast"; | |
| 42 | 42 | import { openInPreferredEditor } from "~/editorPreferences"; |
| 43 | 43 | import { |
| 44 | 44 | gitInitMutationOptions, |
| ... | ... | @@ -474,12 +474,14 @@ export default function GitActionsControl({ |
| 474 | 474 | return; |
| 475 | 475 | } |
| 476 | 476 | 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 | ); | |
| 483 | 485 | }); |
| 484 | 486 | }, [gitStatusForActions, threadToastData]); |
| 485 | 487 | |
| ... | ... | @@ -670,33 +672,44 @@ export default function GitActionsControl({ |
| 670 | 672 | }; |
| 671 | 673 | } |
| 672 | 674 | |
| 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 | }; | |
| 683 | 679 | |
| 684 | 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 | 694 | 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, | |
| 688 | 700 | }); |
| 689 | } else { | |
| 690 | toastManager.update(resolvedProgressToastId, successToastBase); | |
| 691 | 701 | } |
| 692 | 702 | } catch (err) { |
| 693 | 703 | 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 | ); | |
| 700 | 713 | } |
| 701 | 714 | }, |
| 702 | 715 | ); |
| ... | ... | @@ -753,7 +766,10 @@ export default function GitActionsControl({ |
| 753 | 766 | } |
| 754 | 767 | if (quickAction.kind === "run_pull") { |
| 755 | 768 | const promise = pullMutation.mutateAsync(); |
| 756 | toastManager.promise(promise, { | |
| 769 | void toastManager.promise< | |
| 770 | Awaited<ReturnType<typeof pullMutation.mutateAsync>>, | |
| 771 | ThreadToastData | |
| 772 | >(promise, { | |
| 757 | 773 | loading: { title: "Pulling...", data: threadToastData }, |
| 758 | 774 | success: (result) => ({ |
| 759 | 775 | title: result.status === "pulled" ? "Pulled" : "Already up to date", |
| ... | ... | @@ -832,12 +848,14 @@ export default function GitActionsControl({ |
| 832 | 848 | } |
| 833 | 849 | const target = resolvePathLinkTarget(filePath, gitCwd); |
| 834 | 850 | 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 | ); | |
| 841 | 859 | }); |
| 842 | 860 | }, |
| 843 | 861 | [gitCwd, threadToastData], |
apps/web/src/components/PlanSidebar.tsx+8-6| ... | ... | @@ -26,7 +26,7 @@ import { |
| 26 | 26 | } from "../proposedPlan"; |
| 27 | 27 | import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; |
| 28 | 28 | import { readEnvironmentApi } from "~/environmentApi"; |
| 29 | import { toastManager } from "./ui/toast"; | |
| 29 | import { stackedThreadToast, toastManager } from "./ui/toast"; | |
| 30 | 30 | import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; |
| 31 | 31 | |
| 32 | 32 | function stepStatusIcon(status: string): React.ReactNode { |
| ... | ... | @@ -112,11 +112,13 @@ const PlanSidebar = memo(function PlanSidebar({ |
| 112 | 112 | }); |
| 113 | 113 | }) |
| 114 | 114 | .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 | ); | |
| 120 | 122 | }) |
| 121 | 123 | .then( |
| 122 | 124 | () => setIsSavingToWorkspace(false), |
apps/web/src/components/Sidebar.tsx+155-128| ... | ... | @@ -94,7 +94,7 @@ import { |
| 94 | 94 | resolveThreadRouteRef, |
| 95 | 95 | resolveThreadRouteTarget, |
| 96 | 96 | } from "../threadRoutes"; |
| 97 | import { toastManager } from "./ui/toast"; | |
| 97 | import { stackedThreadToast, toastManager } from "./ui/toast"; | |
| 98 | 98 | import { formatRelativeTimeLabel } from "../timestampFormat"; |
| 99 | 99 | import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; |
| 100 | 100 | import { Kbd } from "./ui/kbd"; |
| ... | ... | @@ -950,11 +950,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 950 | 950 | }); |
| 951 | 951 | }, |
| 952 | 952 | 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 | ); | |
| 958 | 960 | }, |
| 959 | 961 | }); |
| 960 | 962 | const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ |
| ... | ... | @@ -968,11 +970,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 968 | 970 | }); |
| 969 | 971 | }, |
| 970 | 972 | 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 | ); | |
| 976 | 980 | }, |
| 977 | 981 | }); |
| 978 | 982 | const openPrLink = useCallback((event: React.MouseEvent<HTMLElement>, prUrl: string) => { |
| ... | ... | @@ -989,11 +993,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 989 | 993 | } |
| 990 | 994 | |
| 991 | 995 | 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 | ); | |
| 997 | 1003 | }); |
| 998 | 1004 | }, []); |
| 999 | 1005 | const sidebarThreads = useStore( |
| ... | ... | @@ -1299,72 +1305,73 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1299 | 1305 | const memberProjectRef = scopeProjectRef(member.environmentId, member.id); |
| 1300 | 1306 | const memberThreadCount = memberThreadCountByPhysicalKey.get(member.physicalProjectKey) ?? 0; |
| 1301 | 1307 | 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.", | |
| 1308 | 1313 | 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 | } | |
| 1349 | 1353 | |
| 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 | ); | |
| 1363 | 1370 | }); |
| 1364 | }); | |
| 1371 | }, | |
| 1365 | 1372 | }, |
| 1366 | }, | |
| 1367 | }); | |
| 1373 | }), | |
| 1374 | ); | |
| 1368 | 1375 | return; |
| 1369 | 1376 | } |
| 1370 | 1377 | |
| ... | ... | @@ -1388,11 +1395,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1388 | 1395 | environmentId: member.environmentId, |
| 1389 | 1396 | error, |
| 1390 | 1397 | }); |
| 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 | ); | |
| 1396 | 1405 | } |
| 1397 | 1406 | }, |
| 1398 | 1407 | [memberThreadCountByPhysicalKey, removeProject], |
| ... | ... | @@ -1705,11 +1714,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1705 | 1714 | try { |
| 1706 | 1715 | await archiveThread(threadRef); |
| 1707 | 1716 | } 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 | ); | |
| 1713 | 1724 | } |
| 1714 | 1725 | }, |
| 1715 | 1726 | [archiveThread], |
| ... | ... | @@ -1757,11 +1768,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1757 | 1768 | title: trimmed, |
| 1758 | 1769 | }); |
| 1759 | 1770 | } 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 | ); | |
| 1765 | 1778 | } |
| 1766 | 1779 | finishRename(); |
| 1767 | 1780 | }, |
| ... | ... | @@ -1794,11 +1807,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1794 | 1807 | |
| 1795 | 1808 | const api = readEnvironmentApi(projectRenameTarget.environmentId); |
| 1796 | 1809 | 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 | ); | |
| 1802 | 1817 | return; |
| 1803 | 1818 | } |
| 1804 | 1819 | |
| ... | ... | @@ -1811,11 +1826,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1811 | 1826 | }); |
| 1812 | 1827 | closeProjectRenameDialog(); |
| 1813 | 1828 | } 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 | ); | |
| 1819 | 1836 | } |
| 1820 | 1837 | }, [closeProjectRenameDialog, projectRenameTarget, projectRenameTitle]); |
| 1821 | 1838 | |
| ... | ... | @@ -1885,11 +1902,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1885 | 1902 | } |
| 1886 | 1903 | if (clicked === "copy-path") { |
| 1887 | 1904 | 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 | ); | |
| 1893 | 1912 | return; |
| 1894 | 1913 | } |
| 1895 | 1914 | copyPathToClipboard(threadWorkspacePath, { path: threadWorkspacePath }); |
| ... | ... | @@ -3220,18 +3239,22 @@ export default function Sidebar() { |
| 3220 | 3239 | if (!shouldToastDesktopUpdateActionResult(result)) return; |
| 3221 | 3240 | const actionError = getDesktopUpdateActionError(result); |
| 3222 | 3241 | 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 | ); | |
| 3228 | 3249 | }) |
| 3229 | 3250 | .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 | ); | |
| 3235 | 3258 | }); |
| 3236 | 3259 | return; |
| 3237 | 3260 | } |
| ... | ... | @@ -3247,18 +3270,22 @@ export default function Sidebar() { |
| 3247 | 3270 | if (!shouldToastDesktopUpdateActionResult(result)) return; |
| 3248 | 3271 | const actionError = getDesktopUpdateActionError(result); |
| 3249 | 3272 | 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 | ); | |
| 3255 | 3280 | }) |
| 3256 | 3281 | .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 | ); | |
| 3262 | 3289 | }); |
| 3263 | 3290 | } |
| 3264 | 3291 | }, [desktopUpdateButtonAction, desktopUpdateButtonDisabled, desktopUpdateState]); |
apps/web/src/components/WebSocketConnectionSurface.tsx+58-29| ... | ... | @@ -10,7 +10,7 @@ import { |
| 10 | 10 | useWsConnectionStatus, |
| 11 | 11 | WS_RECONNECT_MAX_ATTEMPTS, |
| 12 | 12 | } from "../rpc/wsConnectionState"; |
| 13 | import { toastManager } from "./ui/toast"; | |
| 13 | import { stackedThreadToast, toastManager } from "./ui/toast"; | |
| 14 | 14 | import { getPrimaryEnvironmentConnection } from "../environments/runtime"; |
| 15 | 15 | |
| 16 | 16 | const FORCED_WS_RECONNECT_DEBOUNCE_MS = 5_000; |
| ... | ... | @@ -75,13 +75,34 @@ function describeRecoveredToast( |
| 75 | 75 | return "Connection restored."; |
| 76 | 76 | } |
| 77 | 77 | |
| 78 | function describeSlowRpcAckToast(requests: ReadonlyArray<SlowRpcAckRequest>): ReactNode { | |
| 78 | function describeSlowRpcAckToast(requests: ReadonlyArray<SlowRpcAckRequest>): string { | |
| 79 | 79 | const count = requests.length; |
| 80 | 80 | const thresholdSeconds = Math.round((requests[0]?.thresholdMs ?? 0) / 1000); |
| 81 | 81 | |
| 82 | 82 | return `${count} request${count === 1 ? "" : "s"} waiting longer than ${thresholdSeconds}s.`; |
| 83 | 83 | } |
| 84 | 84 | |
| 85 | function 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 | ||
| 85 | 106 | export function shouldAutoReconnect( |
| 86 | 107 | status: WsConnectionStatus, |
| 87 | 108 | trigger: WsAutoReconnectTrigger, |
| ... | ... | @@ -138,15 +159,18 @@ export function WebSocketConnectionCoordinator() { |
| 138 | 159 | console.warn("Automatic WebSocket reconnect failed", { error }); |
| 139 | 160 | return; |
| 140 | 161 | } |
| 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 | ); | |
| 150 | 174 | }); |
| 151 | 175 | }); |
| 152 | 176 | const syncBrowserOnlineStatus = useEffectEvent(() => { |
| ... | ... | @@ -253,45 +277,45 @@ export function WebSocketConnectionCoordinator() { |
| 253 | 277 | |
| 254 | 278 | if (shouldShowReconnectToast || shouldShowOfflineToast || shouldShowExhaustedToast) { |
| 255 | 279 | const toastPayload = shouldShowOfflineToast |
| 256 | ? { | |
| 257 | description: describeOfflineToast(), | |
| 258 | timeout: 0, | |
| 259 | title: "Offline", | |
| 260 | type: "warning" as const, | |
| 280 | ? stackedThreadToast({ | |
| 261 | 281 | data: { |
| 262 | 282 | hideCopyButton: true, |
| 263 | 283 | }, |
| 264 | } | |
| 284 | description: describeOfflineToast(), | |
| 285 | timeout: 0, | |
| 286 | title: "Offline", | |
| 287 | type: "warning", | |
| 288 | }) | |
| 265 | 289 | : shouldShowExhaustedToast |
| 266 | ? { | |
| 290 | ? stackedThreadToast({ | |
| 267 | 291 | actionProps: { |
| 268 | 292 | children: "Retry", |
| 269 | 293 | onClick: triggerManualReconnect, |
| 270 | 294 | }, |
| 271 | description: describeExhaustedToast(), | |
| 272 | timeout: 0, | |
| 273 | title: "Disconnected from T3 Server", | |
| 274 | type: "error" as const, | |
| 275 | 295 | data: { |
| 276 | 296 | hideCopyButton: true, |
| 277 | 297 | }, |
| 278 | } | |
| 279 | : { | |
| 298 | description: describeExhaustedToast(), | |
| 299 | timeout: 0, | |
| 300 | title: "Disconnected from T3 Server", | |
| 301 | type: "error", | |
| 302 | }) | |
| 303 | : stackedThreadToast({ | |
| 280 | 304 | actionProps: { |
| 281 | 305 | children: "Retry now", |
| 282 | 306 | onClick: triggerManualReconnect, |
| 283 | 307 | }, |
| 308 | data: { | |
| 309 | hideCopyButton: true, | |
| 310 | }, | |
| 284 | 311 | description: |
| 285 | 312 | status.nextRetryAt === null |
| 286 | 313 | ? `Reconnecting... ${formatReconnectAttemptLabel(status)}` |
| 287 | 314 | : `Reconnecting in ${formatRetryCountdown(status.nextRetryAt, nowMs)}... ${formatReconnectAttemptLabel(status)}`, |
| 288 | 315 | timeout: 0, |
| 289 | 316 | title: buildReconnectTitle(status), |
| 290 | type: "loading" as const, | |
| 291 | data: { | |
| 292 | hideCopyButton: true, | |
| 293 | }, | |
| 294 | }; | |
| 317 | type: "loading", | |
| 318 | }); | |
| 295 | 319 | |
| 296 | 320 | if (toastIdRef.current) { |
| 297 | 321 | toastManager.update(toastIdRef.current, toastPayload); |
| ... | ... | @@ -369,6 +393,11 @@ export function SlowRpcAckToastCoordinator() { |
| 369 | 393 | } |
| 370 | 394 | |
| 371 | 395 | const nextToast = { |
| 396 | data: { | |
| 397 | expandableContent: <SlowRpcAckRequestDetails requests={slowRequests} />, | |
| 398 | expandableDescriptionTrigger: true, | |
| 399 | expandableLabels: { collapse: "Hide requests", expand: "Show requests" }, | |
| 400 | }, | |
| 372 | 401 | description: describeSlowRpcAckToast(slowRequests), |
| 373 | 402 | timeout: 0, |
| 374 | 403 | title: "Some requests are slow", |
apps/web/src/components/chat/ProposedPlanCard.tsx+22-16| ... | ... | @@ -24,7 +24,7 @@ import { |
| 24 | 24 | DialogPopup, |
| 25 | 25 | DialogTitle, |
| 26 | 26 | } from "../ui/dialog"; |
| 27 | import { toastManager } from "../ui/toast"; | |
| 27 | import { stackedThreadToast, toastManager } from "../ui/toast"; | |
| 28 | 28 | import { readEnvironmentApi } from "~/environmentApi"; |
| 29 | 29 | import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; |
| 30 | 30 | |
| ... | ... | @@ -45,11 +45,13 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({ |
| 45 | 45 | const [isSavingToWorkspace, setIsSavingToWorkspace] = useState(false); |
| 46 | 46 | const { copyToClipboard, isCopied } = useCopyToClipboard({ |
| 47 | 47 | 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 | ); | |
| 53 | 55 | }, |
| 54 | 56 | }); |
| 55 | 57 | const savePathInputId = useId(); |
| ... | ... | @@ -73,11 +75,13 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({ |
| 73 | 75 | |
| 74 | 76 | const openSaveDialog = () => { |
| 75 | 77 | 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 | ); | |
| 81 | 85 | return; |
| 82 | 86 | } |
| 83 | 87 | setSavePath((existing) => (existing.length > 0 ? existing : downloadFilename)); |
| ... | ... | @@ -114,11 +118,13 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({ |
| 114 | 118 | }); |
| 115 | 119 | }) |
| 116 | 120 | .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 | ); | |
| 122 | 128 | }) |
| 123 | 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 | 41 | import { QRCodeSvg } from "../ui/qr-code"; |
| 42 | 42 | import { Spinner } from "../ui/spinner"; |
| 43 | 43 | import { Switch } from "../ui/switch"; |
| 44 | import { toastManager } from "../ui/toast"; | |
| 44 | import { stackedThreadToast, toastManager } from "../ui/toast"; | |
| 45 | 45 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 46 | 46 | import { Button } from "../ui/button"; |
| 47 | 47 | import { Textarea } from "../ui/textarea"; |
| ... | ... | @@ -302,11 +302,13 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ |
| 302 | 302 | }, |
| 303 | 303 | onError: (error) => { |
| 304 | 304 | 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 | ); | |
| 310 | 312 | }, |
| 311 | 313 | }); |
| 312 | 314 | |
| ... | ... | @@ -535,11 +537,13 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio |
| 535 | 537 | setDialogOpen(false); |
| 536 | 538 | } catch (error) { |
| 537 | 539 | 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 | ); | |
| 543 | 547 | } finally { |
| 544 | 548 | setIsCreatingPairingLink(false); |
| 545 | 549 | } |
| ... | ... | @@ -828,11 +832,13 @@ export function ConnectionsSettings() { |
| 828 | 832 | error instanceof Error ? error.message : "Failed to update network exposure."; |
| 829 | 833 | setPendingDesktopServerExposureMode(null); |
| 830 | 834 | 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 | ); | |
| 836 | 842 | setIsUpdatingDesktopServerExposure(false); |
| 837 | 843 | } |
| 838 | 844 | }, |
| ... | ... | @@ -853,11 +859,13 @@ export function ConnectionsSettings() { |
| 853 | 859 | } catch (error) { |
| 854 | 860 | const message = error instanceof Error ? error.message : "Failed to revoke pairing link."; |
| 855 | 861 | 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 | ); | |
| 861 | 869 | } finally { |
| 862 | 870 | setRevokingDesktopPairingLinkId(null); |
| 863 | 871 | } |
| ... | ... | @@ -872,11 +880,13 @@ export function ConnectionsSettings() { |
| 872 | 880 | } catch (error) { |
| 873 | 881 | const message = error instanceof Error ? error.message : "Failed to revoke client access."; |
| 874 | 882 | 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 | ); | |
| 880 | 890 | } finally { |
| 881 | 891 | setRevokingDesktopClientSessionId(null); |
| 882 | 892 | } |
| ... | ... | @@ -897,11 +907,13 @@ export function ConnectionsSettings() { |
| 897 | 907 | } catch (error) { |
| 898 | 908 | const message = error instanceof Error ? error.message : "Failed to revoke other clients."; |
| 899 | 909 | 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 | ); | |
| 905 | 917 | } finally { |
| 906 | 918 | setIsRevokingOtherDesktopClients(false); |
| 907 | 919 | } |
| ... | ... | @@ -933,11 +945,13 @@ export function ConnectionsSettings() { |
| 933 | 945 | } catch (error) { |
| 934 | 946 | const message = error instanceof Error ? error.message : "Failed to add backend."; |
| 935 | 947 | 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 | ); | |
| 941 | 955 | } finally { |
| 942 | 956 | setIsAddingSavedBackend(false); |
| 943 | 957 | } |
| ... | ... | @@ -957,11 +971,13 @@ export function ConnectionsSettings() { |
| 957 | 971 | } catch (error) { |
| 958 | 972 | const message = error instanceof Error ? error.message : "Failed to reconnect backend."; |
| 959 | 973 | 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 | ); | |
| 965 | 981 | } finally { |
| 966 | 982 | setReconnectingSavedEnvironmentId(null); |
| 967 | 983 | } |
| ... | ... | @@ -975,11 +991,13 @@ export function ConnectionsSettings() { |
| 975 | 991 | } catch (error) { |
| 976 | 992 | const message = error instanceof Error ? error.message : "Failed to remove backend."; |
| 977 | 993 | 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 | ); | |
| 983 | 1001 | } finally { |
| 984 | 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 | 63 | import { Input } from "../ui/input"; |
| 64 | 64 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; |
| 65 | 65 | import { Switch } from "../ui/switch"; |
| 66 | import { toastManager } from "../ui/toast"; | |
| 66 | import { stackedThreadToast, toastManager } from "../ui/toast"; | |
| 67 | 67 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 68 | 68 | import { |
| 69 | 69 | SettingResetButton, |
| ... | ... | @@ -282,11 +282,13 @@ function AboutVersionSection() { |
| 282 | 282 | setDesktopUpdateStateQueryData(queryClient, state); |
| 283 | 283 | }) |
| 284 | 284 | .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 | ); | |
| 290 | 292 | }) |
| 291 | 293 | .finally(() => { |
| 292 | 294 | setIsChangingUpdateChannel(false); |
| ... | ... | @@ -308,11 +310,13 @@ function AboutVersionSection() { |
| 308 | 310 | setDesktopUpdateStateQueryData(queryClient, result.state); |
| 309 | 311 | }) |
| 310 | 312 | .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 | ); | |
| 316 | 320 | }); |
| 317 | 321 | return; |
| 318 | 322 | } |
| ... | ... | @@ -330,11 +334,13 @@ function AboutVersionSection() { |
| 330 | 334 | setDesktopUpdateStateQueryData(queryClient, result.state); |
| 331 | 335 | }) |
| 332 | 336 | .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 | ); | |
| 338 | 344 | }); |
| 339 | 345 | return; |
| 340 | 346 | } |
| ... | ... | @@ -345,20 +351,24 @@ function AboutVersionSection() { |
| 345 | 351 | .then((result) => { |
| 346 | 352 | setDesktopUpdateStateQueryData(queryClient, result.state); |
| 347 | 353 | 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 | ); | |
| 354 | 362 | } |
| 355 | 363 | }) |
| 356 | 364 | .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 | ); | |
| 362 | 372 | }); |
| 363 | 373 | }, [queryClient, updateState]); |
| 364 | 374 | |
| ... | ... | @@ -1688,11 +1698,13 @@ export function ArchivedThreadsPanel() { |
| 1688 | 1698 | try { |
| 1689 | 1699 | await unarchiveThread(threadRef); |
| 1690 | 1700 | } 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 | ); | |
| 1696 | 1708 | } |
| 1697 | 1709 | return; |
| 1698 | 1710 | } |
| ... | ... | @@ -1756,12 +1768,14 @@ export function ArchivedThreadsPanel() { |
| 1756 | 1768 | onClick={() => |
| 1757 | 1769 | void unarchiveThread(scopeThreadRef(thread.environmentId, thread.id)).catch( |
| 1758 | 1770 | (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 | ); | |
| 1765 | 1779 | }, |
| 1766 | 1780 | ) |
| 1767 | 1781 | } |
apps/web/src/components/sidebar/SidebarUpdatePill.tsx+29-21| ... | ... | @@ -6,7 +6,7 @@ import { |
| 6 | 6 | setDesktopUpdateStateQueryData, |
| 7 | 7 | useDesktopUpdateState, |
| 8 | 8 | } from "../../lib/desktopUpdateReactQuery"; |
| 9 | import { toastManager } from "../ui/toast"; | |
| 9 | import { stackedThreadToast, toastManager } from "../ui/toast"; | |
| 10 | 10 | import { |
| 11 | 11 | getArm64IntelBuildWarningDescription, |
| 12 | 12 | getDesktopUpdateActionError, |
| ... | ... | @@ -55,18 +55,22 @@ export function SidebarUpdatePill() { |
| 55 | 55 | if (!shouldToastDesktopUpdateActionResult(result)) return; |
| 56 | 56 | const actionError = getDesktopUpdateActionError(result); |
| 57 | 57 | 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 | ); | |
| 63 | 65 | }) |
| 64 | 66 | .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 | ); | |
| 70 | 74 | }); |
| 71 | 75 | return; |
| 72 | 76 | } |
| ... | ... | @@ -81,18 +85,22 @@ export function SidebarUpdatePill() { |
| 81 | 85 | if (!shouldToastDesktopUpdateActionResult(result)) return; |
| 82 | 86 | const actionError = getDesktopUpdateActionError(result); |
| 83 | 87 | 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 | ); | |
| 89 | 95 | }) |
| 90 | 96 | .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 | ); | |
| 96 | 104 | }); |
| 97 | 105 | } |
| 98 | 106 | }, [action, disabled, queryClient, state]); |
apps/web/src/components/ui/toast.logic.test.ts+56| ... | ... | @@ -45,6 +45,62 @@ describe("buildVisibleToastLayout", () => { |
| 45 | 45 | ); |
| 46 | 46 | }); |
| 47 | 47 | |
| 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 | 104 | it("treats missing heights as zero", () => { |
| 49 | 105 | const layout = buildVisibleToastLayout([ |
| 50 | 106 | { id: "a" }, |
apps/web/src/components/ui/toast.logic.ts+52-10| ... | ... | @@ -14,6 +14,12 @@ type ToastWithHeight = { |
| 14 | 14 | height?: number | null | undefined; |
| 15 | 15 | }; |
| 16 | 16 | |
| 17 | type ToastWithTransitionStatus = { | |
| 18 | transitionStatus?: "starting" | "ending" | undefined; | |
| 19 | }; | |
| 20 | ||
| 21 | type ToastWithLayoutProps = ToastWithHeight & ToastWithTransitionStatus; | |
| 22 | ||
| 17 | 23 | type VisibleToastLayoutItem<TToast extends object> = { |
| 18 | 24 | toast: TToast; |
| 19 | 25 | visibleIndex: number; |
| ... | ... | @@ -21,25 +27,61 @@ type VisibleToastLayoutItem<TToast extends object> = { |
| 21 | 27 | }; |
| 22 | 28 | |
| 23 | 29 | export function buildVisibleToastLayout<TToast extends object>( |
| 24 | visibleToasts: readonly (TToast & ToastWithHeight)[], | |
| 30 | visibleToasts: readonly (TToast & ToastWithLayoutProps)[], | |
| 25 | 31 | ): { |
| 26 | 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 | } | |
| 30 | 63 | |
| 31 | return { | |
| 32 | frontmostHeight: normalizeToastHeight(visibleToasts[0]?.height), | |
| 33 | items: visibleToasts.map((toast, visibleIndex) => { | |
| 34 | 64 | const item = { |
| 35 | 65 | toast, |
| 36 | visibleIndex, | |
| 37 | offsetY, | |
| 66 | visibleIndex: liveIndex, | |
| 67 | offsetY: liveOffsetY, | |
| 38 | 68 | }; |
| 39 | 69 | |
| 40 | offsetY += normalizeToastHeight(toast.height); | |
| 70 | fullOffsetY += height; | |
| 71 | fullIndex += 1; | |
| 72 | liveOffsetY += height; | |
| 73 | liveIndex += 1; | |
| 41 | 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 | } |
| 45 | 87 |
apps/web/src/components/ui/toast.tsx+340-113| ... | ... | @@ -1,17 +1,27 @@ |
| 1 | 1 | "use client"; |
| 2 | 2 | |
| 3 | 3 | import { Toast } from "@base-ui/react/toast"; |
| 4 | import { useEffect, useMemo, type CSSProperties } from "react"; | |
| 4 | import { | |
| 5 | useEffect, | |
| 6 | useMemo, | |
| 7 | useState, | |
| 8 | type CSSProperties, | |
| 9 | type KeyboardEvent, | |
| 10 | type ReactNode, | |
| 11 | } from "react"; | |
| 5 | 12 | import { useParams } from "@tanstack/react-router"; |
| 6 | 13 | import { type ScopedThreadRef, type ThreadId } from "@t3tools/contracts"; |
| 7 | 14 | import { |
| 8 | 15 | CheckIcon, |
| 16 | ChevronDownIcon, | |
| 17 | ChevronUpIcon, | |
| 9 | 18 | CircleAlertIcon, |
| 10 | 19 | CircleCheckIcon, |
| 11 | 20 | CopyIcon, |
| 12 | 21 | InfoIcon, |
| 13 | 22 | LoaderCircleIcon, |
| 14 | 23 | TriangleAlertIcon, |
| 24 | XIcon, | |
| 15 | 25 | } from "lucide-react"; |
| 16 | 26 | |
| 17 | 27 | import { cn } from "~/lib/utils"; |
| ... | ... | @@ -31,6 +41,11 @@ export type ThreadToastData = { |
| 31 | 41 | tooltipStyle?: boolean; |
| 32 | 42 | dismissAfterVisibleMs?: number; |
| 33 | 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 | 49 | actionLayout?: "inline" | "stacked-end"; |
| 35 | 50 | actionVariant?: |
| 36 | 51 | | "default" |
| ... | ... | @@ -55,25 +70,265 @@ const TOAST_ICONS = { |
| 55 | 70 | warning: TriangleAlertIcon, |
| 56 | 71 | } as const; |
| 57 | 72 | |
| 73 | /** Visually shorten long error bodies; clipboard copy still uses the full `description` string. */ | |
| 74 | const ERROR_DESCRIPTION_CLAMP_MIN_CHARS = 180; | |
| 75 | function 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). */ | |
| 86 | const toastCornerDismissClass = "absolute z-20 -top-1.5 -right-1.5"; | |
| 87 | const 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 | ||
| 58 | 93 | function CopyErrorButton({ text }: { text: string }) { |
| 59 | 94 | const { copyToClipboard, isCopied } = useCopyToClipboard(); |
| 60 | 95 | |
| 61 | 96 | return ( |
| 62 | 97 | <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 | 99 | onClick={() => copyToClipboard(text)} |
| 65 | 100 | title="Copy error" |
| 66 | 101 | type="button" |
| 67 | 102 | > |
| 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" />} | |
| 73 | 104 | </button> |
| 74 | 105 | ); |
| 75 | 106 | } |
| 76 | 107 | |
| 108 | /** Scrollable cap for long expandable lists (~10rem); keeps the toast from growing without bound. */ | |
| 109 | const toastExpandablePanelClassName = | |
| 110 | "mt-2 max-h-40 min-h-0 overflow-y-auto overscroll-contain pr-0.5 select-text"; | |
| 111 | ||
| 112 | function 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 | ||
| 143 | function 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 | ||
| 229 | type ToastIconComponent = (typeof TOAST_ICONS)[keyof typeof TOAST_ICONS]; | |
| 230 | ||
| 231 | interface 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 | ||
| 240 | function 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 | ||
| 267 | interface 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 | ||
| 274 | function 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 | ||
| 77 | 332 | type ToastPosition = |
| 78 | 333 | | "top-left" |
| 79 | 334 | | "top-center" |
| ... | ... | @@ -236,19 +491,17 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) { |
| 236 | 491 | } |
| 237 | 492 | > |
| 238 | 493 | {visibleToastLayout.items.map(({ toast, visibleIndex, offsetY }) => { |
| 239 | const Icon = toast.type ? TOAST_ICONS[toast.type as keyof typeof TOAST_ICONS] : null; | |
| 240 | 494 | const hideCollapsedContent = shouldHideCollapsedToastContent( |
| 241 | 495 | visibleIndex, |
| 242 | 496 | visibleToastLayout.items.length, |
| 243 | 497 | ); |
| 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; | |
| 247 | 500 | |
| 248 | 501 | return ( |
| 249 | 502 | <Toast.Root |
| 250 | 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 | 505 | // Base positioning using data-position |
| 253 | 506 | "data-[position*=right]:right-0 data-[position*=right]:left-auto", |
| 254 | 507 | "data-[position*=left]:right-auto data-[position*=left]:left-0", |
| ... | ... | @@ -259,11 +512,19 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) { |
| 259 | 512 | "after:absolute after:left-0 after:h-[calc(var(--toast-gap)+1px)] after:w-full", |
| 260 | 513 | "data-[position*=top]:after:top-full", |
| 261 | 514 | "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", | |
| 267 | 528 | // Define offset-y variable |
| 268 | 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 | 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 | 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 | 534 | // Limited state |
| 274 | 535 | "data-limited:opacity-0", |
| 275 | // Expanded state | |
| 276 | "data-expanded:h-(--toast-height)", | |
| 536 | // Expanded stack | |
| 277 | 537 | "data-position:data-expanded:transform-[translateX(var(--toast-swipe-movement-x))_translateY(var(--toast-calc-offset-y))]", |
| 278 | 538 | // Starting and ending animations |
| 279 | 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 | 574 | dismissAfterVisibleMs={toast.data?.dismissAfterVisibleMs} |
| 315 | 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 | 588 | <Toast.Content |
| 318 | 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 | 593 | 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), | |
| 323 | 596 | hideCollapsedContent && |
| 324 | 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"> | |
| 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 | /> | |
| 365 | 607 | </Toast.Content> |
| 366 | 608 | </Toast.Root> |
| 367 | 609 | ); |
| ... | ... | @@ -390,12 +632,10 @@ function AnchoredToasts() { |
| 390 | 632 | {toasts |
| 391 | 633 | .filter((toast) => shouldRenderThreadScopedToast(toast.data, activeThreadRef)) |
| 392 | 634 | .map((toast) => { |
| 393 | const Icon = toast.type ? TOAST_ICONS[toast.type as keyof typeof TOAST_ICONS] : null; | |
| 394 | 635 | const tooltipStyle = toast.data?.tooltipStyle ?? false; |
| 395 | 636 | 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; | |
| 399 | 639 | |
| 400 | 640 | if (!positionerProps?.anchor) { |
| 401 | 641 | return null; |
| ... | ... | @@ -411,7 +651,7 @@ function AnchoredToasts() { |
| 411 | 651 | > |
| 412 | 652 | <Toast.Root |
| 413 | 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 | 655 | tooltipStyle |
| 416 | 656 | ? "rounded-md shadow-md/5 before:rounded-[calc(var(--radius-md)-1px)]" |
| 417 | 657 | : "rounded-lg shadow-lg/5 before:rounded-[calc(var(--radius-lg)-1px)]", |
| ... | ... | @@ -424,55 +664,39 @@ function AnchoredToasts() { |
| 424 | 664 | <Toast.Title data-slot="toast-title" /> |
| 425 | 665 | </Toast.Content> |
| 426 | 666 | ) : ( |
| 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" | |
| 471 | 675 | > |
| 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 | </> | |
| 476 | 700 | )} |
| 477 | 701 | </Toast.Root> |
| 478 | 702 | </Toast.Positioner> |
| ... | ... | @@ -483,6 +707,9 @@ function AnchoredToasts() { |
| 483 | 707 | ); |
| 484 | 708 | } |
| 485 | 709 | |
| 710 | export { stackedThreadToast } from "./toastHelpers"; | |
| 711 | export type { StackedThreadToastOptions } from "./toastHelpers"; | |
| 712 | ||
| 486 | 713 | export { |
| 487 | 714 | ToastProvider, |
| 488 | 715 | type ToastPosition, |
apps/web/src/components/ui/toastHelpers.ts created+58| ... | ... | @@ -0,0 +1,58 @@ |
| 1 | "use client"; | |
| 2 | ||
| 3 | import type { ToastManagerAddOptions } from "@base-ui/react/toast"; | |
| 4 | import type { ComponentPropsWithoutRef, ReactNode } from "react"; | |
| 5 | ||
| 6 | import type { ThreadToastData } from "./toast"; | |
| 7 | ||
| 8 | export 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 | */ | |
| 23 | export 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 | 20 | import { useTerminalStateStore } from "../terminalStateStore"; |
| 21 | 21 | import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; |
| 22 | 22 | import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; |
| 23 | import { toastManager } from "../components/ui/toast"; | |
| 23 | import { stackedThreadToast, toastManager } from "../components/ui/toast"; | |
| 24 | 24 | import { useSettings } from "./useSettings"; |
| 25 | 25 | |
| 26 | 26 | export function useThreadActions() { |
| ... | ... | @@ -225,11 +225,13 @@ export function useThreadActions() { |
| 225 | 225 | worktreePath: orphanedWorktreePath, |
| 226 | 226 | error, |
| 227 | 227 | }); |
| 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 | ); | |
| 233 | 235 | } |
| 234 | 236 | }, |
| 235 | 237 | [ |
apps/web/src/routes/__root.tsx+40-30| ... | ... | @@ -19,7 +19,12 @@ import { |
| 19 | 19 | WebSocketConnectionSurface, |
| 20 | 20 | } from "../components/WebSocketConnectionSurface"; |
| 21 | 21 | import { Button } from "../components/ui/button"; |
| 22 | import { AnchoredToastProvider, ToastProvider, toastManager } from "../components/ui/toast"; | |
| 22 | import { | |
| 23 | AnchoredToastProvider, | |
| 24 | stackedThreadToast, | |
| 25 | ToastProvider, | |
| 26 | toastManager, | |
| 27 | } from "../components/ui/toast"; | |
| 23 | 28 | import { resolveAndPersistPreferredEditor } from "../editorPreferences"; |
| 24 | 29 | import { readLocalApi } from "../localApi"; |
| 25 | 30 | import { useSettings } from "../hooks/useSettings"; |
| ... | ... | @@ -290,37 +295,42 @@ function EventRouter() { |
| 290 | 295 | return; |
| 291 | 296 | } |
| 292 | 297 | |
| 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 | ); | |
| 319 | 329 | }); |
| 320 | }); | |
| 330 | }, | |
| 321 | 331 | }, |
| 322 | }, | |
| 323 | }); | |
| 332 | }), | |
| 333 | ); | |
| 324 | 334 | }, |
| 325 | 335 | ); |
| 326 | 336 |