authorgravatar for mrmg@deflexion.netMark Griffin <mrmg@deflexion.net> 2026-08-15 13:03:14+01:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-15 14:03:14+02:00
log3cde99b259249a02e50079f326a00b5ba38e7235
tree4c024259988be49952e4a80e33976a6082a6d254
parent51c6daa3bd1d65e809b5196bf63333013427338a
signature Signed by PGP key B5690EEEBB952194

fix(web): hide provider Update toast action while an update is running (#6544)

Co-authored-by: Cursor <cursoragent@cursor.com>

6 files changed, 69 insertions(+), 10 deletions(-)

apps/web/src/components/ProviderUpdatePrimaryNotification.tsx+5-5
...@@ -20,7 +20,7 @@ import {...@@ -20,7 +20,7 @@ import {
20 providerUpdateNotificationKey,20 providerUpdateNotificationKey,
21 type ProviderUpdateToastView,21 type ProviderUpdateToastView,
22} from "./ProviderUpdateLaunchNotification.logic";22} from "./ProviderUpdateLaunchNotification.logic";
23import { stackedThreadToast, toastManager } from "./ui/toast";23import { hiddenToastActionProps, stackedThreadToast, toastManager } from "./ui/toast";
24import { useAtomCommand } from "../state/use-atom-command";24import { useAtomCommand } from "../state/use-atom-command";
2525
26const seenProviderUpdateNotificationKeys = new Set<string>();26const seenProviderUpdateNotificationKeys = new Set<string>();
...@@ -68,10 +68,10 @@ function updateProviderUpdateToast(input: {...@@ -68,10 +68,10 @@ function updateProviderUpdateToast(input: {
68 title: input.view.title,68 title: input.view.title,
69 description: input.view.description,69 description: input.view.description,
70 timeout: 0,70 timeout: 0,
71 // Base UI merges toast updates with the existing toast. Explicitly clear71 // Base UI merges toast updates and omits `undefined` keys, so `undefined`
72 // the prompt action so its guarded Update handler cannot linger as a72 // would leave the prompt's Update button in place. Replace it with a
73 // visible no-op while the update is running (or after it succeeds).73 // defined empty action so the CTA cannot linger while the update runs.
74 actionProps: undefined,74 actionProps: hiddenToastActionProps,
75 data: {75 data: {
76 hideCopyButton: true,76 hideCopyButton: true,
77 ...(input.view.dismissAfterVisibleMs !== undefined77 ...(input.view.dismissAfterVisibleMs !== undefined
apps/web/src/components/ui/toast.logic.test.ts+13
...@@ -2,10 +2,23 @@ import type { ScopedThreadRef } from "@t3tools/contracts";...@@ -2,10 +2,23 @@ import type { ScopedThreadRef } from "@t3tools/contracts";
2import { assert, describe, it } from "vite-plus/test";2import { assert, describe, it } from "vite-plus/test";
3import {3import {
4 buildVisibleToastLayout,4 buildVisibleToastLayout,
5 hasVisibleToastAction,
5 shouldHideCollapsedToastContent,6 shouldHideCollapsedToastContent,
6 shouldRenderThreadScopedToast,7 shouldRenderThreadScopedToast,
7} from "./toast.logic";8} from "./toast.logic";
89
10describe("hasVisibleToastAction", () => {
11 it("treats a labeled action as visible", () => {
12 assert.equal(hasVisibleToastAction({ children: "Update" }), true);
13 });
14
15 it("hides an explicit empty action used to clear a previous CTA", () => {
16 assert.equal(hasVisibleToastAction({ children: null }), false);
17 assert.equal(hasVisibleToastAction({ children: "" }), false);
18 assert.equal(hasVisibleToastAction(undefined), false);
19 });
20});
21
9describe("shouldHideCollapsedToastContent", () => {22describe("shouldHideCollapsedToastContent", () => {
10 it("keeps a single visible toast readable", () => {23 it("keeps a single visible toast readable", () => {
11 assert.equal(shouldHideCollapsedToastContent(0, 1), false);24 assert.equal(shouldHideCollapsedToastContent(0, 1), false);
apps/web/src/components/ui/toast.logic.ts+16
...@@ -1,5 +1,21 @@...@@ -1,5 +1,21 @@
1import type { ScopedThreadRef, ThreadId } from "@t3tools/contracts";1import type { ScopedThreadRef, ThreadId } from "@t3tools/contracts";
22
3/**
4 * Base UI toast updates omit `undefined` fields, so callers that need to remove
5 * an action must pass a defined `actionProps` whose `children` are empty.
6 * Treat that payload (and missing children) as "no visible action".
7 */
8export function hasVisibleToastAction(actionProps: unknown): boolean {
9 if (actionProps == null || typeof actionProps !== "object") {
10 return false;
11 }
12 if (!("children" in actionProps)) {
13 return false;
14 }
15 const children = actionProps.children;
16 return children != null && children !== false && children !== "";
17}
18
3export function shouldHideCollapsedToastContent(19export function shouldHideCollapsedToastContent(
4 visibleToastIndex: number,20 visibleToastIndex: number,
5 visibleToastCount: number,21 visibleToastCount: number,
apps/web/src/components/ui/toast.tsx+6-5
...@@ -32,6 +32,7 @@ import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";...@@ -32,6 +32,7 @@ import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
32import { resolveThreadRouteTarget } from "~/threadRoutes";32import { resolveThreadRouteTarget } from "~/threadRoutes";
33import {33import {
34 buildVisibleToastLayout,34 buildVisibleToastLayout,
35 hasVisibleToastAction,
35 shouldHideCollapsedToastContent,36 shouldHideCollapsedToastContent,
36 shouldRenderThreadScopedToast,37 shouldRenderThreadScopedToast,
37} from "./toast.logic";38} from "./toast.logic";
...@@ -288,7 +289,7 @@ function deriveToastBodyDescriptor(toast: {...@@ -288,7 +289,7 @@ function deriveToastBodyDescriptor(toast: {
288}): ToastBodyDescriptor {289}): ToastBodyDescriptor {
289 const Icon = toast.type ? TOAST_ICONS[toast.type as keyof typeof TOAST_ICONS] : null;290 const Icon = toast.type ? TOAST_ICONS[toast.type as keyof typeof TOAST_ICONS] : null;
290 const stackedActionLayout =291 const stackedActionLayout =
291 toast.actionProps !== undefined && toast.data?.actionLayout === "stacked-end";292 hasVisibleToastAction(toast.actionProps) && toast.data?.actionLayout === "stacked-end";
292 const actionVariant: NonNullable<ThreadToastData["actionVariant"]> =293 const actionVariant: NonNullable<ThreadToastData["actionVariant"]> =
293 toast.data?.actionVariant ?? "default";294 toast.data?.actionVariant ?? "default";
294 const secondaryActionVariant: NonNullable<ThreadToastData["secondaryActionVariant"]> =295 const secondaryActionVariant: NonNullable<ThreadToastData["secondaryActionVariant"]> =
...@@ -301,7 +302,7 @@ function deriveToastBodyDescriptor(toast: {...@@ -301,7 +302,7 @@ function deriveToastBodyDescriptor(toast: {
301 const hasSecondaryAction = toast.data?.secondaryActionProps !== undefined;302 const hasSecondaryAction = toast.data?.secondaryActionProps !== undefined;
302 const hasTrailingControls =303 const hasTrailingControls =
303 copyErrorText !== null ||304 copyErrorText !== null ||
304 toast.actionProps !== undefined ||305 hasVisibleToastAction(toast.actionProps) ||
305 hasAdditionalActions ||306 hasAdditionalActions ||
306 hasSecondaryAction;307 hasSecondaryAction;
307 const inlineContentEndPad = hasTrailingControls ? "pr-6" : "pr-10";308 const inlineContentEndPad = hasTrailingControls ? "pr-6" : "pr-10";
...@@ -400,12 +401,12 @@ function ToastBodyContent({...@@ -400,12 +401,12 @@ function ToastBodyContent({
400 variant={secondaryActionVariant}401 variant={secondaryActionVariant}
401 />402 />
402 ) : null}403 ) : null}
403 {actionProps ? (404 {hasVisibleToastAction(actionProps) ? (
404 <Toast.Action405 <Toast.Action
405 className={cn(buttonVariants({ size: "xs", variant: actionVariant }), "shrink-0")}406 className={cn(buttonVariants({ size: "xs", variant: actionVariant }), "shrink-0")}
406 data-slot="toast-action"407 data-slot="toast-action"
407 >408 >
408 {actionProps.children}409 {actionProps?.children}
409 </Toast.Action>410 </Toast.Action>
410 ) : null}411 ) : null}
411 </div>412 </div>
...@@ -798,7 +799,7 @@ function AnchoredToasts() {...@@ -798,7 +799,7 @@ function AnchoredToasts() {
798 );799 );
799}800}
800801
801export { stackedThreadToast } from "./toastHelpers";802export { hiddenToastActionProps, stackedThreadToast } from "./toastHelpers";
802export type { StackedThreadToastOptions } from "./toastHelpers";803export type { StackedThreadToastOptions } from "./toastHelpers";
803804
804export {805export {
apps/web/src/components/ui/toastHelpers.test.ts created+21
...@@ -0,0 +1,21 @@
1import { assert, describe, it } from "vite-plus/test";
2
3import { hiddenToastActionProps, stackedThreadToast } from "./toastHelpers";
4
5describe("hiddenToastActionProps", () => {
6 it("is a defined update payload so Base UI can replace a previous action", () => {
7 assert.equal(hiddenToastActionProps.children, null);
8 assert.equal(
9 "actionProps" in stackedThreadToast({ type: "loading", title: "Updating" }),
10 false,
11 );
12 assert.deepEqual(
13 stackedThreadToast({
14 type: "loading",
15 title: "Updating",
16 actionProps: hiddenToastActionProps,
17 }).actionProps,
18 hiddenToastActionProps,
19 );
20 });
21});
apps/web/src/components/ui/toastHelpers.ts+8
...@@ -17,6 +17,14 @@ export type StackedThreadToastOptions = {...@@ -17,6 +17,14 @@ export type StackedThreadToastOptions = {
17 data?: Omit<ThreadToastData, "actionLayout">;17 data?: Omit<ThreadToastData, "actionLayout">;
18};18};
1919
20/**
21 * Defined `actionProps` that hide a previous toast CTA on `toastManager.update`.
22 * Passing `actionProps: undefined` is a no-op because updates omit undefined keys.
23 */
24export const hiddenToastActionProps = {
25 children: null,
26} as const satisfies Pick<ComponentPropsWithoutRef<"button">, "children">;
27
20/**28/**
21 * Thread toast using the stacked body + bottom action row (copy for errors, CTA on its own row).29 * Thread toast using the stacked body + bottom action row (copy for errors, CTA on its own row).
22 */30 */