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 {
2020 providerUpdateNotificationKey,
2121 type ProviderUpdateToastView,
2222} from "./ProviderUpdateLaunchNotification.logic";
23import { stackedThreadToast, toastManager } from "./ui/toast";
23import { hiddenToastActionProps, stackedThreadToast, toastManager } from "./ui/toast";
2424import { useAtomCommand } from "../state/use-atom-command";
2525
2626const seenProviderUpdateNotificationKeys = new Set<string>();
......@@ -68,10 +68,10 @@ function updateProviderUpdateToast(input: {
6868 title: input.view.title,
6969 description: input.view.description,
7070 timeout: 0,
71 // Base UI merges toast updates with the existing toast. Explicitly clear
72 // the prompt action so its guarded Update handler cannot linger as a
73 // visible no-op while the update is running (or after it succeeds).
74 actionProps: undefined,
71 // Base UI merges toast updates and omits `undefined` keys, so `undefined`
72 // would leave the prompt's Update button in place. Replace it with a
73 // defined empty action so the CTA cannot linger while the update runs.
74 actionProps: hiddenToastActionProps,
7575 data: {
7676 hideCopyButton: true,
7777 ...(input.view.dismissAfterVisibleMs !== undefined
apps/web/src/components/ui/toast.logic.test.ts+13
......@@ -2,10 +2,23 @@ import type { ScopedThreadRef } from "@t3tools/contracts";
22import { assert, describe, it } from "vite-plus/test";
33import {
44 buildVisibleToastLayout,
5 hasVisibleToastAction,
56 shouldHideCollapsedToastContent,
67 shouldRenderThreadScopedToast,
78} 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
922describe("shouldHideCollapsedToastContent", () => {
1023 it("keeps a single visible toast readable", () => {
1124 assert.equal(shouldHideCollapsedToastContent(0, 1), false);
apps/web/src/components/ui/toast.logic.ts+16
......@@ -1,5 +1,21 @@
11import 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
319export function shouldHideCollapsedToastContent(
420 visibleToastIndex: number,
521 visibleToastCount: number,
apps/web/src/components/ui/toast.tsx+6-5
......@@ -32,6 +32,7 @@ import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
3232import { resolveThreadRouteTarget } from "~/threadRoutes";
3333import {
3434 buildVisibleToastLayout,
35 hasVisibleToastAction,
3536 shouldHideCollapsedToastContent,
3637 shouldRenderThreadScopedToast,
3738} from "./toast.logic";
......@@ -288,7 +289,7 @@ function deriveToastBodyDescriptor(toast: {
288289}): ToastBodyDescriptor {
289290 const Icon = toast.type ? TOAST_ICONS[toast.type as keyof typeof TOAST_ICONS] : null;
290291 const stackedActionLayout =
291 toast.actionProps !== undefined && toast.data?.actionLayout === "stacked-end";
292 hasVisibleToastAction(toast.actionProps) && toast.data?.actionLayout === "stacked-end";
292293 const actionVariant: NonNullable<ThreadToastData["actionVariant"]> =
293294 toast.data?.actionVariant ?? "default";
294295 const secondaryActionVariant: NonNullable<ThreadToastData["secondaryActionVariant"]> =
......@@ -301,7 +302,7 @@ function deriveToastBodyDescriptor(toast: {
301302 const hasSecondaryAction = toast.data?.secondaryActionProps !== undefined;
302303 const hasTrailingControls =
303304 copyErrorText !== null ||
304 toast.actionProps !== undefined ||
305 hasVisibleToastAction(toast.actionProps) ||
305306 hasAdditionalActions ||
306307 hasSecondaryAction;
307308 const inlineContentEndPad = hasTrailingControls ? "pr-6" : "pr-10";
......@@ -400,12 +401,12 @@ function ToastBodyContent({
400401 variant={secondaryActionVariant}
401402 />
402403 ) : null}
403 {actionProps ? (
404 {hasVisibleToastAction(actionProps) ? (
404405 <Toast.Action
405406 className={cn(buttonVariants({ size: "xs", variant: actionVariant }), "shrink-0")}
406407 data-slot="toast-action"
407408 >
408 {actionProps.children}
409 {actionProps?.children}
409410 </Toast.Action>
410411 ) : null}
411412 </div>
......@@ -798,7 +799,7 @@ function AnchoredToasts() {
798799 );
799800}
800801
801export { stackedThreadToast } from "./toastHelpers";
802export { hiddenToastActionProps, stackedThreadToast } from "./toastHelpers";
802803export type { StackedThreadToastOptions } from "./toastHelpers";
803804
804805export {
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 = {
1717 data?: Omit<ThreadToastData, "actionLayout">;
1818};
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
2028/**
2129 * Thread toast using the stacked body + bottom action row (copy for errors, CTA on its own row).
2230 */