diff --git a/apps/web/src/components/ProviderUpdateEnvironmentRows.test.tsx b/apps/web/src/components/ProviderUpdateEnvironmentRows.test.tsx index b34f82a775ac..9d05a57a5e2b 100644 --- a/apps/web/src/components/ProviderUpdateEnvironmentRows.test.tsx +++ b/apps/web/src/components/ProviderUpdateEnvironmentRows.test.tsx @@ -6,12 +6,14 @@ import { ProviderInstanceId, type ServerProvider, } from "@t3tools/contracts"; +import { Cause } from "effect"; import { AsyncResult } from "effect/unstable/reactivity"; import type { LocalEnvironmentUpdateGroup, ProviderUpdateCandidate, ProviderUpdateRowStatus, + ProviderUpdateToastView, } from "./ProviderUpdateLaunchNotification.logic"; const testState = vi.hoisted(() => ({ @@ -101,18 +103,29 @@ vi.mock("./ProviderUpdateLaunchNotification.environments", () => ({ }), })); -import { ProviderUpdateEnvironmentRows } from "./ProviderUpdateEnvironmentRows"; +import { + createProviderUpdateResultDelivery, + ProviderUpdateEnvironmentRows, + type ProviderUpdateResultClaim, +} from "./ProviderUpdateEnvironmentRows"; const environmentId = "env-wsl" as EnvironmentId; const pendingExpiryMs = 6 * 60_000; -function provider(updateStatus?: "succeeded"): ServerProvider { +function provider( + updateStatus?: "failed" | "succeeded" | "unchanged", + timestamps: { + readonly startedAt?: string; + readonly finishedAt?: string; + } = {}, +): ServerProvider { + const succeeded = updateStatus === "succeeded"; const result: ServerProvider = { instanceId: ProviderInstanceId.make("codex-wsl"), driver: ProviderDriverKind.make("codex"), enabled: true, installed: true, - version: updateStatus ? "1.1.0" : "1.0.0", + version: succeeded ? "1.1.0" : "1.0.0", status: "ready", auth: { status: "authenticated" }, checkedAt: "2026-06-26T12:00:00.000Z", @@ -120,13 +133,13 @@ function provider(updateStatus?: "succeeded"): ServerProvider { slashCommands: [], skills: [], versionAdvisory: { - status: updateStatus ? "current" : "behind_latest", - currentVersion: updateStatus ? "1.1.0" : "1.0.0", + status: succeeded ? "current" : "behind_latest", + currentVersion: succeeded ? "1.1.0" : "1.0.0", latestVersion: "1.1.0", updateCommand: "npm install -g @openai/codex@latest", canUpdate: true, checkedAt: "2026-06-26T12:00:00.000Z", - message: updateStatus ? "Up to date." : "Update available.", + message: succeeded ? "Up to date." : "Update available.", }, }; @@ -135,9 +148,9 @@ function provider(updateStatus?: "succeeded"): ServerProvider { ...result, updateState: { status: updateStatus, - startedAt: "2026-06-26T12:00:00.000Z", - finishedAt: "2026-06-26T12:00:01.000Z", - message: "Provider updated.", + startedAt: timestamps.startedAt ?? "2026-06-26T12:00:01.000Z", + finishedAt: timestamps.finishedAt ?? "2026-06-26T12:00:02.000Z", + message: updateStatus === "failed" ? "Provider update failed." : "Provider updated.", output: null, }, } @@ -157,9 +170,18 @@ type RowElement = ReactElement<{ readonly onUpdate: () => void; }>; -function renderRow(): RowElement { +function renderRow( + callbacks: { + readonly onUpdateFinished?: ( + environmentId: EnvironmentId, + generation: number, + view: ProviderUpdateToastView, + ) => void; + readonly onUpdateStarted?: (claim: ProviderUpdateResultClaim) => void; + } = {}, +): RowElement { hooks.beginRender(); - const output = ProviderUpdateEnvironmentRows({}) as ReactElement<{ + const output = ProviderUpdateEnvironmentRows(callbacks) as ReactElement<{ readonly children: RowElement | RowElement[]; }>; const children = output.props.children; @@ -175,6 +197,7 @@ async function flushPromises(): Promise { describe("ProviderUpdateEnvironmentRows", () => { beforeEach(() => { vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-26T12:00:00.000Z")); hooks.reset(); testState.updateProvider.mockReset(); const candidate = provider() as ProviderUpdateCandidate; @@ -218,9 +241,202 @@ describe("ProviderUpdateEnvironmentRows", () => { expect(renderRow().props.status.kind).toBe("loading"); - successorRequest.resolve(AsyncResult.success({ providers: [provider("succeeded")] })); + successorRequest.resolve( + AsyncResult.success({ + providers: [ + provider("succeeded", { + startedAt: "2026-06-26T12:06:01.000Z", + finishedAt: "2026-06-26T12:06:02.000Z", + }), + ], + }), + ); await flushPromises(); expect(renderRow().props.status.kind).toBe("success"); }); + + it("keeps a live terminal result when the command response hangs", async () => { + const request = + deferred>>(); + const onUpdateFinished = vi.fn(); + testState.updateProvider.mockReturnValue(request.promise); + + renderRow({ onUpdateFinished }).props.onUpdate(); + testState.groups = [ + { + ...testState.groups[0]!, + candidates: [], + providers: [provider("succeeded")], + }, + ]; + expect(renderRow({ onUpdateFinished }).props.status.kind).toBe("success"); + + await vi.advanceTimersByTimeAsync(pendingExpiryMs); + expect(renderRow({ onUpdateFinished }).props.status.kind).toBe("success"); + expect(onUpdateFinished).toHaveBeenCalledTimes(1); + expect(onUpdateFinished).toHaveBeenCalledWith( + environmentId, + 1, + expect.objectContaining({ phase: "succeeded" }), + ); + }); + + it("turns an interrupted dispatch into a retryable result", async () => { + const onUpdateFinished = vi.fn(); + testState.updateProvider.mockResolvedValue(AsyncResult.failure(Cause.interrupt())); + + renderRow({ onUpdateFinished }).props.onUpdate(); + await flushPromises(); + + expect(renderRow({ onUpdateFinished }).props.status.kind).toBe("failed"); + expect(onUpdateFinished).toHaveBeenCalledTimes(1); + expect(onUpdateFinished).toHaveBeenCalledWith( + environmentId, + 1, + expect.objectContaining({ + phase: "failed", + description: "Provider update was interrupted. Try again.", + }), + ); + }); +}); + +describe("provider update result delivery", () => { + const unchangedView: ProviderUpdateToastView = { + phase: "unchanged", + type: "warning", + title: "Provider still needs an update", + description: "Codex still appears outdated.", + }; + + function claim( + generation = 1, + startedAfterIso = "2026-06-26T12:00:00.000Z", + ): ProviderUpdateResultClaim { + return { + environmentId, + generation, + providerCount: 1, + providerInstanceIds: new Set([ProviderInstanceId.make("codex-wsl")]), + startedAfterIso, + }; + } + + function groupWith(providerSnapshot: ServerProvider): LocalEnvironmentUpdateGroup { + return { + ...testState.groups[0]!, + candidates: [provider() as ProviderUpdateCandidate], + providers: [providerSnapshot], + }; + } + + beforeEach(() => { + vi.useFakeTimers(); + testState.groups = [ + { + environmentId, + label: "WSL", + isPrimary: false, + isSettling: false, + candidates: [provider() as ProviderUpdateCandidate], + providers: [provider()], + }, + ]; + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("reports a dismissed popover's result exactly once", () => { + let isPopoverOpen = true; + const onResult = vi.fn(); + const delivery = createProviderUpdateResultDelivery({ + isPopoverOpen: () => isPopoverOpen, + onResult, + }); + delivery.startUpdate(claim()); + isPopoverOpen = false; + + expect(delivery.finishUpdate(environmentId, 1, unchangedView)).toBe(true); + expect(delivery.finishUpdate(environmentId, 1, unchangedView)).toBe(false); + expect(onResult).toHaveBeenCalledTimes(1); + expect(onResult).toHaveBeenCalledWith(unchangedView); + }); + + it("keeps a terminal result in the open popover", () => { + const onResult = vi.fn(); + const delivery = createProviderUpdateResultDelivery({ + isPopoverOpen: () => true, + onResult, + }); + delivery.startUpdate(claim()); + + expect(delivery.finishUpdate(environmentId, 1, unchangedView)).toBe(true); + expect(onResult).not.toHaveBeenCalled(); + }); + + it("lets a fresh live unchanged state claim before the RPC result", () => { + const onResult = vi.fn(); + const delivery = createProviderUpdateResultDelivery({ + isPopoverOpen: () => false, + onResult, + }); + delivery.startUpdate(claim()); + + delivery.observeGroups([groupWith(provider("unchanged"))]); + expect(delivery.finishUpdate(environmentId, 1, unchangedView)).toBe(false); + + expect(onResult).toHaveBeenCalledTimes(1); + expect(onResult).toHaveBeenCalledWith(expect.objectContaining({ phase: "unchanged" })); + }); + + it("rejects stale terminal state and an older attempt's completion", () => { + const onResult = vi.fn(); + const delivery = createProviderUpdateResultDelivery({ + isPopoverOpen: () => false, + onResult, + }); + delivery.startUpdate(claim(1, "2026-06-26T12:00:00.000Z")); + delivery.startUpdate(claim(2, "2026-06-26T12:00:03.000Z")); + + delivery.observeGroups([ + groupWith( + provider("unchanged", { + startedAt: "2026-06-26T12:00:01.000Z", + finishedAt: "2026-06-26T12:00:04.000Z", + }), + ), + ]); + expect(delivery.finishUpdate(environmentId, 1, unchangedView)).toBe(false); + expect(onResult).not.toHaveBeenCalled(); + + delivery.observeGroups([ + groupWith( + provider("unchanged", { + startedAt: "2026-06-26T12:00:04.000Z", + finishedAt: "2026-06-26T12:00:05.000Z", + }), + ), + ]); + expect(onResult).toHaveBeenCalledTimes(1); + }); + + it("reports expiry once after the popover is dismissed", async () => { + const onResult = vi.fn(); + const delivery = createProviderUpdateResultDelivery({ + isPopoverOpen: () => false, + onResult, + }); + delivery.startUpdate(claim()); + + await vi.advanceTimersByTimeAsync(pendingExpiryMs); + expect(onResult).toHaveBeenCalledTimes(1); + expect(onResult).toHaveBeenCalledWith( + expect.objectContaining({ phase: "failed", description: "Update timed out. Try again." }), + ); + expect(delivery.finishUpdate(environmentId, 1, unchangedView)).toBe(false); + expect(onResult).toHaveBeenCalledTimes(1); + }); }); diff --git a/apps/web/src/components/ProviderUpdateEnvironmentRows.tsx b/apps/web/src/components/ProviderUpdateEnvironmentRows.tsx index 28242b88fd3a..21cedd04de9b 100644 --- a/apps/web/src/components/ProviderUpdateEnvironmentRows.tsx +++ b/apps/web/src/components/ProviderUpdateEnvironmentRows.tsx @@ -15,6 +15,7 @@ import { collectProviderUpdateOutcomeSnapshots, firstRejectedProviderUpdateMessage, getProviderUpdateProgressToastView, + getProviderUpdateRejectedToastView, getProviderUpdateSidebarPillView, isTerminalProviderUpdatePhase, resolveEnvironmentUpdateRowStatus, @@ -32,11 +33,15 @@ type ProviderUpdateCommandResult = AtomCommandResult< unknown >; +interface ProviderUpdateDispatchOutcome { + readonly result: PromiseSettledResult; + readonly interrupted: boolean; +} + /** * Map one targeted instance's update command result into the settled-outcome - * shape the multi-backend reducers consume: a non-interrupted failure becomes a - * rejection carrying its message; a success carries the post-update snapshot of - * the targeted instance (null when the backend did not report it). + * shape the multi-backend reducers consume. The interruption flag stays separate + * so a dispatch with no terminal snapshot can still show a retryable result. */ function toProviderUpdateOutcome(input: { readonly environmentId: EnvironmentId; @@ -46,26 +51,30 @@ function toProviderUpdateOutcome(input: { readonly instanceId: ServerProvider["instanceId"]; }; readonly result: ProviderUpdateCommandResult; -}): PromiseSettledResult { +}): ProviderUpdateDispatchOutcome { if (input.result._tag === "Failure") { if (isAtomCommandInterrupted(input.result)) { - // An interrupted dispatch (e.g. superseded) is neither a success nor a - // hard failure — surface it as a non-contributing, non-rejecting outcome. return { - status: "fulfilled", - value: { - environmentId: input.environmentId, - isPrimary: input.isPrimary, - driver: input.target.driver, - instanceId: input.target.instanceId, - provider: null, + interrupted: true, + result: { + status: "fulfilled", + value: { + environmentId: input.environmentId, + isPrimary: input.isPrimary, + driver: input.target.driver, + instanceId: input.target.instanceId, + provider: null, + }, }, }; } const error = squashAtomCommandFailure(input.result); return { - status: "rejected", - reason: error instanceof Error ? error : new Error("Provider update failed."), + interrupted: false, + result: { + status: "rejected", + reason: error instanceof Error ? error : new Error("Provider update failed."), + }, }; } @@ -74,13 +83,16 @@ function toProviderUpdateOutcome(input: { (candidate) => candidate.instanceId === input.target.instanceId, ) ?? null; return { - status: "fulfilled", - value: { - environmentId: input.environmentId, - isPrimary: input.isPrimary, - driver: input.target.driver, - instanceId: input.target.instanceId, - provider, + interrupted: false, + result: { + status: "fulfilled", + value: { + environmentId: input.environmentId, + isPrimary: input.isPrimary, + driver: input.target.driver, + instanceId: input.target.instanceId, + provider, + }, }, }; } @@ -92,6 +104,125 @@ function toProviderUpdateOutcome(input: { // update (npm installs routinely run tens of seconds) is never cut off and left // showing a dead, unresponsive Update button. const PENDING_EXPIRY_MS = 6 * 60_000; +const UPDATE_INTERRUPTED_MESSAGE = "Provider update was interrupted. Try again."; +const UPDATE_TIMED_OUT_MESSAGE = "Update timed out. Try again."; + +export interface ProviderUpdateResultClaim { + readonly environmentId: EnvironmentId; + readonly generation: number; + readonly providerInstanceIds: ReadonlySet; + readonly providerCount: number; + readonly startedAfterIso: string; +} + +interface ActiveProviderUpdateResultClaim extends ProviderUpdateResultClaim { + readonly timeout: ReturnType; +} + +function isProviderUpdateSnapshotAfter(provider: ServerProvider, startedAfterIso: string): boolean { + const state = provider.updateState; + if (state?.startedAt === null || state?.startedAt === undefined) { + return false; + } + if (state.startedAt < startedAfterIso) { + return false; + } + if (state.status === "failed" || state.status === "succeeded" || state.status === "unchanged") { + return state.finishedAt !== null && state.finishedAt >= startedAfterIso; + } + return state.finishedAt === null || state.finishedAt >= startedAfterIso; +} + +function getTerminalProviderUpdateView( + groups: ReadonlyArray, + claim: ProviderUpdateResultClaim, +): ProviderUpdateToastView | null { + const group = groups.find((candidate) => candidate.environmentId === claim.environmentId); + if (!group) { + return null; + } + const providers = group.providers.filter( + (provider) => + claim.providerInstanceIds.has(provider.instanceId) && + isProviderUpdateSnapshotAfter(provider, claim.startedAfterIso), + ); + const view = getProviderUpdateProgressToastView({ + providers, + providerCount: claim.providerCount, + }); + return isTerminalProviderUpdatePhase(view.phase) ? view : null; +} + +/** + * Keep terminal-result claims in the notification host so dismissing the + * popover cannot discard an in-flight update's result. The row still owns all + * visible progress while the popover remains open. + */ +export function createProviderUpdateResultDelivery(input: { + readonly isPopoverOpen: () => boolean; + readonly onResult: (view: ProviderUpdateToastView) => void; +}) { + const activeUpdates = new Map(); + let latestGroups: ReadonlyArray = []; + + const finishUpdate = ( + environmentId: EnvironmentId, + generation: number, + view: ProviderUpdateToastView, + ): boolean => { + const activeUpdate = activeUpdates.get(environmentId); + if (activeUpdate && activeUpdate.generation !== generation) { + return false; + } + const timeout = activeUpdate?.timeout; + if (!activeUpdates.delete(environmentId)) { + return false; + } + if (timeout !== undefined) { + clearTimeout(timeout); + } + if (!input.isPopoverOpen()) { + input.onResult(view); + } + return true; + }; + + const startUpdate = (claim: ProviderUpdateResultClaim): void => { + const previous = activeUpdates.get(claim.environmentId); + if (previous) { + clearTimeout(previous.timeout); + } + const timeout = setTimeout(() => { + const liveView = getTerminalProviderUpdateView(latestGroups, claim); + finishUpdate( + claim.environmentId, + claim.generation, + liveView ?? + getProviderUpdateRejectedToastView(claim.providerCount, UPDATE_TIMED_OUT_MESSAGE), + ); + }, PENDING_EXPIRY_MS); + activeUpdates.set(claim.environmentId, { ...claim, timeout }); + }; + + const observeGroups = (groups: ReadonlyArray): void => { + latestGroups = groups; + for (const claim of activeUpdates.values()) { + const view = getTerminalProviderUpdateView(groups, claim); + if (view) { + finishUpdate(claim.environmentId, claim.generation, view); + } + } + }; + + const dispose = (): void => { + for (const claim of activeUpdates.values()) { + clearTimeout(claim.timeout); + } + activeUpdates.clear(); + }; + + return { dispose, finishUpdate, observeGroups, startUpdate }; +} function rowToneClass(kind: ProviderUpdateRowStatusKind): string { switch (kind) { @@ -158,9 +289,19 @@ function EnvironmentUpdateRow({ */ export function ProviderUpdateEnvironmentRows({ onInteract, + onUpdateFinished, + onUpdateStarted, }: { - /** Called the first time the user triggers an update, so the host can stop refreshing the prompt. */ + /** Called when the user triggers an update, so the host keeps this popover open. */ readonly onInteract?: () => void; + /** Hands terminal command results to the host-owned exactly-once claim. */ + readonly onUpdateFinished?: ( + environmentId: EnvironmentId, + generation: number, + view: ProviderUpdateToastView, + ) => void; + /** Registers result delivery before dispatching an environment's updates. */ + readonly onUpdateStarted?: (claim: ProviderUpdateResultClaim) => void; }) { const { groups } = useLocalEnvironmentUpdateGroups(); const updateProvider = useAtomCommand(serverEnvironment.updateProvider, { @@ -170,9 +311,16 @@ export function ProviderUpdateEnvironmentRows({ () => new Map(groups.map((group) => [group.environmentId, group] as const)), [groups], ); + const latestGroupsRef = useRef(groups); + latestGroupsRef.current = groups; - // Only surface results that land after this popover opened. - const visibleAfterIsoRef = useRef(new Date().toISOString()); + // Before an environment is updated, ignore terminal state from before this + // popover opened. Once dispatched, use that attempt's start time instead. + const popoverOpenedAfterIsoRef = useRef(new Date().toISOString()); + const visibleAfterIsoByEnvironmentRef = useRef>(new Map()); + const trackedProviderIdsByEnvironmentRef = useRef< + Map> + >(new Map()); // Synchronous re-entry guard. setPendingEnvironments is an async state update, // and PENDING_EXPIRY_MS can clear the spinner while a request is still in @@ -223,12 +371,23 @@ export function ProviderUpdateEnvironmentRows({ requestVersionRef.current.set(environmentId, requestVersion); const isCurrentRequest = () => requestVersionRef.current.get(environmentId) === requestVersion; + const startedAfterIso = new Date().toISOString(); + visibleAfterIsoByEnvironmentRef.current.set(environmentId, startedAfterIso); onInteract?.(); const providerCount = group.candidates.length; const targets = group.candidates.map((candidate) => ({ driver: candidate.driver, instanceId: candidate.instanceId, })); + const providerInstanceIds = new Set(targets.map((target) => target.instanceId)); + trackedProviderIdsByEnvironmentRef.current.set(environmentId, providerInstanceIds); + onUpdateStarted?.({ + environmentId, + generation: requestVersion, + providerCount, + providerInstanceIds, + startedAfterIso, + }); setPendingEnvironments((previous) => new Set(previous).add(environmentId)); setErrorByEnvironment((previous) => { @@ -260,15 +419,32 @@ export function ProviderUpdateEnvironmentRows({ // rather than silently reverting to idle. inFlightEnvironmentsRef.current.delete(environmentId); clearPending(environmentId); + const liveView = getTerminalProviderUpdateView(latestGroupsRef.current, { + environmentId, + generation: requestVersion, + providerCount, + providerInstanceIds, + startedAfterIso, + }); + if (liveView) { + setResultByEnvironment((previous) => new Map(previous).set(environmentId, liveView)); + onUpdateFinished?.(environmentId, requestVersion, liveView); + return; + } setErrorByEnvironment((previous) => - new Map(previous).set(environmentId, "Update timed out — try again."), + new Map(previous).set(environmentId, UPDATE_TIMED_OUT_MESSAGE), + ); + onUpdateFinished?.( + environmentId, + requestVersion, + getProviderUpdateRejectedToastView(providerCount, UPDATE_TIMED_OUT_MESSAGE), ); }, PENDING_EXPIRY_MS); try { // Dispatch each candidate's update to this environment's own backend and // normalize every settled outcome into the multi-backend reducer shape. - const results = await Promise.all( - targets.map(async (target): Promise> => { + const dispatchOutcomes = await Promise.all( + targets.map(async (target): Promise => { try { const result = await updateProvider({ environmentId, @@ -282,8 +458,11 @@ export function ProviderUpdateEnvironmentRows({ }); } catch (error) { return { - status: "rejected", - reason: error instanceof Error ? error : new Error("Provider update failed."), + interrupted: false, + result: { + status: "rejected", + reason: error instanceof Error ? error : new Error("Provider update failed."), + }, }; } }), @@ -304,45 +483,47 @@ export function ProviderUpdateEnvironmentRows({ next.delete(environmentId); return next; }); - if (results.length === 0) { - setErrorByEnvironment((previous) => - new Map(previous).set( - environmentId, - "This environment isn’t connected — try again once it reconnects.", - ), - ); - return; - } + const results = dispatchOutcomes.map((outcome) => outcome.result); const rejectedMessage = firstRejectedProviderUpdateMessage(results); if (rejectedMessage) { + const view = getProviderUpdateRejectedToastView(providerCount, rejectedMessage); setErrorByEnvironment((previous) => new Map(previous).set(environmentId, rejectedMessage), ); + onUpdateFinished?.(environmentId, requestVersion, view); return; } const view = getProviderUpdateProgressToastView({ - providers: collectProviderUpdateOutcomeSnapshots(results), + providers: collectProviderUpdateOutcomeSnapshots(results).filter((provider) => + isProviderUpdateSnapshotAfter(provider, startedAfterIso), + ), providerCount, }); - // Only persist a terminal outcome. A non-terminal ("running"/"initial") - // view means this dispatch could not confirm completion — e.g. a snapshot - // came back without its targeted instance (collectProviderUpdateOutcome- - // Snapshots drops null providers), which happens when the command is - // interrupted as a second backend connects and supersedes the in-flight - // update. A stored view never re-polls, so persisting it would pin the - // row's spinner forever once the pending flag expires. Drop it and let - // the live per-environment provider state (pill) plus the pending expiry - // drive the row, so it self-heals to whatever the backend actually did. + // Only persist a terminal outcome. A non-terminal snapshot never + // re-polls, so the live environment state must finish the row instead. if (isTerminalProviderUpdatePhase(view.phase)) { setResultByEnvironment((previous) => new Map(previous).set(environmentId, view)); + onUpdateFinished?.(environmentId, requestVersion, view); + return; + } + if (dispatchOutcomes.some((outcome) => outcome.interrupted)) { + const interruptedView = getProviderUpdateRejectedToastView( + providerCount, + UPDATE_INTERRUPTED_MESSAGE, + ); + setErrorByEnvironment((previous) => + new Map(previous).set(environmentId, UPDATE_INTERRUPTED_MESSAGE), + ); + onUpdateFinished?.(environmentId, requestVersion, interruptedView); } } catch (error) { if (isCurrentRequest()) { - setErrorByEnvironment((previous) => - new Map(previous).set( - environmentId, - error instanceof Error ? error.message : "Provider update failed.", - ), + const message = error instanceof Error ? error.message : "Provider update failed."; + setErrorByEnvironment((previous) => new Map(previous).set(environmentId, message)); + onUpdateFinished?.( + environmentId, + requestVersion, + getProviderUpdateRejectedToastView(providerCount, message), ); } } finally { @@ -355,27 +536,45 @@ export function ProviderUpdateEnvironmentRows({ } } }, - [clearPending, groupByEnvironment, onInteract, updateProvider], + [ + clearPending, + groupByEnvironment, + onInteract, + onUpdateFinished, + onUpdateStarted, + updateProvider, + ], ); const rows = groups - .map((group) => ({ - group, - status: resolveEnvironmentUpdateRowStatus({ + .map((group) => { + const trackedProviderIds = trackedProviderIdsByEnvironmentRef.current.get( + group.environmentId, + ); + const visibleAfterIso = + visibleAfterIsoByEnvironmentRef.current.get(group.environmentId) ?? + popoverOpenedAfterIsoRef.current; + const liveProviders = trackedProviderIds + ? group.providers.filter( + (provider) => + trackedProviderIds.has(provider.instanceId) && + isProviderUpdateSnapshotAfter(provider, visibleAfterIso), + ) + : group.candidates; + return { group, - error: errorByEnvironment.get(group.environmentId), - result: resultByEnvironment.get(group.environmentId), - // Derive the live pill from the candidates this row is actually - // tracking, not every provider in the environment. Otherwise an - // unrelated provider's recent success (or one candidate succeeding while - // another was interrupted) makes the pill report success and hides the - // Update action for candidates that are still outdated. - pill: getProviderUpdateSidebarPillView(group.candidates, { - visibleAfterIso: visibleAfterIsoRef.current, + status: resolveEnvironmentUpdateRowStatus({ + group, + error: errorByEnvironment.get(group.environmentId), + result: resultByEnvironment.get(group.environmentId), + // Before dispatch, only candidates can contribute state. Afterward, + // keep tracking those instance ids even when a successful update + // removes them from the candidate set. + pill: getProviderUpdateSidebarPillView(liveProviders, { visibleAfterIso }), + isPending: pendingEnvironments.has(group.environmentId), }), - isPending: pendingEnvironments.has(group.environmentId), - }), - })) + }; + }) .filter(({ group, status }) => group.candidates.length > 0 || status.kind !== "idle"); if (rows.length === 0) { diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.test.tsx b/apps/web/src/components/ProviderUpdateLaunchNotification.test.tsx new file mode 100644 index 000000000000..eff4711625a8 --- /dev/null +++ b/apps/web/src/components/ProviderUpdateLaunchNotification.test.tsx @@ -0,0 +1,368 @@ +import type { Dispatch, ReactElement, SetStateAction } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { + type EnvironmentId, + ProviderDriverKind, + ProviderInstanceId, + type ServerProvider, +} from "@t3tools/contracts"; +import { AsyncResult } from "effect/unstable/reactivity"; + +import type { + LocalEnvironmentUpdateGroup, + ProviderUpdateCandidate, + ProviderUpdateRowStatus, +} from "./ProviderUpdateLaunchNotification.logic"; + +const testState = vi.hoisted(() => ({ + addProviderUpdateToast: vi.fn(), + dismissNotificationKey: vi.fn(), + dismissedNotificationKeys: new Set(), + groups: [] as LocalEnvironmentUpdateGroup[], + navigate: vi.fn(), + toastAdd: vi.fn(), + toastClose: vi.fn(), + updateProvider: vi.fn(), +})); + +const hooks = vi.hoisted(() => { + interface HookSlot { + cleanup: (() => void) | undefined; + deps: ReadonlyArray | undefined; + hasValue: boolean; + value: unknown; + } + + interface HookContext { + cursor: number; + readonly slots: HookSlot[]; + } + + const contexts = new Map(); + let currentKey: string | null = null; + + const current = (): HookContext => { + if (currentKey === null) { + throw new Error("Hook called outside a test render."); + } + let context = contexts.get(currentKey); + if (!context) { + context = { cursor: 0, slots: [] }; + contexts.set(currentKey, context); + } + return context; + }; + + const nextSlot = (): HookSlot => { + const context = current(); + const index = context.cursor++; + return (context.slots[index] ??= { + cleanup: undefined, + deps: undefined, + hasValue: false, + value: undefined, + }); + }; + + const sameDeps = ( + previous: ReadonlyArray | undefined, + next: ReadonlyArray | undefined, + ): boolean => + previous !== undefined && + next !== undefined && + previous.length === next.length && + previous.every((value, index) => Object.is(value, next[index])); + + function memo(factory: () => T, deps?: ReadonlyArray): T { + const slot = nextSlot(); + if (slot.hasValue && sameDeps(slot.deps, deps)) { + return slot.value as T; + } + const value = factory(); + slot.deps = deps; + slot.hasValue = true; + slot.value = value; + return value; + } + + return { + render(key: string, render: () => T): T { + currentKey = key; + current().cursor = 0; + try { + return render(); + } finally { + currentKey = null; + } + }, + reset(): void { + for (const context of contexts.values()) { + for (const slot of context.slots) { + slot.cleanup?.(); + } + } + contexts.clear(); + currentKey = null; + }, + useCallback(callback: T, deps?: ReadonlyArray): T { + return memo(() => callback, deps); + }, + useEffect(effect: () => void | (() => void), deps?: ReadonlyArray): void { + const slot = nextSlot(); + if (slot.hasValue && sameDeps(slot.deps, deps)) { + return; + } + slot.cleanup?.(); + slot.cleanup = effect() ?? undefined; + slot.deps = deps; + slot.hasValue = true; + }, + useMemo: memo, + useMemoCache(size: number): unknown[] { + return memo( + () => Array.from({ length: size }, () => Symbol.for("react.memo_cache_sentinel")), + [], + ); + }, + useRef(initialValue: T): { current: T } { + return memo(() => ({ current: initialValue }), []); + }, + useState(initialValue: T | (() => T)): [T, Dispatch>] { + const slot = nextSlot(); + if (!slot.hasValue) { + slot.value = + typeof initialValue === "function" ? (initialValue as () => T)() : initialValue; + slot.hasValue = true; + } + const setValue: Dispatch> = (nextValue) => { + slot.value = + typeof nextValue === "function" + ? (nextValue as (previous: T) => T)(slot.value as T) + : nextValue; + }; + return [slot.value as T, setValue]; + }, + }; +}); + +vi.mock("react", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useCallback: hooks.useCallback, + useEffect: hooks.useEffect, + useMemo: hooks.useMemo, + useRef: hooks.useRef, + useState: hooks.useState, + }; +}); + +vi.mock("react/compiler-runtime", () => ({ c: hooks.useMemoCache })); +vi.mock("@tanstack/react-router", () => ({ useNavigate: () => testState.navigate })); +vi.mock("~/connection/desktopLocal", () => ({ isDesktopLocalConnectionTarget: () => false })); +vi.mock("~/state/environments", () => ({ useEnvironments: () => ({ environments: [] }) })); +vi.mock("~/state/server", () => ({ + serverEnvironment: { updateProvider: Symbol("updateProvider") }, +})); +vi.mock("~/state/use-atom-command", () => ({ + useAtomCommand: () => testState.updateProvider, +})); +vi.mock("../providerUpdateDismissal", () => ({ + useDismissedProviderUpdateNotificationKeys: () => ({ + dismissedNotificationKeys: testState.dismissedNotificationKeys, + dismissNotificationKey: testState.dismissNotificationKey, + }), +})); +vi.mock("./ProviderUpdateLaunchNotification.environments", () => ({ + useLocalEnvironmentUpdateGroups: () => ({ + groups: testState.groups, + isAnySettling: false, + }), +})); +vi.mock("./ProviderUpdatePrimaryNotification", () => ({ + addProviderUpdateToast: testState.addProviderUpdateToast, + ProviderUpdatePrimaryNotification: () => null, +})); +vi.mock("./ui/toast", () => ({ + stackedThreadToast: (toast: unknown) => toast, + toastManager: { + add: testState.toastAdd, + close: testState.toastClose, + }, +})); + +import { ProviderUpdateEnvironmentRows } from "./ProviderUpdateEnvironmentRows"; +import { ProviderUpdateEnvironmentsNotification } from "./ProviderUpdateLaunchNotification"; + +let fixtureIndex = 0; + +function provider( + environmentId: EnvironmentId, + updateStatus?: "succeeded" | "unchanged", +): ServerProvider { + const succeeded = updateStatus === "succeeded"; + const suffix = environmentId as string; + const result: ServerProvider = { + instanceId: ProviderInstanceId.make(`codex-${suffix}`), + driver: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + version: succeeded ? "1.1.0" : "1.0.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-06-26T12:00:00.000Z", + models: [], + slashCommands: [], + skills: [], + versionAdvisory: { + status: succeeded ? "current" : "behind_latest", + currentVersion: succeeded ? "1.1.0" : "1.0.0", + latestVersion: "1.1.0", + updateCommand: "npm install -g @openai/codex@latest", + canUpdate: true, + checkedAt: "2026-06-26T12:00:00.000Z", + message: succeeded ? "Up to date." : "Update available.", + }, + }; + return updateStatus + ? { + ...result, + updateState: { + status: updateStatus, + startedAt: "2026-06-26T12:00:01.000Z", + finishedAt: "2026-06-26T12:00:02.000Z", + message: "Provider update finished.", + output: null, + }, + } + : result; +} + +function setGroup(environmentId: EnvironmentId, snapshot = provider(environmentId)): void { + const candidate = provider(environmentId) as ProviderUpdateCandidate; + testState.groups = [ + { + environmentId, + label: "WSL", + isPrimary: false, + isSettling: false, + candidates: [candidate], + providers: [snapshot], + }, + ]; +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((complete) => { + resolve = complete; + }); + return { promise, resolve }; +} + +function renderHost(): void { + hooks.render("host", () => ProviderUpdateEnvironmentsNotification()); +} + +type RowElement = ReactElement<{ + readonly status: ProviderUpdateRowStatus; + readonly onUpdate: () => void; +}>; + +type PromptToast = { + readonly data: { readonly onClose: () => void }; + readonly description: ReactElement[0]>; +}; + +function promptToast(): PromptToast { + return testState.toastAdd.mock.calls[0]![0] as PromptToast; +} + +function renderRows(description: PromptToast["description"]): RowElement { + const output = hooks.render("rows", () => + ProviderUpdateEnvironmentRows(description.props), + ) as ReactElement<{ + readonly children: RowElement | RowElement[]; + }>; + const children = output.props.children; + return Array.isArray(children) ? children[0]! : children; +} + +async function flushPromises(): Promise { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); +} + +describe("ProviderUpdateEnvironmentsNotification result ownership", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-26T12:00:00.000Z")); + hooks.reset(); + testState.addProviderUpdateToast.mockReset(); + testState.dismissNotificationKey.mockReset(); + testState.dismissedNotificationKeys.clear(); + testState.navigate.mockReset(); + testState.toastAdd.mockReset().mockReturnValue(1); + testState.toastClose.mockReset(); + testState.updateProvider.mockReset(); + fixtureIndex += 1; + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("delivers an RPC-first unchanged result once after dismissal", async () => { + const environmentId = `env-rpc-${fixtureIndex}` as EnvironmentId; + const request = + deferred>>(); + setGroup(environmentId); + testState.updateProvider.mockReturnValue(request.promise); + renderHost(); + + const prompt = promptToast(); + renderRows(prompt.description).props.onUpdate(); + expect(testState.toastClose).not.toHaveBeenCalled(); + prompt.data.onClose(); + + const terminalProvider = provider(environmentId, "unchanged"); + request.resolve(AsyncResult.success({ providers: [terminalProvider] })); + await flushPromises(); + expect(testState.addProviderUpdateToast).toHaveBeenCalledTimes(1); + expect(testState.addProviderUpdateToast).toHaveBeenCalledWith( + expect.objectContaining({ view: expect.objectContaining({ phase: "unchanged" }) }), + ); + + setGroup(environmentId, terminalProvider); + renderHost(); + expect(testState.addProviderUpdateToast).toHaveBeenCalledTimes(1); + }); + + it("delivers a live-state-first unchanged result once after dismissal", async () => { + const environmentId = `env-live-${fixtureIndex}` as EnvironmentId; + const request = + deferred>>(); + setGroup(environmentId); + testState.updateProvider.mockReturnValue(request.promise); + renderHost(); + + const prompt = promptToast(); + renderRows(prompt.description).props.onUpdate(); + expect(testState.toastClose).not.toHaveBeenCalled(); + prompt.data.onClose(); + + const terminalProvider = provider(environmentId, "unchanged"); + setGroup(environmentId, terminalProvider); + expect(renderRows(prompt.description).props.status.kind).toBe("unchanged"); + renderHost(); + expect(testState.addProviderUpdateToast).toHaveBeenCalledTimes(1); + + request.resolve(AsyncResult.success({ providers: [terminalProvider] })); + await flushPromises(); + expect(testState.addProviderUpdateToast).toHaveBeenCalledTimes(1); + expect(testState.addProviderUpdateToast).toHaveBeenCalledWith( + expect.objectContaining({ view: expect.objectContaining({ phase: "unchanged" }) }), + ); + }); +}); diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.tsx b/apps/web/src/components/ProviderUpdateLaunchNotification.tsx index 2292a2c45103..d0159b3f7463 100644 --- a/apps/web/src/components/ProviderUpdateLaunchNotification.tsx +++ b/apps/web/src/components/ProviderUpdateLaunchNotification.tsx @@ -5,15 +5,22 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useEnvironments } from "~/state/environments"; import { isDesktopLocalConnectionTarget } from "~/connection/desktopLocal"; import { useDismissedProviderUpdateNotificationKeys } from "../providerUpdateDismissal"; -import { ProviderUpdateEnvironmentRows } from "./ProviderUpdateEnvironmentRows"; +import { + createProviderUpdateResultDelivery, + ProviderUpdateEnvironmentRows, +} from "./ProviderUpdateEnvironmentRows"; import { useLocalEnvironmentUpdateGroups } from "./ProviderUpdateLaunchNotification.environments"; import { collectProviderUpdateCandidates, environmentGroupsWithUpdates, getProviderUpdateInitialToastView, localEnvironmentUpdateNotificationKey, + type ProviderUpdateToastView, } from "./ProviderUpdateLaunchNotification.logic"; -import { ProviderUpdatePrimaryNotification } from "./ProviderUpdatePrimaryNotification"; +import { + addProviderUpdateToast, + ProviderUpdatePrimaryNotification, +} from "./ProviderUpdatePrimaryNotification"; import { stackedThreadToast, toastManager } from "./ui/toast"; /** @@ -55,7 +62,7 @@ type ProviderUpdateToastId = ReturnType; // suppress the primary's updates indefinitely. const SETTLING_GRACE_MS = 30_000; -function ProviderUpdateEnvironmentsNotification() { +export function ProviderUpdateEnvironmentsNotification() { const navigate = useNavigate(); const { groups, isAnySettling } = useLocalEnvironmentUpdateGroups(); const { dismissedNotificationKeys, dismissNotificationKey } = @@ -107,14 +114,39 @@ function ProviderUpdateEnvironmentsNotification() { }, [isAnySettling]); const isGated = isAnySettling && !settleGraceElapsed; - const openProviderSettings = useCallback(() => { - const active = activeToastRef.current; - if (active !== null) { - toastManager.close(active.toastId); - activeToastRef.current = null; - } - void navigate({ to: "/settings/providers" }); - }, [navigate]); + const openProviderSettings = useCallback( + (toastId?: ProviderUpdateToastId) => { + if (toastId !== undefined) { + toastManager.close(toastId); + } else { + const active = activeToastRef.current; + if (active !== null) { + toastManager.close(active.toastId); + activeToastRef.current = null; + } + } + void navigate({ to: "/settings/providers" }); + }, + [navigate], + ); + const reportUpdateResult = useCallback( + (view: ProviderUpdateToastView) => + addProviderUpdateToast({ view, openSettings: openProviderSettings }), + [openProviderSettings], + ); + const reportUpdateResultRef = useRef(reportUpdateResult); + reportUpdateResultRef.current = reportUpdateResult; + const [resultDelivery] = useState(() => + createProviderUpdateResultDelivery({ + isPopoverOpen: () => activeToastRef.current !== null, + onResult: (view) => reportUpdateResultRef.current(view), + }), + ); + + useEffect(() => resultDelivery.dispose, [resultDelivery]); + useEffect(() => { + resultDelivery.observeGroups(groups); + }, [groups, resultDelivery]); useEffect(() => { // Whether a fresh prompt can actually be shown for the current update set. @@ -169,12 +201,14 @@ function ProviderUpdateEnvironmentsNotification() { onInteract={() => { hasInteractedRef.current = true; }} + onUpdateFinished={resultDelivery.finishUpdate} + onUpdateStarted={resultDelivery.startUpdate} /> ), timeout: 0, actionProps: { children: "Settings", - onClick: openProviderSettings, + onClick: () => openProviderSettings(), }, actionVariant: "outline", data: { @@ -192,6 +226,7 @@ function ProviderUpdateEnvironmentsNotification() { dismissedNotificationKeys, dismissNotificationKey, openProviderSettings, + resultDelivery, ]); return null; diff --git a/apps/web/src/components/ProviderUpdatePrimaryNotification.tsx b/apps/web/src/components/ProviderUpdatePrimaryNotification.tsx index 639f07c38c13..0db1c51cc5cd 100644 --- a/apps/web/src/components/ProviderUpdatePrimaryNotification.tsx +++ b/apps/web/src/components/ProviderUpdatePrimaryNotification.tsx @@ -56,7 +56,7 @@ function ProviderUpdateToastIcon({ provider }: { provider: ProviderDriverKind }) ); } -function addProviderUpdateToast(input: { +export function addProviderUpdateToast(input: { readonly view: ProviderUpdateToastView; readonly openSettings: (toastId: ProviderUpdateToastId) => void; }) {