History

Add targeted provider compatibility updates

- Generate provider-specific install commands from compatibility advisories - Surface provider update actions in the chat UI and block sends when unavailable - Skip stale advisories for disabled providers before version probing

Julius Marminge committed 6 months ago

Commit b924b401 parent 4beb69222 files · +925 −244Browse files

Unified · Split

22 files
  1. apps/server/src/provider/Drivers/ClaudeDriver.ts +6 −1
  2. apps/server/src/provider/Drivers/CodexDriver.ts +6 −1
  3. apps/server/src/provider/Drivers/OpenCodeDriver.ts +6 −1
  4. apps/server/src/provider/Layers/CursorProvider.ts +6 −1
  5. apps/server/src/provider/ProviderCompatibility.test.ts +69 −11
  6. apps/server/src/provider/ProviderCompatibility.ts +55 −9
  7. apps/server/src/provider/providerMaintenance.test.ts +19 −0
  8. apps/server/src/provider/providerMaintenance.ts +26 −0
  9. apps/server/src/provider/providerMaintenanceRunner.test.ts +30 −0
  10. apps/server/src/provider/providerMaintenanceRunner.ts +9 −2
  11. apps/web/src/components/ChatView.tsx +192 −29
  12. apps/web/src/components/chat/ChatComposer.tsx +31 −5
  13. apps/web/src/components/chat/ComposerPrimaryActions.tsx +29 −14
  14. apps/web/src/components/chat/ProviderStatusBanner.tsx +0 −34
  15. apps/web/src/components/chat/ThreadErrorBanner.tsx +3 −2
  16. apps/web/src/components/settings/ProviderInstanceCard.tsx +203 −128
  17. apps/web/src/components/settings/SettingsPanels.browser.tsx +58 −1
  18. apps/web/src/components/settings/SettingsPanels.tsx +83 −0
  19. apps/web/src/components/settings/providerStatus.test.ts +31 −1
  20. apps/web/src/components/settings/providerStatus.ts +56 −0
  21. packages/contracts/src/server.ts +3 −0
  22. provider-compatibility.v1.json +4 −4
apps/server/src/provider/Drivers/ClaudeDriver.ts+6−1
@@ -174,7 +174,12 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = { · 173 unmodified lines
174174 checkProvider,
175175 enrichSnapshot: ({ snapshot, publishSnapshot }) =>
176176 enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities).pipe(
177− Effect.flatMap(ProviderCompatibility.enrichProviderSnapshotWithCompatibilityAdvisory),
177+ Effect.flatMap((snapshot) =>
178+ ProviderCompatibility.enrichProviderSnapshotWithTargetedCompatibilityAdvisory(
179+ snapshot,
180+ maintenanceCapabilities,
181+ ),
182+ ),
178183 Effect.provideService(HttpClient.HttpClient, httpClient),
179184 Effect.provideService(
180185 ProviderCompatibility.ProviderCompatibilityService,
apps/server/src/provider/Drivers/CodexDriver.ts+6−1
@@ -174,7 +174,12 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = { · 173 unmodified lines
174174 checkProvider,
175175 enrichSnapshot: ({ snapshot, publishSnapshot }) =>
176176 enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities).pipe(
177− Effect.flatMap(ProviderCompatibility.enrichProviderSnapshotWithCompatibilityAdvisory),
177+ Effect.flatMap((snapshot) =>
178+ ProviderCompatibility.enrichProviderSnapshotWithTargetedCompatibilityAdvisory(
179+ snapshot,
180+ maintenanceCapabilities,
181+ ),
182+ ),
178183 Effect.provideService(HttpClient.HttpClient, httpClient),
179184 Effect.provideService(
180185 ProviderCompatibility.ProviderCompatibilityService,
apps/server/src/provider/Drivers/OpenCodeDriver.ts+6−1
@@ -153,7 +153,12 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv> · 152 unmodified lines
153153 checkProvider,
154154 enrichSnapshot: ({ snapshot, publishSnapshot }) =>
155155 enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities).pipe(
156− Effect.flatMap(ProviderCompatibility.enrichProviderSnapshotWithCompatibilityAdvisory),
156+ Effect.flatMap((snapshot) =>
157+ ProviderCompatibility.enrichProviderSnapshotWithTargetedCompatibilityAdvisory(
158+ snapshot,
159+ maintenanceCapabilities,
160+ ),
161+ ),
157162 Effect.provideService(HttpClient.HttpClient, httpClient),
158163 Effect.provideService(
159164 ProviderCompatibility.ProviderCompatibilityService,
apps/server/src/provider/Layers/CursorProvider.ts+6−1
@@ -1247,7 +1247,12 @@ export const enrichCursorSnapshot = (input: { · 1246 unmodified lines
12471247 snapshot,
12481248 input.maintenanceCapabilities,
12491249 ).pipe(
1250− Effect.flatMap(ProviderCompatibility.enrichProviderSnapshotWithCompatibilityAdvisory),
1250+ Effect.flatMap((enrichedSnapshot) =>
1251+ ProviderCompatibility.enrichProviderSnapshotWithTargetedCompatibilityAdvisory(
1252+ enrichedSnapshot,
1253+ input.maintenanceCapabilities,
1254+ ),
1255+ ),
12511256 Effect.provideService(HttpClient.HttpClient, input.httpClient),
12521257 Effect.flatMap((enrichedSnapshot) =>
12531258 publishSnapshot(stampIdentity(enrichedSnapshot)).pipe(Effect.as(enrichedSnapshot)),
apps/server/src/provider/ProviderCompatibility.test.ts+69−11
@@ -5,6 +5,7 @@ import * as Layer from "effect/Layer"; · 4 unmodified lines
55 import { HttpClient, HttpClientResponse } from "effect/unstable/http";
66
77 import * as ProviderCompatibility from "./ProviderCompatibility.ts";
8+import { makeProviderMaintenanceCapabilities } from "./providerMaintenance.ts";
89
910 const codexDriver = ProviderDriverKind.make("codex");
1011 const claudeDriver = ProviderDriverKind.make("claudeAgent");
@@ -26,6 +27,14 @@ const baseProvider: ServerProvider = { · 15 unmodified lines
2627 skills: [],
2728 };
2829
30+const codexNpmUpdateCapabilities = makeProviderMaintenanceCapabilities({
31+ provider: codexDriver,
32+ packageName: "@openai/codex",
33+ updateExecutable: "npm",
34+ updateArgs: ["install", "-g", "@openai/codex@latest"],
35+ updateLockKey: "npm-global",
36+});
37+
2938 function jsonHttpClient(
3039 responseForUrl: (url: string) => { readonly payload: unknown; readonly status?: number },
3140 ): HttpClient.HttpClient {
@@ -87,12 +96,57 @@ describe("provider compatibility", () => { · 55 unmodified lines
8796 });
8897 });
8998
90− it("classifies the initial hosted compatibility policies for T3 Code 0.0.24+", () => {
99+ it("adds a targeted compatibility update command when a recommended version is available", () => {
91100 const document: ProviderCompatibility.ProviderCompatibilityDocument = {
92101 version: 1,
93102 policies: [
94103 {
95− t3CodeRange: ">=0.0.24",
104+ t3CodeRange: ">=0.0.0",
105+ driver: codexDriver,
106+ recommendedRange: ">=0.129.0",
107+ recommendedVersion: "0.129.0",
108+ ranges: [{ status: "broken", range: "<0.129.0" }],
109+ },
110+ ],
111+ };
112+
113+ expect(
114+ ProviderCompatibility.createProviderCompatibilityAdvisory({
115+ driver: codexDriver,
116+ currentVersion: "0.128.0",
117+ document,
118+ maintenanceCapabilities: codexNpmUpdateCapabilities,
119+ }),
120+ ).toMatchObject({
121+ status: "broken",
122+ canUpdate: true,
123+ updateCommand: "npm install -g @openai/codex@0.129.0",
124+ });
125+ });
126+
127+ it("does not warn on disabled providers before their version has been probed", () => {
128+ const enriched = ProviderCompatibility.applyBundledProviderCompatibilityAdvisory({
129+ snapshot: {
130+ ...baseProvider,
131+ driver: cursorDriver,
132+ enabled: false,
133+ version: null,
134+ status: "disabled",
135+ },
136+ driver: cursorDriver,
137+ currentVersion: null,
138+ });
139+
140+ expect(enriched.compatibilityAdvisory).toBeUndefined();
141+ expect(enriched.status).toBe("disabled");
142+ });
143+
144+ it("classifies the bundled compatibility policies for current T3 Code builds", () => {
145+ const document: ProviderCompatibility.ProviderCompatibilityDocument = {
146+ version: 1,
147+ policies: [
148+ {
149+ t3CodeRange: ">=0.0.0",
96150 driver: codexDriver,
97151 recommendedRange: ">=0.129.0",
98152 recommendedVersion: "0.129.0",
@@ -102,14 +156,14 @@ describe("provider compatibility", () => { · 3 unmodified lines
102156 ],
103157 },
104158 {
105− t3CodeRange: ">=0.0.24",
159+ t3CodeRange: ">=0.0.0",
106160 driver: claudeDriver,
107161 recommendedRange: ">=0.2.111",
108162 recommendedVersion: "0.2.111",
109163 ranges: [{ status: "supported", range: ">=0.2.111" }],
110164 },
111165 {
112− t3CodeRange: ">=0.0.24",
166+ t3CodeRange: ">=0.0.0",
113167 driver: opencodeDriver,
114168 recommendedRange: ">=1.14.19",
115169 recommendedVersion: "1.14.19",
@@ -119,7 +173,7 @@ describe("provider compatibility", () => { · 3 unmodified lines
119173 ],
120174 },
121175 {
122− t3CodeRange: ">=0.0.24",
176+ t3CodeRange: ">=0.0.0",
123177 driver: cursorDriver,
124178 recommendedRange: ">=2026.05.09",
125179 recommendedVersion: "2026.05.09",
@@ -136,7 +190,7 @@ describe("provider compatibility", () => { · 10 unmodified lines
136190 driver,
137191 currentVersion,
138192 document,
139− t3CodeVersion: "0.0.24",
193+ t3CodeVersion: "0.0.22",
140194 })?.status;
141195
142196 expect(classify(codexDriver, "0.129.0")).toBe("supported");
@@ -188,14 +242,18 @@ describe("provider compatibility", () => { · 45 unmodified lines
188242 };
189243
190244 return Effect.gen(function* () {
191− const enriched = yield* ProviderCompatibility.enrichProviderSnapshotWithCompatibilityAdvisory(
192− baseProvider,
193− ).pipe(provideCompatibility(() => ({ payload: remoteDocument })));
245+ const enriched =
246+ yield* ProviderCompatibility.enrichProviderSnapshotWithTargetedCompatibilityAdvisory(
247+ baseProvider,
248+ codexNpmUpdateCapabilities,
249+ ).pipe(provideCompatibility(() => ({ payload: remoteDocument })));
194250
195251 expect(enriched.status).toBe("error");
196252 expect(enriched.compatibilityAdvisory).toMatchObject({
197253 status: "broken",
198254 recommendedVersion: "0.129.0",
255+ canUpdate: true,
256+ updateCommand: "npm install -g @openai/codex@0.129.0",
199257 });
200258 });
201259 });
@@ -355,8 +413,8 @@ describe("provider compatibility", () => { · 153 unmodified lines
355413 },
356414 ).pipe(provideCompatibility(() => ({ payload: {}, status: 404 })));
357415
358− expect(enriched.status).toBe("ready");
359− expect(enriched.compatibilityAdvisory).toBeUndefined();
416+ expect(enriched.status).toBe("error");
417+ expect(enriched.compatibilityAdvisory).toMatchObject({ status: "broken" });
360418 }),
361419 );
362420 });
apps/server/src/provider/ProviderCompatibility.ts+55−9
@@ -17,6 +17,10 @@ import { HttpClient, HttpClientRequest } from "effect/unstable/http"; · 16 unmodified lines
1717
1818 import bundledCompatibilityDocumentJson from "../../../../provider-compatibility.v1.json" with { type: "json" };
1919 import packageJson from "../../package.json" with { type: "json" };
20+import {
21+ makeTargetedProviderUpdateAction,
22+ type ProviderMaintenanceCapabilities,
23+} from "./providerMaintenance.ts";
2024
2125 const T3_CODE_VERSION = packageJson.version;
2226 const REMOTE_COMPATIBILITY_CACHE_TTL = Duration.minutes(15);
@@ -145,6 +149,7 @@ function createProviderCompatibilityAdvisoryFromDocument(input: { · 122 unmodified lines
145149 readonly document: typeof RemoteCompatibilityDocument.Type;
146150 readonly driver: ProviderDriverKind;
147151 readonly currentVersion: string | null;
152+ readonly maintenanceCapabilities?: ProviderMaintenanceCapabilities | undefined;
148153 readonly t3CodeVersion?: string;
149154 }): ServerProviderCompatibilityAdvisory | undefined {
150155 const policy = compatibilityPolicyForDriver({
@@ -163,6 +168,9 @@ function createProviderCompatibilityAdvisoryFromDocument(input: { · 12 unmodified lines
163168 : policy.ranges.find((range) => satisfiesSemverRange(currentVersion, range.range));
164169 const status = matchedRange?.status ?? (currentVersion === null ? "unknown" : "unsupported");
165170 const recommendedVersion = policy.recommendedVersion ?? null;
171+ const targetedUpdateAction = input.maintenanceCapabilities
172+ ? makeTargetedProviderUpdateAction(input.maintenanceCapabilities, recommendedVersion)
173+ : null;
166174
167175 return {
168176 status,
@@ -176,20 +184,31 @@ function createProviderCompatibilityAdvisoryFromDocument(input: { · 7 unmodified lines
176184 }),
177185 recommendedRange: policy.recommendedRange,
178186 recommendedVersion,
187+ updateCommand: targetedUpdateAction?.command ?? null,
188+ canUpdate: targetedUpdateAction !== null,
179189 ranges: [...policy.ranges],
180190 };
181191 }
182192
193+function shouldSkipCompatibilityAdvisory(input: {
194+ readonly snapshot: ProviderCompatibilitySnapshot;
195+ readonly currentVersion: string | null;
196+}): boolean {
197+ return !input.snapshot.enabled && input.currentVersion === null;
198+}
199+
183200 export function createProviderCompatibilityAdvisory(input: {
184201 readonly driver: ProviderDriverKind;
185202 readonly currentVersion: string | null;
186203 readonly document?: typeof RemoteCompatibilityDocument.Type;
204+ readonly maintenanceCapabilities?: ProviderMaintenanceCapabilities | undefined;
187205 readonly t3CodeVersion?: string;
188206 }): ServerProviderCompatibilityAdvisory | undefined {
189207 return createProviderCompatibilityAdvisoryFromDocument({
190208 document: input.document ?? bundledProviderCompatibilityDocument,
191209 driver: input.driver,
192210 currentVersion: input.currentVersion,
211+ maintenanceCapabilities: input.maintenanceCapabilities,
193212 ...(input.t3CodeVersion ? { t3CodeVersion: input.t3CodeVersion } : {}),
194213 });
195214 }
@@ -300,12 +319,23 @@ export function applyBundledProviderCompatibilityAdvisory< · 104 unmodified lines
300319 readonly snapshot: Snapshot;
301320 readonly driver: ProviderDriverKind;
302321 readonly currentVersion: string | null;
322+ readonly maintenanceCapabilities?: ProviderMaintenanceCapabilities | undefined;
303323 }): Snapshot {
324+ if (
325+ shouldSkipCompatibilityAdvisory({
326+ snapshot: input.snapshot,
327+ currentVersion: input.currentVersion,
328+ })
329+ ) {
330+ return removeExistingCompatibilityAdvisory(input.snapshot);
331+ }
332+
304333 return applyCompatibilityAdvisory(
305334 input.snapshot,
306335 createProviderCompatibilityAdvisory({
307336 driver: input.driver,
308337 currentVersion: input.currentVersion,
338+ maintenanceCapabilities: input.maintenanceCapabilities,
309339 }),
310340 );
311341 }
@@ -317,9 +347,19 @@ export const enrichProviderSnapshotWithCompatibilityAdvisory = Effect.fn( · 5 unmodified lines
317347 return yield* compatibility.enrichSnapshot(snapshot);
318348 });
319349
350+export const enrichProviderSnapshotWithTargetedCompatibilityAdvisory = Effect.fn(
351+ "enrichProviderSnapshotWithTargetedCompatibilityAdvisory",
352+)(function* (snapshot: ServerProvider, maintenanceCapabilities: ProviderMaintenanceCapabilities) {
353+ const compatibility = yield* ProviderCompatibilityService;
354+ return yield* compatibility.enrichSnapshot(snapshot, maintenanceCapabilities);
355+});
356+
320357 export interface ProviderCompatibilityServiceShape {
321358 readonly resolveRemoteDocument: Effect.Effect<typeof RemoteCompatibilityDocument.Type | null>;
322− readonly enrichSnapshot: (snapshot: ServerProvider) => Effect.Effect<ServerProvider>;
359+ readonly enrichSnapshot: (
360+ snapshot: ServerProvider,
361+ maintenanceCapabilities?: ProviderMaintenanceCapabilities | undefined,
362+ ) => Effect.Effect<ServerProvider>;
323363 }
324364
325365 export class ProviderCompatibilityService extends Context.Service<
@@ -347,17 +387,23 @@ export const makeProviderCompatibilityService = Effect.fn("makeProviderCompatibi · 21 unmodified lines
347387
348388 return {
349389 resolveRemoteDocument,
350− enrichSnapshot: (snapshot) =>
390+ enrichSnapshot: (snapshot, maintenanceCapabilities) =>
351391 resolveRemoteDocument.pipe(
352392 Effect.map((remoteDocument) =>
353− applyCompatibilityAdvisory(
393+ shouldSkipCompatibilityAdvisory({
354394 snapshot,
355− createProviderCompatibilityAdvisory({
356− driver: snapshot.driver,
357− currentVersion: snapshot.version,
358− ...(remoteDocument ? { document: remoteDocument } : {}),
359− }),
360− ),
395+ currentVersion: snapshot.version,
396+ })
397+ ? removeExistingCompatibilityAdvisory(snapshot)
398+ : applyCompatibilityAdvisory(
399+ snapshot,
400+ createProviderCompatibilityAdvisory({
401+ driver: snapshot.driver,
402+ currentVersion: snapshot.version,
403+ maintenanceCapabilities,
404+ ...(remoteDocument ? { document: remoteDocument } : {}),
405+ }),
406+ ),
361407 ),
362408 ),
363409 } satisfies ProviderCompatibilityServiceShape;
apps/server/src/provider/providerMaintenance.test.ts+19−0
@@ -13,6 +13,7 @@ import { · 12 unmodified lines
1313 makePackageManagedProviderMaintenanceResolver,
1414 makeProviderMaintenanceCapabilities,
1515 makeStaticProviderMaintenanceResolver,
16+ makeTargetedProviderUpdateAction,
1617 normalizeCommandPath,
1718 resolveProviderMaintenanceCapabilitiesEffect,
1819 } from "./providerMaintenance.ts";
@@ -132,6 +133,24 @@ describe("providerMaintenance", () => { · 113 unmodified lines
132133 });
133134 });
134135
136+ it("targets package install commands that omit an explicit latest suffix", () => {
137+ expect(
138+ makeTargetedProviderUpdateAction(
139+ makeProviderMaintenanceCapabilities({
140+ provider: driver("packageTool"),
141+ packageName: "@example/package-tool",
142+ updateExecutable: "vp",
143+ updateArgs: ["i", "-g", "@example/package-tool"],
144+ updateLockKey: "vite-plus-global",
145+ }),
146+ "1.2.3",
147+ ),
148+ ).toMatchObject({
149+ command: "vp i -g @example/package-tool@1.2.3",
150+ args: ["i", "-g", "@example/package-tool@1.2.3"],
151+ });
152+ });
153+
135154 it.effect(
136155 "switches package-managed providers to vite-plus updates when the resolved binary lives in vite-plus global bin",
137156 () =>
apps/server/src/provider/providerMaintenance.ts+26−0
@@ -108,6 +108,32 @@ export function makeManualOnlyProviderMaintenanceCapabilities(input: { · 107 unmodified lines
108108 });
109109 }
110110
111+export function makeTargetedProviderUpdateAction(
112+ capabilities: ProviderMaintenanceCapabilities,
113+ targetVersion: string | null,
114+): ProviderMaintenanceCommandAction | null {
115+ if (!capabilities.update || !capabilities.packageName || !targetVersion) {
116+ return null;
117+ }
118+
119+ const versionedPackageArg = `${capabilities.packageName}@latest`;
120+ const targetPackageArg = `${capabilities.packageName}@${targetVersion}`;
121+ const packageArgIndex = capabilities.update.args.findIndex(
122+ (arg) => arg === versionedPackageArg || arg === capabilities.packageName,
123+ );
124+ if (packageArgIndex < 0) {
125+ return null;
126+ }
127+
128+ const args = [...capabilities.update.args];
129+ args[packageArgIndex] = targetPackageArg;
130+ return {
131+ ...capabilities.update,
132+ args,
133+ command: [capabilities.update.executable, ...args].join(" "),
134+ };
135+}
136+
111137 function makeNpmGlobalProviderMaintenanceCapabilities(
112138 definition: PackageManagedProviderMaintenanceDefinition,
113139 ): ProviderMaintenanceCapabilities {
apps/server/src/provider/providerMaintenanceRunner.test.ts+30−0
@@ -288,6 +288,36 @@ describe("providerMaintenanceRunner", () => { · 287 unmodified lines
288288 );
289289 });
290290
291+ it.effect("runs a targeted package update when a target version is supplied", () => {
292+ const calls: Array<{ command: string; args: ReadonlyArray<string> }> = [];
293+ return Effect.gen(function* () {
294+ const { registry } = yield* makeRegistry(baseProvider);
295+ const updater = yield* makeTestRunner(registry);
296+
297+ yield* updater.updateProvider({
298+ provider: CODEX_DRIVER,
299+ targetVersion: "0.129.0",
300+ });
301+
302+ assert.deepStrictEqual(calls, [
303+ {
304+ command: "npm",
305+ args: ["install", "-g", "@openai/codex@0.129.0"],
306+ },
307+ ]);
308+ }).pipe(
309+ Effect.provide(
310+ Layer.mergeAll(
311+ latestVersionHttpClient("0.0.0"),
312+ mockSpawnerLayer((command, args) => {
313+ calls.push({ command, args });
314+ return { stdout: "updated" };
315+ }),
316+ ),
317+ ),
318+ );
319+ });
320+
291321 it.effect(
292322 "runs update commands through Effect ChildProcess when no test runner is injected",
293323 () => {
apps/server/src/provider/providerMaintenanceRunner.ts+9−2
@@ -22,7 +22,10 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; · 21 unmodified lines
2222
2323 import { ProviderRegistry } from "./Services/ProviderRegistry.ts";
2424 import { makeProviderMaintenanceCommandCoordinator } from "./providerMaintenanceCommandCoordinator.ts";
25−import { enrichProviderSnapshotWithVersionAdvisory } from "./providerMaintenance.ts";
25+import {
26+ enrichProviderSnapshotWithVersionAdvisory,
27+ makeTargetedProviderUpdateAction,
28+} from "./providerMaintenance.ts";
2629 import type { ProviderMaintenanceCapabilities } from "./providerMaintenance.ts";
2730 import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts";
2831 const isServerProviderUpdateError = Schema.is(ServerProviderUpdateError);
@@ -46,6 +49,7 @@ export interface ProviderMaintenanceRunnerShape { · 17 unmodified lines
4649 | {
4750 readonly provider: ProviderDriverKind;
4851 readonly instanceId?: ProviderInstanceId | undefined;
52+ readonly targetVersion?: string | undefined;
4953 },
5054 ) => Effect.Effect<ServerProviderUpdatedPayload, ServerProviderUpdateError>;
5155 }
@@ -283,12 +287,15 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { · 231 unmodified lines
283287 typeof target === "string"
284288 ? defaultInstanceIdForDriver(provider)
285289 : (target.instanceId ?? defaultInstanceIdForDriver(provider));
290+ const targetVersion = typeof target === "string" ? undefined : target.targetVersion;
286291 const targetKey = `instance:${instanceId}`;
287292 const capabilities = yield* providerRegistry.getProviderMaintenanceCapabilitiesForInstance(
288293 instanceId,
289294 provider,
290295 );
291− const update = capabilities.update;
296+ const update = targetVersion
297+ ? makeTargetedProviderUpdateAction(capabilities, targetVersion)
298+ : capabilities.update;
292299 if (!update) {
293300 return yield* new ServerProviderUpdateError({
294301 provider,
apps/web/src/components/ChatView.tsx+192−29
@@ -42,7 +42,7 @@ import { useGitStatus } from "~/lib/gitStatusState"; · 41 unmodified lines
4242 import { usePrimaryEnvironmentId } from "../environments/primary";
4343 import { readEnvironmentApi } from "../environmentApi";
4444 import { isElectron } from "../env";
45−import { readLocalApi } from "../localApi";
45+import { ensureLocalApi, readLocalApi } from "../localApi";
4646 import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";
4747 import {
4848 collapseExpandedComposerCursor,
@@ -104,7 +104,14 @@ import { BranchToolbar } from "./BranchToolbar"; · 55 unmodified lines
104104 import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";
105105 import PlanSidebar from "./PlanSidebar";
106106 import ThreadTerminalDrawer from "./ThreadTerminalDrawer";
107−import { ChevronDownIcon, TriangleAlertIcon, WifiOffIcon } from "lucide-react";
107+import {
108+ ChevronDownIcon,
109+ CircleAlertIcon,
110+ DownloadIcon,
111+ LoaderIcon,
112+ TriangleAlertIcon,
113+ WifiOffIcon,
114+} from "lucide-react";
108115 import { cn, randomUUID } from "~/lib/utils";
109116 import { stackedThreadToast, toastManager } from "./ui/toast";
110117 import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings";
@@ -147,7 +154,6 @@ import { ChatHeader } from "./chat/ChatHeader"; · 36 unmodified lines
147154 import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview";
148155 import { NoActiveThreadState } from "./NoActiveThreadState";
149156 import { resolveEffectiveEnvMode, resolveEnvironmentOptionLabel } from "./BranchToolbar.logic";
150−import { ProviderStatusBanner } from "./chat/ProviderStatusBanner";
151157 import { ThreadErrorBanner } from "./chat/ThreadErrorBanner";
152158 import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack";
153159 import {
@@ -183,6 +189,11 @@ import { sanitizeThreadErrorMessage } from "~/rpc/transportError"; · 29 unmodified lines
183189 import { retainThreadDetailSubscription } from "../environments/runtime/service";
184190 import { RightPanelSheet } from "./RightPanelSheet";
185191 import { Button } from "./ui/button";
192+import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover";
193+import {
194+ canRunProviderCompatibilityUpdate,
195+ getProviderCompatibilityUpdateCommand,
196+} from "./settings/providerStatus";
186197 import {
187198 buildVersionMismatchDismissalKey,
188199 dismissVersionMismatch,
@@ -197,6 +208,75 @@ const EMPTY_PROPOSED_PLANS: Thread["proposedPlans"] = []; · 8 unmodified lines
197208 const EMPTY_PROVIDERS: ServerProvider[] = [];
198209 const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = [];
199210 const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {};
211+
212+function stripProviderCompatibilityInstallHint(message: string, recommendedVersion: string | null) {
213+ if (!recommendedVersion) {
214+ return message;
215+ }
216+ const escapedVersion = recommendedVersion.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
217+ return message.replace(new RegExp(`\\s*Use\\s+${escapedVersion}\\.?\\s*$`, "i"), "").trim();
218+}
219+
220+function ProviderCompatibilityBannerAction({
221+ providerLabel,
222+ recommendedVersion,
223+ updateCommand,
224+ canRunUpdate,
225+ updating,
226+ onRunUpdate,
227+}: {
228+ readonly providerLabel: string;
229+ readonly recommendedVersion: string;
230+ readonly updateCommand: string | null;
231+ readonly canRunUpdate: boolean;
232+ readonly updating: boolean;
233+ readonly onRunUpdate: () => void;
234+}) {
235+ return (
236+ <Popover>
237+ <PopoverTrigger
238+ render={
239+ <Button type="button" size="xs" variant="outline">
240+ Install {recommendedVersion}
241+ </Button>
242+ }
243+ />
244+ <PopoverPopup side="top" align="end" className="w-84">
245+ <div className="grid gap-3">
246+ <div className="grid gap-0.5">
247+ <p className="text-[13px] font-semibold leading-tight text-foreground">
248+ Install compatible {providerLabel}
249+ </p>
250+ <p className="text-xs leading-snug text-muted-foreground">
251+ Install provider harness version {recommendedVersion}.
252+ </p>
253+ </div>
254+ {canRunUpdate ? (
255+ <Button
256+ type="button"
257+ size="xs"
258+ variant="default"
259+ className="w-full"
260+ disabled={updating}
261+ onClick={onRunUpdate}
262+ >
263+ {updating ? <LoaderIcon className="animate-spin" /> : <DownloadIcon />}
264+ {updating ? "Installing" : "Install now"}
265+ </Button>
266+ ) : null}
267+ {updateCommand ? (
268+ <div className="rounded-md border border-border/70 bg-muted/40 px-2 py-1.5">
269+ <code className="block overflow-x-auto whitespace-nowrap font-mono text-[11px] text-foreground [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
270+ {updateCommand}
271+ </code>
272+ </div>
273+ ) : null}
274+ </div>
275+ </PopoverPopup>
276+ </Popover>
277+ );
278+}
279+
200280 type EnvironmentUnavailableState = {
201281 readonly environmentId: EnvironmentId;
202282 readonly label: string;
@@ -1150,6 +1230,8 @@ export default function ChatView(props: ChatViewProps) { · 947 unmodified lines
11501230 const [dismissedVersionMismatchKey, setDismissedVersionMismatchKey] = useState<string | null>(
11511231 null,
11521232 );
1233+ const [compatibilityUpdatingProviderId, setCompatibilityUpdatingProviderId] =
1234+ useState<ProviderInstanceId | null>(null);
11531235 const versionMismatchDismissed =
11541236 versionMismatchDismissKey === dismissedVersionMismatchKey ||
11551237 isVersionMismatchDismissed(versionMismatchDismissKey);
@@ -1178,8 +1260,112 @@ export default function ChatView(props: ChatViewProps) { · 22 unmodified lines
11781260 savedEnvironmentRuntimeById,
11791261 serverConfig?.environment.label,
11801262 ]);
1263+ const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS;
1264+ const unlockedSelectedProvider = resolveSelectableProvider(
1265+ providerStatuses,
1266+ selectedProviderByThreadId ?? threadProvider ?? ProviderDriverKind.make("codex"),
1267+ );
1268+ const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider;
1269+ // Prefer an instance-id match so a custom Codex instance (e.g.
1270+ // `codex_personal`) surfaces its own status/message in the banner rather
1271+ // than the default Codex's. Falls back to first-match-by-kind when no
1272+ // saved instance id is available or the instance no longer exists.
1273+ const activeProviderInstanceId =
1274+ composerActiveProvider ??
1275+ activeThread?.session?.providerInstanceId ??
1276+ activeThread?.modelSelection.instanceId ??
1277+ activeProject?.defaultModelSelection?.instanceId ??
1278+ null;
1279+ const activeProviderStatus = useMemo(() => {
1280+ if (activeProviderInstanceId) {
1281+ return (
1282+ providerStatuses.find((status) => status.instanceId === activeProviderInstanceId) ?? null
1283+ );
1284+ }
1285+ const defaultInstanceId = defaultInstanceIdForDriver(selectedProvider);
1286+ return providerStatuses.find((status) => status.instanceId === defaultInstanceId) ?? null;
1287+ }, [activeProviderInstanceId, providerStatuses, selectedProvider]);
1288+ const runActiveProviderCompatibilityUpdate = useCallback(async () => {
1289+ const provider = activeProviderStatus;
1290+ const targetVersion = provider?.compatibilityAdvisory?.recommendedVersion ?? null;
1291+ if (!provider || !targetVersion) {
1292+ return;
1293+ }
1294+
1295+ setCompatibilityUpdatingProviderId(provider.instanceId);
1296+ try {
1297+ await ensureLocalApi().server.updateProvider({
1298+ provider: provider.driver,
1299+ instanceId: provider.instanceId,
1300+ targetVersion,
1301+ });
1302+ } catch (error) {
1303+ toastManager.add(
1304+ stackedThreadToast({
1305+ type: "error",
1306+ title: `Could not update ${provider.displayName ?? provider.driver}`,
1307+ description:
1308+ error instanceof Error
1309+ ? error.message
1310+ : "The provider update command could not be started.",
1311+ }),
1312+ );
1313+ } finally {
1314+ setCompatibilityUpdatingProviderId((current) =>
1315+ current === provider.instanceId ? null : current,
1316+ );
1317+ }
1318+ }, [activeProviderStatus]);
11811319 const composerBannerItems = useMemo<ComposerBannerStackItem[]>(() => {
11821320 const items: ComposerBannerStackItem[] = [];
1321+ if (
1322+ activeProviderStatus &&
1323+ activeProviderStatus.status !== "ready" &&
1324+ activeProviderStatus.status !== "disabled"
1325+ ) {
1326+ const defaultMessage =
1327+ activeProviderStatus.status === "error"
1328+ ? "Provider is unavailable."
1329+ : "Provider needs attention.";
1330+ const recommendedVersion =
1331+ activeProviderStatus.compatibilityAdvisory?.recommendedVersion ?? null;
1332+ const compatibilityUpdateCommand =
1333+ getProviderCompatibilityUpdateCommand(activeProviderStatus);
1334+ const canRunCompatibilityUpdate = canRunProviderCompatibilityUpdate(activeProviderStatus);
1335+ const compatibilityUpdating =
1336+ compatibilityUpdatingProviderId === activeProviderStatus.instanceId;
1337+ const hasCompatibilityInstallAction =
1338+ activeProviderStatus.compatibilityAdvisory?.status !== "supported" &&
1339+ recommendedVersion !== null &&
1340+ (canRunCompatibilityUpdate || compatibilityUpdateCommand !== null);
1341+ const rawMessage = activeProviderStatus.message ?? defaultMessage;
1342+ const message = hasCompatibilityInstallAction
1343+ ? stripProviderCompatibilityInstallHint(rawMessage, recommendedVersion)
1344+ : rawMessage;
1345+ const providerLabel = activeProviderStatus.displayName ?? activeProviderStatus.driver;
1346+ items.push({
1347+ id: `provider-status:${activeProviderStatus.instanceId}:${activeProviderStatus.status}`,
1348+ variant: activeProviderStatus.status === "error" ? "error" : "warning",
1349+ icon: <CircleAlertIcon />,
1350+ title: `${providerLabel} provider status`,
1351+ description: (
1352+ <span className="line-clamp-3" title={message}>
1353+ {message}
1354+ </span>
1355+ ),
1356+ actions:
1357+ hasCompatibilityInstallAction && recommendedVersion ? (
1358+ <ProviderCompatibilityBannerAction
1359+ providerLabel={providerLabel}
1360+ recommendedVersion={recommendedVersion}
1361+ updateCommand={compatibilityUpdateCommand}
1362+ canRunUpdate={canRunCompatibilityUpdate}
1363+ updating={compatibilityUpdating}
1364+ onRunUpdate={() => void runActiveProviderCompatibilityUpdate()}
1365+ />
1366+ ) : undefined,
1367+ });
1368+ }
11831369 if (activeEnvironmentUnavailableState) {
11841370 items.push({
11851371 id: `environment-unavailable:${activeEnvironmentUnavailableState.environmentId}`,
@@ -1248,20 +1434,17 @@ export default function ChatView(props: ChatViewProps) { · 62 unmodified lines
12481434 return items;
12491435 }, [
12501436 activeEnvironmentUnavailableState,
1437+ activeProviderStatus,
1438+ compatibilityUpdatingProviderId,
12511439 handleReconnectActiveEnvironment,
12521440 navigate,
12531441 reconnectingEnvironmentId,
1442+ runActiveProviderCompatibilityUpdate,
12541443 showVersionMismatchBanner,
12551444 versionMismatch,
12561445 versionMismatchDismissKey,
12571446 versionMismatchServerLabel,
12581447 ]);
1259− const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS;
1260− const unlockedSelectedProvider = resolveSelectableProvider(
1261− providerStatuses,
1262− selectedProviderByThreadId ?? threadProvider ?? ProviderDriverKind.make("codex"),
1263− );
1264− const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider;
12651448 const phase = derivePhase(activeThread?.session ?? null);
12661449 const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES;
12671450 const workLogEntries = useMemo(
@@ -1633,24 +1816,6 @@ export default function ChatView(props: ChatViewProps) { · 365 unmodified lines
16331816 const gitStatusQuery = useGitStatus({ environmentId, cwd: gitCwd });
16341817 const keybindings = useServerKeybindings();
16351818 const availableEditors = useServerAvailableEditors();
1636− // Prefer an instance-id match so a custom Codex instance (e.g.
1637− // `codex_personal`) surfaces its own status/message in the banner rather
1638− // than the default Codex's. Falls back to first-match-by-kind when no
1639− // saved instance id is available or the instance no longer exists.
1640− const activeProviderInstanceId =
1641− activeThread?.session?.providerInstanceId ??
1642− activeThread?.modelSelection.instanceId ??
1643− activeProject?.defaultModelSelection?.instanceId ??
1644− null;
1645− const activeProviderStatus = useMemo(() => {
1646− if (activeProviderInstanceId) {
1647− return (
1648− providerStatuses.find((status) => status.instanceId === activeProviderInstanceId) ?? null
1649− );
1650− }
1651− const defaultInstanceId = defaultInstanceIdForDriver(selectedProvider);
1652− return providerStatuses.find((status) => status.instanceId === defaultInstanceId) ?? null;
1653− }, [activeProviderInstanceId, providerStatuses, selectedProvider]);
16541819 const activeProjectCwd = activeProject?.cwd ?? null;
16551820 const activeThreadWorktreePath = activeThread?.worktreePath ?? null;
16561821 const activeWorkspaceRoot = activeThreadWorktreePath ?? activeProjectCwd ?? undefined;
@@ -3540,8 +3705,6 @@ export default function ChatView(props: ChatViewProps) { · 1883 unmodified lines
35403705 />
35413706 </header>
35423707
3543− {/* Error banner */}
3544− <ProviderStatusBanner status={activeProviderStatus} />
35453708 <ThreadErrorBanner
35463709 error={activeThread.error}
35473710 onDismiss={() => setThreadError(activeThread.id, null)}
apps/web/src/components/chat/ChatComposer.tsx+31−5
@@ -302,6 +302,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( · 301 unmodified lines
302302 isSendBusy: boolean;
303303 isConnecting: boolean;
304304 isEnvironmentUnavailable: boolean;
305+ isProviderUnavailable: boolean;
305306 hasSendableContent: boolean;
306307 preserveComposerFocusOnPointerDown?: boolean;
307308 onPreviousPendingQuestion: () => void;
@@ -323,6 +324,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( · 15 unmodified lines
323324 isSendBusy={props.isSendBusy}
324325 isConnecting={props.isConnecting}
325326 isEnvironmentUnavailable={props.isEnvironmentUnavailable}
327+ isProviderUnavailable={props.isProviderUnavailable}
326328 isPreparingWorktree={props.isPreparingWorktree}
327329 hasSendableContent={props.hasSendableContent}
328330 preserveComposerFocusOnPointerDown={props.preserveComposerFocusOnPointerDown ?? false}
@@ -716,6 +718,11 @@ export const ChatComposer = memo( · 387 unmodified lines
716718 () => selectedProviderEntry?.snapshot ?? null,
717719 [selectedProviderEntry],
718720 );
721+ const selectedProviderUnavailableMessage =
722+ selectedProviderStatus?.status === "error"
723+ ? (selectedProviderStatus.message ?? "Selected provider is unavailable.")
724+ : null;
725+ const isSelectedProviderUnavailable = selectedProviderUnavailableMessage !== null;
719726 const selectedProviderModels = useMemo<ReadonlyArray<ServerProvider["models"][number]>>(
720727 () => selectedProviderEntry?.models ?? [],
721728 [selectedProviderEntry],
@@ -1053,7 +1060,11 @@ export const ChatComposer = memo( · 331 unmodified lines
10531060 [activePendingIsResponding, activePendingProgress, activePendingResolvedAnswers],
10541061 );
10551062 const collapsedComposerPrimaryActionDisabled =
1056− phase === "running" || isSendBusy || isConnecting || !composerSendState.hasSendableContent;
1063+ phase === "running" ||
1064+ isSendBusy ||
1065+ isConnecting ||
1066+ isSelectedProviderUnavailable ||
1067+ !composerSendState.hasSendableContent;
10571068 const collapsedComposerPrimaryActionLabel = "Send message";
10581069 const showMobilePendingAnswerActions =
10591070 isMobileViewport && !isComposerCollapsedMobile && pendingPrimaryAction !== null;
@@ -1619,12 +1630,21 @@ export const ChatComposer = memo( · 559 unmodified lines
16191630
16201631 const submitComposer = useCallback(
16211632 (event?: { preventDefault: () => void }) => {
1633+ if (isSelectedProviderUnavailable) {
1634+ event?.preventDefault();
1635+ return;
1636+ }
16221637 onSend(event);
16231638 if (shouldBlurMobileComposerOnSubmit()) {
16241639 blurMobileComposerAfterSend();
16251640 }
16261641 },
1627− [blurMobileComposerAfterSend, onSend, shouldBlurMobileComposerOnSubmit],
1642+ [
1643+ blurMobileComposerAfterSend,
1644+ isSelectedProviderUnavailable,
1645+ onSend,
1646+ shouldBlurMobileComposerOnSubmit,
1647+ ],
16281648 );
16291649 const expandMobileComposer = useCallback(() => {
16301650 if (composerBlurFrameRef.current !== null) {
@@ -2079,6 +2099,7 @@ export const ChatComposer = memo( · 448 unmodified lines
20792099 isSendBusy={isSendBusy}
20802100 isConnecting={isConnecting}
20812101 isEnvironmentUnavailable={environmentUnavailable !== null}
2102+ isProviderUnavailable={isSelectedProviderUnavailable}
20822103 isPreparingWorktree={false}
20832104 hasSendableContent={false}
20842105 preserveComposerFocusOnPointerDown
@@ -2265,12 +2286,15 @@ export const ChatComposer = memo( · 180 unmodified lines
22652286 ? "connecting"
22662287 : "disconnected"
22672288 }`
2268− : phase === "disconnected"
2269− ? "Ask for follow-up changes or attach images"
2270− : "Ask anything, @tag files/folders, $use skills, or / for commands"
2289+ : selectedProviderUnavailableMessage
2290+ ? selectedProviderUnavailableMessage
2291+ : phase === "disconnected"
2292+ ? "Ask for follow-up changes or attach images"
2293+ : "Ask anything, @tag files/folders, $use skills, or / for commands"
22712294 }
22722295 disabled={
22732296 isConnecting ||
2297+ isSelectedProviderUnavailable ||
22742298 isComposerApprovalState ||
22752299 (environmentUnavailable !== null && activePendingProgress === null)
22762300 }
@@ -2289,6 +2313,7 @@ export const ChatComposer = memo( · 12 unmodified lines
22892313 isSendBusy={isSendBusy}
22902314 isConnecting={isConnecting}
22912315 isEnvironmentUnavailable={environmentUnavailable !== null}
2316+ isProviderUnavailable={isSelectedProviderUnavailable}
22922317 isPreparingWorktree={false}
22932318 hasSendableContent={false}
22942319 preserveComposerFocusOnPointerDown
@@ -2405,6 +2430,7 @@ export const ChatComposer = memo( · 110 unmodified lines
24052430 isSendBusy={isSendBusy}
24062431 isConnecting={isConnecting}
24072432 isEnvironmentUnavailable={environmentUnavailable !== null}
2433+ isProviderUnavailable={isSelectedProviderUnavailable}
24082434 isPreparingWorktree={isPreparingWorktree}
24092435 hasSendableContent={composerSendState.hasSendableContent}
24102436 preserveComposerFocusOnPointerDown={isMobileViewport}
apps/web/src/components/chat/ComposerPrimaryActions.tsx+29−14
@@ -21,6 +21,7 @@ interface ComposerPrimaryActionsProps { · 20 unmodified lines
2121 isSendBusy: boolean;
2222 isConnecting: boolean;
2323 isEnvironmentUnavailable: boolean;
24+ isProviderUnavailable: boolean;
2425 isPreparingWorktree: boolean;
2526 hasSendableContent: boolean;
2627 preserveComposerFocusOnPointerDown?: boolean;
@@ -60,6 +61,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ · 33 unmodified lines
6061 isSendBusy,
6162 isConnecting,
6263 isEnvironmentUnavailable,
64+ isProviderUnavailable,
6365 isPreparingWorktree,
6466 hasSendableContent,
6567 preserveComposerFocusOnPointerDown = false,
@@ -107,6 +109,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ · 41 unmodified lines
107109 {...pointerFocusProps}
108110 disabled={
109111 isEnvironmentUnavailable ||
112+ isProviderUnavailable ||
110113 pendingAction.isResponding ||
111114 (pendingAction.isLastQuestion ? !pendingAction.isComplete : !pendingAction.canAdvance)
112115 }
@@ -146,7 +149,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ · 33 unmodified lines
146149 size="sm"
147150 className={cn("rounded-full", compact ? "h-9 px-3 sm:h-8" : "h-9 px-4 sm:h-8")}
148151 {...pointerFocusProps}
149− disabled={isSendBusy || isConnecting || isEnvironmentUnavailable}
152+ disabled={isSendBusy || isConnecting || isEnvironmentUnavailable || isProviderUnavailable}
150153 >
151154 {isConnecting || isSendBusy ? "Sending..." : "Refine"}
152155 </Button>
@@ -160,7 +163,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ · 7 unmodified lines
160163 size="sm"
161164 className="h-9 rounded-l-full rounded-r-none px-4 sm:h-8"
162165 {...pointerFocusProps}
163− disabled={isSendBusy || isConnecting || isEnvironmentUnavailable}
166+ disabled={isSendBusy || isConnecting || isEnvironmentUnavailable || isProviderUnavailable}
164167 >
165168 {isConnecting || isSendBusy ? "Sending..." : "Implement"}
166169 </Button>
@@ -173,7 +176,9 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ · 6 unmodified lines
173176 className="h-9 rounded-l-none rounded-r-full border-l-white/12 px-2 sm:h-8"
174177 aria-label="Implementation actions"
175178 {...pointerFocusProps}
176− disabled={isSendBusy || isConnecting || isEnvironmentUnavailable}
179+ disabled={
180+ isSendBusy || isConnecting || isEnvironmentUnavailable || isProviderUnavailable
181+ }
177182 />
178183 }
179184 >
@@ -181,7 +186,9 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ · 1 unmodified line
181186 </MenuTrigger>
182187 <MenuPopup align="end" side="top">
183188 <MenuItem
184− disabled={isSendBusy || isConnecting || isEnvironmentUnavailable}
189+ disabled={
190+ isSendBusy || isConnecting || isEnvironmentUnavailable || isProviderUnavailable
191+ }
185192 onClick={() => void onImplementPlanInNewThread()}
186193 >
187194 Implement in a new thread
@@ -197,17 +204,25 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ · 9 unmodified lines
197204 type="submit"
198205 className="flex h-9 w-9 enabled:cursor-pointer items-center justify-center rounded-full bg-primary/90 text-primary-foreground transition-all duration-150 hover:bg-primary hover:scale-105 disabled:pointer-events-none disabled:opacity-30 disabled:hover:scale-100 sm:h-8 sm:w-8"
199206 {...pointerFocusProps}
200− disabled={isSendBusy || isConnecting || isEnvironmentUnavailable || !hasSendableContent}
207+ disabled={
208+ isSendBusy ||
209+ isConnecting ||
210+ isEnvironmentUnavailable ||
211+ isProviderUnavailable ||
212+ !hasSendableContent
213+ }
201214 aria-label={
202− isEnvironmentUnavailable
203− ? "Environment disconnected"
204− : isConnecting
205− ? "Connecting"
206− : isPreparingWorktree
207− ? "Preparing worktree"
208− : isSendBusy
209− ? "Sending"
210− : "Send message"
215+ isProviderUnavailable
216+ ? "Provider unavailable"
217+ : isEnvironmentUnavailable
218+ ? "Environment disconnected"
219+ : isConnecting
220+ ? "Connecting"
221+ : isPreparingWorktree
222+ ? "Preparing worktree"
223+ : isSendBusy
224+ ? "Sending"
225+ : "Send message"
211226 }
212227 >
213228 {isConnecting || isSendBusy ? (
apps/web/src/components/chat/ProviderStatusBanner.tsx+0−34
@@ -1,34 +0,0 @@
1−import { type ServerProvider } from "@t3tools/contracts";
2−import { memo } from "react";
3−import { Alert, AlertDescription, AlertTitle } from "../ui/alert";
4−import { CircleAlertIcon } from "lucide-react";
5−import { formatProviderDriverKindLabel } from "../../providerModels";
6−
7−export const ProviderStatusBanner = memo(function ProviderStatusBanner({
8− status,
9−}: {
10− status: ServerProvider | null;
11−}) {
12− if (!status || status.status === "ready" || status.status === "disabled") {
13− return null;
14− }
15−
16− const providerLabel = status.displayName?.trim() || formatProviderDriverKindLabel(status.driver);
17− const defaultMessage =
18− status.status === "error"
19− ? `${providerLabel} provider is unavailable.`
20− : `${providerLabel} provider has limited availability.`;
21− const title = `${providerLabel} provider status`;
22−
23− return (
24− <div className="pt-3 mx-auto max-w-3xl">
25− <Alert variant={status.status === "error" ? "error" : "warning"}>
26− <CircleAlertIcon />
27− <AlertTitle>{title}</AlertTitle>
28− <AlertDescription className="line-clamp-3" title={status.message ?? defaultMessage}>
29− {status.message ?? defaultMessage}
30− </AlertDescription>
31− </Alert>
32− </div>
33− );
34−});
apps/web/src/components/chat/ThreadErrorBanner.tsx+3−2
@@ -1,7 +1,8 @@
11 import { memo } from "react";
2−import { Alert, AlertAction, AlertDescription } from "../ui/alert";
32 import { CircleAlertIcon, XIcon } from "lucide-react";
43
4+import { Alert, AlertAction, AlertDescription } from "../ui/alert";
5+
56 export const ThreadErrorBanner = memo(function ThreadErrorBanner({
67 error,
78 onDismiss,
@@ -11,7 +12,7 @@ export const ThreadErrorBanner = memo(function ThreadErrorBanner({ · 3 unmodified lines
1112 }) {
1213 if (!error) return null;
1314 return (
14− <div className="pt-3 mx-auto max-w-3xl">
15+ <div className="mx-auto max-w-3xl pt-3">
1516 <Alert variant="error">
1617 <CircleAlertIcon />
1718 <AlertDescription className="line-clamp-3" title={error}>
apps/web/src/components/settings/ProviderInstanceCard.tsx+203−128
@@ -39,6 +39,7 @@ import { ProviderModelsSection } from "./ProviderModelsSection"; · 38 unmodified lines
3939 import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon";
4040 import { RedactedSensitiveText } from "./RedactedSensitiveText";
4141 import {
42+ getProviderCompatibilityUpdateCommand,
4243 getProviderVersionAdvisoryPresentation,
4344 PROVIDER_STATUS_STYLES,
4445 getProviderCompatibilityAdvisoryPresentation,
@@ -57,6 +58,8 @@ const PROVIDER_ACCENT_SWATCHES = [ · 12 unmodified lines
5758 ] as const;
5859
5960 const ENVIRONMENT_VARIABLE_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
61+const ADVISORY_ICON_CLUSTER_CLASS = "-my-0.5 inline-flex h-5 shrink-0 items-center gap-2";
62+const ADVISORY_ICON_SLOT_CLASS = "inline-flex size-5 shrink-0 items-center justify-center";
6063
6164 let environmentVariableDraftId = 0;
6265 const nextEnvironmentVariableDraftId = () => `provider-env-${environmentVariableDraftId++}`;
@@ -422,6 +425,8 @@ interface ProviderInstanceCardProps { · 359 unmodified lines
422425 readonly onModelOrderChange: (next: ReadonlyArray<string>) => void;
423426 readonly onRunUpdate?: (() => void) | undefined;
424427 readonly isUpdating?: boolean | undefined;
428+ readonly onRunCompatibilityUpdate?: (() => void) | undefined;
429+ readonly isCompatibilityUpdating?: boolean | undefined;
425430 }
426431
427432 /**
@@ -466,6 +471,8 @@ export function ProviderInstanceCard({ · 38 unmodified lines
466471 onModelOrderChange,
467472 onRunUpdate,
468473 isUpdating = false,
474+ onRunCompatibilityUpdate,
475+ isCompatibilityUpdating = false,
469476 }: ProviderInstanceCardProps) {
470477 const enabled = instance.enabled ?? true;
471478 // The server-reported status wins when present; otherwise fall back to
@@ -487,7 +494,9 @@ export function ProviderInstanceCard({ · 15 unmodified lines
487494 liveProvider?.compatibilityAdvisory,
488495 );
489496 const versionAdvisory = getProviderVersionAdvisoryPresentation(liveProvider?.versionAdvisory);
497+ const hasProviderAdvisoryIcons = compatibilityAdvisory !== null || versionAdvisory !== null;
490498 const updateCommand = versionAdvisory?.updateCommand ?? null;
499+ const compatibilityUpdateCommand = getProviderCompatibilityUpdateCommand(liveProvider);
491500 const FallbackIconComponent = driverOption?.icon;
492501 const displayName =
493502 instance.displayName?.trim() || driverOption?.label || String(instance.driver);
@@ -681,134 +690,200 @@ export function ProviderInstanceCard({ · 187 unmodified lines
681690 <div className="min-w-0 flex-1 space-y-1">
682691 <div className="flex min-w-0 flex-wrap items-center gap-2">
683692 {titleHeadNode}
684− {versionCodeNode}
685− {compatibilityAdvisory ? (
686− <Popover>
687− <PopoverTrigger
688− render={
689− <Button
690− type="button"
691− size="icon-xs"
692− variant="ghost"
693− className={cn(
694− "size-5 rounded-sm p-0",
695− compatibilityAdvisory.emphasis === "strong"
696− ? "text-destructive hover:text-destructive"
697− : "text-warning hover:text-warning",
698− )}
699− aria-label={`${compatibilityAdvisory.title} — view details`}
700− >
701− <AlertTriangleIcon className="size-3.5" />
702− </Button>
703− }
704− />
705− <PopoverPopup side="bottom" align="start" className="w-84">
706− <div className="grid gap-1">
707− <p className="text-[13px] font-semibold leading-tight text-foreground">
708− {compatibilityAdvisory.title}
709− </p>
710− <p
711− className={cn(
712− "text-xs leading-snug",
713− compatibilityAdvisory.emphasis === "strong"
714− ? "text-destructive"
715− : "text-warning",
716− )}
717− >
718− {compatibilityAdvisory.detail}
719− </p>
720− </div>
721− </PopoverPopup>
722− </Popover>
723− ) : null}
724− {versionAdvisory ? (
725− <Popover>
726− <PopoverTrigger
727− render={
728− <Button
729− type="button"
730− size="icon-xs"
731− variant="ghost"
732− className={cn(
733− "size-5 rounded-sm p-0",
734− versionAdvisory.emphasis === "strong"
735− ? "text-warning hover:text-warning"
736− : "text-primary hover:text-primary",
737− )}
738− aria-label="Update available — view details"
739− >
740− <ArrowUpCircleIcon className="size-3.5 [animation:bounce_2.4s_ease-in-out_infinite] motion-reduce:animate-none" />
741− </Button>
742− }
743− />
744− <PopoverPopup side="bottom" align="start" className="w-84">
745− <div className="grid gap-3">
746− <div className="grid gap-0.5">
747− <p className="text-[13px] font-semibold leading-tight text-foreground">
748− Update available
749− </p>
750− <p
751− className={cn(
752− "text-xs leading-snug",
753− versionAdvisory.emphasis === "strong"
754− ? "text-warning"
755− : "text-muted-foreground",
756− )}
757− >
758− {versionAdvisory.detail}
759− </p>
760− </div>
761− {onRunUpdate ? (
762− <Button
763− type="button"
764− size="xs"
765− variant="default"
766− className="w-full"
767− disabled={isUpdating}
768− onClick={onRunUpdate}
769− >
770− {isUpdating ? <LoaderIcon className="animate-spin" /> : <DownloadIcon />}
771− {isUpdating ? "Updating" : "Update now"}
772− </Button>
773− ) : null}
774− {onRunUpdate && updateCommand ? (
775− <div className="flex items-center gap-2 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
776− <span aria-hidden className="h-px flex-1 bg-border" />
777− or, update manually using
778− <span aria-hidden className="h-px flex-1 bg-border" />
779− </div>
780− ) : null}
781− {updateCommand ? (
782− <div className="flex items-center gap-1 rounded-md border border-border/70 bg-muted/40 py-0.5 pr-0.5 pl-2">
783− <code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap font-mono text-[11px] text-foreground [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
784− {updateCommand}
785− </code>
786− <Tooltip>
787− <TooltipTrigger
788− render={
789− <Button
790− type="button"
791− size="icon-xs"
792− variant="ghost"
793− className="size-6 shrink-0 rounded-sm p-0 text-muted-foreground hover:text-foreground"
794− onClick={() =>
795− copyToClipboard(updateCommand, {
796− providerName: displayName,
797− })
798− }
799− aria-label="Copy update command"
800− >
801− <CopyIcon className="size-3" />
802− </Button>
803− }
804− />
805− <TooltipPopup side="top">Copy command</TooltipPopup>
806− </Tooltip>
807− </div>
808− ) : null}
809− </div>
810− </PopoverPopup>
811− </Popover>
693+ {versionCodeNode || hasProviderAdvisoryIcons ? (
694+ <span className={ADVISORY_ICON_CLUSTER_CLASS}>
695+ {versionCodeNode}
696+ {compatibilityAdvisory ? (
697+ <span className={ADVISORY_ICON_SLOT_CLASS}>
698+ <Popover>
699+ <PopoverTrigger
700+ render={
701+ <Button
702+ type="button"
703+ size="icon-xs"
704+ variant="ghost"
705+ className={cn(
706+ "size-5 rounded-sm p-0",
707+ compatibilityAdvisory.emphasis === "strong"
708+ ? "text-destructive hover:text-destructive"
709+ : "text-warning hover:text-warning",
710+ )}
711+ aria-label={`${compatibilityAdvisory.title} — view details`}
712+ >
713+ <AlertTriangleIcon className="size-3.5" />
714+ </Button>
715+ }
716+ />
717+ <PopoverPopup side="bottom" align="start" className="w-84">
718+ <div className="grid gap-3">
719+ <div className="grid gap-0.5">
720+ <p className="text-[13px] font-semibold leading-tight text-foreground">
721+ {compatibilityAdvisory.title}
722+ </p>
723+ <p
724+ className={cn(
725+ "text-xs leading-snug",
726+ compatibilityAdvisory.emphasis === "strong"
727+ ? "text-destructive"
728+ : "text-warning",
729+ )}
730+ >
731+ {compatibilityAdvisory.detail}
732+ </p>
733+ </div>
734+ {onRunCompatibilityUpdate ? (
735+ <Button
736+ type="button"
737+ size="xs"
738+ variant="default"
739+ className="w-full"
740+ disabled={isCompatibilityUpdating}
741+ onClick={onRunCompatibilityUpdate}
742+ >
743+ {isCompatibilityUpdating ? (
744+ <LoaderIcon className="animate-spin" />
745+ ) : (
746+ <DownloadIcon />
747+ )}
748+ {isCompatibilityUpdating ? "Updating" : "Update now"}
749+ </Button>
750+ ) : null}
751+ {onRunCompatibilityUpdate && compatibilityUpdateCommand ? (
752+ <div className="flex items-center gap-2 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
753+ <span aria-hidden className="h-px flex-1 bg-border" />
754+ or, update manually using
755+ <span aria-hidden className="h-px flex-1 bg-border" />
756+ </div>
757+ ) : null}
758+ {compatibilityUpdateCommand ? (
759+ <div className="flex items-center gap-1 rounded-md border border-border/70 bg-muted/40 py-0.5 pr-0.5 pl-2">
760+ <code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap font-mono text-[11px] text-foreground [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
761+ {compatibilityUpdateCommand}
762+ </code>
763+ <Tooltip>
764+ <TooltipTrigger
765+ render={
766+ <Button
767+ type="button"
768+ size="icon-xs"
769+ variant="ghost"
770+ className="size-6 shrink-0 rounded-sm p-0 text-muted-foreground hover:text-foreground"
771+ onClick={() =>
772+ copyToClipboard(compatibilityUpdateCommand, {
773+ providerName: displayName,
774+ })
775+ }
776+ aria-label="Copy compatibility update command"
777+ >
778+ <CopyIcon className="size-3" />
779+ </Button>
780+ }
781+ />
782+ <TooltipPopup side="top">Copy command</TooltipPopup>
783+ </Tooltip>
784+ </div>
785+ ) : null}
786+ </div>
787+ </PopoverPopup>
788+ </Popover>
789+ </span>
790+ ) : null}
791+ {versionAdvisory ? (
792+ <span className={ADVISORY_ICON_SLOT_CLASS}>
793+ <Popover>
794+ <PopoverTrigger
795+ render={
796+ <Button
797+ type="button"
798+ size="icon-xs"
799+ variant="ghost"
800+ className={cn(
801+ "size-5 rounded-sm p-0",
802+ versionAdvisory.emphasis === "strong"
803+ ? "text-warning hover:text-warning"
804+ : "text-primary hover:text-primary",
805+ )}
806+ aria-label="Update available — view details"
807+ >
808+ <ArrowUpCircleIcon className="size-3.5 [animation:bounce_2.4s_ease-in-out_infinite] motion-reduce:animate-none" />
809+ </Button>
810+ }
811+ />
812+ <PopoverPopup side="bottom" align="start" className="w-84">
813+ <div className="grid gap-3">
814+ <div className="grid gap-0.5">
815+ <p className="text-[13px] font-semibold leading-tight text-foreground">
816+ Update available
817+ </p>
818+ <p
819+ className={cn(
820+ "text-xs leading-snug",
821+ versionAdvisory.emphasis === "strong"
822+ ? "text-warning"
823+ : "text-muted-foreground",
824+ )}
825+ >
826+ {versionAdvisory.detail}
827+ </p>
828+ </div>
829+ {onRunUpdate ? (
830+ <Button
831+ type="button"
832+ size="xs"
833+ variant="default"
834+ className="w-full"
835+ disabled={isUpdating}
836+ onClick={onRunUpdate}
837+ >
838+ {isUpdating ? (
839+ <LoaderIcon className="animate-spin" />
840+ ) : (
841+ <DownloadIcon />
842+ )}
843+ {isUpdating ? "Updating" : "Update now"}
844+ </Button>
845+ ) : null}
846+ {onRunUpdate && updateCommand ? (
847+ <div className="flex items-center gap-2 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
848+ <span aria-hidden className="h-px flex-1 bg-border" />
849+ or, update manually using
850+ <span aria-hidden className="h-px flex-1 bg-border" />
851+ </div>
852+ ) : null}
853+ {updateCommand ? (
854+ <div className="flex items-center gap-1 rounded-md border border-border/70 bg-muted/40 py-0.5 pr-0.5 pl-2">
855+ <code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap font-mono text-[11px] text-foreground [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
856+ {updateCommand}
857+ </code>
858+ <Tooltip>
859+ <TooltipTrigger
860+ render={
861+ <Button
862+ type="button"
863+ size="icon-xs"
864+ variant="ghost"
865+ className="size-6 shrink-0 rounded-sm p-0 text-muted-foreground hover:text-foreground"
866+ onClick={() =>
867+ copyToClipboard(updateCommand, {
868+ providerName: displayName,
869+ })
870+ }
871+ aria-label="Copy update command"
872+ >
873+ <CopyIcon className="size-3" />
874+ </Button>
875+ }
876+ />
877+ <TooltipPopup side="top">Copy command</TooltipPopup>
878+ </Tooltip>
879+ </div>
880+ ) : null}
881+ </div>
882+ </PopoverPopup>
883+ </Popover>
884+ </span>
885+ ) : null}
886+ </span>
812887 ) : null}
813888 {titleTailNode}
814889 </div>
apps/web/src/components/settings/SettingsPanels.browser.tsx+58−1
@@ -255,7 +255,7 @@ function createOutdatedProvider(driver: string): ServerProvider { · 254 unmodified lines
255255 latestVersion: "1.1.0",
256256 message: "Update available.",
257257 checkedAt: "2026-05-04T10:00:00.000Z",
258− updateCommand: "npm install -g openai/codex@latest",
258+ updateCommand: "npm install -g @openai/codex@latest",
259259 canUpdate: true,
260260 },
261261 };
@@ -1155,6 +1155,63 @@ describe("GeneralSettingsPanel observability", () => { · 893 unmodified lines
11551155 instanceId: ProviderInstanceId.make("codex"),
11561156 });
11571157 });
1158+
1159+ it("runs targeted compatibility updates from the provider card", async () => {
1160+ const updateProvider = vi.fn<LocalApi["server"]["updateProvider"]>().mockResolvedValue({
1161+ providers: [],
1162+ });
1163+ window.nativeApi = {
1164+ persistence: {
1165+ getClientSettings: vi.fn().mockResolvedValue(null),
1166+ setClientSettings: vi.fn().mockResolvedValue(undefined),
1167+ },
1168+ server: {
1169+ updateProvider,
1170+ },
1171+ } as unknown as LocalApi;
1172+ const incompatibleProvider: ServerProvider = {
1173+ ...createOutdatedProvider("codex"),
1174+ version: "0.128.0",
1175+ status: "error",
1176+ message:
1177+ "This provider harness version 0.128.0 is known to be incompatible with this T3 Code release. Use 0.129.0.",
1178+ compatibilityAdvisory: {
1179+ status: "broken",
1180+ severity: "error",
1181+ currentVersion: "0.128.0",
1182+ message:
1183+ "This provider harness version 0.128.0 is known to be incompatible with this T3 Code release. Use 0.129.0.",
1184+ recommendedRange: ">=0.129.0",
1185+ recommendedVersion: "0.129.0",
1186+ ranges: [{ status: "broken", range: "<0.129.0" }],
1187+ },
1188+ };
1189+
1190+ setServerConfigSnapshot({
1191+ ...createBaseServerConfig(),
1192+ providers: [incompatibleProvider],
1193+ });
1194+
1195+ mounted = await render(
1196+ <AppAtomRegistryProvider>
1197+ <ProviderSettingsPanel />
1198+ </AppAtomRegistryProvider>,
1199+ );
1200+
1201+ await page
1202+ .getByRole("button", { name: "Incompatible provider version — view details" })
1203+ .click();
1204+ await expect
1205+ .element(page.getByText("npm install -g @openai/codex@0.129.0"))
1206+ .toBeInTheDocument();
1207+ await page.getByRole("button", { name: "Update now" }).click();
1208+
1209+ expect(updateProvider).toHaveBeenCalledWith({
1210+ provider: ProviderDriverKind.make("codex"),
1211+ instanceId: ProviderInstanceId.make("codex"),
1212+ targetVersion: "0.129.0",
1213+ });
1214+ });
11581215 });
11591216
11601217 describe("SourceControlSettingsPanel discovery states", () => {
apps/web/src/components/settings/SettingsPanels.tsx+83−0
@@ -10,6 +10,7 @@ import { · 9 unmodified lines
1010 type ProviderInstanceConfig,
1111 type ProviderInstanceId,
1212 type ScopedThreadRef,
13+ type ServerProvider,
1314 } from "@t3tools/contracts";
1415 import { scopeThreadRef } from "@t3tools/client-runtime";
1516 import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";
@@ -63,6 +64,10 @@ import { · 47 unmodified lines
6364 type ProviderUpdateCandidate,
6465 } from "../ProviderUpdateLaunchNotification.logic";
6566 import { ProviderInstanceCard } from "./ProviderInstanceCard";
67+import {
68+ canRunProviderCompatibilityUpdate,
69+ getProviderCompatibilityUpdateCommand,
70+} from "./providerStatus";
6671 import { DRIVER_OPTIONS, getDriverOption } from "./providerDriverMeta";
6772 import {
6873 buildProviderInstanceUpdatePatch,
@@ -1010,6 +1015,55 @@ export function ProviderSettingsPanel() { · 941 unmodified lines
10101015 }
10111016 }, []);
10121017
1018+ const runProviderCompatibilityUpdate = useCallback(async (provider: ServerProvider) => {
1019+ const targetVersion = provider.compatibilityAdvisory?.recommendedVersion ?? null;
1020+ if (!targetVersion) {
1021+ return;
1022+ }
1023+
1024+ let started = false;
1025+ setUpdatingProviderDrivers((previous) => {
1026+ if (previous.has(provider.driver)) {
1027+ return previous;
1028+ }
1029+ started = true;
1030+ const next = new Set(previous);
1031+ next.add(provider.driver);
1032+ return next;
1033+ });
1034+ if (!started) {
1035+ return;
1036+ }
1037+
1038+ try {
1039+ await ensureLocalApi().server.updateProvider({
1040+ provider: provider.driver,
1041+ instanceId: provider.instanceId,
1042+ targetVersion,
1043+ });
1044+ } catch (error) {
1045+ toastManager.add(
1046+ stackedThreadToast({
1047+ type: "error",
1048+ title: `Could not update ${PROVIDER_DISPLAY_NAMES[provider.driver] ?? provider.driver}`,
1049+ description:
1050+ error instanceof Error
1051+ ? error.message
1052+ : "The provider update command could not be started.",
1053+ }),
1054+ );
1055+ } finally {
1056+ setUpdatingProviderDrivers((previous) => {
1057+ if (!previous.has(provider.driver)) {
1058+ return previous;
1059+ }
1060+ const next = new Set(previous);
1061+ next.delete(provider.driver);
1062+ return next;
1063+ });
1064+ }
1065+ }, []);
1066+
10131067 interface InstanceRow {
10141068 readonly instanceId: ProviderInstanceId;
10151069 readonly instance: ProviderInstanceConfig;
@@ -1245,6 +1299,22 @@ export function ProviderSettingsPanel() { · 229 unmodified lines
12451299 updateCandidate !== undefined &&
12461300 canOneClickUpdateProviderCandidate(updateCandidate, serverProviders) &&
12471301 !updatingProviderDrivers.has(updateCandidate.driver);
1302+ const compatibilityUpdateCommand = getProviderCompatibilityUpdateCommand(liveProvider);
1303+ const showInlineCompatibilityUpdateButton =
1304+ canRunProviderCompatibilityUpdate(liveProvider) &&
1305+ compatibilityUpdateCommand !== null &&
1306+ Boolean(liveProvider?.compatibilityAdvisory?.recommendedVersion);
1307+ const isCompatibilityUpdateRunning =
1308+ liveProvider !== undefined &&
1309+ (updatingProviderDrivers.has(liveProvider.driver) ||
1310+ serverProviders.some(
1311+ (provider) =>
1312+ provider.driver === liveProvider.driver && isProviderUpdateActive(provider),
1313+ ));
1314+ const canRunInlineCompatibilityUpdate =
1315+ showInlineCompatibilityUpdateButton &&
1316+ liveProvider !== undefined &&
1317+ !updatingProviderDrivers.has(liveProvider.driver);
12481318 const modelPreferences = settings.providerModelPreferences?.[row.instanceId] ?? {
12491319 hiddenModels: [],
12501320 modelOrder: [],
@@ -1318,6 +1388,19 @@ export function ProviderSettingsPanel() { · 67 unmodified lines
13181388 : undefined
13191389 }
13201390 isUpdating={showInlineUpdateButton ? isDriverUpdateRunning : undefined}
1391+ onRunCompatibilityUpdate={
1392+ showInlineCompatibilityUpdateButton && liveProvider
1393+ ? () => {
1394+ if (!canRunInlineCompatibilityUpdate) {
1395+ return;
1396+ }
1397+ void runProviderCompatibilityUpdate(liveProvider);
1398+ }
1399+ : undefined
1400+ }
1401+ isCompatibilityUpdating={
1402+ showInlineCompatibilityUpdateButton ? isCompatibilityUpdateRunning : undefined
1403+ }
13211404 />
13221405 );
13231406 })}
apps/web/src/components/settings/providerStatus.test.ts+31−1
@@ -1,6 +1,9 @@
11 import { describe, expect, it } from "vitest";
22
3−import { getProviderCompatibilityAdvisoryPresentation } from "./providerStatus";
3+import {
4+ getProviderCompatibilityAdvisoryPresentation,
5+ getProviderCompatibilityUpdateCommand,
6+} from "./providerStatus";
47
58 describe("getProviderCompatibilityAdvisoryPresentation", () => {
69 it("hides supported compatibility advisories", () => {
@@ -29,7 +32,34 @@ describe("getProviderCompatibilityAdvisoryPresentation", () => { · 22 unmodified lines
2932 ).toEqual({
3033 title: "Incompatible provider version",
3134 detail: "Known incompatible.",
35+ updateCommand: null,
36+ canUpdate: false,
3237 emphasis: "strong",
3338 });
3439 });
40+
41+ it("derives targeted compatibility update commands from package install commands", () => {
42+ expect(
43+ getProviderCompatibilityUpdateCommand({
44+ compatibilityAdvisory: {
45+ status: "broken",
46+ severity: "error",
47+ currentVersion: "0.128.0",
48+ message: "Known incompatible.",
49+ recommendedRange: ">=0.129.0",
50+ recommendedVersion: "0.129.0",
51+ ranges: [],
52+ },
53+ versionAdvisory: {
54+ status: "behind_latest",
55+ currentVersion: "0.128.0",
56+ latestVersion: "0.130.0",
57+ checkedAt: "2026-05-13T00:00:00.000Z",
58+ message: "Update available.",
59+ updateCommand: "vp i -g @openai/codex",
60+ canUpdate: true,
61+ },
62+ }),
63+ ).toBe("vp i -g @openai/codex@0.129.0");
64+ });
3565 });
apps/web/src/components/settings/providerStatus.ts+56−0
@@ -120,11 +120,65 @@ export function getProviderVersionAdvisoryPresentation( · 119 unmodified lines
120120 };
121121 }
122122
123+function makeTargetedUpdateCommand(input: {
124+ readonly updateCommand: string | null | undefined;
125+ readonly recommendedVersion: string | null | undefined;
126+}): string | null {
127+ if (!input.updateCommand || !input.recommendedVersion) {
128+ return null;
129+ }
130+ if (!input.updateCommand.includes("@latest")) {
131+ const packageNameMatch = input.updateCommand.match(
132+ /(?:^|\s)(@[^\s]+\/[^\s@]+|[^\s@]+)(?=\s*$)/,
133+ );
134+ if (!packageNameMatch?.[1]) {
135+ return null;
136+ }
137+ return input.updateCommand.replace(
138+ packageNameMatch[1],
139+ `${packageNameMatch[1]}@${input.recommendedVersion}`,
140+ );
141+ }
142+ return input.updateCommand.replace("@latest", `@${input.recommendedVersion}`);
143+}
144+
145+export function getProviderCompatibilityUpdateCommand(
146+ provider: Pick<ServerProvider, "compatibilityAdvisory" | "versionAdvisory"> | null | undefined,
147+): string | null {
148+ const compatibilityAdvisory = provider?.compatibilityAdvisory;
149+ if (!compatibilityAdvisory || compatibilityAdvisory.status === "supported") {
150+ return null;
151+ }
152+ return (
153+ compatibilityAdvisory.updateCommand ??
154+ makeTargetedUpdateCommand({
155+ updateCommand: provider.versionAdvisory?.updateCommand,
156+ recommendedVersion: compatibilityAdvisory.recommendedVersion,
157+ })
158+ );
159+}
160+
161+export function canRunProviderCompatibilityUpdate(
162+ provider: Pick<ServerProvider, "compatibilityAdvisory" | "versionAdvisory"> | null | undefined,
163+): boolean {
164+ const compatibilityAdvisory = provider?.compatibilityAdvisory;
165+ if (!compatibilityAdvisory || compatibilityAdvisory.status === "supported") {
166+ return false;
167+ }
168+ return (
169+ compatibilityAdvisory.canUpdate === true ||
170+ (provider?.versionAdvisory?.canUpdate === true &&
171+ getProviderCompatibilityUpdateCommand(provider) !== null)
172+ );
173+}
174+
123175 export function getProviderCompatibilityAdvisoryPresentation(
124176 advisory: ServerProviderCompatibilityAdvisory | undefined,
125177 ): {
126178 readonly title: string;
127179 readonly detail: string;
180+ readonly updateCommand: string | null;
181+ readonly canUpdate: boolean;
128182 readonly emphasis: "normal" | "strong";
129183 } | null {
130184 if (!advisory || advisory.status === "supported") {
@@ -142,6 +196,8 @@ export function getProviderCompatibilityAdvisoryPresentation( · 11 unmodified lines
142196 title:
143197 advisory.status === "broken" ? "Incompatible provider version" : "Provider version warning",
144198 detail: advisory.message ?? fallback,
199+ updateCommand: advisory.updateCommand ?? null,
200+ canUpdate: advisory.canUpdate === true,
145201 emphasis: advisory.severity === "error" ? "strong" : "normal",
146202 };
147203 }
packages/contracts/src/server.ts+3−0
@@ -160,6 +160,8 @@ export const ServerProviderCompatibilityAdvisory = Schema.Struct({ · 159 unmodified lines
160160 message: Schema.NullOr(TrimmedNonEmptyString),
161161 recommendedRange: Schema.NullOr(TrimmedNonEmptyString),
162162 recommendedVersion: Schema.optionalKey(Schema.NullOr(TrimmedNonEmptyString)),
163+ updateCommand: Schema.optionalKey(Schema.NullOr(TrimmedNonEmptyString)),
164+ canUpdate: Schema.optionalKey(Schema.Boolean),
163165 ranges: Schema.Array(ServerProviderCompatibilityRange).pipe(
164166 Schema.withDecodingDefault(Effect.succeed([])),
165167 ),
@@ -523,6 +525,7 @@ export type ServerProviderUpdatedPayload = typeof ServerProviderUpdatedPayload.T · 357 unmodified lines
523525 export const ServerProviderUpdateInput = Schema.Struct({
524526 provider: ProviderDriverKind,
525527 instanceId: Schema.optionalKey(ProviderInstanceId),
528+ targetVersion: Schema.optionalKey(TrimmedNonEmptyString),
526529 });
527530 export type ServerProviderUpdateInput = typeof ServerProviderUpdateInput.Type;
528531
provider-compatibility.v1.json+4−4
@@ -2,7 +2,7 @@ · 1 unmodified line
22 "version": 1,
33 "policies": [
44 {
5− "t3CodeRange": ">=0.0.24",
5+ "t3CodeRange": ">=0.0.0",
66 "driver": "codex",
77 "recommendedRange": ">=0.129.0",
88 "recommendedVersion": "0.129.0",
@@ -20,7 +20,7 @@ · 11 unmodified lines
2020 ]
2121 },
2222 {
23− "t3CodeRange": ">=0.0.24",
23+ "t3CodeRange": ">=0.0.0",
2424 "driver": "claudeAgent",
2525 "recommendedRange": ">=0.2.111",
2626 "recommendedVersion": "0.2.111",
@@ -33,7 +33,7 @@ · 6 unmodified lines
3333 ]
3434 },
3535 {
36− "t3CodeRange": ">=0.0.24",
36+ "t3CodeRange": ">=0.0.0",
3737 "driver": "opencode",
3838 "recommendedRange": ">=1.14.19",
3939 "recommendedVersion": "1.14.19",
@@ -51,7 +51,7 @@ · 11 unmodified lines
5151 ]
5252 },
5353 {
54− "t3CodeRange": ">=0.0.24",
54+ "t3CodeRange": ">=0.0.0",
5555 "driver": "cursor",
5656 "recommendedRange": ">=2026.05.09",
5757 "recommendedVersion": "2026.05.09",

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t