From edad6db1066274c0fc574728e651c0382916d71b Mon Sep 17 00:00:00 2001 From: Jeremy Odell Date: Tue, 19 May 2026 00:56:07 -0400 Subject: [PATCH] Clear stale update CTA from provider update toasts - Factor toast update construction into shared logic - Remove prompt actions from loading and success update states - Keep rejected update states actionable via Settings --- ...iderUpdateLaunchNotification.logic.test.ts | 60 +++++++++++++++++++ .../ProviderUpdateLaunchNotification.logic.ts | 43 +++++++++++++ .../ProviderUpdateLaunchNotification.tsx | 33 ++-------- 3 files changed, 107 insertions(+), 29 deletions(-) diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts index defb6eb20bd..02d7bb3375d 100644 --- a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts +++ b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; import { + buildProviderUpdateToastUpdate, canOneClickUpdateProviderCandidate, collectProviderUpdateCandidates, collectUpdatedProviderSnapshots, @@ -9,13 +10,16 @@ import { getProviderUpdateInitialToastView, getProviderUpdateProgressToastView, getProviderUpdateRejectedToastView, + getProviderUpdateRunningToastView, getProviderUpdateSidebarPillView, getSingleProviderUpdateProgressToastView, hasOneClickUpdateProviderCandidate, isProviderUpdateCandidate, providerUpdateNotificationKey, type ProviderUpdateCandidate, + type ProviderUpdateToastView, } from "./ProviderUpdateLaunchNotification.logic"; +import { stackedThreadToast } from "./ui/toastHelpers"; const checkedAt = "2026-04-23T10:00:00.000Z"; const sessionStartedAt = "2026-04-23T09:59:00.000Z"; @@ -69,6 +73,28 @@ function updateCandidate(input: Parameters[0]): ProviderUpdateC return provider(input) as ProviderUpdateCandidate; } +function promptToastWithUpdateAction() { + return stackedThreadToast({ + type: "warning", + title: "Updates Available: 2 providers", + description: "Install the update now or review provider settings.", + timeout: 0, + actionProps: { + children: "Update", + onClick: () => undefined, + }, + actionVariant: "default", + data: { + hideCopyButton: true, + secondaryActionProps: { + children: "Settings", + onClick: () => undefined, + }, + secondaryActionVariant: "outline", + }, + }); +} + describe("provider update launch notification logic", () => { it("detects enabled providers with a latest-version advisory", () => { expect(isProviderUpdateCandidate(provider({ driver: driver("codex") }))).toBe(true); @@ -272,6 +298,40 @@ describe("provider update launch notification logic", () => { }); }); + it("clears the prompt update action from actionless progress toast views", () => { + const successView: ProviderUpdateToastView = { + phase: "succeeded", + type: "success", + title: "Provider updates finished", + description: "New sessions will use the updated providers.", + dismissAfterVisibleMs: 3_000, + }; + + for (const view of [getProviderUpdateRunningToastView(2), successView]) { + const update = buildProviderUpdateToastUpdate({ + view, + openSettings: () => undefined, + }); + const mergedToast = { ...promptToastWithUpdateAction(), ...update }; + + expect(Object.hasOwn(update, "actionProps")).toBe(true); + expect(mergedToast.actionProps).toBeUndefined(); + expect(mergedToast.data?.secondaryActionProps).toBeUndefined(); + expect(mergedToast.data?.actionLayout).toBeUndefined(); + } + }); + + it("keeps failed update toast views actionable from settings", () => { + const update = buildProviderUpdateToastUpdate({ + view: getProviderUpdateRejectedToastView(2, "WebSocket closed"), + openSettings: () => undefined, + }); + const mergedToast = { ...promptToastWithUpdateAction(), ...update }; + + expect(mergedToast.actionProps?.children).toBe("Settings"); + expect(mergedToast.data?.actionLayout).toBe("stacked-end"); + }); + it("describes settings-only updates without one-click support", () => { const view = getProviderUpdateInitialToastView({ updateProviders: [ diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts index f45b2916ce4..582f5d01421 100644 --- a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts +++ b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts @@ -1,3 +1,4 @@ +import type { ToastManagerUpdateOptions } from "@base-ui/react/toast"; import { defaultInstanceIdForDriver, PROVIDER_DISPLAY_NAMES, @@ -6,6 +7,9 @@ import { type ServerProvider, } from "@t3tools/contracts"; +import type { ThreadToastData } from "./ui/toast"; +import { stackedThreadToast } from "./ui/toastHelpers"; + export type ProviderUpdateCandidate = ServerProvider & { readonly versionAdvisory: NonNullable & { readonly status: "behind_latest"; @@ -235,6 +239,45 @@ export function getProviderUpdateRejectedToastView( }; } +export function buildProviderUpdateToastUpdate(input: { + readonly view: ProviderUpdateToastView; + readonly openSettings: () => void; +}): ToastManagerUpdateOptions { + if (input.view.type !== "loading" && input.view.type !== "success") { + return stackedThreadToast({ + type: input.view.type, + title: input.view.title, + description: input.view.description, + timeout: 0, + actionProps: { + children: "Settings", + onClick: input.openSettings, + }, + actionVariant: "outline", + data: { + hideCopyButton: true, + }, + }); + } + + const data: ThreadToastData = { + hideCopyButton: true, + }; + if (input.view.dismissAfterVisibleMs !== undefined) { + data.dismissAfterVisibleMs = input.view.dismissAfterVisibleMs; + } + + return { + type: input.view.type, + title: input.view.title, + description: input.view.description, + timeout: 0, + // Base UI shallow-merges toast updates, so the old prompt CTA must be explicitly cleared. + actionProps: undefined, + data, + }; +} + export function getProviderUpdateProgressToastView(input: { readonly providers: ReadonlyArray; readonly providerCount: number; diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.tsx b/apps/web/src/components/ProviderUpdateLaunchNotification.tsx index 69cd83bf8dc..eaa9813e8be 100644 --- a/apps/web/src/components/ProviderUpdateLaunchNotification.tsx +++ b/apps/web/src/components/ProviderUpdateLaunchNotification.tsx @@ -9,6 +9,7 @@ import { useServerProviders } from "../rpc/serverState"; import { PROVIDER_ICON_BY_PROVIDER } from "./chat/providerIconUtils"; import { canOneClickUpdateProviderCandidate, + buildProviderUpdateToastUpdate, collectProviderUpdateCandidates, collectUpdatedProviderSnapshots, firstRejectedProviderUpdateMessage, @@ -60,37 +61,11 @@ function updateProviderUpdateToast(input: { readonly view: ProviderUpdateToastView; readonly openSettings: () => void; }) { - if (input.view.type === "loading" || input.view.type === "success") { - toastManager.update(input.toastId, { - type: input.view.type, - title: input.view.title, - description: input.view.description, - timeout: 0, - data: { - hideCopyButton: true, - ...(input.view.dismissAfterVisibleMs !== undefined - ? { dismissAfterVisibleMs: input.view.dismissAfterVisibleMs } - : {}), - }, - }); - return; - } - toastManager.update( input.toastId, - stackedThreadToast({ - type: input.view.type, - title: input.view.title, - description: input.view.description, - timeout: 0, - actionProps: { - children: "Settings", - onClick: input.openSettings, - }, - actionVariant: "outline", - data: { - hideCopyButton: true, - }, + buildProviderUpdateToastUpdate({ + view: input.view, + openSettings: input.openSettings, }), ); }