Skip to content

Commit edad6db

Browse files
committed
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
1 parent d1e85c4 commit edad6db

3 files changed

Lines changed: 107 additions & 29 deletions

File tree

apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,24 @@ import { describe, expect, it } from "vitest";
22
import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts";
33

44
import {
5+
buildProviderUpdateToastUpdate,
56
canOneClickUpdateProviderCandidate,
67
collectProviderUpdateCandidates,
78
collectUpdatedProviderSnapshots,
89
firstRejectedProviderUpdateMessage,
910
getProviderUpdateInitialToastView,
1011
getProviderUpdateProgressToastView,
1112
getProviderUpdateRejectedToastView,
13+
getProviderUpdateRunningToastView,
1214
getProviderUpdateSidebarPillView,
1315
getSingleProviderUpdateProgressToastView,
1416
hasOneClickUpdateProviderCandidate,
1517
isProviderUpdateCandidate,
1618
providerUpdateNotificationKey,
1719
type ProviderUpdateCandidate,
20+
type ProviderUpdateToastView,
1821
} from "./ProviderUpdateLaunchNotification.logic";
22+
import { stackedThreadToast } from "./ui/toastHelpers";
1923

2024
const checkedAt = "2026-04-23T10:00:00.000Z";
2125
const sessionStartedAt = "2026-04-23T09:59:00.000Z";
@@ -69,6 +73,28 @@ function updateCandidate(input: Parameters<typeof provider>[0]): ProviderUpdateC
6973
return provider(input) as ProviderUpdateCandidate;
7074
}
7175

76+
function promptToastWithUpdateAction() {
77+
return stackedThreadToast({
78+
type: "warning",
79+
title: "Updates Available: 2 providers",
80+
description: "Install the update now or review provider settings.",
81+
timeout: 0,
82+
actionProps: {
83+
children: "Update",
84+
onClick: () => undefined,
85+
},
86+
actionVariant: "default",
87+
data: {
88+
hideCopyButton: true,
89+
secondaryActionProps: {
90+
children: "Settings",
91+
onClick: () => undefined,
92+
},
93+
secondaryActionVariant: "outline",
94+
},
95+
});
96+
}
97+
7298
describe("provider update launch notification logic", () => {
7399
it("detects enabled providers with a latest-version advisory", () => {
74100
expect(isProviderUpdateCandidate(provider({ driver: driver("codex") }))).toBe(true);
@@ -272,6 +298,40 @@ describe("provider update launch notification logic", () => {
272298
});
273299
});
274300

301+
it("clears the prompt update action from actionless progress toast views", () => {
302+
const successView: ProviderUpdateToastView = {
303+
phase: "succeeded",
304+
type: "success",
305+
title: "Provider updates finished",
306+
description: "New sessions will use the updated providers.",
307+
dismissAfterVisibleMs: 3_000,
308+
};
309+
310+
for (const view of [getProviderUpdateRunningToastView(2), successView]) {
311+
const update = buildProviderUpdateToastUpdate({
312+
view,
313+
openSettings: () => undefined,
314+
});
315+
const mergedToast = { ...promptToastWithUpdateAction(), ...update };
316+
317+
expect(Object.hasOwn(update, "actionProps")).toBe(true);
318+
expect(mergedToast.actionProps).toBeUndefined();
319+
expect(mergedToast.data?.secondaryActionProps).toBeUndefined();
320+
expect(mergedToast.data?.actionLayout).toBeUndefined();
321+
}
322+
});
323+
324+
it("keeps failed update toast views actionable from settings", () => {
325+
const update = buildProviderUpdateToastUpdate({
326+
view: getProviderUpdateRejectedToastView(2, "WebSocket closed"),
327+
openSettings: () => undefined,
328+
});
329+
const mergedToast = { ...promptToastWithUpdateAction(), ...update };
330+
331+
expect(mergedToast.actionProps?.children).toBe("Settings");
332+
expect(mergedToast.data?.actionLayout).toBe("stacked-end");
333+
});
334+
275335
it("describes settings-only updates without one-click support", () => {
276336
const view = getProviderUpdateInitialToastView({
277337
updateProviders: [

apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { ToastManagerUpdateOptions } from "@base-ui/react/toast";
12
import {
23
defaultInstanceIdForDriver,
34
PROVIDER_DISPLAY_NAMES,
@@ -6,6 +7,9 @@ import {
67
type ServerProvider,
78
} from "@t3tools/contracts";
89

10+
import type { ThreadToastData } from "./ui/toast";
11+
import { stackedThreadToast } from "./ui/toastHelpers";
12+
913
export type ProviderUpdateCandidate = ServerProvider & {
1014
readonly versionAdvisory: NonNullable<ServerProvider["versionAdvisory"]> & {
1115
readonly status: "behind_latest";
@@ -235,6 +239,45 @@ export function getProviderUpdateRejectedToastView(
235239
};
236240
}
237241

242+
export function buildProviderUpdateToastUpdate(input: {
243+
readonly view: ProviderUpdateToastView;
244+
readonly openSettings: () => void;
245+
}): ToastManagerUpdateOptions<ThreadToastData> {
246+
if (input.view.type !== "loading" && input.view.type !== "success") {
247+
return stackedThreadToast({
248+
type: input.view.type,
249+
title: input.view.title,
250+
description: input.view.description,
251+
timeout: 0,
252+
actionProps: {
253+
children: "Settings",
254+
onClick: input.openSettings,
255+
},
256+
actionVariant: "outline",
257+
data: {
258+
hideCopyButton: true,
259+
},
260+
});
261+
}
262+
263+
const data: ThreadToastData = {
264+
hideCopyButton: true,
265+
};
266+
if (input.view.dismissAfterVisibleMs !== undefined) {
267+
data.dismissAfterVisibleMs = input.view.dismissAfterVisibleMs;
268+
}
269+
270+
return {
271+
type: input.view.type,
272+
title: input.view.title,
273+
description: input.view.description,
274+
timeout: 0,
275+
// Base UI shallow-merges toast updates, so the old prompt CTA must be explicitly cleared.
276+
actionProps: undefined,
277+
data,
278+
};
279+
}
280+
238281
export function getProviderUpdateProgressToastView(input: {
239282
readonly providers: ReadonlyArray<ServerProvider>;
240283
readonly providerCount: number;

apps/web/src/components/ProviderUpdateLaunchNotification.tsx

Lines changed: 4 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { useServerProviders } from "../rpc/serverState";
99
import { PROVIDER_ICON_BY_PROVIDER } from "./chat/providerIconUtils";
1010
import {
1111
canOneClickUpdateProviderCandidate,
12+
buildProviderUpdateToastUpdate,
1213
collectProviderUpdateCandidates,
1314
collectUpdatedProviderSnapshots,
1415
firstRejectedProviderUpdateMessage,
@@ -60,37 +61,11 @@ function updateProviderUpdateToast(input: {
6061
readonly view: ProviderUpdateToastView;
6162
readonly openSettings: () => void;
6263
}) {
63-
if (input.view.type === "loading" || input.view.type === "success") {
64-
toastManager.update(input.toastId, {
65-
type: input.view.type,
66-
title: input.view.title,
67-
description: input.view.description,
68-
timeout: 0,
69-
data: {
70-
hideCopyButton: true,
71-
...(input.view.dismissAfterVisibleMs !== undefined
72-
? { dismissAfterVisibleMs: input.view.dismissAfterVisibleMs }
73-
: {}),
74-
},
75-
});
76-
return;
77-
}
78-
7964
toastManager.update(
8065
input.toastId,
81-
stackedThreadToast({
82-
type: input.view.type,
83-
title: input.view.title,
84-
description: input.view.description,
85-
timeout: 0,
86-
actionProps: {
87-
children: "Settings",
88-
onClick: input.openSettings,
89-
},
90-
actionVariant: "outline",
91-
data: {
92-
hideCopyButton: true,
93-
},
66+
buildProviderUpdateToastUpdate({
67+
view: input.view,
68+
openSettings: input.openSettings,
9469
}),
9570
);
9671
}

0 commit comments

Comments
 (0)