From a205539f58528e40eb3ea528ee15b435d77ecc64 Mon Sep 17 00:00:00 2001 From: BinBandit Date: Wed, 11 Mar 2026 20:30:28 +1100 Subject: [PATCH 1/2] fix(web): add dismiss control for error toasts --- .../components/KeybindingsToast.browser.tsx | 54 ++++++ apps/web/src/components/ui/toast.tsx | 164 ++++++++++-------- 2 files changed, 150 insertions(+), 68 deletions(-) diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index ba4c8f432..19d1ddefd 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -26,6 +26,7 @@ const PROJECT_ID = "project-1" as ProjectId; const NOW_ISO = "2026-03-04T12:00:00.000Z"; interface TestFixture { + openInEditorErrorMessage: string | null; snapshot: OrchestrationReadModel; serverConfig: ServerConfig; welcome: WsWelcomePayload; @@ -116,6 +117,7 @@ function createMinimalSnapshot(): OrchestrationReadModel { function buildFixture(): TestFixture { return { + openInEditorErrorMessage: null, snapshot: createMinimalSnapshot(), serverConfig: createBaseServerConfig(), welcome: { @@ -181,6 +183,17 @@ const worker = setupWorker( } const method = request.body?._tag; if (typeof method !== "string") return; + if (method === WS_METHODS.shellOpenInEditor && fixture.openInEditorErrorMessage) { + client.send( + JSON.stringify({ + id: request.id, + error: { + message: fixture.openInEditorErrorMessage, + }, + }), + ); + return; + } client.send( JSON.stringify({ id: request.id, @@ -214,6 +227,10 @@ function queryToastTitles(): string[] { ); } +function queryDismissButtons(): HTMLButtonElement[] { + return Array.from(document.querySelectorAll('[data-slot="toast-close"]')); +} + async function waitForElement( query: () => T | null, errorMessage: string, @@ -293,6 +310,7 @@ describe("Keybindings update toast", () => { }); beforeEach(() => { + fixture = buildFixture(); localStorage.clear(); document.body.innerHTML = ""; pushSequence = 1; @@ -340,6 +358,42 @@ describe("Keybindings update toast", () => { } }); + it("lets users dismiss the follow-up error toast from the keybindings warning action", async () => { + fixture.openInEditorErrorMessage = "Editor unavailable"; + const mounted = await mountApp(); + + try { + sendServerConfigUpdatedPush([ + { kind: "keybindings.malformed-config", message: "Expected JSON array" }, + ]); + await waitForToast("Invalid keybindings configuration"); + expect(queryDismissButtons()).toHaveLength(0); + + const openButton = await waitForElement( + () => + Array.from( + document.querySelectorAll('[data-slot="toast-action"]'), + ).find((element) => element.textContent === "Open keybindings.json") ?? null, + "Warning toast should render its action button", + ); + openButton.click(); + + await waitForToast("Unable to open keybindings file"); + await vi.waitFor( + () => { + expect(queryDismissButtons()).toHaveLength(1); + }, + { timeout: 4_000, interval: 16 }, + ); + + queryDismissButtons()[0]?.click(); + await waitForNoToast("Unable to open keybindings file"); + } finally { + fixture.openInEditorErrorMessage = null; + await mounted.cleanup(); + } + }); + it("does not show a toast from the replayed cached value on subscribe", async () => { const mounted = await mountApp(); diff --git a/apps/web/src/components/ui/toast.tsx b/apps/web/src/components/ui/toast.tsx index 768a083e2..46a366133 100644 --- a/apps/web/src/components/ui/toast.tsx +++ b/apps/web/src/components/ui/toast.tsx @@ -1,7 +1,7 @@ "use client"; import { Toast } from "@base-ui/react/toast"; -import { useEffect, type CSSProperties } from "react"; +import { useEffect, type CSSProperties, type ReactNode } from "react"; import { useParams } from "@tanstack/react-router"; import { ThreadId } from "@t3tools/contracts"; import { @@ -10,10 +10,12 @@ import { InfoIcon, LoaderCircleIcon, TriangleAlertIcon, + type LucideIcon, + XIcon, } from "lucide-react"; import { cn } from "~/lib/utils"; -import { buttonVariants } from "~/components/ui/button"; +import { Button, buttonVariants } from "~/components/ui/button"; import { buildVisibleToastLayout, shouldHideCollapsedToastContent } from "./toast.logic"; type ThreadToastData = { @@ -142,6 +144,82 @@ function ThreadToastVisibleAutoDismiss({ return null; } +function ErrorToastDismissButton() { + return ( + } + > + + + ); +} + +function ToastCardContent({ + actionLabel, + className, + hideCollapsedContent = false, + icon: Icon, + showDismissButton = false, +}: { + actionLabel?: ReactNode | undefined; + className?: string | undefined; + hideCollapsedContent?: boolean | undefined; + icon?: LucideIcon | null | undefined; + showDismissButton?: boolean | undefined; +}) { + const actionButton = actionLabel ? ( + + {actionLabel} + + ) : null; + + return ( + +
+ {Icon && ( +
+ +
+ )} + +
+ + +
+
+ {showDismissButton ? ( +
+ + {actionButton} +
+ ) : ( + actionButton + )} +
+ ); +} + function ToastProvider({ children, position = "top-right", ...props }: ToastProviderProps) { return ( @@ -196,6 +274,7 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) { visibleIndex, visibleToastLayout.items.length, ); + const showDismissButton = toast.type === "error"; return ( - -
- {Icon && ( -
- -
- )} - -
- - -
-
- {toast.actionProps && ( - - {toast.actionProps.children} - - )} -
+ hideCollapsedContent={hideCollapsedContent} + icon={Icon} + showDismissButton={showDismissButton} + />
); })} @@ -333,6 +385,7 @@ function AnchoredToasts() { const Icon = toast.type ? TOAST_ICONS[toast.type as keyof typeof TOAST_ICONS] : null; const tooltipStyle = toast.data?.tooltipStyle ?? false; const positionerProps = toast.positionerProps; + const showDismissButton = toast.type === "error"; if (!positionerProps?.anchor) { return null; @@ -361,37 +414,12 @@ function AnchoredToasts() { ) : ( - -
- {Icon && ( -
- -
- )} - -
- - -
-
- {toast.actionProps && ( - - {toast.actionProps.children} - - )} -
+ )} From c73c714457e114c486ebda19e21c082b95e5edb9 Mon Sep 17 00:00:00 2001 From: BinBandit Date: Wed, 11 Mar 2026 20:37:51 +1100 Subject: [PATCH 2/2] test(web): stabilize keybindings toast browser coverage --- apps/web/src/components/KeybindingsToast.browser.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index 19d1ddefd..5ea67b401 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -14,9 +14,14 @@ import { import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; import { ws, http, HttpResponse } from "msw"; import { setupWorker } from "msw/browser"; +import type { ReactNode } from "react"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { render } from "vitest-browser-react"; +vi.mock("../components/DiffWorkerPoolProvider", () => ({ + DiffWorkerPoolProvider: ({ children }: { children?: ReactNode }) => children ?? null, +})); + import { useComposerDraftStore } from "../composerDraftStore"; import { getRouter } from "../router"; import { useStore } from "../store"; @@ -396,6 +401,7 @@ describe("Keybindings update toast", () => { it("does not show a toast from the replayed cached value on subscribe", async () => { const mounted = await mountApp(); + let remounted: Awaited> | null = null; try { sendServerConfigUpdatedPush([]); @@ -405,7 +411,7 @@ describe("Keybindings update toast", () => { // Remount the app — onServerConfigUpdated replays the cached value // synchronously on subscribe. This should NOT produce a toast. await mounted.cleanup(); - const remounted = await mountApp(); + remounted = await mountApp(); // Give it a moment to process the replayed value await new Promise((resolve) => setTimeout(resolve, 500)); @@ -417,7 +423,9 @@ describe("Keybindings update toast", () => { ).toBe(0); await remounted.cleanup(); + remounted = null; } catch (error) { + await remounted?.cleanup().catch(() => {}); await mounted.cleanup().catch(() => {}); throw error; }