- 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
Unified · Split
| @@ -174,7 +174,12 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = { · 173 unmodified lines | ||
| 174 | 174 | checkProvider, |
| 175 | 175 | enrichSnapshot: ({ snapshot, publishSnapshot }) => |
| 176 | 176 | enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities).pipe( |
| 177 | − Effect.flatMap(ProviderCompatibility.enrichProviderSnapshotWithCompatibilityAdvisory), | |
| 177 | + Effect.flatMap((snapshot) => | |
| 178 | + ProviderCompatibility.enrichProviderSnapshotWithTargetedCompatibilityAdvisory( | |
| 179 | + snapshot, | |
| 180 | + maintenanceCapabilities, | |
| 181 | + ), | |
| 182 | + ), | |
| 178 | 183 | Effect.provideService(HttpClient.HttpClient, httpClient), |
| 179 | 184 | Effect.provideService( |
| 180 | 185 | ProviderCompatibility.ProviderCompatibilityService, |
| @@ -174,7 +174,12 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = { · 173 unmodified lines | ||
| 174 | 174 | checkProvider, |
| 175 | 175 | enrichSnapshot: ({ snapshot, publishSnapshot }) => |
| 176 | 176 | enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities).pipe( |
| 177 | − Effect.flatMap(ProviderCompatibility.enrichProviderSnapshotWithCompatibilityAdvisory), | |
| 177 | + Effect.flatMap((snapshot) => | |
| 178 | + ProviderCompatibility.enrichProviderSnapshotWithTargetedCompatibilityAdvisory( | |
| 179 | + snapshot, | |
| 180 | + maintenanceCapabilities, | |
| 181 | + ), | |
| 182 | + ), | |
| 178 | 183 | Effect.provideService(HttpClient.HttpClient, httpClient), |
| 179 | 184 | Effect.provideService( |
| 180 | 185 | ProviderCompatibility.ProviderCompatibilityService, |
| @@ -153,7 +153,12 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv> · 152 unmodified lines | ||
| 153 | 153 | checkProvider, |
| 154 | 154 | enrichSnapshot: ({ snapshot, publishSnapshot }) => |
| 155 | 155 | enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities).pipe( |
| 156 | − Effect.flatMap(ProviderCompatibility.enrichProviderSnapshotWithCompatibilityAdvisory), | |
| 156 | + Effect.flatMap((snapshot) => | |
| 157 | + ProviderCompatibility.enrichProviderSnapshotWithTargetedCompatibilityAdvisory( | |
| 158 | + snapshot, | |
| 159 | + maintenanceCapabilities, | |
| 160 | + ), | |
| 161 | + ), | |
| 157 | 162 | Effect.provideService(HttpClient.HttpClient, httpClient), |
| 158 | 163 | Effect.provideService( |
| 159 | 164 | ProviderCompatibility.ProviderCompatibilityService, |
| @@ -1247,7 +1247,12 @@ export const enrichCursorSnapshot = (input: { · 1246 unmodified lines | ||
| 1247 | 1247 | snapshot, |
| 1248 | 1248 | input.maintenanceCapabilities, |
| 1249 | 1249 | ).pipe( |
| 1250 | − Effect.flatMap(ProviderCompatibility.enrichProviderSnapshotWithCompatibilityAdvisory), | |
| 1250 | + Effect.flatMap((enrichedSnapshot) => | |
| 1251 | + ProviderCompatibility.enrichProviderSnapshotWithTargetedCompatibilityAdvisory( | |
| 1252 | + enrichedSnapshot, | |
| 1253 | + input.maintenanceCapabilities, | |
| 1254 | + ), | |
| 1255 | + ), | |
| 1251 | 1256 | Effect.provideService(HttpClient.HttpClient, input.httpClient), |
| 1252 | 1257 | Effect.flatMap((enrichedSnapshot) => |
| 1253 | 1258 | publishSnapshot(stampIdentity(enrichedSnapshot)).pipe(Effect.as(enrichedSnapshot)), |
| @@ -5,6 +5,7 @@ import * as Layer from "effect/Layer"; · 4 unmodified lines | ||
| 5 | 5 | import { HttpClient, HttpClientResponse } from "effect/unstable/http"; |
| 6 | 6 | |
| 7 | 7 | import * as ProviderCompatibility from "./ProviderCompatibility.ts"; |
| 8 | +import { makeProviderMaintenanceCapabilities } from "./providerMaintenance.ts"; | |
| 8 | 9 | |
| 9 | 10 | const codexDriver = ProviderDriverKind.make("codex"); |
| 10 | 11 | const claudeDriver = ProviderDriverKind.make("claudeAgent"); |
| @@ -26,6 +27,14 @@ const baseProvider: ServerProvider = { · 15 unmodified lines | ||
| 26 | 27 | skills: [], |
| 27 | 28 | }; |
| 28 | 29 | |
| 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 | + | |
| 29 | 38 | function jsonHttpClient( |
| 30 | 39 | responseForUrl: (url: string) => { readonly payload: unknown; readonly status?: number }, |
| 31 | 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 | 100 | const document: ProviderCompatibility.ProviderCompatibilityDocument = { |
| 92 | 101 | version: 1, |
| 93 | 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 | 150 | driver: codexDriver, |
| 97 | 151 | recommendedRange: ">=0.129.0", |
| 98 | 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 | 160 | driver: claudeDriver, |
| 107 | 161 | recommendedRange: ">=0.2.111", |
| 108 | 162 | recommendedVersion: "0.2.111", |
| 109 | 163 | ranges: [{ status: "supported", range: ">=0.2.111" }], |
| 110 | 164 | }, |
| 111 | 165 | { |
| 112 | − t3CodeRange: ">=0.0.24", | |
| 166 | + t3CodeRange: ">=0.0.0", | |
| 113 | 167 | driver: opencodeDriver, |
| 114 | 168 | recommendedRange: ">=1.14.19", |
| 115 | 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 | 177 | driver: cursorDriver, |
| 124 | 178 | recommendedRange: ">=2026.05.09", |
| 125 | 179 | recommendedVersion: "2026.05.09", |
| @@ -136,7 +190,7 @@ describe("provider compatibility", () => { · 10 unmodified lines | ||
| 136 | 190 | driver, |
| 137 | 191 | currentVersion, |
| 138 | 192 | document, |
| 139 | − t3CodeVersion: "0.0.24", | |
| 193 | + t3CodeVersion: "0.0.22", | |
| 140 | 194 | })?.status; |
| 141 | 195 | |
| 142 | 196 | expect(classify(codexDriver, "0.129.0")).toBe("supported"); |
| @@ -188,14 +242,18 @@ describe("provider compatibility", () => { · 45 unmodified lines | ||
| 188 | 242 | }; |
| 189 | 243 | |
| 190 | 244 | 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 }))); | |
| 194 | 250 | |
| 195 | 251 | expect(enriched.status).toBe("error"); |
| 196 | 252 | expect(enriched.compatibilityAdvisory).toMatchObject({ |
| 197 | 253 | status: "broken", |
| 198 | 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 | 414 | ).pipe(provideCompatibility(() => ({ payload: {}, status: 404 }))); |
| 357 | 415 | |
| 358 | − expect(enriched.status).toBe("ready"); | |
| 359 | − expect(enriched.compatibilityAdvisory).toBeUndefined(); | |
| 416 | + expect(enriched.status).toBe("error"); | |
| 417 | + expect(enriched.compatibilityAdvisory).toMatchObject({ status: "broken" }); | |
| 360 | 418 | }), |
| 361 | 419 | ); |
| 362 | 420 | }); |
| @@ -17,6 +17,10 @@ import { HttpClient, HttpClientRequest } from "effect/unstable/http"; · 16 unmodified lines | ||
| 17 | 17 | |
| 18 | 18 | import bundledCompatibilityDocumentJson from "../../../../provider-compatibility.v1.json" with { type: "json" }; |
| 19 | 19 | import packageJson from "../../package.json" with { type: "json" }; |
| 20 | +import { | |
| 21 | + makeTargetedProviderUpdateAction, | |
| 22 | + type ProviderMaintenanceCapabilities, | |
| 23 | +} from "./providerMaintenance.ts"; | |
| 20 | 24 | |
| 21 | 25 | const T3_CODE_VERSION = packageJson.version; |
| 22 | 26 | const REMOTE_COMPATIBILITY_CACHE_TTL = Duration.minutes(15); |
| @@ -145,6 +149,7 @@ function createProviderCompatibilityAdvisoryFromDocument(input: { · 122 unmodified lines | ||
| 145 | 149 | readonly document: typeof RemoteCompatibilityDocument.Type; |
| 146 | 150 | readonly driver: ProviderDriverKind; |
| 147 | 151 | readonly currentVersion: string | null; |
| 152 | + readonly maintenanceCapabilities?: ProviderMaintenanceCapabilities | undefined; | |
| 148 | 153 | readonly t3CodeVersion?: string; |
| 149 | 154 | }): ServerProviderCompatibilityAdvisory | undefined { |
| 150 | 155 | const policy = compatibilityPolicyForDriver({ |
| @@ -163,6 +168,9 @@ function createProviderCompatibilityAdvisoryFromDocument(input: { · 12 unmodified lines | ||
| 163 | 168 | : policy.ranges.find((range) => satisfiesSemverRange(currentVersion, range.range)); |
| 164 | 169 | const status = matchedRange?.status ?? (currentVersion === null ? "unknown" : "unsupported"); |
| 165 | 170 | const recommendedVersion = policy.recommendedVersion ?? null; |
| 171 | + const targetedUpdateAction = input.maintenanceCapabilities | |
| 172 | + ? makeTargetedProviderUpdateAction(input.maintenanceCapabilities, recommendedVersion) | |
| 173 | + : null; | |
| 166 | 174 | |
| 167 | 175 | return { |
| 168 | 176 | status, |
| @@ -176,20 +184,31 @@ function createProviderCompatibilityAdvisoryFromDocument(input: { · 7 unmodified lines | ||
| 176 | 184 | }), |
| 177 | 185 | recommendedRange: policy.recommendedRange, |
| 178 | 186 | recommendedVersion, |
| 187 | + updateCommand: targetedUpdateAction?.command ?? null, | |
| 188 | + canUpdate: targetedUpdateAction !== null, | |
| 179 | 189 | ranges: [...policy.ranges], |
| 180 | 190 | }; |
| 181 | 191 | } |
| 182 | 192 | |
| 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 | + | |
| 183 | 200 | export function createProviderCompatibilityAdvisory(input: { |
| 184 | 201 | readonly driver: ProviderDriverKind; |
| 185 | 202 | readonly currentVersion: string | null; |
| 186 | 203 | readonly document?: typeof RemoteCompatibilityDocument.Type; |
| 204 | + readonly maintenanceCapabilities?: ProviderMaintenanceCapabilities | undefined; | |
| 187 | 205 | readonly t3CodeVersion?: string; |
| 188 | 206 | }): ServerProviderCompatibilityAdvisory | undefined { |
| 189 | 207 | return createProviderCompatibilityAdvisoryFromDocument({ |
| 190 | 208 | document: input.document ?? bundledProviderCompatibilityDocument, |
| 191 | 209 | driver: input.driver, |
| 192 | 210 | currentVersion: input.currentVersion, |
| 211 | + maintenanceCapabilities: input.maintenanceCapabilities, | |
| 193 | 212 | ...(input.t3CodeVersion ? { t3CodeVersion: input.t3CodeVersion } : {}), |
| 194 | 213 | }); |
| 195 | 214 | } |
| @@ -300,12 +319,23 @@ export function applyBundledProviderCompatibilityAdvisory< · 104 unmodified lines | ||
| 300 | 319 | readonly snapshot: Snapshot; |
| 301 | 320 | readonly driver: ProviderDriverKind; |
| 302 | 321 | readonly currentVersion: string | null; |
| 322 | + readonly maintenanceCapabilities?: ProviderMaintenanceCapabilities | undefined; | |
| 303 | 323 | }): Snapshot { |
| 324 | + if ( | |
| 325 | + shouldSkipCompatibilityAdvisory({ | |
| 326 | + snapshot: input.snapshot, | |
| 327 | + currentVersion: input.currentVersion, | |
| 328 | + }) | |
| 329 | + ) { | |
| 330 | + return removeExistingCompatibilityAdvisory(input.snapshot); | |
| 331 | + } | |
| 332 | + | |
| 304 | 333 | return applyCompatibilityAdvisory( |
| 305 | 334 | input.snapshot, |
| 306 | 335 | createProviderCompatibilityAdvisory({ |
| 307 | 336 | driver: input.driver, |
| 308 | 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 | 347 | return yield* compatibility.enrichSnapshot(snapshot); |
| 318 | 348 | }); |
| 319 | 349 | |
| 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 | + | |
| 320 | 357 | export interface ProviderCompatibilityServiceShape { |
| 321 | 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 | |
| 325 | 365 | export class ProviderCompatibilityService extends Context.Service< |
| @@ -347,17 +387,23 @@ export const makeProviderCompatibilityService = Effect.fn("makeProviderCompatibi · 21 unmodified lines | ||
| 347 | 387 | |
| 348 | 388 | return { |
| 349 | 389 | resolveRemoteDocument, |
| 350 | − enrichSnapshot: (snapshot) => | |
| 390 | + enrichSnapshot: (snapshot, maintenanceCapabilities) => | |
| 351 | 391 | resolveRemoteDocument.pipe( |
| 352 | 392 | Effect.map((remoteDocument) => |
| 353 | − applyCompatibilityAdvisory( | |
| 393 | + shouldSkipCompatibilityAdvisory({ | |
| 354 | 394 | 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 | + ), | |
| 361 | 407 | ), |
| 362 | 408 | ), |
| 363 | 409 | } satisfies ProviderCompatibilityServiceShape; |
| @@ -13,6 +13,7 @@ import { · 12 unmodified lines | ||
| 13 | 13 | makePackageManagedProviderMaintenanceResolver, |
| 14 | 14 | makeProviderMaintenanceCapabilities, |
| 15 | 15 | makeStaticProviderMaintenanceResolver, |
| 16 | + makeTargetedProviderUpdateAction, | |
| 16 | 17 | normalizeCommandPath, |
| 17 | 18 | resolveProviderMaintenanceCapabilitiesEffect, |
| 18 | 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 | 154 | it.effect( |
| 136 | 155 | "switches package-managed providers to vite-plus updates when the resolved binary lives in vite-plus global bin", |
| 137 | 156 | () => |
| @@ -108,6 +108,32 @@ export function makeManualOnlyProviderMaintenanceCapabilities(input: { · 107 unmodified lines | ||
| 108 | 108 | }); |
| 109 | 109 | } |
| 110 | 110 | |
| 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 | + | |
| 111 | 137 | function makeNpmGlobalProviderMaintenanceCapabilities( |
| 112 | 138 | definition: PackageManagedProviderMaintenanceDefinition, |
| 113 | 139 | ): ProviderMaintenanceCapabilities { |
| @@ -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 | 321 | it.effect( |
| 292 | 322 | "runs update commands through Effect ChildProcess when no test runner is injected", |
| 293 | 323 | () => { |
| @@ -22,7 +22,10 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; · 21 unmodified lines | ||
| 22 | 22 | |
| 23 | 23 | import { ProviderRegistry } from "./Services/ProviderRegistry.ts"; |
| 24 | 24 | import { makeProviderMaintenanceCommandCoordinator } from "./providerMaintenanceCommandCoordinator.ts"; |
| 25 | −import { enrichProviderSnapshotWithVersionAdvisory } from "./providerMaintenance.ts"; | |
| 25 | +import { | |
| 26 | + enrichProviderSnapshotWithVersionAdvisory, | |
| 27 | + makeTargetedProviderUpdateAction, | |
| 28 | +} from "./providerMaintenance.ts"; | |
| 26 | 29 | import type { ProviderMaintenanceCapabilities } from "./providerMaintenance.ts"; |
| 27 | 30 | import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; |
| 28 | 31 | const isServerProviderUpdateError = Schema.is(ServerProviderUpdateError); |
| @@ -46,6 +49,7 @@ export interface ProviderMaintenanceRunnerShape { · 17 unmodified lines | ||
| 46 | 49 | | { |
| 47 | 50 | readonly provider: ProviderDriverKind; |
| 48 | 51 | readonly instanceId?: ProviderInstanceId | undefined; |
| 52 | + readonly targetVersion?: string | undefined; | |
| 49 | 53 | }, |
| 50 | 54 | ) => Effect.Effect<ServerProviderUpdatedPayload, ServerProviderUpdateError>; |
| 51 | 55 | } |
| @@ -283,12 +287,15 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { · 231 unmodified lines | ||
| 283 | 287 | typeof target === "string" |
| 284 | 288 | ? defaultInstanceIdForDriver(provider) |
| 285 | 289 | : (target.instanceId ?? defaultInstanceIdForDriver(provider)); |
| 290 | + const targetVersion = typeof target === "string" ? undefined : target.targetVersion; | |
| 286 | 291 | const targetKey = `instance:${instanceId}`; |
| 287 | 292 | const capabilities = yield* providerRegistry.getProviderMaintenanceCapabilitiesForInstance( |
| 288 | 293 | instanceId, |
| 289 | 294 | provider, |
| 290 | 295 | ); |
| 291 | − const update = capabilities.update; | |
| 296 | + const update = targetVersion | |
| 297 | + ? makeTargetedProviderUpdateAction(capabilities, targetVersion) | |
| 298 | + : capabilities.update; | |
| 292 | 299 | if (!update) { |
| 293 | 300 | return yield* new ServerProviderUpdateError({ |
| 294 | 301 | provider, |
| @@ -42,7 +42,7 @@ import { useGitStatus } from "~/lib/gitStatusState"; · 41 unmodified lines | ||
| 42 | 42 | import { usePrimaryEnvironmentId } from "../environments/primary"; |
| 43 | 43 | import { readEnvironmentApi } from "../environmentApi"; |
| 44 | 44 | import { isElectron } from "../env"; |
| 45 | −import { readLocalApi } from "../localApi"; | |
| 45 | +import { ensureLocalApi, readLocalApi } from "../localApi"; | |
| 46 | 46 | import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; |
| 47 | 47 | import { |
| 48 | 48 | collapseExpandedComposerCursor, |
| @@ -104,7 +104,14 @@ import { BranchToolbar } from "./BranchToolbar"; · 55 unmodified lines | ||
| 104 | 104 | import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; |
| 105 | 105 | import PlanSidebar from "./PlanSidebar"; |
| 106 | 106 | 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"; | |
| 108 | 115 | import { cn, randomUUID } from "~/lib/utils"; |
| 109 | 116 | import { stackedThreadToast, toastManager } from "./ui/toast"; |
| 110 | 117 | import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; |
| @@ -147,7 +154,6 @@ import { ChatHeader } from "./chat/ChatHeader"; · 36 unmodified lines | ||
| 147 | 154 | import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; |
| 148 | 155 | import { NoActiveThreadState } from "./NoActiveThreadState"; |
| 149 | 156 | import { resolveEffectiveEnvMode, resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; |
| 150 | −import { ProviderStatusBanner } from "./chat/ProviderStatusBanner"; | |
| 151 | 157 | import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; |
| 152 | 158 | import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; |
| 153 | 159 | import { |
| @@ -183,6 +189,11 @@ import { sanitizeThreadErrorMessage } from "~/rpc/transportError"; · 29 unmodified lines | ||
| 183 | 189 | import { retainThreadDetailSubscription } from "../environments/runtime/service"; |
| 184 | 190 | import { RightPanelSheet } from "./RightPanelSheet"; |
| 185 | 191 | import { Button } from "./ui/button"; |
| 192 | +import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; | |
| 193 | +import { | |
| 194 | + canRunProviderCompatibilityUpdate, | |
| 195 | + getProviderCompatibilityUpdateCommand, | |
| 196 | +} from "./settings/providerStatus"; | |
| 186 | 197 | import { |
| 187 | 198 | buildVersionMismatchDismissalKey, |
| 188 | 199 | dismissVersionMismatch, |
| @@ -197,6 +208,75 @@ const EMPTY_PROPOSED_PLANS: Thread["proposedPlans"] = []; · 8 unmodified lines | ||
| 197 | 208 | const EMPTY_PROVIDERS: ServerProvider[] = []; |
| 198 | 209 | const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; |
| 199 | 210 | 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 | + | |
| 200 | 280 | type EnvironmentUnavailableState = { |
| 201 | 281 | readonly environmentId: EnvironmentId; |
| 202 | 282 | readonly label: string; |
| @@ -1150,6 +1230,8 @@ export default function ChatView(props: ChatViewProps) { · 947 unmodified lines | ||
| 1150 | 1230 | const [dismissedVersionMismatchKey, setDismissedVersionMismatchKey] = useState<string | null>( |
| 1151 | 1231 | null, |
| 1152 | 1232 | ); |
| 1233 | + const [compatibilityUpdatingProviderId, setCompatibilityUpdatingProviderId] = | |
| 1234 | + useState<ProviderInstanceId | null>(null); | |
| 1153 | 1235 | const versionMismatchDismissed = |
| 1154 | 1236 | versionMismatchDismissKey === dismissedVersionMismatchKey || |
| 1155 | 1237 | isVersionMismatchDismissed(versionMismatchDismissKey); |
| @@ -1178,8 +1260,112 @@ export default function ChatView(props: ChatViewProps) { · 22 unmodified lines | ||
| 1178 | 1260 | savedEnvironmentRuntimeById, |
| 1179 | 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 | 1319 | const composerBannerItems = useMemo<ComposerBannerStackItem[]>(() => { |
| 1182 | 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 | 1369 | if (activeEnvironmentUnavailableState) { |
| 1184 | 1370 | items.push({ |
| 1185 | 1371 | id: `environment-unavailable:${activeEnvironmentUnavailableState.environmentId}`, |
| @@ -1248,20 +1434,17 @@ export default function ChatView(props: ChatViewProps) { · 62 unmodified lines | ||
| 1248 | 1434 | return items; |
| 1249 | 1435 | }, [ |
| 1250 | 1436 | activeEnvironmentUnavailableState, |
| 1437 | + activeProviderStatus, | |
| 1438 | + compatibilityUpdatingProviderId, | |
| 1251 | 1439 | handleReconnectActiveEnvironment, |
| 1252 | 1440 | navigate, |
| 1253 | 1441 | reconnectingEnvironmentId, |
| 1442 | + runActiveProviderCompatibilityUpdate, | |
| 1254 | 1443 | showVersionMismatchBanner, |
| 1255 | 1444 | versionMismatch, |
| 1256 | 1445 | versionMismatchDismissKey, |
| 1257 | 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 | 1448 | const phase = derivePhase(activeThread?.session ?? null); |
| 1266 | 1449 | const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES; |
| 1267 | 1450 | const workLogEntries = useMemo( |
| @@ -1633,24 +1816,6 @@ export default function ChatView(props: ChatViewProps) { · 365 unmodified lines | ||
| 1633 | 1816 | const gitStatusQuery = useGitStatus({ environmentId, cwd: gitCwd }); |
| 1634 | 1817 | const keybindings = useServerKeybindings(); |
| 1635 | 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 | 1819 | const activeProjectCwd = activeProject?.cwd ?? null; |
| 1655 | 1820 | const activeThreadWorktreePath = activeThread?.worktreePath ?? null; |
| 1656 | 1821 | const activeWorkspaceRoot = activeThreadWorktreePath ?? activeProjectCwd ?? undefined; |
| @@ -3540,8 +3705,6 @@ export default function ChatView(props: ChatViewProps) { · 1883 unmodified lines | ||
| 3540 | 3705 | /> |
| 3541 | 3706 | </header> |
| 3542 | 3707 | |
| 3543 | − {/* Error banner */} | |
| 3544 | − <ProviderStatusBanner status={activeProviderStatus} /> | |
| 3545 | 3708 | <ThreadErrorBanner |
| 3546 | 3709 | error={activeThread.error} |
| 3547 | 3710 | onDismiss={() => setThreadError(activeThread.id, null)} |
| @@ -302,6 +302,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( · 301 unmodified lines | ||
| 302 | 302 | isSendBusy: boolean; |
| 303 | 303 | isConnecting: boolean; |
| 304 | 304 | isEnvironmentUnavailable: boolean; |
| 305 | + isProviderUnavailable: boolean; | |
| 305 | 306 | hasSendableContent: boolean; |
| 306 | 307 | preserveComposerFocusOnPointerDown?: boolean; |
| 307 | 308 | onPreviousPendingQuestion: () => void; |
| @@ -323,6 +324,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( · 15 unmodified lines | ||
| 323 | 324 | isSendBusy={props.isSendBusy} |
| 324 | 325 | isConnecting={props.isConnecting} |
| 325 | 326 | isEnvironmentUnavailable={props.isEnvironmentUnavailable} |
| 327 | + isProviderUnavailable={props.isProviderUnavailable} | |
| 326 | 328 | isPreparingWorktree={props.isPreparingWorktree} |
| 327 | 329 | hasSendableContent={props.hasSendableContent} |
| 328 | 330 | preserveComposerFocusOnPointerDown={props.preserveComposerFocusOnPointerDown ?? false} |
| @@ -716,6 +718,11 @@ export const ChatComposer = memo( · 387 unmodified lines | ||
| 716 | 718 | () => selectedProviderEntry?.snapshot ?? null, |
| 717 | 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 | 726 | const selectedProviderModels = useMemo<ReadonlyArray<ServerProvider["models"][number]>>( |
| 720 | 727 | () => selectedProviderEntry?.models ?? [], |
| 721 | 728 | [selectedProviderEntry], |
| @@ -1053,7 +1060,11 @@ export const ChatComposer = memo( · 331 unmodified lines | ||
| 1053 | 1060 | [activePendingIsResponding, activePendingProgress, activePendingResolvedAnswers], |
| 1054 | 1061 | ); |
| 1055 | 1062 | const collapsedComposerPrimaryActionDisabled = |
| 1056 | − phase === "running" || isSendBusy || isConnecting || !composerSendState.hasSendableContent; | |
| 1063 | + phase === "running" || | |
| 1064 | + isSendBusy || | |
| 1065 | + isConnecting || | |
| 1066 | + isSelectedProviderUnavailable || | |
| 1067 | + !composerSendState.hasSendableContent; | |
| 1057 | 1068 | const collapsedComposerPrimaryActionLabel = "Send message"; |
| 1058 | 1069 | const showMobilePendingAnswerActions = |
| 1059 | 1070 | isMobileViewport && !isComposerCollapsedMobile && pendingPrimaryAction !== null; |
| @@ -1619,12 +1630,21 @@ export const ChatComposer = memo( · 559 unmodified lines | ||
| 1619 | 1630 | |
| 1620 | 1631 | const submitComposer = useCallback( |
| 1621 | 1632 | (event?: { preventDefault: () => void }) => { |
| 1633 | + if (isSelectedProviderUnavailable) { | |
| 1634 | + event?.preventDefault(); | |
| 1635 | + return; | |
| 1636 | + } | |
| 1622 | 1637 | onSend(event); |
| 1623 | 1638 | if (shouldBlurMobileComposerOnSubmit()) { |
| 1624 | 1639 | blurMobileComposerAfterSend(); |
| 1625 | 1640 | } |
| 1626 | 1641 | }, |
| 1627 | − [blurMobileComposerAfterSend, onSend, shouldBlurMobileComposerOnSubmit], | |
| 1642 | + [ | |
| 1643 | + blurMobileComposerAfterSend, | |
| 1644 | + isSelectedProviderUnavailable, | |
| 1645 | + onSend, | |
| 1646 | + shouldBlurMobileComposerOnSubmit, | |
| 1647 | + ], | |
| 1628 | 1648 | ); |
| 1629 | 1649 | const expandMobileComposer = useCallback(() => { |
| 1630 | 1650 | if (composerBlurFrameRef.current !== null) { |
| @@ -2079,6 +2099,7 @@ export const ChatComposer = memo( · 448 unmodified lines | ||
| 2079 | 2099 | isSendBusy={isSendBusy} |
| 2080 | 2100 | isConnecting={isConnecting} |
| 2081 | 2101 | isEnvironmentUnavailable={environmentUnavailable !== null} |
| 2102 | + isProviderUnavailable={isSelectedProviderUnavailable} | |
| 2082 | 2103 | isPreparingWorktree={false} |
| 2083 | 2104 | hasSendableContent={false} |
| 2084 | 2105 | preserveComposerFocusOnPointerDown |
| @@ -2265,12 +2286,15 @@ export const ChatComposer = memo( · 180 unmodified lines | ||
| 2265 | 2286 | ? "connecting" |
| 2266 | 2287 | : "disconnected" |
| 2267 | 2288 | }` |
| 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" | |
| 2271 | 2294 | } |
| 2272 | 2295 | disabled={ |
| 2273 | 2296 | isConnecting || |
| 2297 | + isSelectedProviderUnavailable || | |
| 2274 | 2298 | isComposerApprovalState || |
| 2275 | 2299 | (environmentUnavailable !== null && activePendingProgress === null) |
| 2276 | 2300 | } |
| @@ -2289,6 +2313,7 @@ export const ChatComposer = memo( · 12 unmodified lines | ||
| 2289 | 2313 | isSendBusy={isSendBusy} |
| 2290 | 2314 | isConnecting={isConnecting} |
| 2291 | 2315 | isEnvironmentUnavailable={environmentUnavailable !== null} |
| 2316 | + isProviderUnavailable={isSelectedProviderUnavailable} | |
| 2292 | 2317 | isPreparingWorktree={false} |
| 2293 | 2318 | hasSendableContent={false} |
| 2294 | 2319 | preserveComposerFocusOnPointerDown |
| @@ -2405,6 +2430,7 @@ export const ChatComposer = memo( · 110 unmodified lines | ||
| 2405 | 2430 | isSendBusy={isSendBusy} |
| 2406 | 2431 | isConnecting={isConnecting} |
| 2407 | 2432 | isEnvironmentUnavailable={environmentUnavailable !== null} |
| 2433 | + isProviderUnavailable={isSelectedProviderUnavailable} | |
| 2408 | 2434 | isPreparingWorktree={isPreparingWorktree} |
| 2409 | 2435 | hasSendableContent={composerSendState.hasSendableContent} |
| 2410 | 2436 | preserveComposerFocusOnPointerDown={isMobileViewport} |
| @@ -21,6 +21,7 @@ interface ComposerPrimaryActionsProps { · 20 unmodified lines | ||
| 21 | 21 | isSendBusy: boolean; |
| 22 | 22 | isConnecting: boolean; |
| 23 | 23 | isEnvironmentUnavailable: boolean; |
| 24 | + isProviderUnavailable: boolean; | |
| 24 | 25 | isPreparingWorktree: boolean; |
| 25 | 26 | hasSendableContent: boolean; |
| 26 | 27 | preserveComposerFocusOnPointerDown?: boolean; |
| @@ -60,6 +61,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ · 33 unmodified lines | ||
| 60 | 61 | isSendBusy, |
| 61 | 62 | isConnecting, |
| 62 | 63 | isEnvironmentUnavailable, |
| 64 | + isProviderUnavailable, | |
| 63 | 65 | isPreparingWorktree, |
| 64 | 66 | hasSendableContent, |
| 65 | 67 | preserveComposerFocusOnPointerDown = false, |
| @@ -107,6 +109,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ · 41 unmodified lines | ||
| 107 | 109 | {...pointerFocusProps} |
| 108 | 110 | disabled={ |
| 109 | 111 | isEnvironmentUnavailable || |
| 112 | + isProviderUnavailable || | |
| 110 | 113 | pendingAction.isResponding || |
| 111 | 114 | (pendingAction.isLastQuestion ? !pendingAction.isComplete : !pendingAction.canAdvance) |
| 112 | 115 | } |
| @@ -146,7 +149,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ · 33 unmodified lines | ||
| 146 | 149 | size="sm" |
| 147 | 150 | className={cn("rounded-full", compact ? "h-9 px-3 sm:h-8" : "h-9 px-4 sm:h-8")} |
| 148 | 151 | {...pointerFocusProps} |
| 149 | − disabled={isSendBusy || isConnecting || isEnvironmentUnavailable} | |
| 152 | + disabled={isSendBusy || isConnecting || isEnvironmentUnavailable || isProviderUnavailable} | |
| 150 | 153 | > |
| 151 | 154 | {isConnecting || isSendBusy ? "Sending..." : "Refine"} |
| 152 | 155 | </Button> |
| @@ -160,7 +163,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ · 7 unmodified lines | ||
| 160 | 163 | size="sm" |
| 161 | 164 | className="h-9 rounded-l-full rounded-r-none px-4 sm:h-8" |
| 162 | 165 | {...pointerFocusProps} |
| 163 | − disabled={isSendBusy || isConnecting || isEnvironmentUnavailable} | |
| 166 | + disabled={isSendBusy || isConnecting || isEnvironmentUnavailable || isProviderUnavailable} | |
| 164 | 167 | > |
| 165 | 168 | {isConnecting || isSendBusy ? "Sending..." : "Implement"} |
| 166 | 169 | </Button> |
| @@ -173,7 +176,9 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ · 6 unmodified lines | ||
| 173 | 176 | className="h-9 rounded-l-none rounded-r-full border-l-white/12 px-2 sm:h-8" |
| 174 | 177 | aria-label="Implementation actions" |
| 175 | 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 | 186 | </MenuTrigger> |
| 182 | 187 | <MenuPopup align="end" side="top"> |
| 183 | 188 | <MenuItem |
| 184 | − disabled={isSendBusy || isConnecting || isEnvironmentUnavailable} | |
| 189 | + disabled={ | |
| 190 | + isSendBusy || isConnecting || isEnvironmentUnavailable || isProviderUnavailable | |
| 191 | + } | |
| 185 | 192 | onClick={() => void onImplementPlanInNewThread()} |
| 186 | 193 | > |
| 187 | 194 | Implement in a new thread |
| @@ -197,17 +204,25 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ · 9 unmodified lines | ||
| 197 | 204 | type="submit" |
| 198 | 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 | 206 | {...pointerFocusProps} |
| 200 | − disabled={isSendBusy || isConnecting || isEnvironmentUnavailable || !hasSendableContent} | |
| 207 | + disabled={ | |
| 208 | + isSendBusy || | |
| 209 | + isConnecting || | |
| 210 | + isEnvironmentUnavailable || | |
| 211 | + isProviderUnavailable || | |
| 212 | + !hasSendableContent | |
| 213 | + } | |
| 201 | 214 | 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" | |
| 211 | 226 | } |
| 212 | 227 | > |
| 213 | 228 | {isConnecting || isSendBusy ? ( |
| @@ -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 | −}); | |
| @@ -1,7 +1,8 @@ | ||
| 1 | 1 | import { memo } from "react"; |
| 2 | −import { Alert, AlertAction, AlertDescription } from "../ui/alert"; | |
| 3 | 2 | import { CircleAlertIcon, XIcon } from "lucide-react"; |
| 4 | 3 | |
| 4 | +import { Alert, AlertAction, AlertDescription } from "../ui/alert"; | |
| 5 | + | |
| 5 | 6 | export const ThreadErrorBanner = memo(function ThreadErrorBanner({ |
| 6 | 7 | error, |
| 7 | 8 | onDismiss, |
| @@ -11,7 +12,7 @@ export const ThreadErrorBanner = memo(function ThreadErrorBanner({ · 3 unmodified lines | ||
| 11 | 12 | }) { |
| 12 | 13 | if (!error) return null; |
| 13 | 14 | return ( |
| 14 | − <div className="pt-3 mx-auto max-w-3xl"> | |
| 15 | + <div className="mx-auto max-w-3xl pt-3"> | |
| 15 | 16 | <Alert variant="error"> |
| 16 | 17 | <CircleAlertIcon /> |
| 17 | 18 | <AlertDescription className="line-clamp-3" title={error}> |
| @@ -39,6 +39,7 @@ import { ProviderModelsSection } from "./ProviderModelsSection"; · 38 unmodified lines | ||
| 39 | 39 | import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; |
| 40 | 40 | import { RedactedSensitiveText } from "./RedactedSensitiveText"; |
| 41 | 41 | import { |
| 42 | + getProviderCompatibilityUpdateCommand, | |
| 42 | 43 | getProviderVersionAdvisoryPresentation, |
| 43 | 44 | PROVIDER_STATUS_STYLES, |
| 44 | 45 | getProviderCompatibilityAdvisoryPresentation, |
| @@ -57,6 +58,8 @@ const PROVIDER_ACCENT_SWATCHES = [ · 12 unmodified lines | ||
| 57 | 58 | ] as const; |
| 58 | 59 | |
| 59 | 60 | 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"; | |
| 60 | 63 | |
| 61 | 64 | let environmentVariableDraftId = 0; |
| 62 | 65 | const nextEnvironmentVariableDraftId = () => `provider-env-${environmentVariableDraftId++}`; |
| @@ -422,6 +425,8 @@ interface ProviderInstanceCardProps { · 359 unmodified lines | ||
| 422 | 425 | readonly onModelOrderChange: (next: ReadonlyArray<string>) => void; |
| 423 | 426 | readonly onRunUpdate?: (() => void) | undefined; |
| 424 | 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 | 471 | onModelOrderChange, |
| 467 | 472 | onRunUpdate, |
| 468 | 473 | isUpdating = false, |
| 474 | + onRunCompatibilityUpdate, | |
| 475 | + isCompatibilityUpdating = false, | |
| 469 | 476 | }: ProviderInstanceCardProps) { |
| 470 | 477 | const enabled = instance.enabled ?? true; |
| 471 | 478 | // The server-reported status wins when present; otherwise fall back to |
| @@ -487,7 +494,9 @@ export function ProviderInstanceCard({ · 15 unmodified lines | ||
| 487 | 494 | liveProvider?.compatibilityAdvisory, |
| 488 | 495 | ); |
| 489 | 496 | const versionAdvisory = getProviderVersionAdvisoryPresentation(liveProvider?.versionAdvisory); |
| 497 | + const hasProviderAdvisoryIcons = compatibilityAdvisory !== null || versionAdvisory !== null; | |
| 490 | 498 | const updateCommand = versionAdvisory?.updateCommand ?? null; |
| 499 | + const compatibilityUpdateCommand = getProviderCompatibilityUpdateCommand(liveProvider); | |
| 491 | 500 | const FallbackIconComponent = driverOption?.icon; |
| 492 | 501 | const displayName = |
| 493 | 502 | instance.displayName?.trim() || driverOption?.label || String(instance.driver); |
| @@ -681,134 +690,200 @@ export function ProviderInstanceCard({ · 187 unmodified lines | ||
| 681 | 690 | <div className="min-w-0 flex-1 space-y-1"> |
| 682 | 691 | <div className="flex min-w-0 flex-wrap items-center gap-2"> |
| 683 | 692 | {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> | |
| 812 | 887 | ) : null} |
| 813 | 888 | {titleTailNode} |
| 814 | 889 | </div> |
| @@ -255,7 +255,7 @@ function createOutdatedProvider(driver: string): ServerProvider { · 254 unmodified lines | ||
| 255 | 255 | latestVersion: "1.1.0", |
| 256 | 256 | message: "Update available.", |
| 257 | 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 | 259 | canUpdate: true, |
| 260 | 260 | }, |
| 261 | 261 | }; |
| @@ -1155,6 +1155,63 @@ describe("GeneralSettingsPanel observability", () => { · 893 unmodified lines | ||
| 1155 | 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 | |
| 1160 | 1217 | describe("SourceControlSettingsPanel discovery states", () => { |
| @@ -10,6 +10,7 @@ import { · 9 unmodified lines | ||
| 10 | 10 | type ProviderInstanceConfig, |
| 11 | 11 | type ProviderInstanceId, |
| 12 | 12 | type ScopedThreadRef, |
| 13 | + type ServerProvider, | |
| 13 | 14 | } from "@t3tools/contracts"; |
| 14 | 15 | import { scopeThreadRef } from "@t3tools/client-runtime"; |
| 15 | 16 | import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; |
| @@ -63,6 +64,10 @@ import { · 47 unmodified lines | ||
| 63 | 64 | type ProviderUpdateCandidate, |
| 64 | 65 | } from "../ProviderUpdateLaunchNotification.logic"; |
| 65 | 66 | import { ProviderInstanceCard } from "./ProviderInstanceCard"; |
| 67 | +import { | |
| 68 | + canRunProviderCompatibilityUpdate, | |
| 69 | + getProviderCompatibilityUpdateCommand, | |
| 70 | +} from "./providerStatus"; | |
| 66 | 71 | import { DRIVER_OPTIONS, getDriverOption } from "./providerDriverMeta"; |
| 67 | 72 | import { |
| 68 | 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 | 1067 | interface InstanceRow { |
| 1014 | 1068 | readonly instanceId: ProviderInstanceId; |
| 1015 | 1069 | readonly instance: ProviderInstanceConfig; |
| @@ -1245,6 +1299,22 @@ export function ProviderSettingsPanel() { · 229 unmodified lines | ||
| 1245 | 1299 | updateCandidate !== undefined && |
| 1246 | 1300 | canOneClickUpdateProviderCandidate(updateCandidate, serverProviders) && |
| 1247 | 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 | 1318 | const modelPreferences = settings.providerModelPreferences?.[row.instanceId] ?? { |
| 1249 | 1319 | hiddenModels: [], |
| 1250 | 1320 | modelOrder: [], |
| @@ -1318,6 +1388,19 @@ export function ProviderSettingsPanel() { · 67 unmodified lines | ||
| 1318 | 1388 | : undefined |
| 1319 | 1389 | } |
| 1320 | 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 | })} |
| @@ -1,6 +1,9 @@ | ||
| 1 | 1 | import { describe, expect, it } from "vitest"; |
| 2 | 2 | |
| 3 | −import { getProviderCompatibilityAdvisoryPresentation } from "./providerStatus"; | |
| 3 | +import { | |
| 4 | + getProviderCompatibilityAdvisoryPresentation, | |
| 5 | + getProviderCompatibilityUpdateCommand, | |
| 6 | +} from "./providerStatus"; | |
| 4 | 7 | |
| 5 | 8 | describe("getProviderCompatibilityAdvisoryPresentation", () => { |
| 6 | 9 | it("hides supported compatibility advisories", () => { |
| @@ -29,7 +32,34 @@ describe("getProviderCompatibilityAdvisoryPresentation", () => { · 22 unmodified lines | ||
| 29 | 32 | ).toEqual({ |
| 30 | 33 | title: "Incompatible provider version", |
| 31 | 34 | detail: "Known incompatible.", |
| 35 | + updateCommand: null, | |
| 36 | + canUpdate: false, | |
| 32 | 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 | }); |
| @@ -120,11 +120,65 @@ export function getProviderVersionAdvisoryPresentation( · 119 unmodified lines | ||
| 120 | 120 | }; |
| 121 | 121 | } |
| 122 | 122 | |
| 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 | + | |
| 123 | 175 | export function getProviderCompatibilityAdvisoryPresentation( |
| 124 | 176 | advisory: ServerProviderCompatibilityAdvisory | undefined, |
| 125 | 177 | ): { |
| 126 | 178 | readonly title: string; |
| 127 | 179 | readonly detail: string; |
| 180 | + readonly updateCommand: string | null; | |
| 181 | + readonly canUpdate: boolean; | |
| 128 | 182 | readonly emphasis: "normal" | "strong"; |
| 129 | 183 | } | null { |
| 130 | 184 | if (!advisory || advisory.status === "supported") { |
| @@ -142,6 +196,8 @@ export function getProviderCompatibilityAdvisoryPresentation( · 11 unmodified lines | ||
| 142 | 196 | title: |
| 143 | 197 | advisory.status === "broken" ? "Incompatible provider version" : "Provider version warning", |
| 144 | 198 | detail: advisory.message ?? fallback, |
| 199 | + updateCommand: advisory.updateCommand ?? null, | |
| 200 | + canUpdate: advisory.canUpdate === true, | |
| 145 | 201 | emphasis: advisory.severity === "error" ? "strong" : "normal", |
| 146 | 202 | }; |
| 147 | 203 | } |
| @@ -160,6 +160,8 @@ export const ServerProviderCompatibilityAdvisory = Schema.Struct({ · 159 unmodified lines | ||
| 160 | 160 | message: Schema.NullOr(TrimmedNonEmptyString), |
| 161 | 161 | recommendedRange: Schema.NullOr(TrimmedNonEmptyString), |
| 162 | 162 | recommendedVersion: Schema.optionalKey(Schema.NullOr(TrimmedNonEmptyString)), |
| 163 | + updateCommand: Schema.optionalKey(Schema.NullOr(TrimmedNonEmptyString)), | |
| 164 | + canUpdate: Schema.optionalKey(Schema.Boolean), | |
| 163 | 165 | ranges: Schema.Array(ServerProviderCompatibilityRange).pipe( |
| 164 | 166 | Schema.withDecodingDefault(Effect.succeed([])), |
| 165 | 167 | ), |
| @@ -523,6 +525,7 @@ export type ServerProviderUpdatedPayload = typeof ServerProviderUpdatedPayload.T · 357 unmodified lines | ||
| 523 | 525 | export const ServerProviderUpdateInput = Schema.Struct({ |
| 524 | 526 | provider: ProviderDriverKind, |
| 525 | 527 | instanceId: Schema.optionalKey(ProviderInstanceId), |
| 528 | + targetVersion: Schema.optionalKey(TrimmedNonEmptyString), | |
| 526 | 529 | }); |
| 527 | 530 | export type ServerProviderUpdateInput = typeof ServerProviderUpdateInput.Type; |
| 528 | 531 | |
| @@ -2,7 +2,7 @@ · 1 unmodified line | ||
| 2 | 2 | "version": 1, |
| 3 | 3 | "policies": [ |
| 4 | 4 | { |
| 5 | − "t3CodeRange": ">=0.0.24", | |
| 5 | + "t3CodeRange": ">=0.0.0", | |
| 6 | 6 | "driver": "codex", |
| 7 | 7 | "recommendedRange": ">=0.129.0", |
| 8 | 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 | 24 | "driver": "claudeAgent", |
| 25 | 25 | "recommendedRange": ">=0.2.111", |
| 26 | 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 | 37 | "driver": "opencode", |
| 38 | 38 | "recommendedRange": ">=1.14.19", |
| 39 | 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 | 55 | "driver": "cursor", |
| 56 | 56 | "recommendedRange": ">=2026.05.09", |
| 57 | 57 | "recommendedVersion": "2026.05.09", |