| 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,7 +20,7 @@ import { |
| 20 | providerUpdateNotificationKey, | 20 | providerUpdateNotificationKey, |
| 21 | type ProviderUpdateToastView, | 21 | type ProviderUpdateToastView, |
| 22 | } from "./ProviderUpdateLaunchNotification.logic"; | 22 | } from "./ProviderUpdateLaunchNotification.logic"; |
| 23 | import { stackedThreadToast, toastManager } from "./ui/toast"; | 23 | import { hiddenToastActionProps, stackedThreadToast, toastManager } from "./ui/toast"; |
| 24 | import { useAtomCommand } from "../state/use-atom-command"; | 24 | import { useAtomCommand } from "../state/use-atom-command"; |
| 25 | 25 | ||
| 26 | const seenProviderUpdateNotificationKeys = new Set<string>(); | 26 | const 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 clear | 71 | // Base UI merges toast updates and omits `undefined` keys, so `undefined` |
| 72 | // the prompt action so its guarded Update handler cannot linger as a | 72 | // 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 !== undefined | 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,10 +2,23 @@ import type { ScopedThreadRef } from "@t3tools/contracts"; |
| 2 | import { assert, describe, it } from "vite-plus/test"; | 2 | import { assert, describe, it } from "vite-plus/test"; |
| 3 | import { | 3 | import { |
| 4 | buildVisibleToastLayout, | 4 | buildVisibleToastLayout, |
| 5 | hasVisibleToastAction, | ||
| 5 | shouldHideCollapsedToastContent, | 6 | shouldHideCollapsedToastContent, |
| 6 | shouldRenderThreadScopedToast, | 7 | shouldRenderThreadScopedToast, |
| 7 | } from "./toast.logic"; | 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 | describe("shouldHideCollapsedToastContent", () => { | 22 | describe("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 @@ |
| 1 | import type { ScopedThreadRef, ThreadId } from "@t3tools/contracts"; | 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 | export function shouldHideCollapsedToastContent( | 19 | export 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"; |
| 32 | import { resolveThreadRouteTarget } from "~/threadRoutes"; | 32 | import { resolveThreadRouteTarget } from "~/threadRoutes"; |
| 33 | import { | 33 | import { |
| 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.Action | 405 | <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 | } |
| 800 | 801 | ||
| 801 | export { stackedThreadToast } from "./toastHelpers"; | 802 | export { hiddenToastActionProps, stackedThreadToast } from "./toastHelpers"; |
| 802 | export type { StackedThreadToastOptions } from "./toastHelpers"; | 803 | export type { StackedThreadToastOptions } from "./toastHelpers"; |
| 803 | 804 | ||
| 804 | export { | 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,6 +17,14 @@ export type StackedThreadToastOptions = { |
| 17 | data?: Omit<ThreadToastData, "actionLayout">; | 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 | * 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 | */ |