Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,24 @@ import { describe, expect, it } from "vitest";
import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts";

import {
buildProviderUpdateToastUpdate,
canOneClickUpdateProviderCandidate,
collectProviderUpdateCandidates,
collectUpdatedProviderSnapshots,
firstRejectedProviderUpdateMessage,
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";
Expand Down Expand Up @@ -69,6 +73,28 @@ function updateCandidate(input: Parameters<typeof provider>[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);
Expand Down Expand Up @@ -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: [
Expand Down
43 changes: 43 additions & 0 deletions apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { ToastManagerUpdateOptions } from "@base-ui/react/toast";
import {
defaultInstanceIdForDriver,
PROVIDER_DISPLAY_NAMES,
Expand All @@ -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<ServerProvider["versionAdvisory"]> & {
readonly status: "behind_latest";
Expand Down Expand Up @@ -235,6 +239,45 @@ export function getProviderUpdateRejectedToastView(
};
}

export function buildProviderUpdateToastUpdate(input: {
readonly view: ProviderUpdateToastView;
readonly openSettings: () => void;
}): ToastManagerUpdateOptions<ThreadToastData> {
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<ServerProvider>;
readonly providerCount: number;
Expand Down
33 changes: 4 additions & 29 deletions apps/web/src/components/ProviderUpdateLaunchNotification.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { useServerProviders } from "../rpc/serverState";
import { PROVIDER_ICON_BY_PROVIDER } from "./chat/providerIconUtils";
import {
canOneClickUpdateProviderCandidate,
buildProviderUpdateToastUpdate,
collectProviderUpdateCandidates,
collectUpdatedProviderSnapshots,
firstRejectedProviderUpdateMessage,
Expand Down Expand Up @@ -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,
}),
);
}
Expand Down
Loading