diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 6d1906ff975c..c6e3f62a2d67 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -339,6 +339,7 @@ import { ThreadErrorBanner, } from "./chat/ThreadErrorBanner"; import type { ComposerBannerStackItem } from "./chat/ComposerBannerStack"; +import { useComposerProviderUpdateBannerItem } from "./chat/ComposerProviderUpdateNotice"; import { ComposerSurface } from "./chat/ComposerSurface"; import { hasAvailableCompactionProvider, @@ -2345,6 +2346,7 @@ export default function ChatView(props: ChatViewProps) { const serverUpdateFailureDismissed = serverUpdateState === dismissedServerUpdateState || isServerUpdateFailureDismissed(serverUpdateState); + const providerUpdateBannerItem = useComposerProviderUpdateBannerItem(serverUpdateEnvironmentId); const systemComposerBannerItems = useMemo(() => { const items: ComposerBannerStackItem[] = []; const updateRunning = serverUpdateState.status === "running"; @@ -2495,9 +2497,14 @@ export default function ChatView(props: ChatViewProps) { }), }); } + // After the server notice, so a version skew keeps priority over a provider one. + if (providerUpdateBannerItem) { + items.push(providerUpdateBannerItem); + } return items; }, [ activeEnvironmentUnavailableState, + providerUpdateBannerItem, reconnectWarningGraceElapsed, handleReconnectActiveEnvironment, navigate, diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.environments.ts b/apps/web/src/components/ProviderUpdateLaunchNotification.environments.ts index 2fb9c16b654a..491a9e0e4d8f 100644 --- a/apps/web/src/components/ProviderUpdateLaunchNotification.environments.ts +++ b/apps/web/src/components/ProviderUpdateLaunchNotification.environments.ts @@ -18,7 +18,7 @@ import { * loopback with a bearer token and carries a `local:` * connection id. SSH, relay, and other remote targets are excluded. */ -function isLocalConnectionTarget(target: ConnectionCatalogEntry["target"]): boolean { +export function isLocalConnectionTarget(target: ConnectionCatalogEntry["target"]): boolean { return target._tag === "PrimaryConnectionTarget" || isDesktopLocalConnectionTarget(target); } diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts index 12f63f3e16fd..880d2a26ab8e 100644 --- a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts +++ b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts @@ -10,6 +10,7 @@ import { AsyncResult } from "effect/unstable/reactivity"; import { buildLocalEnvironmentUpdateGroups, + buildRemoteProviderUpdateNotice, canOneClickUpdateProviderCandidate, collectProviderUpdateCandidates, collectProviderUpdateOutcomeSnapshots, @@ -1038,3 +1039,154 @@ describe("provider update launch notification logic", () => { }); }); }); + +describe("remote environment provider update notice", () => { + const base = { + environmentId: "env-remote" as EnvironmentId, + environmentLabel: "office-linux", + dismissedKeys: new Set(), + }; + + it("stays quiet when there is no one-click update to offer", () => { + expect( + buildRemoteProviderUpdateNotice({ + ...base, + providers: [ + provider({ driver: driver("codex"), advisoryStatus: "current", latestVersion: null }), + provider({ driver: driver("cursor"), enabled: false }), + provider({ driver: driver("claude"), updateCommand: null }), + ], + }), + ).toBeNull(); + }); + + it("names the provider, version and environment, and targets the driver once", () => { + const notice = buildRemoteProviderUpdateNotice({ + ...base, + providers: [ + provider({ + driver: driver("codex"), + instanceId: instanceId("codex"), + latestVersion: "1.1.0", + }), + provider({ + driver: driver("codex"), + instanceId: instanceId("codex_work"), + latestVersion: "1.1.0", + }), + ], + }); + expect(notice).toMatchObject({ + title: "Codex v1.1.0 is available on office-linux", + status: "idle", + failureMessage: null, + }); + expect(notice?.candidates).toHaveLength(1); + }); + + it("lists distinct providers in one notice", () => { + expect( + buildRemoteProviderUpdateNotice({ + ...base, + providers: [ + provider({ driver: driver("codex"), latestVersion: "1.1.0" }), + provider({ driver: driver("cursor"), latestVersion: "0.3.0" }), + ], + })?.title, + ).toBe("Codex and Cursor updates are available on office-linux"); + }); + + it("stays dismissed until a newer version arrives", () => { + const providers = [provider({ driver: driver("codex"), latestVersion: "1.1.0" })]; + const dismissedKeys = new Set(["env-remote|codex:1.1.0"]); + expect(buildRemoteProviderUpdateNotice({ ...base, providers })?.dismissalKey).toBe( + "env-remote|codex:1.1.0", + ); + expect(buildRemoteProviderUpdateNotice({ ...base, providers, dismissedKeys })).toBeNull(); + expect( + buildRemoteProviderUpdateNotice({ + ...base, + dismissedKeys, + providers: [provider({ driver: driver("codex"), latestVersion: "1.2.0" })], + })?.dismissalKey, + ).toBe("env-remote|codex:1.2.0"); + }); + + it("reports live update progress from the environment", () => { + expect( + buildRemoteProviderUpdateNotice({ + ...base, + providers: [ + provider({ + driver: driver("codex"), + updateState: { + status: "running", + startedAt: checkedAt, + finishedAt: null, + message: null, + output: null, + }, + }), + ], + }), + ).toMatchObject({ status: "running" }); + }); + + it("reports progress from a sibling instance of the same driver", () => { + expect( + buildRemoteProviderUpdateNotice({ + ...base, + providers: [ + provider({ driver: driver("codex") }), + provider({ + driver: driver("codex"), + instanceId: instanceId("codex_work"), + updateState: { + status: "queued", + startedAt: checkedAt, + finishedAt: null, + message: null, + output: null, + }, + }), + ], + }), + ).toMatchObject({ status: "running" }); + }); + + it("keeps a retry visible after a failed or unchanged update, failure first", () => { + const settledState = (status: "failed" | "unchanged", message: string) => ({ + status, + startedAt: checkedAt, + finishedAt: laterCheckedAt, + message, + output: null, + }); + expect( + buildRemoteProviderUpdateNotice({ + ...base, + providers: [ + provider({ + driver: driver("codex"), + updateState: settledState("unchanged", "codex is still on v1.0.0"), + }), + ], + }), + ).toMatchObject({ status: "failed", failureMessage: "codex is still on v1.0.0" }); + expect( + buildRemoteProviderUpdateNotice({ + ...base, + providers: [ + provider({ + driver: driver("codex"), + updateState: settledState("unchanged", "codex is still on v1.0.0"), + }), + provider({ + driver: driver("cursor"), + updateState: settledState("failed", "npm exited with 1"), + }), + ], + }), + ).toMatchObject({ status: "failed", failureMessage: "npm exited with 1" }); + }); +}); diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts index 6da4eaac6dde..ff0decbcbae0 100644 --- a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts +++ b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts @@ -780,3 +780,65 @@ export function resolveEnvironmentUpdateRowStatus(input: { } return { kind: "idle", text: environmentProviderNames(input.group) }; } + +// Local environments get the launch popover above; remote ones (SSH, relay, T3 +// Connect) never do, so the same one-click update becomes a composer notice. + +export interface RemoteProviderUpdateNotice { + /** Covers environment, driver and latest version, so a newer release re-surfaces. */ + readonly dismissalKey: string; + readonly title: string; + readonly status: "idle" | "running" | "failed"; + readonly failureMessage: string | null; + /** One per driver: instances of a driver share an installer, so one dispatch covers them all. */ + readonly candidates: ProviderUpdateCandidate[]; +} + +/** + * The composer notice for one remote environment, or null when there is nothing + * to offer: nothing outdated, an update this environment cannot run itself + * (provider settings still show the manual command), or a candidate set the user + * already dismissed. + */ +export function buildRemoteProviderUpdateNotice(input: { + readonly environmentId: EnvironmentId; + readonly environmentLabel: string; + readonly providers: ReadonlyArray; + readonly dismissedKeys: ReadonlySet; +}): RemoteProviderUpdateNotice | null { + // Deliberately not canOneClickUpdateProviderCandidate: an update already in + // flight must keep its notice so the banner can report progress. + const candidates = collectProviderUpdateCandidates(input.providers).filter((candidate) => + hasOneClickUpdateProviderCandidate(candidate, input.providers), + ); + const [first] = candidates; + const notificationKey = providerUpdateNotificationKey(candidates); + if (first === undefined || notificationKey === null) { + return null; + } + const dismissalKey = `${input.environmentId}|${notificationKey}`; + if (input.dismissedKeys.has(dismissalKey)) { + return null; + } + + // Progress and outcome come from every instance of a candidate driver: the + // deduped representative may be idle while a sibling instance is mid-update. + const driverProviders = input.providers.filter((provider) => + candidates.some((candidate) => candidate.driver === provider.driver), + ); + // A real failure outranks an update that ran but changed nothing. + const settled = + driverProviders.find((provider) => provider.updateState?.status === "failed") ?? + driverProviders.find((provider) => provider.updateState?.status === "unchanged"); + const providerName = PROVIDER_DISPLAY_NAMES[first.driver] ?? first.driver; + return { + dismissalKey, + title: + candidates.length > 1 + ? `${formatProviderList(candidates)} updates are available on ${input.environmentLabel}` + : `${providerName} ${formatVersion(first.versionAdvisory.latestVersion)} is available on ${input.environmentLabel}`, + status: driverProviders.some(isProviderUpdateActive) ? "running" : settled ? "failed" : "idle", + failureMessage: settled?.updateState?.message ?? null, + candidates, + }; +} diff --git a/apps/web/src/components/chat/ComposerProviderUpdateNotice.tsx b/apps/web/src/components/chat/ComposerProviderUpdateNotice.tsx new file mode 100644 index 000000000000..3683e4fa9905 --- /dev/null +++ b/apps/web/src/components/chat/ComposerProviderUpdateNotice.tsx @@ -0,0 +1,101 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import { useMemo } from "react"; + +import { useEnvironment } from "~/state/environments"; +import { serverEnvironment } from "~/state/server"; +import { useAtomCommand } from "~/state/use-atom-command"; +import { useDismissedProviderUpdateNotificationKeys } from "../../providerUpdateDismissal"; +import { isLocalConnectionTarget } from "../ProviderUpdateLaunchNotification.environments"; +import { buildRemoteProviderUpdateNotice } from "../ProviderUpdateLaunchNotification.logic"; +import { Button } from "../ui/button"; +import type { ComposerBannerStackItem } from "./ComposerBannerStack"; +import { ComposerServerUpdateIcon } from "./ComposerServerUpdateStatus"; + +/** + * The provider update notice for a remote environment (SSH, relay, T3 Connect). + * Local environments already get the one-click update from the launch popover, + * so they are skipped here and never double-notified. + * + * Progress comes only from the environment's published `updateState`: the + * backend marks a target queued the moment it accepts the dispatch and refuses a + * second one for the same instance, so there is nothing for optimistic client + * state to cover. + */ +export function useComposerProviderUpdateBannerItem( + environmentId: EnvironmentId | null, +): ComposerBannerStackItem | null { + const environment = useEnvironment(environmentId); + const { dismissedNotificationKeys, dismissNotificationKey } = + useDismissedProviderUpdateNotificationKeys(); + const updateProvider = useAtomCommand(serverEnvironment.updateProvider); + + const environmentLabel = environment?.label; + const target = environment?.entry.target; + // Providers survive a disconnect, so offering an update we cannot dispatch + // needs the live phase to rule it out. + const isConnected = environment?.connection.phase === "connected"; + const providers = environment?.serverConfig?.providers; + + return useMemo(() => { + if ( + environmentId === null || + environmentLabel === undefined || + target === undefined || + isLocalConnectionTarget(target) || + !isConnected + ) { + return null; + } + const notice = buildRemoteProviderUpdateNotice({ + environmentId, + environmentLabel, + providers: providers ?? [], + dismissedKeys: dismissedNotificationKeys, + }); + if (notice === null) { + return null; + } + const { status } = notice; + return { + id: `provider-update:${environmentId}`, + variant: status === "failed" ? "error" : "default", + // Match the server notice: progress outranks passive notices. + priority: status === "running" ? "urgent" : "notice", + icon: , + title: notice.title, + description: notice.failureMessage ?? undefined, + actions: ( + + ), + ...(status === "running" + ? {} + : { + dismissLabel: "Dismiss provider update notice", + onDismiss: () => dismissNotificationKey(notice.dismissalKey), + }), + }; + }, [ + dismissNotificationKey, + dismissedNotificationKeys, + environmentId, + environmentLabel, + isConnected, + providers, + target, + updateProvider, + ]); +} diff --git a/docs/user/install.md b/docs/user/install.md index 17e9291bf1a0..6a4acc147665 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -86,7 +86,9 @@ available version. **Update now** appears only when T3 Code can tell which installer owns the CLI (its own update command, Homebrew, or a global npm, pnpm, bun, or Vite+ install) and runs that installer. Otherwise update the CLI the same way you installed it. Homebrew installs compare against the version Homebrew -offers, which can trail the npm release by a few hours. +offers, which can trail the npm release by a few hours. When the thread you have +open runs on a remote environment, **Update now** is also offered above the +message box; dismiss it there and it returns for the next release. Add another provider instance for a separate account or configuration. Each instance can have its own environment variables, such as API keys or a custom