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
174 checkProvider,174 checkProvider,
175 enrichSnapshot: ({ snapshot, publishSnapshot }) =>175 enrichSnapshot: ({ snapshot, publishSnapshot }) =>
176 enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities).pipe(176 enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities).pipe(
177 Effect.flatMap(ProviderCompatibility.enrichProviderSnapshotWithCompatibilityAdvisory),177 Effect.flatMap((snapshot) =>
178 ProviderCompatibility.enrichProviderSnapshotWithTargetedCompatibilityAdvisory(
179 snapshot,
180 maintenanceCapabilities,
181 ),
182 ),
178 Effect.provideService(HttpClient.HttpClient, httpClient),183 Effect.provideService(HttpClient.HttpClient, httpClient),
179 Effect.provideService(184 Effect.provideService(
180 ProviderCompatibility.ProviderCompatibilityService,185 ProviderCompatibility.ProviderCompatibilityService,
apps/server/src/provider/Drivers/CodexDriver.ts+6−1
@@ -174,7 +174,12 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = { · 173 unmodified lines
174 checkProvider,174 checkProvider,
175 enrichSnapshot: ({ snapshot, publishSnapshot }) =>175 enrichSnapshot: ({ snapshot, publishSnapshot }) =>
176 enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities).pipe(176 enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities).pipe(
177 Effect.flatMap(ProviderCompatibility.enrichProviderSnapshotWithCompatibilityAdvisory),177 Effect.flatMap((snapshot) =>
178 ProviderCompatibility.enrichProviderSnapshotWithTargetedCompatibilityAdvisory(
179 snapshot,
180 maintenanceCapabilities,
181 ),
182 ),
178 Effect.provideService(HttpClient.HttpClient, httpClient),183 Effect.provideService(HttpClient.HttpClient, httpClient),
179 Effect.provideService(184 Effect.provideService(
180 ProviderCompatibility.ProviderCompatibilityService,185 ProviderCompatibility.ProviderCompatibilityService,
apps/server/src/provider/Drivers/OpenCodeDriver.ts+6−1
@@ -153,7 +153,12 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv> · 152 unmodified lines
153 checkProvider,153 checkProvider,
154 enrichSnapshot: ({ snapshot, publishSnapshot }) =>154 enrichSnapshot: ({ snapshot, publishSnapshot }) =>
155 enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities).pipe(155 enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities).pipe(
156 Effect.flatMap(ProviderCompatibility.enrichProviderSnapshotWithCompatibilityAdvisory),156 Effect.flatMap((snapshot) =>
157 ProviderCompatibility.enrichProviderSnapshotWithTargetedCompatibilityAdvisory(
158 snapshot,
159 maintenanceCapabilities,
160 ),
161 ),
157 Effect.provideService(HttpClient.HttpClient, httpClient),162 Effect.provideService(HttpClient.HttpClient, httpClient),
158 Effect.provideService(163 Effect.provideService(
159 ProviderCompatibility.ProviderCompatibilityService,164 ProviderCompatibility.ProviderCompatibilityService,
apps/server/src/provider/Layers/CursorProvider.ts+6−1
@@ -1247,7 +1247,12 @@ export const enrichCursorSnapshot = (input: { · 1246 unmodified lines
1247 snapshot,1247 snapshot,
1248 input.maintenanceCapabilities,1248 input.maintenanceCapabilities,
1249 ).pipe(1249 ).pipe(
1250 Effect.flatMap(ProviderCompatibility.enrichProviderSnapshotWithCompatibilityAdvisory),1250 Effect.flatMap((enrichedSnapshot) =>
1251 ProviderCompatibility.enrichProviderSnapshotWithTargetedCompatibilityAdvisory(
1252 enrichedSnapshot,
1253 input.maintenanceCapabilities,
1254 ),
1255 ),
1251 Effect.provideService(HttpClient.HttpClient, input.httpClient),1256 Effect.provideService(HttpClient.HttpClient, input.httpClient),
1252 Effect.flatMap((enrichedSnapshot) =>1257 Effect.flatMap((enrichedSnapshot) =>
1253 publishSnapshot(stampIdentity(enrichedSnapshot)).pipe(Effect.as(enrichedSnapshot)),1258 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
5import { HttpClient, HttpClientResponse } from "effect/unstable/http";5import { HttpClient, HttpClientResponse } from "effect/unstable/http";
6 6
7import * as ProviderCompatibility from "./ProviderCompatibility.ts";7import * as ProviderCompatibility from "./ProviderCompatibility.ts";
8import { makeProviderMaintenanceCapabilities } from "./providerMaintenance.ts";
8 9
9const codexDriver = ProviderDriverKind.make("codex");10const codexDriver = ProviderDriverKind.make("codex");
10const claudeDriver = ProviderDriverKind.make("claudeAgent");11const claudeDriver = ProviderDriverKind.make("claudeAgent");
@@ -26,6 +27,14 @@ const baseProvider: ServerProvider = { · 15 unmodified lines
26 skills: [],27 skills: [],
27};28};
28 29
30const 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
29function jsonHttpClient(38function jsonHttpClient(
30 responseForUrl: (url: string) => { readonly payload: unknown; readonly status?: number },39 responseForUrl: (url: string) => { readonly payload: unknown; readonly status?: number },
31): HttpClient.HttpClient {40): HttpClient.HttpClient {
@@ -87,12 +96,57 @@ describe("provider compatibility", () => { · 55 unmodified lines
87 });96 });
88 });97 });
89 98
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", () => {
91 const document: ProviderCompatibility.ProviderCompatibilityDocument = {100 const document: ProviderCompatibility.ProviderCompatibilityDocument = {
92 version: 1,101 version: 1,
93 policies: [102 policies: [
94 {103 {
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",
96 driver: codexDriver,150 driver: codexDriver,
97 recommendedRange: ">=0.129.0",151 recommendedRange: ">=0.129.0",
98 recommendedVersion: "0.129.0",152 recommendedVersion: "0.129.0",
@@ -102,14 +156,14 @@ describe("provider compatibility", () => { · 3 unmodified lines
102 ],156 ],
103 },157 },
104 {158 {
105 t3CodeRange: ">=0.0.24",159 t3CodeRange: ">=0.0.0",
106 driver: claudeDriver,160 driver: claudeDriver,
107 recommendedRange: ">=0.2.111",161 recommendedRange: ">=0.2.111",
108 recommendedVersion: "0.2.111",162 recommendedVersion: "0.2.111",
109 ranges: [{ status: "supported", range: ">=0.2.111" }],163 ranges: [{ status: "supported", range: ">=0.2.111" }],
110 },164 },
111 {165 {
112 t3CodeRange: ">=0.0.24",166 t3CodeRange: ">=0.0.0",
113 driver: opencodeDriver,167 driver: opencodeDriver,
114 recommendedRange: ">=1.14.19",168 recommendedRange: ">=1.14.19",
115 recommendedVersion: "1.14.19",169 recommendedVersion: "1.14.19",
@@ -119,7 +173,7 @@ describe("provider compatibility", () => { · 3 unmodified lines
119 ],173 ],
120 },174 },
121 {175 {
122 t3CodeRange: ">=0.0.24",176 t3CodeRange: ">=0.0.0",
123 driver: cursorDriver,177 driver: cursorDriver,
124 recommendedRange: ">=2026.05.09",178 recommendedRange: ">=2026.05.09",
125 recommendedVersion: "2026.05.09",179 recommendedVersion: "2026.05.09",
@@ -136,7 +190,7 @@ describe("provider compatibility", () => { · 10 unmodified lines
136 driver,190 driver,
137 currentVersion,191 currentVersion,
138 document,192 document,
139 t3CodeVersion: "0.0.24",193 t3CodeVersion: "0.0.22",
140 })?.status;194 })?.status;
141 195
142 expect(classify(codexDriver, "0.129.0")).toBe("supported");196 expect(classify(codexDriver, "0.129.0")).toBe("supported");
@@ -188,14 +242,18 @@ describe("provider compatibility", () => { · 45 unmodified lines
188 };242 };
189 243
190 return Effect.gen(function* () {244 return Effect.gen(function* () {
191 const enriched = yield* ProviderCompatibility.enrichProviderSnapshotWithCompatibilityAdvisory(245 const enriched =
192 baseProvider,246 yield* ProviderCompatibility.enrichProviderSnapshotWithTargetedCompatibilityAdvisory(
193 ).pipe(provideCompatibility(() => ({ payload: remoteDocument })));247 baseProvider,
248 codexNpmUpdateCapabilities,
249 ).pipe(provideCompatibility(() => ({ payload: remoteDocument })));
194 250
195 expect(enriched.status).toBe("error");251 expect(enriched.status).toBe("error");
196 expect(enriched.compatibilityAdvisory).toMatchObject({252 expect(enriched.compatibilityAdvisory).toMatchObject({
197 status: "broken",253 status: "broken",
198 recommendedVersion: "0.129.0",254 recommendedVersion: "0.129.0",
255 canUpdate: true,
256 updateCommand: "npm install -g @openai/codex@0.129.0",
199 });257 });
200 });258 });
201 });259 });
@@ -355,8 +413,8 @@ describe("provider compatibility", () => { · 153 unmodified lines
355 },413 },
356 ).pipe(provideCompatibility(() => ({ payload: {}, status: 404 })));414 ).pipe(provideCompatibility(() => ({ payload: {}, status: 404 })));
357 415
358 expect(enriched.status).toBe("ready");416 expect(enriched.status).toBe("error");
359 expect(enriched.compatibilityAdvisory).toBeUndefined();417 expect(enriched.compatibilityAdvisory).toMatchObject({ status: "broken" });
360 }),418 }),
361 );419 );
362});420});
apps/server/src/provider/ProviderCompatibility.ts+55−9
@@ -17,6 +17,10 @@ import { HttpClient, HttpClientRequest } from "effect/unstable/http"; · 16 unmodified lines
17 17
18import bundledCompatibilityDocumentJson from "../../../../provider-compatibility.v1.json" with { type: "json" };18import bundledCompatibilityDocumentJson from "../../../../provider-compatibility.v1.json" with { type: "json" };
19import packageJson from "../../package.json" with { type: "json" };19import packageJson from "../../package.json" with { type: "json" };
20import {
21 makeTargetedProviderUpdateAction,
22 type ProviderMaintenanceCapabilities,
23} from "./providerMaintenance.ts";
20 24
21const T3_CODE_VERSION = packageJson.version;25const T3_CODE_VERSION = packageJson.version;
22const REMOTE_COMPATIBILITY_CACHE_TTL = Duration.minutes(15);26const REMOTE_COMPATIBILITY_CACHE_TTL = Duration.minutes(15);
@@ -145,6 +149,7 @@ function createProviderCompatibilityAdvisoryFromDocument(input: { · 122 unmodified lines
145 readonly document: typeof RemoteCompatibilityDocument.Type;149 readonly document: typeof RemoteCompatibilityDocument.Type;
146 readonly driver: ProviderDriverKind;150 readonly driver: ProviderDriverKind;
147 readonly currentVersion: string | null;151 readonly currentVersion: string | null;
152 readonly maintenanceCapabilities?: ProviderMaintenanceCapabilities | undefined;
148 readonly t3CodeVersion?: string;153 readonly t3CodeVersion?: string;
149}): ServerProviderCompatibilityAdvisory | undefined {154}): ServerProviderCompatibilityAdvisory | undefined {
150 const policy = compatibilityPolicyForDriver({155 const policy = compatibilityPolicyForDriver({
@@ -163,6 +168,9 @@ function createProviderCompatibilityAdvisoryFromDocument(input: { · 12 unmodified lines
163 : policy.ranges.find((range) => satisfiesSemverRange(currentVersion, range.range));168 : policy.ranges.find((range) => satisfiesSemverRange(currentVersion, range.range));
164 const status = matchedRange?.status ?? (currentVersion === null ? "unknown" : "unsupported");169 const status = matchedRange?.status ?? (currentVersion === null ? "unknown" : "unsupported");
165 const recommendedVersion = policy.recommendedVersion ?? null;170 const recommendedVersion = policy.recommendedVersion ?? null;
171 const targetedUpdateAction = input.maintenanceCapabilities
172 ? makeTargetedProviderUpdateAction(input.maintenanceCapabilities, recommendedVersion)
173 : null;
166 174
167 return {175 return {
168 status,176 status,
@@ -176,20 +184,31 @@ function createProviderCompatibilityAdvisoryFromDocument(input: { · 7 unmodified lines
176 }),184 }),
177 recommendedRange: policy.recommendedRange,185 recommendedRange: policy.recommendedRange,
178 recommendedVersion,186 recommendedVersion,
187 updateCommand: targetedUpdateAction?.command ?? null,
188 canUpdate: targetedUpdateAction !== null,
179 ranges: [...policy.ranges],189 ranges: [...policy.ranges],
180 };190 };
181}191}
182 192
193function shouldSkipCompatibilityAdvisory(input: {
194 readonly snapshot: ProviderCompatibilitySnapshot;
195 readonly currentVersion: string | null;
196}): boolean {
197 return !input.snapshot.enabled && input.currentVersion === null;
198}
199
183export function createProviderCompatibilityAdvisory(input: {200export function createProviderCompatibilityAdvisory(input: {
184 readonly driver: ProviderDriverKind;201 readonly driver: ProviderDriverKind;
185 readonly currentVersion: string | null;202 readonly currentVersion: string | null;
186 readonly document?: typeof RemoteCompatibilityDocument.Type;203 readonly document?: typeof RemoteCompatibilityDocument.Type;
204 readonly maintenanceCapabilities?: ProviderMaintenanceCapabilities | undefined;
187 readonly t3CodeVersion?: string;205 readonly t3CodeVersion?: string;
188}): ServerProviderCompatibilityAdvisory | undefined {206}): ServerProviderCompatibilityAdvisory | undefined {
189 return createProviderCompatibilityAdvisoryFromDocument({207 return createProviderCompatibilityAdvisoryFromDocument({
190 document: input.document ?? bundledProviderCompatibilityDocument,208 document: input.document ?? bundledProviderCompatibilityDocument,
191 driver: input.driver,209 driver: input.driver,
192 currentVersion: input.currentVersion,210 currentVersion: input.currentVersion,
211 maintenanceCapabilities: input.maintenanceCapabilities,
193 ...(input.t3CodeVersion ? { t3CodeVersion: input.t3CodeVersion } : {}),212 ...(input.t3CodeVersion ? { t3CodeVersion: input.t3CodeVersion } : {}),
194 });213 });
195}214}
@@ -300,12 +319,23 @@ export function applyBundledProviderCompatibilityAdvisory< · 104 unmodified lines
300 readonly snapshot: Snapshot;319 readonly snapshot: Snapshot;
301 readonly driver: ProviderDriverKind;320 readonly driver: ProviderDriverKind;
302 readonly currentVersion: string | null;321 readonly currentVersion: string | null;
322 readonly maintenanceCapabilities?: ProviderMaintenanceCapabilities | undefined;
303}): Snapshot {323}): Snapshot {
324 if (
325 shouldSkipCompatibilityAdvisory({
326 snapshot: input.snapshot,
327 currentVersion: input.currentVersion,
328 })
329 ) {
330 return removeExistingCompatibilityAdvisory(input.snapshot);
331 }
332
304 return applyCompatibilityAdvisory(333 return applyCompatibilityAdvisory(
305 input.snapshot,334 input.snapshot,
306 createProviderCompatibilityAdvisory({335 createProviderCompatibilityAdvisory({
307 driver: input.driver,336 driver: input.driver,
308 currentVersion: input.currentVersion,337 currentVersion: input.currentVersion,
338 maintenanceCapabilities: input.maintenanceCapabilities,
309 }),339 }),
310 );340 );
311}341}
@@ -317,9 +347,19 @@ export const enrichProviderSnapshotWithCompatibilityAdvisory = Effect.fn( · 5 unmodified lines
317 return yield* compatibility.enrichSnapshot(snapshot);347 return yield* compatibility.enrichSnapshot(snapshot);
318});348});
319 349
350export 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
320export interface ProviderCompatibilityServiceShape {357export interface ProviderCompatibilityServiceShape {
321 readonly resolveRemoteDocument: Effect.Effect<typeof RemoteCompatibilityDocument.Type | null>;358 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>;
323}363}
324 364
325export class ProviderCompatibilityService extends Context.Service<365export class ProviderCompatibilityService extends Context.Service<
@@ -347,17 +387,23 @@ export const makeProviderCompatibilityService = Effect.fn("makeProviderCompatibi · 21 unmodified lines
347 387
348 return {388 return {
349 resolveRemoteDocument,389 resolveRemoteDocument,
350 enrichSnapshot: (snapshot) =>390 enrichSnapshot: (snapshot, maintenanceCapabilities) =>
351 resolveRemoteDocument.pipe(391 resolveRemoteDocument.pipe(
352 Effect.map((remoteDocument) =>392 Effect.map((remoteDocument) =>
353 applyCompatibilityAdvisory(393 shouldSkipCompatibilityAdvisory({
354 snapshot,394 snapshot,
355 createProviderCompatibilityAdvisory({395 currentVersion: snapshot.version,
356 driver: snapshot.driver,396 })
357 currentVersion: snapshot.version,397 ? removeExistingCompatibilityAdvisory(snapshot)
358 ...(remoteDocument ? { document: remoteDocument } : {}),398 : applyCompatibilityAdvisory(
359 }),399 snapshot,
360 ),400 createProviderCompatibilityAdvisory({
401 driver: snapshot.driver,
402 currentVersion: snapshot.version,
403 maintenanceCapabilities,
404 ...(remoteDocument ? { document: remoteDocument } : {}),
405 }),
406 ),
361 ),407 ),
362 ),408 ),
363 } satisfies ProviderCompatibilityServiceShape;409 } satisfies ProviderCompatibilityServiceShape;
apps/server/src/provider/providerMaintenance.test.ts+19−0
@@ -13,6 +13,7 @@ import { · 12 unmodified lines
13 makePackageManagedProviderMaintenanceResolver,13 makePackageManagedProviderMaintenanceResolver,
14 makeProviderMaintenanceCapabilities,14 makeProviderMaintenanceCapabilities,
15 makeStaticProviderMaintenanceResolver,15 makeStaticProviderMaintenanceResolver,
16 makeTargetedProviderUpdateAction,
16 normalizeCommandPath,17 normalizeCommandPath,
17 resolveProviderMaintenanceCapabilitiesEffect,18 resolveProviderMaintenanceCapabilitiesEffect,
18} from "./providerMaintenance.ts";19} from "./providerMaintenance.ts";
@@ -132,6 +133,24 @@ describe("providerMaintenance", () => { · 113 unmodified lines
132 });133 });
133 });134 });
134 135
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
135 it.effect(154 it.effect(
136 "switches package-managed providers to vite-plus updates when the resolved binary lives in vite-plus global bin",155 "switches package-managed providers to vite-plus updates when the resolved binary lives in vite-plus global bin",
137 () =>156 () =>
apps/server/src/provider/providerMaintenance.ts+26−0
@@ -108,6 +108,32 @@ export function makeManualOnlyProviderMaintenanceCapabilities(input: { · 107 unmodified lines
108 });108 });
109}109}
110 110
111export 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
111function makeNpmGlobalProviderMaintenanceCapabilities(137function makeNpmGlobalProviderMaintenanceCapabilities(
112 definition: PackageManagedProviderMaintenanceDefinition,138 definition: PackageManagedProviderMaintenanceDefinition,
113): ProviderMaintenanceCapabilities {139): ProviderMaintenanceCapabilities {
apps/server/src/provider/providerMaintenanceRunner.test.ts+30−0
@@ -288,6 +288,36 @@ describe("providerMaintenanceRunner", () => { · 287 unmodified lines
288 );288 );
289 });289 });
290 290
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
291 it.effect(321 it.effect(
292 "runs update commands through Effect ChildProcess when no test runner is injected",322 "runs update commands through Effect ChildProcess when no test runner is injected",
293 () => {323 () => {
apps/server/src/provider/providerMaintenanceRunner.ts+9−2
@@ -22,7 +22,10 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; · 21 unmodified lines
22 22
23import { ProviderRegistry } from "./Services/ProviderRegistry.ts";23import { ProviderRegistry } from "./Services/ProviderRegistry.ts";
24import { makeProviderMaintenanceCommandCoordinator } from "./providerMaintenanceCommandCoordinator.ts";24import { makeProviderMaintenanceCommandCoordinator } from "./providerMaintenanceCommandCoordinator.ts";
25import { enrichProviderSnapshotWithVersionAdvisory } from "./providerMaintenance.ts";25import {
26 enrichProviderSnapshotWithVersionAdvisory,
27 makeTargetedProviderUpdateAction,
28} from "./providerMaintenance.ts";
26import type { ProviderMaintenanceCapabilities } from "./providerMaintenance.ts";29import type { ProviderMaintenanceCapabilities } from "./providerMaintenance.ts";
27import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts";30import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts";
28const isServerProviderUpdateError = Schema.is(ServerProviderUpdateError);31const isServerProviderUpdateError = Schema.is(ServerProviderUpdateError);
@@ -46,6 +49,7 @@ export interface ProviderMaintenanceRunnerShape { · 17 unmodified lines
46 | {49 | {
47 readonly provider: ProviderDriverKind;50 readonly provider: ProviderDriverKind;
48 readonly instanceId?: ProviderInstanceId | undefined;51 readonly instanceId?: ProviderInstanceId | undefined;
52 readonly targetVersion?: string | undefined;
49 },53 },
50 ) => Effect.Effect<ServerProviderUpdatedPayload, ServerProviderUpdateError>;54 ) => Effect.Effect<ServerProviderUpdatedPayload, ServerProviderUpdateError>;
51}55}
@@ -283,12 +287,15 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { · 231 unmodified lines
283 typeof target === "string"287 typeof target === "string"
284 ? defaultInstanceIdForDriver(provider)288 ? defaultInstanceIdForDriver(provider)
285 : (target.instanceId ?? defaultInstanceIdForDriver(provider));289 : (target.instanceId ?? defaultInstanceIdForDriver(provider));
290 const targetVersion = typeof target === "string" ? undefined : target.targetVersion;
286 const targetKey = `instance:${instanceId}`;291 const targetKey = `instance:${instanceId}`;
287 const capabilities = yield* providerRegistry.getProviderMaintenanceCapabilitiesForInstance(292 const capabilities = yield* providerRegistry.getProviderMaintenanceCapabilitiesForInstance(
288 instanceId,293 instanceId,
289 provider,294 provider,
290 );295 );
291 const update = capabilities.update;296 const update = targetVersion
297 ? makeTargetedProviderUpdateAction(capabilities, targetVersion)
298 : capabilities.update;
292 if (!update) {299 if (!update) {
293 return yield* new ServerProviderUpdateError({300 return yield* new ServerProviderUpdateError({
294 provider,301 provider,
apps/web/src/components/ChatView.tsx+192−29
@@ -42,7 +42,7 @@ import { useGitStatus } from "~/lib/gitStatusState"; · 41 unmodified lines
42import { usePrimaryEnvironmentId } from "../environments/primary";42import { usePrimaryEnvironmentId } from "../environments/primary";
43import { readEnvironmentApi } from "../environmentApi";43import { readEnvironmentApi } from "../environmentApi";
44import { isElectron } from "../env";44import { isElectron } from "../env";
45import { readLocalApi } from "../localApi";45import { ensureLocalApi, readLocalApi } from "../localApi";
46import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";46import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";
47import {47import {
48 collapseExpandedComposerCursor,48 collapseExpandedComposerCursor,
@@ -104,7 +104,14 @@ import { BranchToolbar } from "./BranchToolbar"; · 55 unmodified lines
104import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";104import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";
105import PlanSidebar from "./PlanSidebar";105import PlanSidebar from "./PlanSidebar";
106import ThreadTerminalDrawer from "./ThreadTerminalDrawer";106import ThreadTerminalDrawer from "./ThreadTerminalDrawer";
107import { ChevronDownIcon, TriangleAlertIcon, WifiOffIcon } from "lucide-react";107import {
108 ChevronDownIcon,
109 CircleAlertIcon,
110 DownloadIcon,
111 LoaderIcon,
112 TriangleAlertIcon,
113 WifiOffIcon,
114} from "lucide-react";
108import { cn, randomUUID } from "~/lib/utils";115import { cn, randomUUID } from "~/lib/utils";
109import { stackedThreadToast, toastManager } from "./ui/toast";116import { stackedThreadToast, toastManager } from "./ui/toast";
110import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings";117import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings";
@@ -147,7 +154,6 @@ import { ChatHeader } from "./chat/ChatHeader"; · 36 unmodified lines
147import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview";154import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview";
148import { NoActiveThreadState } from "./NoActiveThreadState";155import { NoActiveThreadState } from "./NoActiveThreadState";
149import { resolveEffectiveEnvMode, resolveEnvironmentOptionLabel } from "./BranchToolbar.logic";156import { resolveEffectiveEnvMode, resolveEnvironmentOptionLabel } from "./BranchToolbar.logic";
150import { ProviderStatusBanner } from "./chat/ProviderStatusBanner";
151import { ThreadErrorBanner } from "./chat/ThreadErrorBanner";157import { ThreadErrorBanner } from "./chat/ThreadErrorBanner";
152import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack";158import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack";
153import {159import {
@@ -183,6 +189,11 @@ import { sanitizeThreadErrorMessage } from "~/rpc/transportError"; · 29 unmodified lines
183import { retainThreadDetailSubscription } from "../environments/runtime/service";189import { retainThreadDetailSubscription } from "../environments/runtime/service";
184import { RightPanelSheet } from "./RightPanelSheet";190import { RightPanelSheet } from "./RightPanelSheet";
185import { Button } from "./ui/button";191import { Button } from "./ui/button";
192import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover";
193import {
194 canRunProviderCompatibilityUpdate,
195 getProviderCompatibilityUpdateCommand,
196} from "./settings/providerStatus";
186import {197import {
187 buildVersionMismatchDismissalKey,198 buildVersionMismatchDismissalKey,
188 dismissVersionMismatch,199 dismissVersionMismatch,
@@ -197,6 +208,75 @@ const EMPTY_PROPOSED_PLANS: Thread["proposedPlans"] = []; · 8 unmodified lines
197const EMPTY_PROVIDERS: ServerProvider[] = [];208const EMPTY_PROVIDERS: ServerProvider[] = [];
198const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = [];209const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = [];
199const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {};210const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {};
211
212function 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
220function 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
200type EnvironmentUnavailableState = {280type EnvironmentUnavailableState = {
201 readonly environmentId: EnvironmentId;281 readonly environmentId: EnvironmentId;
202 readonly label: string;282 readonly label: string;
@@ -1150,6 +1230,8 @@ export default function ChatView(props: ChatViewProps) { · 947 unmodified lines
1150 const [dismissedVersionMismatchKey, setDismissedVersionMismatchKey] = useState<string | null>(1230 const [dismissedVersionMismatchKey, setDismissedVersionMismatchKey] = useState<string | null>(
1151 null,1231 null,
1152 );1232 );
1233 const [compatibilityUpdatingProviderId, setCompatibilityUpdatingProviderId] =
1234 useState<ProviderInstanceId | null>(null);
1153 const versionMismatchDismissed =1235 const versionMismatchDismissed =
1154 versionMismatchDismissKey === dismissedVersionMismatchKey ||1236 versionMismatchDismissKey === dismissedVersionMismatchKey ||
1155 isVersionMismatchDismissed(versionMismatchDismissKey);1237 isVersionMismatchDismissed(versionMismatchDismissKey);
@@ -1178,8 +1260,112 @@ export default function ChatView(props: ChatViewProps) { · 22 unmodified lines
1178 savedEnvironmentRuntimeById,1260 savedEnvironmentRuntimeById,
1179 serverConfig?.environment.label,1261 serverConfig?.environment.label,
1180 ]);1262 ]);
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]);
1181 const composerBannerItems = useMemo<ComposerBannerStackItem[]>(() => {1319 const composerBannerItems = useMemo<ComposerBannerStackItem[]>(() => {
1182 const items: ComposerBannerStackItem[] = [];1320 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 }
1183 if (activeEnvironmentUnavailableState) {1369 if (activeEnvironmentUnavailableState) {
1184 items.push({1370 items.push({
1185 id: `environment-unavailable:${activeEnvironmentUnavailableState.environmentId}`,1371 id: `environment-unavailable:${activeEnvironmentUnavailableState.environmentId}`,
@@ -1248,20 +1434,17 @@ export default function ChatView(props: ChatViewProps) { · 62 unmodified lines
1248 return items;1434 return items;
1249 }, [1435 }, [
1250 activeEnvironmentUnavailableState,1436 activeEnvironmentUnavailableState,
1437 activeProviderStatus,
1438 compatibilityUpdatingProviderId,
1251 handleReconnectActiveEnvironment,1439 handleReconnectActiveEnvironment,
1252 navigate,1440 navigate,
1253 reconnectingEnvironmentId,1441 reconnectingEnvironmentId,
1442 runActiveProviderCompatibilityUpdate,
1254 showVersionMismatchBanner,1443 showVersionMismatchBanner,
1255 versionMismatch,1444 versionMismatch,
1256 versionMismatchDismissKey,1445 versionMismatchDismissKey,
1257 versionMismatchServerLabel,1446 versionMismatchServerLabel,
1258 ]);1447 ]);
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;
1265 const phase = derivePhase(activeThread?.session ?? null);1448 const phase = derivePhase(activeThread?.session ?? null);
1266 const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES;1449 const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES;
1267 const workLogEntries = useMemo(1450 const workLogEntries = useMemo(
@@ -1633,24 +1816,6 @@ export default function ChatView(props: ChatViewProps) { · 365 unmodified lines
1633 const gitStatusQuery = useGitStatus({ environmentId, cwd: gitCwd });1816 const gitStatusQuery = useGitStatus({ environmentId, cwd: gitCwd });
1634 const keybindings = useServerKeybindings();1817 const keybindings = useServerKeybindings();
1635 const availableEditors = useServerAvailableEditors();1818 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]);
1654 const activeProjectCwd = activeProject?.cwd ?? null;1819 const activeProjectCwd = activeProject?.cwd ?? null;
1655 const activeThreadWorktreePath = activeThread?.worktreePath ?? null;1820 const activeThreadWorktreePath = activeThread?.worktreePath ?? null;
1656 const activeWorkspaceRoot = activeThreadWorktreePath ?? activeProjectCwd ?? undefined;1821 const activeWorkspaceRoot = activeThreadWorktreePath ?? activeProjectCwd ?? undefined;
@@ -3540,8 +3705,6 @@ export default function ChatView(props: ChatViewProps) { · 1883 unmodified lines
3540 />3705 />
3541 </header>3706 </header>
3542 3707
3543 {/* Error banner */}
3544 <ProviderStatusBanner status={activeProviderStatus} />
3545 <ThreadErrorBanner3708 <ThreadErrorBanner
3546 error={activeThread.error}3709 error={activeThread.error}
3547 onDismiss={() => setThreadError(activeThread.id, null)}3710 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
302 isSendBusy: boolean;302 isSendBusy: boolean;
303 isConnecting: boolean;303 isConnecting: boolean;
304 isEnvironmentUnavailable: boolean;304 isEnvironmentUnavailable: boolean;
305 isProviderUnavailable: boolean;
305 hasSendableContent: boolean;306 hasSendableContent: boolean;
306 preserveComposerFocusOnPointerDown?: boolean;307 preserveComposerFocusOnPointerDown?: boolean;
307 onPreviousPendingQuestion: () => void;308 onPreviousPendingQuestion: () => void;
@@ -323,6 +324,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( · 15 unmodified lines
323 isSendBusy={props.isSendBusy}324 isSendBusy={props.isSendBusy}
324 isConnecting={props.isConnecting}325 isConnecting={props.isConnecting}
325 isEnvironmentUnavailable={props.isEnvironmentUnavailable}326 isEnvironmentUnavailable={props.isEnvironmentUnavailable}
327 isProviderUnavailable={props.isProviderUnavailable}
326 isPreparingWorktree={props.isPreparingWorktree}328 isPreparingWorktree={props.isPreparingWorktree}
327 hasSendableContent={props.hasSendableContent}329 hasSendableContent={props.hasSendableContent}
328 preserveComposerFocusOnPointerDown={props.preserveComposerFocusOnPointerDown ?? false}330 preserveComposerFocusOnPointerDown={props.preserveComposerFocusOnPointerDown ?? false}
@@ -716,6 +718,11 @@ export const ChatComposer = memo( · 387 unmodified lines
716 () => selectedProviderEntry?.snapshot ?? null,718 () => selectedProviderEntry?.snapshot ?? null,
717 [selectedProviderEntry],719 [selectedProviderEntry],
718 );720 );
721 const selectedProviderUnavailableMessage =
722 selectedProviderStatus?.status === "error"
723 ? (selectedProviderStatus.message ?? "Selected provider is unavailable.")
724 : null;
725 const isSelectedProviderUnavailable = selectedProviderUnavailableMessage !== null;
719 const selectedProviderModels = useMemo<ReadonlyArray<ServerProvider["models"][number]>>(726 const selectedProviderModels = useMemo<ReadonlyArray<ServerProvider["models"][number]>>(
720 () => selectedProviderEntry?.models ?? [],727 () => selectedProviderEntry?.models ?? [],
721 [selectedProviderEntry],728 [selectedProviderEntry],
@@ -1053,7 +1060,11 @@ export const ChatComposer = memo( · 331 unmodified lines
1053 [activePendingIsResponding, activePendingProgress, activePendingResolvedAnswers],1060 [activePendingIsResponding, activePendingProgress, activePendingResolvedAnswers],
1054 );1061 );
1055 const collapsedComposerPrimaryActionDisabled =1062 const collapsedComposerPrimaryActionDisabled =
1056 phase === "running" || isSendBusy || isConnecting || !composerSendState.hasSendableContent;1063 phase === "running" ||
1064 isSendBusy ||
1065 isConnecting ||
1066 isSelectedProviderUnavailable ||
1067 !composerSendState.hasSendableContent;
1057 const collapsedComposerPrimaryActionLabel = "Send message";1068 const collapsedComposerPrimaryActionLabel = "Send message";
1058 const showMobilePendingAnswerActions =1069 const showMobilePendingAnswerActions =
1059 isMobileViewport && !isComposerCollapsedMobile && pendingPrimaryAction !== null;1070 isMobileViewport && !isComposerCollapsedMobile && pendingPrimaryAction !== null;
@@ -1619,12 +1630,21 @@ export const ChatComposer = memo( · 559 unmodified lines
1619 1630
1620 const submitComposer = useCallback(1631 const submitComposer = useCallback(
1621 (event?: { preventDefault: () => void }) => {1632 (event?: { preventDefault: () => void }) => {
1633 if (isSelectedProviderUnavailable) {
1634 event?.preventDefault();
1635 return;
1636 }
1622 onSend(event);1637 onSend(event);
1623 if (shouldBlurMobileComposerOnSubmit()) {1638 if (shouldBlurMobileComposerOnSubmit()) {
1624 blurMobileComposerAfterSend();1639 blurMobileComposerAfterSend();
1625 }1640 }
1626 },1641 },
1627 [blurMobileComposerAfterSend, onSend, shouldBlurMobileComposerOnSubmit],1642 [
1643 blurMobileComposerAfterSend,
1644 isSelectedProviderUnavailable,
1645 onSend,
1646 shouldBlurMobileComposerOnSubmit,
1647 ],
1628 );1648 );
1629 const expandMobileComposer = useCallback(() => {1649 const expandMobileComposer = useCallback(() => {
1630 if (composerBlurFrameRef.current !== null) {1650 if (composerBlurFrameRef.current !== null) {
@@ -2079,6 +2099,7 @@ export const ChatComposer = memo( · 448 unmodified lines
2079 isSendBusy={isSendBusy}2099 isSendBusy={isSendBusy}
2080 isConnecting={isConnecting}2100 isConnecting={isConnecting}
2081 isEnvironmentUnavailable={environmentUnavailable !== null}2101 isEnvironmentUnavailable={environmentUnavailable !== null}
2102 isProviderUnavailable={isSelectedProviderUnavailable}
2082 isPreparingWorktree={false}2103 isPreparingWorktree={false}
2083 hasSendableContent={false}2104 hasSendableContent={false}
2084 preserveComposerFocusOnPointerDown2105 preserveComposerFocusOnPointerDown
@@ -2265,12 +2286,15 @@ export const ChatComposer = memo( · 180 unmodified lines
2265 ? "connecting"2286 ? "connecting"
2266 : "disconnected"2287 : "disconnected"
2267 }`2288 }`
2268 : phase === "disconnected"2289 : selectedProviderUnavailableMessage
2269 ? "Ask for follow-up changes or attach images"2290 ? selectedProviderUnavailableMessage
2270 : "Ask anything, @tag files/folders, $use skills, or / for commands"2291 : phase === "disconnected"
2292 ? "Ask for follow-up changes or attach images"
2293 : "Ask anything, @tag files/folders, $use skills, or / for commands"
2271 }2294 }
2272 disabled={2295 disabled={
2273 isConnecting ||2296 isConnecting ||
2297 isSelectedProviderUnavailable ||
2274 isComposerApprovalState ||2298 isComposerApprovalState ||
2275 (environmentUnavailable !== null && activePendingProgress === null)2299 (environmentUnavailable !== null && activePendingProgress === null)
2276 }2300 }
@@ -2289,6 +2313,7 @@ export const ChatComposer = memo( · 12 unmodified lines
2289 isSendBusy={isSendBusy}2313 isSendBusy={isSendBusy}
2290 isConnecting={isConnecting}2314 isConnecting={isConnecting}
2291 isEnvironmentUnavailable={environmentUnavailable !== null}2315 isEnvironmentUnavailable={environmentUnavailable !== null}
2316 isProviderUnavailable={isSelectedProviderUnavailable}
2292 isPreparingWorktree={false}2317 isPreparingWorktree={false}
2293 hasSendableContent={false}2318 hasSendableContent={false}
2294 preserveComposerFocusOnPointerDown2319 preserveComposerFocusOnPointerDown
@@ -2405,6 +2430,7 @@ export const ChatComposer = memo( · 110 unmodified lines
2405 isSendBusy={isSendBusy}2430 isSendBusy={isSendBusy}
2406 isConnecting={isConnecting}2431 isConnecting={isConnecting}
2407 isEnvironmentUnavailable={environmentUnavailable !== null}2432 isEnvironmentUnavailable={environmentUnavailable !== null}
2433 isProviderUnavailable={isSelectedProviderUnavailable}
2408 isPreparingWorktree={isPreparingWorktree}2434 isPreparingWorktree={isPreparingWorktree}
2409 hasSendableContent={composerSendState.hasSendableContent}2435 hasSendableContent={composerSendState.hasSendableContent}
2410 preserveComposerFocusOnPointerDown={isMobileViewport}2436 preserveComposerFocusOnPointerDown={isMobileViewport}
apps/web/src/components/chat/ComposerPrimaryActions.tsx+29−14
@@ -21,6 +21,7 @@ interface ComposerPrimaryActionsProps { · 20 unmodified lines
21 isSendBusy: boolean;21 isSendBusy: boolean;
22 isConnecting: boolean;22 isConnecting: boolean;
23 isEnvironmentUnavailable: boolean;23 isEnvironmentUnavailable: boolean;
24 isProviderUnavailable: boolean;
24 isPreparingWorktree: boolean;25 isPreparingWorktree: boolean;
25 hasSendableContent: boolean;26 hasSendableContent: boolean;
26 preserveComposerFocusOnPointerDown?: boolean;27 preserveComposerFocusOnPointerDown?: boolean;
@@ -60,6 +61,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ · 33 unmodified lines
60 isSendBusy,61 isSendBusy,
61 isConnecting,62 isConnecting,
62 isEnvironmentUnavailable,63 isEnvironmentUnavailable,
64 isProviderUnavailable,
63 isPreparingWorktree,65 isPreparingWorktree,
64 hasSendableContent,66 hasSendableContent,
65 preserveComposerFocusOnPointerDown = false,67 preserveComposerFocusOnPointerDown = false,
@@ -107,6 +109,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ · 41 unmodified lines
107 {...pointerFocusProps}109 {...pointerFocusProps}
108 disabled={110 disabled={
109 isEnvironmentUnavailable ||111 isEnvironmentUnavailable ||
112 isProviderUnavailable ||
110 pendingAction.isResponding ||113 pendingAction.isResponding ||
111 (pendingAction.isLastQuestion ? !pendingAction.isComplete : !pendingAction.canAdvance)114 (pendingAction.isLastQuestion ? !pendingAction.isComplete : !pendingAction.canAdvance)
112 }115 }
@@ -146,7 +149,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ · 33 unmodified lines
146 size="sm"149 size="sm"
147 className={cn("rounded-full", compact ? "h-9 px-3 sm:h-8" : "h-9 px-4 sm:h-8")}150 className={cn("rounded-full", compact ? "h-9 px-3 sm:h-8" : "h-9 px-4 sm:h-8")}
148 {...pointerFocusProps}151 {...pointerFocusProps}
149 disabled={isSendBusy || isConnecting || isEnvironmentUnavailable}152 disabled={isSendBusy || isConnecting || isEnvironmentUnavailable || isProviderUnavailable}
150 >153 >
151 {isConnecting || isSendBusy ? "Sending..." : "Refine"}154 {isConnecting || isSendBusy ? "Sending..." : "Refine"}
152 </Button>155 </Button>
@@ -160,7 +163,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ · 7 unmodified lines
160 size="sm"163 size="sm"
161 className="h-9 rounded-l-full rounded-r-none px-4 sm:h-8"164 className="h-9 rounded-l-full rounded-r-none px-4 sm:h-8"
162 {...pointerFocusProps}165 {...pointerFocusProps}
163 disabled={isSendBusy || isConnecting || isEnvironmentUnavailable}166 disabled={isSendBusy || isConnecting || isEnvironmentUnavailable || isProviderUnavailable}
164 >167 >
165 {isConnecting || isSendBusy ? "Sending..." : "Implement"}168 {isConnecting || isSendBusy ? "Sending..." : "Implement"}
166 </Button>169 </Button>
@@ -173,7 +176,9 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ · 6 unmodified lines
173 className="h-9 rounded-l-none rounded-r-full border-l-white/12 px-2 sm:h-8"176 className="h-9 rounded-l-none rounded-r-full border-l-white/12 px-2 sm:h-8"
174 aria-label="Implementation actions"177 aria-label="Implementation actions"
175 {...pointerFocusProps}178 {...pointerFocusProps}
176 disabled={isSendBusy || isConnecting || isEnvironmentUnavailable}179 disabled={
180 isSendBusy || isConnecting || isEnvironmentUnavailable || isProviderUnavailable
181 }
177 />182 />
178 }183 }
179 >184 >
@@ -181,7 +186,9 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ · 1 unmodified line
181 </MenuTrigger>186 </MenuTrigger>
182 <MenuPopup align="end" side="top">187 <MenuPopup align="end" side="top">
183 <MenuItem188 <MenuItem
184 disabled={isSendBusy || isConnecting || isEnvironmentUnavailable}189 disabled={
190 isSendBusy || isConnecting || isEnvironmentUnavailable || isProviderUnavailable
191 }
185 onClick={() => void onImplementPlanInNewThread()}192 onClick={() => void onImplementPlanInNewThread()}
186 >193 >
187 Implement in a new thread194 Implement in a new thread
@@ -197,17 +204,25 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ · 9 unmodified lines
197 type="submit"204 type="submit"
198 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"205 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"
199 {...pointerFocusProps}206 {...pointerFocusProps}
200 disabled={isSendBusy || isConnecting || isEnvironmentUnavailable || !hasSendableContent}207 disabled={
208 isSendBusy ||
209 isConnecting ||
210 isEnvironmentUnavailable ||
211 isProviderUnavailable ||
212 !hasSendableContent
213 }
201 aria-label={214 aria-label={
202 isEnvironmentUnavailable215 isProviderUnavailable
203 ? "Environment disconnected"216 ? "Provider unavailable"
204 : isConnecting217 : isEnvironmentUnavailable
205 ? "Connecting"218 ? "Environment disconnected"
206 : isPreparingWorktree219 : isConnecting
207 ? "Preparing worktree"220 ? "Connecting"
208 : isSendBusy221 : isPreparingWorktree
209 ? "Sending"222 ? "Preparing worktree"
210 : "Send message"223 : isSendBusy
224 ? "Sending"
225 : "Send message"
211 }226 }
212 >227 >
213 {isConnecting || isSendBusy ? (228 {isConnecting || isSendBusy ? (
apps/web/src/components/chat/ProviderStatusBanner.tsx+0−34
@@ -1,34 +0,0 @@
1import { type ServerProvider } from "@t3tools/contracts";
2import { memo } from "react";
3import { Alert, AlertDescription, AlertTitle } from "../ui/alert";
4import { CircleAlertIcon } from "lucide-react";
5import { formatProviderDriverKindLabel } from "../../providerModels";
6
7export 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 @@
1import { memo } from "react";1import { memo } from "react";
2import { Alert, AlertAction, AlertDescription } from "../ui/alert";
3import { CircleAlertIcon, XIcon } from "lucide-react";2import { CircleAlertIcon, XIcon } from "lucide-react";
4 3
4import { Alert, AlertAction, AlertDescription } from "../ui/alert";
5
5export const ThreadErrorBanner = memo(function ThreadErrorBanner({6export const ThreadErrorBanner = memo(function ThreadErrorBanner({
6 error,7 error,
7 onDismiss,8 onDismiss,
@@ -11,7 +12,7 @@ export const ThreadErrorBanner = memo(function ThreadErrorBanner({ · 3 unmodified lines
11}) {12}) {
12 if (!error) return null;13 if (!error) return null;
13 return (14 return (
14 <div className="pt-3 mx-auto max-w-3xl">15 <div className="mx-auto max-w-3xl pt-3">
15 <Alert variant="error">16 <Alert variant="error">
16 <CircleAlertIcon />17 <CircleAlertIcon />
17 <AlertDescription className="line-clamp-3" title={error}>18 <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
39import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon";39import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon";
40import { RedactedSensitiveText } from "./RedactedSensitiveText";40import { RedactedSensitiveText } from "./RedactedSensitiveText";
41import {41import {
42 getProviderCompatibilityUpdateCommand,
42 getProviderVersionAdvisoryPresentation,43 getProviderVersionAdvisoryPresentation,
43 PROVIDER_STATUS_STYLES,44 PROVIDER_STATUS_STYLES,
44 getProviderCompatibilityAdvisoryPresentation,45 getProviderCompatibilityAdvisoryPresentation,
@@ -57,6 +58,8 @@ const PROVIDER_ACCENT_SWATCHES = [ · 12 unmodified lines
57] as const;58] as const;
58 59
59const ENVIRONMENT_VARIABLE_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/;60const ENVIRONMENT_VARIABLE_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
61const ADVISORY_ICON_CLUSTER_CLASS = "-my-0.5 inline-flex h-5 shrink-0 items-center gap-2";
62const ADVISORY_ICON_SLOT_CLASS = "inline-flex size-5 shrink-0 items-center justify-center";
60 63
61let environmentVariableDraftId = 0;64let environmentVariableDraftId = 0;
62const nextEnvironmentVariableDraftId = () => `provider-env-${environmentVariableDraftId++}`;65const nextEnvironmentVariableDraftId = () => `provider-env-${environmentVariableDraftId++}`;
@@ -422,6 +425,8 @@ interface ProviderInstanceCardProps { · 359 unmodified lines
422 readonly onModelOrderChange: (next: ReadonlyArray<string>) => void;425 readonly onModelOrderChange: (next: ReadonlyArray<string>) => void;
423 readonly onRunUpdate?: (() => void) | undefined;426 readonly onRunUpdate?: (() => void) | undefined;
424 readonly isUpdating?: boolean | undefined;427 readonly isUpdating?: boolean | undefined;
428 readonly onRunCompatibilityUpdate?: (() => void) | undefined;
429 readonly isCompatibilityUpdating?: boolean | undefined;
425}430}
426 431
427/**432/**
@@ -466,6 +471,8 @@ export function ProviderInstanceCard({ · 38 unmodified lines
466 onModelOrderChange,471 onModelOrderChange,
467 onRunUpdate,472 onRunUpdate,
468 isUpdating = false,473 isUpdating = false,
474 onRunCompatibilityUpdate,
475 isCompatibilityUpdating = false,
469}: ProviderInstanceCardProps) {476}: ProviderInstanceCardProps) {
470 const enabled = instance.enabled ?? true;477 const enabled = instance.enabled ?? true;
471 // The server-reported status wins when present; otherwise fall back to478 // The server-reported status wins when present; otherwise fall back to
@@ -487,7 +494,9 @@ export function ProviderInstanceCard({ · 15 unmodified lines
487 liveProvider?.compatibilityAdvisory,494 liveProvider?.compatibilityAdvisory,
488 );495 );
489 const versionAdvisory = getProviderVersionAdvisoryPresentation(liveProvider?.versionAdvisory);496 const versionAdvisory = getProviderVersionAdvisoryPresentation(liveProvider?.versionAdvisory);
497 const hasProviderAdvisoryIcons = compatibilityAdvisory !== null || versionAdvisory !== null;
490 const updateCommand = versionAdvisory?.updateCommand ?? null;498 const updateCommand = versionAdvisory?.updateCommand ?? null;
499 const compatibilityUpdateCommand = getProviderCompatibilityUpdateCommand(liveProvider);
491 const FallbackIconComponent = driverOption?.icon;500 const FallbackIconComponent = driverOption?.icon;
492 const displayName =501 const displayName =
493 instance.displayName?.trim() || driverOption?.label || String(instance.driver);502 instance.displayName?.trim() || driverOption?.label || String(instance.driver);
@@ -681,134 +690,200 @@ export function ProviderInstanceCard({ · 187 unmodified lines
681 <div className="min-w-0 flex-1 space-y-1">690 <div className="min-w-0 flex-1 space-y-1">
682 <div className="flex min-w-0 flex-wrap items-center gap-2">691 <div className="flex min-w-0 flex-wrap items-center gap-2">
683 {titleHeadNode}692 {titleHeadNode}
684 {versionCodeNode}693 {versionCodeNode || hasProviderAdvisoryIcons ? (
685 {compatibilityAdvisory ? (694 <span className={ADVISORY_ICON_CLUSTER_CLASS}>
686 <Popover>695 {versionCodeNode}
687 <PopoverTrigger696 {compatibilityAdvisory ? (
688 render={697 <span className={ADVISORY_ICON_SLOT_CLASS}>
689 <Button698 <Popover>
690 type="button"699 <PopoverTrigger
691 size="icon-xs"700 render={
692 variant="ghost"701 <Button
693 className={cn(702 type="button"
694 "size-5 rounded-sm p-0",703 size="icon-xs"
695 compatibilityAdvisory.emphasis === "strong"704 variant="ghost"
696 ? "text-destructive hover:text-destructive"705 className={cn(
697 : "text-warning hover:text-warning",706 "size-5 rounded-sm p-0",
698 )}707 compatibilityAdvisory.emphasis === "strong"
699 aria-label={`${compatibilityAdvisory.title} — view details`}708 ? "text-destructive hover:text-destructive"
700 >709 : "text-warning hover:text-warning",
701 <AlertTriangleIcon className="size-3.5" />710 )}
702 </Button>711 aria-label={`${compatibilityAdvisory.title} — view details`}
703 }712 >
704 />713 <AlertTriangleIcon className="size-3.5" />
705 <PopoverPopup side="bottom" align="start" className="w-84">714 </Button>
706 <div className="grid gap-1">715 }
707 <p className="text-[13px] font-semibold leading-tight text-foreground">716 />
708 {compatibilityAdvisory.title}717 <PopoverPopup side="bottom" align="start" className="w-84">
709 </p>718 <div className="grid gap-3">
710 <p719 <div className="grid gap-0.5">
711 className={cn(720 <p className="text-[13px] font-semibold leading-tight text-foreground">
712 "text-xs leading-snug",721 {compatibilityAdvisory.title}
713 compatibilityAdvisory.emphasis === "strong"722 </p>
714 ? "text-destructive"723 <p
715 : "text-warning",724 className={cn(
716 )}725 "text-xs leading-snug",
717 >726 compatibilityAdvisory.emphasis === "strong"
718 {compatibilityAdvisory.detail}727 ? "text-destructive"
719 </p>728 : "text-warning",
720 </div>729 )}
721 </PopoverPopup>730 >
722 </Popover>731 {compatibilityAdvisory.detail}
723 ) : null}732 </p>
724 {versionAdvisory ? (733 </div>
725 <Popover>734 {onRunCompatibilityUpdate ? (
726 <PopoverTrigger735 <Button
727 render={736 type="button"
728 <Button737 size="xs"
729 type="button"738 variant="default"
730 size="icon-xs"739 className="w-full"
731 variant="ghost"740 disabled={isCompatibilityUpdating}
732 className={cn(741 onClick={onRunCompatibilityUpdate}
733 "size-5 rounded-sm p-0",742 >
734 versionAdvisory.emphasis === "strong"743 {isCompatibilityUpdating ? (
735 ? "text-warning hover:text-warning"744 <LoaderIcon className="animate-spin" />
736 : "text-primary hover:text-primary",745 ) : (
737 )}746 <DownloadIcon />
738 aria-label="Update available — view details"747 )}
739 >748 {isCompatibilityUpdating ? "Updating" : "Update now"}
740 <ArrowUpCircleIcon className="size-3.5 [animation:bounce_2.4s_ease-in-out_infinite] motion-reduce:animate-none" />749 </Button>
741 </Button>750 ) : null}
742 }751 {onRunCompatibilityUpdate && compatibilityUpdateCommand ? (
743 />752 <div className="flex items-center gap-2 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
744 <PopoverPopup side="bottom" align="start" className="w-84">753 <span aria-hidden className="h-px flex-1 bg-border" />
745 <div className="grid gap-3">754 or, update manually using
746 <div className="grid gap-0.5">755 <span aria-hidden className="h-px flex-1 bg-border" />
747 <p className="text-[13px] font-semibold leading-tight text-foreground">756 </div>
748 Update available757 ) : null}
749 </p>758 {compatibilityUpdateCommand ? (
750 <p759 <div className="flex items-center gap-1 rounded-md border border-border/70 bg-muted/40 py-0.5 pr-0.5 pl-2">
751 className={cn(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">
752 "text-xs leading-snug",761 {compatibilityUpdateCommand}
753 versionAdvisory.emphasis === "strong"762 </code>
754 ? "text-warning"763 <Tooltip>
755 : "text-muted-foreground",764 <TooltipTrigger
756 )}765 render={
757 >766 <Button
758 {versionAdvisory.detail}767 type="button"
759 </p>768 size="icon-xs"
760 </div>769 variant="ghost"
761 {onRunUpdate ? (770 className="size-6 shrink-0 rounded-sm p-0 text-muted-foreground hover:text-foreground"
762 <Button771 onClick={() =>
763 type="button"772 copyToClipboard(compatibilityUpdateCommand, {
764 size="xs"773 providerName: displayName,
765 variant="default"774 })
766 className="w-full"775 }
767 disabled={isUpdating}776 aria-label="Copy compatibility update command"
768 onClick={onRunUpdate}777 >
769 >778 <CopyIcon className="size-3" />
770 {isUpdating ? <LoaderIcon className="animate-spin" /> : <DownloadIcon />}779 </Button>
771 {isUpdating ? "Updating" : "Update now"}780 }
772 </Button>781 />
773 ) : null}782 <TooltipPopup side="top">Copy command</TooltipPopup>
774 {onRunUpdate && updateCommand ? (783 </Tooltip>
775 <div className="flex items-center gap-2 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">784 </div>
776 <span aria-hidden className="h-px flex-1 bg-border" />785 ) : null}
777 or, update manually using786 </div>
778 <span aria-hidden className="h-px flex-1 bg-border" />787 </PopoverPopup>
779 </div>788 </Popover>
780 ) : null}789 </span>
781 {updateCommand ? (790 ) : null}
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">791 {versionAdvisory ? (
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">792 <span className={ADVISORY_ICON_SLOT_CLASS}>
784 {updateCommand}793 <Popover>
785 </code>794 <PopoverTrigger
786 <Tooltip>795 render={
787 <TooltipTrigger796 <Button
788 render={797 type="button"
789 <Button798 size="icon-xs"
790 type="button"799 variant="ghost"
791 size="icon-xs"800 className={cn(
792 variant="ghost"801 "size-5 rounded-sm p-0",
793 className="size-6 shrink-0 rounded-sm p-0 text-muted-foreground hover:text-foreground"802 versionAdvisory.emphasis === "strong"
794 onClick={() =>803 ? "text-warning hover:text-warning"
795 copyToClipboard(updateCommand, {804 : "text-primary hover:text-primary",
796 providerName: displayName,805 )}
797 })806 aria-label="Update available — view details"
798 }807 >
799 aria-label="Copy update command"808 <ArrowUpCircleIcon className="size-3.5 [animation:bounce_2.4s_ease-in-out_infinite] motion-reduce:animate-none" />
800 >809 </Button>
801 <CopyIcon className="size-3" />810 }
802 </Button>811 />
803 }812 <PopoverPopup side="bottom" align="start" className="w-84">
804 />813 <div className="grid gap-3">
805 <TooltipPopup side="top">Copy command</TooltipPopup>814 <div className="grid gap-0.5">
806 </Tooltip>815 <p className="text-[13px] font-semibold leading-tight text-foreground">
807 </div>816 Update available
808 ) : null}817 </p>
809 </div>818 <p
810 </PopoverPopup>819 className={cn(
811 </Popover>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>
812 ) : null}887 ) : null}
813 {titleTailNode}888 {titleTailNode}
814 </div>889 </div>
apps/web/src/components/settings/SettingsPanels.browser.tsx+58−1
@@ -255,7 +255,7 @@ function createOutdatedProvider(driver: string): ServerProvider { · 254 unmodified lines
255 latestVersion: "1.1.0",255 latestVersion: "1.1.0",
256 message: "Update available.",256 message: "Update available.",
257 checkedAt: "2026-05-04T10:00:00.000Z",257 checkedAt: "2026-05-04T10:00:00.000Z",
258 updateCommand: "npm install -g openai/codex@latest",258 updateCommand: "npm install -g @openai/codex@latest",
259 canUpdate: true,259 canUpdate: true,
260 },260 },
261 };261 };
@@ -1155,6 +1155,63 @@ describe("GeneralSettingsPanel observability", () => { · 893 unmodified lines
1155 instanceId: ProviderInstanceId.make("codex"),1155 instanceId: ProviderInstanceId.make("codex"),
1156 });1156 });
1157 });1157 });
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 });
1158});1215});
1159 1216
1160describe("SourceControlSettingsPanel discovery states", () => {1217describe("SourceControlSettingsPanel discovery states", () => {
apps/web/src/components/settings/SettingsPanels.tsx+83−0
@@ -10,6 +10,7 @@ import { · 9 unmodified lines
10 type ProviderInstanceConfig,10 type ProviderInstanceConfig,
11 type ProviderInstanceId,11 type ProviderInstanceId,
12 type ScopedThreadRef,12 type ScopedThreadRef,
13 type ServerProvider,
13} from "@t3tools/contracts";14} from "@t3tools/contracts";
14import { scopeThreadRef } from "@t3tools/client-runtime";15import { scopeThreadRef } from "@t3tools/client-runtime";
15import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";16import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";
@@ -63,6 +64,10 @@ import { · 47 unmodified lines
63 type ProviderUpdateCandidate,64 type ProviderUpdateCandidate,
64} from "../ProviderUpdateLaunchNotification.logic";65} from "../ProviderUpdateLaunchNotification.logic";
65import { ProviderInstanceCard } from "./ProviderInstanceCard";66import { ProviderInstanceCard } from "./ProviderInstanceCard";
67import {
68 canRunProviderCompatibilityUpdate,
69 getProviderCompatibilityUpdateCommand,
70} from "./providerStatus";
66import { DRIVER_OPTIONS, getDriverOption } from "./providerDriverMeta";71import { DRIVER_OPTIONS, getDriverOption } from "./providerDriverMeta";
67import {72import {
68 buildProviderInstanceUpdatePatch,73 buildProviderInstanceUpdatePatch,
@@ -1010,6 +1015,55 @@ export function ProviderSettingsPanel() { · 941 unmodified lines
1010 }1015 }
1011 }, []);1016 }, []);
1012 1017
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
1013 interface InstanceRow {1067 interface InstanceRow {
1014 readonly instanceId: ProviderInstanceId;1068 readonly instanceId: ProviderInstanceId;
1015 readonly instance: ProviderInstanceConfig;1069 readonly instance: ProviderInstanceConfig;
@@ -1245,6 +1299,22 @@ export function ProviderSettingsPanel() { · 229 unmodified lines
1245 updateCandidate !== undefined &&1299 updateCandidate !== undefined &&
1246 canOneClickUpdateProviderCandidate(updateCandidate, serverProviders) &&1300 canOneClickUpdateProviderCandidate(updateCandidate, serverProviders) &&
1247 !updatingProviderDrivers.has(updateCandidate.driver);1301 !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);
1248 const modelPreferences = settings.providerModelPreferences?.[row.instanceId] ?? {1318 const modelPreferences = settings.providerModelPreferences?.[row.instanceId] ?? {
1249 hiddenModels: [],1319 hiddenModels: [],
1250 modelOrder: [],1320 modelOrder: [],
@@ -1318,6 +1388,19 @@ export function ProviderSettingsPanel() { · 67 unmodified lines
1318 : undefined1388 : undefined
1319 }1389 }
1320 isUpdating={showInlineUpdateButton ? isDriverUpdateRunning : undefined}1390 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 }
1321 />1404 />
1322 );1405 );
1323 })}1406 })}
apps/web/src/components/settings/providerStatus.test.ts+31−1
@@ -1,6 +1,9 @@
1import { describe, expect, it } from "vitest";1import { describe, expect, it } from "vitest";
2 2
3import { getProviderCompatibilityAdvisoryPresentation } from "./providerStatus";3import {
4 getProviderCompatibilityAdvisoryPresentation,
5 getProviderCompatibilityUpdateCommand,
6} from "./providerStatus";
4 7
5describe("getProviderCompatibilityAdvisoryPresentation", () => {8describe("getProviderCompatibilityAdvisoryPresentation", () => {
6 it("hides supported compatibility advisories", () => {9 it("hides supported compatibility advisories", () => {
@@ -29,7 +32,34 @@ describe("getProviderCompatibilityAdvisoryPresentation", () => { · 22 unmodified lines
29 ).toEqual({32 ).toEqual({
30 title: "Incompatible provider version",33 title: "Incompatible provider version",
31 detail: "Known incompatible.",34 detail: "Known incompatible.",
35 updateCommand: null,
36 canUpdate: false,
32 emphasis: "strong",37 emphasis: "strong",
33 });38 });
34 });39 });
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 });
35});65});
apps/web/src/components/settings/providerStatus.ts+56−0
@@ -120,11 +120,65 @@ export function getProviderVersionAdvisoryPresentation( · 119 unmodified lines
120 };120 };
121}121}
122 122
123function 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
145export 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
161export 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
123export function getProviderCompatibilityAdvisoryPresentation(175export function getProviderCompatibilityAdvisoryPresentation(
124 advisory: ServerProviderCompatibilityAdvisory | undefined,176 advisory: ServerProviderCompatibilityAdvisory | undefined,
125): {177): {
126 readonly title: string;178 readonly title: string;
127 readonly detail: string;179 readonly detail: string;
180 readonly updateCommand: string | null;
181 readonly canUpdate: boolean;
128 readonly emphasis: "normal" | "strong";182 readonly emphasis: "normal" | "strong";
129} | null {183} | null {
130 if (!advisory || advisory.status === "supported") {184 if (!advisory || advisory.status === "supported") {
@@ -142,6 +196,8 @@ export function getProviderCompatibilityAdvisoryPresentation( · 11 unmodified lines
142 title:196 title:
143 advisory.status === "broken" ? "Incompatible provider version" : "Provider version warning",197 advisory.status === "broken" ? "Incompatible provider version" : "Provider version warning",
144 detail: advisory.message ?? fallback,198 detail: advisory.message ?? fallback,
199 updateCommand: advisory.updateCommand ?? null,
200 canUpdate: advisory.canUpdate === true,
145 emphasis: advisory.severity === "error" ? "strong" : "normal",201 emphasis: advisory.severity === "error" ? "strong" : "normal",
146 };202 };
147}203}
packages/contracts/src/server.ts+3−0
@@ -160,6 +160,8 @@ export const ServerProviderCompatibilityAdvisory = Schema.Struct({ · 159 unmodified lines
160 message: Schema.NullOr(TrimmedNonEmptyString),160 message: Schema.NullOr(TrimmedNonEmptyString),
161 recommendedRange: Schema.NullOr(TrimmedNonEmptyString),161 recommendedRange: Schema.NullOr(TrimmedNonEmptyString),
162 recommendedVersion: Schema.optionalKey(Schema.NullOr(TrimmedNonEmptyString)),162 recommendedVersion: Schema.optionalKey(Schema.NullOr(TrimmedNonEmptyString)),
163 updateCommand: Schema.optionalKey(Schema.NullOr(TrimmedNonEmptyString)),
164 canUpdate: Schema.optionalKey(Schema.Boolean),
163 ranges: Schema.Array(ServerProviderCompatibilityRange).pipe(165 ranges: Schema.Array(ServerProviderCompatibilityRange).pipe(
164 Schema.withDecodingDefault(Effect.succeed([])),166 Schema.withDecodingDefault(Effect.succeed([])),
165 ),167 ),
@@ -523,6 +525,7 @@ export type ServerProviderUpdatedPayload = typeof ServerProviderUpdatedPayload.T · 357 unmodified lines
523export const ServerProviderUpdateInput = Schema.Struct({525export const ServerProviderUpdateInput = Schema.Struct({
524 provider: ProviderDriverKind,526 provider: ProviderDriverKind,
525 instanceId: Schema.optionalKey(ProviderInstanceId),527 instanceId: Schema.optionalKey(ProviderInstanceId),
528 targetVersion: Schema.optionalKey(TrimmedNonEmptyString),
526});529});
527export type ServerProviderUpdateInput = typeof ServerProviderUpdateInput.Type;530export type ServerProviderUpdateInput = typeof ServerProviderUpdateInput.Type;
528 531
provider-compatibility.v1.json+4−4
@@ -2,7 +2,7 @@ · 1 unmodified line
2 "version": 1,2 "version": 1,
3 "policies": [3 "policies": [
4 {4 {
5 "t3CodeRange": ">=0.0.24",5 "t3CodeRange": ">=0.0.0",
6 "driver": "codex",6 "driver": "codex",
7 "recommendedRange": ">=0.129.0",7 "recommendedRange": ">=0.129.0",
8 "recommendedVersion": "0.129.0",8 "recommendedVersion": "0.129.0",
@@ -20,7 +20,7 @@ · 11 unmodified lines
20 ]20 ]
21 },21 },
22 {22 {
23 "t3CodeRange": ">=0.0.24",23 "t3CodeRange": ">=0.0.0",
24 "driver": "claudeAgent",24 "driver": "claudeAgent",
25 "recommendedRange": ">=0.2.111",25 "recommendedRange": ">=0.2.111",
26 "recommendedVersion": "0.2.111",26 "recommendedVersion": "0.2.111",
@@ -33,7 +33,7 @@ · 6 unmodified lines
33 ]33 ]
34 },34 },
35 {35 {
36 "t3CodeRange": ">=0.0.24",36 "t3CodeRange": ">=0.0.0",
37 "driver": "opencode",37 "driver": "opencode",
38 "recommendedRange": ">=1.14.19",38 "recommendedRange": ">=1.14.19",
39 "recommendedVersion": "1.14.19",39 "recommendedVersion": "1.14.19",
@@ -51,7 +51,7 @@ · 11 unmodified lines
51 ]51 ]
52 },52 },
53 {53 {
54 "t3CodeRange": ">=0.0.24",54 "t3CodeRange": ">=0.0.0",
55 "driver": "cursor",55 "driver": "cursor",
56 "recommendedRange": ">=2026.05.09",56 "recommendedRange": ">=2026.05.09",
57 "recommendedVersion": "2026.05.09",57 "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