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