| author | |
| committer | |
| log | 3cde99b259249a02e50079f326a00b5ba38e7235 |
| tree | 4c024259988be49952e4a80e33976a6082a6d254 |
| parent | 51c6daa3bd1d65e809b5196bf63333013427338a |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 20 | providerUpdateNotificationKey, |
| 21 | 21 | type ProviderUpdateToastView, |
| 22 | 22 | } from "./ProviderUpdateLaunchNotification.logic"; |
| 23 | import { stackedThreadToast, toastManager } from "./ui/toast"; | |
| 23 | import { hiddenToastActionProps, stackedThreadToast, toastManager } from "./ui/toast"; | |
| 24 | 24 | import { useAtomCommand } from "../state/use-atom-command"; |
| 25 | 25 | |
| 26 | 26 | const seenProviderUpdateNotificationKeys = new Set<string>(); |
| ... | ... | @@ -68,10 +68,10 @@ function updateProviderUpdateToast(input: { |
| 68 | 68 | title: input.view.title, |
| 69 | 69 | description: input.view.description, |
| 70 | 70 | 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, | |
| 75 | 75 | data: { |
| 76 | 76 | hideCopyButton: true, |
| 77 | 77 | ...(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 | 2 | import { assert, describe, it } from "vite-plus/test"; |
| 3 | 3 | import { |
| 4 | 4 | buildVisibleToastLayout, |
| 5 | hasVisibleToastAction, | |
| 5 | 6 | shouldHideCollapsedToastContent, |
| 6 | 7 | shouldRenderThreadScopedToast, |
| 7 | 8 | } from "./toast.logic"; |
| 8 | 9 | |
| 10 | describe("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 | ||
| 9 | 22 | describe("shouldHideCollapsedToastContent", () => { |
| 10 | 23 | it("keeps a single visible toast readable", () => { |
| 11 | 24 | assert.equal(shouldHideCollapsedToastContent(0, 1), false); |
apps/web/src/components/ui/toast.logic.ts+16| ... | ... | @@ -1,5 +1,21 @@ |
| 1 | 1 | import type { ScopedThreadRef, ThreadId } from "@t3tools/contracts"; |
| 2 | 2 | |
| 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 | */ | |
| 8 | export 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 | ||
| 3 | 19 | export function shouldHideCollapsedToastContent( |
| 4 | 20 | visibleToastIndex: number, |
| 5 | 21 | visibleToastCount: number, |
apps/web/src/components/ui/toast.tsx+6-5| ... | ... | @@ -32,6 +32,7 @@ import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; |
| 32 | 32 | import { resolveThreadRouteTarget } from "~/threadRoutes"; |
| 33 | 33 | import { |
| 34 | 34 | buildVisibleToastLayout, |
| 35 | hasVisibleToastAction, | |
| 35 | 36 | shouldHideCollapsedToastContent, |
| 36 | 37 | shouldRenderThreadScopedToast, |
| 37 | 38 | } from "./toast.logic"; |
| ... | ... | @@ -288,7 +289,7 @@ function deriveToastBodyDescriptor(toast: { |
| 288 | 289 | }): ToastBodyDescriptor { |
| 289 | 290 | const Icon = toast.type ? TOAST_ICONS[toast.type as keyof typeof TOAST_ICONS] : null; |
| 290 | 291 | const stackedActionLayout = |
| 291 | toast.actionProps !== undefined && toast.data?.actionLayout === "stacked-end"; | |
| 292 | hasVisibleToastAction(toast.actionProps) && toast.data?.actionLayout === "stacked-end"; | |
| 292 | 293 | const actionVariant: NonNullable<ThreadToastData["actionVariant"]> = |
| 293 | 294 | toast.data?.actionVariant ?? "default"; |
| 294 | 295 | const secondaryActionVariant: NonNullable<ThreadToastData["secondaryActionVariant"]> = |
| ... | ... | @@ -301,7 +302,7 @@ function deriveToastBodyDescriptor(toast: { |
| 301 | 302 | const hasSecondaryAction = toast.data?.secondaryActionProps !== undefined; |
| 302 | 303 | const hasTrailingControls = |
| 303 | 304 | copyErrorText !== null || |
| 304 | toast.actionProps !== undefined || | |
| 305 | hasVisibleToastAction(toast.actionProps) || | |
| 305 | 306 | hasAdditionalActions || |
| 306 | 307 | hasSecondaryAction; |
| 307 | 308 | const inlineContentEndPad = hasTrailingControls ? "pr-6" : "pr-10"; |
| ... | ... | @@ -400,12 +401,12 @@ function ToastBodyContent({ |
| 400 | 401 | variant={secondaryActionVariant} |
| 401 | 402 | /> |
| 402 | 403 | ) : null} |
| 403 | {actionProps ? ( | |
| 404 | {hasVisibleToastAction(actionProps) ? ( | |
| 404 | 405 | <Toast.Action |
| 405 | 406 | className={cn(buttonVariants({ size: "xs", variant: actionVariant }), "shrink-0")} |
| 406 | 407 | data-slot="toast-action" |
| 407 | 408 | > |
| 408 | {actionProps.children} | |
| 409 | {actionProps?.children} | |
| 409 | 410 | </Toast.Action> |
| 410 | 411 | ) : null} |
| 411 | 412 | </div> |
| ... | ... | @@ -798,7 +799,7 @@ function AnchoredToasts() { |
| 798 | 799 | ); |
| 799 | 800 | } |
| 800 | 801 | |
| 801 | export { stackedThreadToast } from "./toastHelpers"; | |
| 802 | export { hiddenToastActionProps, stackedThreadToast } from "./toastHelpers"; | |
| 802 | 803 | export type { StackedThreadToastOptions } from "./toastHelpers"; |
| 803 | 804 | |
| 804 | 805 | export { |
apps/web/src/components/ui/toastHelpers.test.ts created+21| ... | ... | @@ -0,0 +1,21 @@ |
| 1 | import { assert, describe, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { hiddenToastActionProps, stackedThreadToast } from "./toastHelpers"; | |
| 4 | ||
| 5 | describe("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 | 17 | data?: Omit<ThreadToastData, "actionLayout">; |
| 18 | 18 | }; |
| 19 | 19 | |
| 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 | */ | |
| 24 | export const hiddenToastActionProps = { | |
| 25 | children: null, | |
| 26 | } as const satisfies Pick<ComponentPropsWithoutRef<"button">, "children">; | |
| 27 | ||
| 20 | 28 | /** |
| 21 | 29 | * Thread toast using the stacked body + bottom action row (copy for errors, CTA on its own row). |
| 22 | 30 | */ |