From 3cde99b259249a02e50079f326a00b5ba38e7235 Mon Sep 17 00:00:00 2001 From: Mark Griffin Date: Sat, 15 Aug 2026 13:03:14 +0100 Subject: [PATCH] fix(web): hide provider Update toast action while an update is running (#6544) Co-authored-by: Cursor --- .../ProviderUpdatePrimaryNotification.tsx | 10 ++++----- .../web/src/components/ui/toast.logic.test.ts | 13 ++++++++++++ apps/web/src/components/ui/toast.logic.ts | 16 ++++++++++++++ apps/web/src/components/ui/toast.tsx | 11 +++++----- .../src/components/ui/toastHelpers.test.ts | 21 +++++++++++++++++++ apps/web/src/components/ui/toastHelpers.ts | 8 +++++++ 6 files changed, 69 insertions(+), 10 deletions(-) create mode 100644 apps/web/src/components/ui/toastHelpers.test.ts diff --git a/apps/web/src/components/ProviderUpdatePrimaryNotification.tsx b/apps/web/src/components/ProviderUpdatePrimaryNotification.tsx index 64399679acfe7e3fb5e6b6ac09419d0797f338aa..00112ccec198e9796972e90b19e722ee02919861 100644 --- a/apps/web/src/components/ProviderUpdatePrimaryNotification.tsx +++ b/apps/web/src/components/ProviderUpdatePrimaryNotification.tsx @@ -20,7 +20,7 @@ import { providerUpdateNotificationKey, type ProviderUpdateToastView, } from "./ProviderUpdateLaunchNotification.logic"; -import { stackedThreadToast, toastManager } from "./ui/toast"; +import { hiddenToastActionProps, stackedThreadToast, toastManager } from "./ui/toast"; import { useAtomCommand } from "../state/use-atom-command"; const seenProviderUpdateNotificationKeys = new Set(); @@ -68,10 +68,10 @@ function updateProviderUpdateToast(input: { title: input.view.title, description: input.view.description, timeout: 0, - // Base UI merges toast updates with the existing toast. Explicitly clear - // the prompt action so its guarded Update handler cannot linger as a - // visible no-op while the update is running (or after it succeeds). - actionProps: undefined, + // Base UI merges toast updates and omits `undefined` keys, so `undefined` + // would leave the prompt's Update button in place. Replace it with a + // defined empty action so the CTA cannot linger while the update runs. + actionProps: hiddenToastActionProps, data: { hideCopyButton: true, ...(input.view.dismissAfterVisibleMs !== undefined diff --git a/apps/web/src/components/ui/toast.logic.test.ts b/apps/web/src/components/ui/toast.logic.test.ts index 576b970bbe0f7ae66c3049235cf5af060c0d9a4a..f711d2b32dc14358744c7fa62a2d139016fa3626 100644 --- a/apps/web/src/components/ui/toast.logic.test.ts +++ b/apps/web/src/components/ui/toast.logic.test.ts @@ -2,10 +2,23 @@ import type { ScopedThreadRef } from "@t3tools/contracts"; import { assert, describe, it } from "vite-plus/test"; import { buildVisibleToastLayout, + hasVisibleToastAction, shouldHideCollapsedToastContent, shouldRenderThreadScopedToast, } from "./toast.logic"; +describe("hasVisibleToastAction", () => { + it("treats a labeled action as visible", () => { + assert.equal(hasVisibleToastAction({ children: "Update" }), true); + }); + + it("hides an explicit empty action used to clear a previous CTA", () => { + assert.equal(hasVisibleToastAction({ children: null }), false); + assert.equal(hasVisibleToastAction({ children: "" }), false); + assert.equal(hasVisibleToastAction(undefined), false); + }); +}); + describe("shouldHideCollapsedToastContent", () => { it("keeps a single visible toast readable", () => { assert.equal(shouldHideCollapsedToastContent(0, 1), false); diff --git a/apps/web/src/components/ui/toast.logic.ts b/apps/web/src/components/ui/toast.logic.ts index 80f23970cee904ec15313520110be0086b1191c3..62c1a8af792db948db72e8ad3feddb7b3862a4cb 100644 --- a/apps/web/src/components/ui/toast.logic.ts +++ b/apps/web/src/components/ui/toast.logic.ts @@ -1,5 +1,21 @@ import type { ScopedThreadRef, ThreadId } from "@t3tools/contracts"; +/** + * Base UI toast updates omit `undefined` fields, so callers that need to remove + * an action must pass a defined `actionProps` whose `children` are empty. + * Treat that payload (and missing children) as "no visible action". + */ +export function hasVisibleToastAction(actionProps: unknown): boolean { + if (actionProps == null || typeof actionProps !== "object") { + return false; + } + if (!("children" in actionProps)) { + return false; + } + const children = actionProps.children; + return children != null && children !== false && children !== ""; +} + export function shouldHideCollapsedToastContent( visibleToastIndex: number, visibleToastCount: number, diff --git a/apps/web/src/components/ui/toast.tsx b/apps/web/src/components/ui/toast.tsx index 232349d456eb382065403ae4429cfe28635a91fd..69fd0ebf3664a27165d9f45413c5fb0764f789c8 100644 --- a/apps/web/src/components/ui/toast.tsx +++ b/apps/web/src/components/ui/toast.tsx @@ -32,6 +32,7 @@ import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { resolveThreadRouteTarget } from "~/threadRoutes"; import { buildVisibleToastLayout, + hasVisibleToastAction, shouldHideCollapsedToastContent, shouldRenderThreadScopedToast, } from "./toast.logic"; @@ -288,7 +289,7 @@ function deriveToastBodyDescriptor(toast: { }): ToastBodyDescriptor { const Icon = toast.type ? TOAST_ICONS[toast.type as keyof typeof TOAST_ICONS] : null; const stackedActionLayout = - toast.actionProps !== undefined && toast.data?.actionLayout === "stacked-end"; + hasVisibleToastAction(toast.actionProps) && toast.data?.actionLayout === "stacked-end"; const actionVariant: NonNullable = toast.data?.actionVariant ?? "default"; const secondaryActionVariant: NonNullable = @@ -301,7 +302,7 @@ function deriveToastBodyDescriptor(toast: { const hasSecondaryAction = toast.data?.secondaryActionProps !== undefined; const hasTrailingControls = copyErrorText !== null || - toast.actionProps !== undefined || + hasVisibleToastAction(toast.actionProps) || hasAdditionalActions || hasSecondaryAction; const inlineContentEndPad = hasTrailingControls ? "pr-6" : "pr-10"; @@ -400,12 +401,12 @@ function ToastBodyContent({ variant={secondaryActionVariant} /> ) : null} - {actionProps ? ( + {hasVisibleToastAction(actionProps) ? ( - {actionProps.children} + {actionProps?.children} ) : null} @@ -798,7 +799,7 @@ function AnchoredToasts() { ); } -export { stackedThreadToast } from "./toastHelpers"; +export { hiddenToastActionProps, stackedThreadToast } from "./toastHelpers"; export type { StackedThreadToastOptions } from "./toastHelpers"; export { diff --git a/apps/web/src/components/ui/toastHelpers.test.ts b/apps/web/src/components/ui/toastHelpers.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..c356aef4030ae37186288ff7c0d4b0e000e80b98 --- /dev/null +++ b/apps/web/src/components/ui/toastHelpers.test.ts @@ -0,0 +1,21 @@ +import { assert, describe, it } from "vite-plus/test"; + +import { hiddenToastActionProps, stackedThreadToast } from "./toastHelpers"; + +describe("hiddenToastActionProps", () => { + it("is a defined update payload so Base UI can replace a previous action", () => { + assert.equal(hiddenToastActionProps.children, null); + assert.equal( + "actionProps" in stackedThreadToast({ type: "loading", title: "Updating" }), + false, + ); + assert.deepEqual( + stackedThreadToast({ + type: "loading", + title: "Updating", + actionProps: hiddenToastActionProps, + }).actionProps, + hiddenToastActionProps, + ); + }); +}); diff --git a/apps/web/src/components/ui/toastHelpers.ts b/apps/web/src/components/ui/toastHelpers.ts index 4ec5d14106c81c960f118e766ea79da7bf90af83..70e77ccd3e7143a3a5747805820ead75ef904edc 100644 --- a/apps/web/src/components/ui/toastHelpers.ts +++ b/apps/web/src/components/ui/toastHelpers.ts @@ -17,6 +17,14 @@ export type StackedThreadToastOptions = { data?: Omit; }; +/** + * Defined `actionProps` that hide a previous toast CTA on `toastManager.update`. + * Passing `actionProps: undefined` is a no-op because updates omit undefined keys. + */ +export const hiddenToastActionProps = { + children: null, +} as const satisfies Pick, "children">; + /** * Thread toast using the stacked body + bottom action row (copy for errors, CTA on its own row). */ -- 2.54.0