- 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 | 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, |
| @@ -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, |
| @@ -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, |
| @@ -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)), |
| @@ -5,6 +5,7 @@ import * as Layer from "effect/Layer"; · 4 unmodified lines | |||
| 5 | import { HttpClient, HttpClientResponse } from "effect/unstable/http"; | 5 | import { HttpClient, HttpClientResponse } from "effect/unstable/http"; |
| 6 | 6 | ||
| 7 | import * as ProviderCompatibility from "./ProviderCompatibility.ts"; | 7 | import * as ProviderCompatibility from "./ProviderCompatibility.ts"; |
| 8 | import { makeProviderMaintenanceCapabilities } from "./providerMaintenance.ts"; | ||
| 8 | 9 | ||
| 9 | const codexDriver = ProviderDriverKind.make("codex"); | 10 | const codexDriver = ProviderDriverKind.make("codex"); |
| 10 | const claudeDriver = ProviderDriverKind.make("claudeAgent"); | 11 | const claudeDriver = ProviderDriverKind.make("claudeAgent"); |
| @@ -26,6 +27,14 @@ const baseProvider: ServerProvider = { · 15 unmodified lines | |||
| 26 | skills: [], | 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 | function jsonHttpClient( | 38 | function 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 | }); |
| @@ -17,6 +17,10 @@ import { HttpClient, HttpClientRequest } from "effect/unstable/http"; · 16 unmodified lines | |||
| 17 | 17 | ||
| 18 | import bundledCompatibilityDocumentJson from "../../../../provider-compatibility.v1.json" with { type: "json" }; | 18 | import bundledCompatibilityDocumentJson from "../../../../provider-compatibility.v1.json" with { type: "json" }; |
| 19 | import packageJson from "../../package.json" with { type: "json" }; | 19 | import packageJson from "../../package.json" with { type: "json" }; |
| 20 | import { | ||
| 21 | makeTargetedProviderUpdateAction, | ||
| 22 | type ProviderMaintenanceCapabilities, | ||
| 23 | } from "./providerMaintenance.ts"; | ||
| 20 | 24 | ||
| 21 | const T3_CODE_VERSION = packageJson.version; | 25 | const T3_CODE_VERSION = packageJson.version; |
| 22 | const REMOTE_COMPATIBILITY_CACHE_TTL = Duration.minutes(15); | 26 | const 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 | ||
| 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 | export function createProviderCompatibilityAdvisory(input: { | 200 | export 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 | ||
| 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 | export interface ProviderCompatibilityServiceShape { | 357 | export 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 | ||
| 325 | export class ProviderCompatibilityService extends Context.Service< | 365 | export 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; |
| @@ -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 | () => |
| @@ -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 | function makeNpmGlobalProviderMaintenanceCapabilities( | 137 | function makeNpmGlobalProviderMaintenanceCapabilities( |
| 112 | definition: PackageManagedProviderMaintenanceDefinition, | 138 | definition: PackageManagedProviderMaintenanceDefinition, |
| 113 | ): ProviderMaintenanceCapabilities { | 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 | 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 | () => { |
| @@ -22,7 +22,10 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; · 21 unmodified lines | |||
| 22 | 22 | ||
| 23 | import { ProviderRegistry } from "./Services/ProviderRegistry.ts"; | 23 | import { ProviderRegistry } from "./Services/ProviderRegistry.ts"; |
| 24 | import { makeProviderMaintenanceCommandCoordinator } from "./providerMaintenanceCommandCoordinator.ts"; | 24 | import { makeProviderMaintenanceCommandCoordinator } from "./providerMaintenanceCommandCoordinator.ts"; |
| 25 | import { enrichProviderSnapshotWithVersionAdvisory } from "./providerMaintenance.ts"; | 25 | import { |
| 26 | enrichProviderSnapshotWithVersionAdvisory, | ||
| 27 | makeTargetedProviderUpdateAction, | ||
| 28 | } from "./providerMaintenance.ts"; | ||
| 26 | import type { ProviderMaintenanceCapabilities } from "./providerMaintenance.ts"; | 29 | import type { ProviderMaintenanceCapabilities } from "./providerMaintenance.ts"; |
| 27 | import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; | 30 | import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; |
| 28 | const isServerProviderUpdateError = Schema.is(ServerProviderUpdateError); | 31 | const 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, |
| @@ -42,7 +42,7 @@ import { useGitStatus } from "~/lib/gitStatusState"; · 41 unmodified lines | |||
| 42 | import { usePrimaryEnvironmentId } from "../environments/primary"; | 42 | import { usePrimaryEnvironmentId } from "../environments/primary"; |
| 43 | import { readEnvironmentApi } from "../environmentApi"; | 43 | import { readEnvironmentApi } from "../environmentApi"; |
| 44 | import { isElectron } from "../env"; | 44 | import { isElectron } from "../env"; |
| 45 | import { readLocalApi } from "../localApi"; | 45 | import { ensureLocalApi, readLocalApi } from "../localApi"; |
| 46 | import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; | 46 | import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; |
| 47 | import { | 47 | import { |
| 48 | collapseExpandedComposerCursor, | 48 | collapseExpandedComposerCursor, |
| @@ -104,7 +104,14 @@ import { BranchToolbar } from "./BranchToolbar"; · 55 unmodified lines | |||
| 104 | import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; | 104 | import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; |
| 105 | import PlanSidebar from "./PlanSidebar"; | 105 | import PlanSidebar from "./PlanSidebar"; |
| 106 | import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; | 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 | import { cn, randomUUID } from "~/lib/utils"; | 115 | import { cn, randomUUID } from "~/lib/utils"; |
| 109 | import { stackedThreadToast, toastManager } from "./ui/toast"; | 116 | import { stackedThreadToast, toastManager } from "./ui/toast"; |
| 110 | import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; | 117 | import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; |
| @@ -147,7 +154,6 @@ import { ChatHeader } from "./chat/ChatHeader"; · 36 unmodified lines | |||
| 147 | import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; | 154 | import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; |
| 148 | import { NoActiveThreadState } from "./NoActiveThreadState"; | 155 | import { NoActiveThreadState } from "./NoActiveThreadState"; |
| 149 | import { resolveEffectiveEnvMode, resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; | 156 | import { resolveEffectiveEnvMode, resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; |
| 150 | import { ProviderStatusBanner } from "./chat/ProviderStatusBanner"; | ||
| 151 | import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; | 157 | import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; |
| 152 | import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; | 158 | import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; |
| 153 | import { | 159 | import { |
| @@ -183,6 +189,11 @@ import { sanitizeThreadErrorMessage } from "~/rpc/transportError"; · 29 unmodified lines | |||
| 183 | import { retainThreadDetailSubscription } from "../environments/runtime/service"; | 189 | import { retainThreadDetailSubscription } from "../environments/runtime/service"; |
| 184 | import { RightPanelSheet } from "./RightPanelSheet"; | 190 | import { RightPanelSheet } from "./RightPanelSheet"; |
| 185 | import { Button } from "./ui/button"; | 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 | import { | 197 | import { |
| 187 | buildVersionMismatchDismissalKey, | 198 | buildVersionMismatchDismissalKey, |
| 188 | dismissVersionMismatch, | 199 | dismissVersionMismatch, |
| @@ -197,6 +208,75 @@ const EMPTY_PROPOSED_PLANS: Thread["proposedPlans"] = []; · 8 unmodified lines | |||
| 197 | const EMPTY_PROVIDERS: ServerProvider[] = []; | 208 | const EMPTY_PROVIDERS: ServerProvider[] = []; |
| 198 | const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; | 209 | const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; |
| 199 | const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {}; | 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 | type EnvironmentUnavailableState = { | 280 | type 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 | <ThreadErrorBanner | 3708 | <ThreadErrorBanner |
| 3546 | error={activeThread.error} | 3709 | error={activeThread.error} |
| 3547 | onDismiss={() => setThreadError(activeThread.id, null)} | 3710 | onDismiss={() => setThreadError(activeThread.id, null)} |
| @@ -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 | preserveComposerFocusOnPointerDown | 2105 | 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 | preserveComposerFocusOnPointerDown | 2319 | 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} |
| @@ -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 | <MenuItem | 188 | <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 thread | 194 | 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 | isEnvironmentUnavailable | 215 | isProviderUnavailable |
| 203 | ? "Environment disconnected" | 216 | ? "Provider unavailable" |
| 204 | : isConnecting | 217 | : isEnvironmentUnavailable |
| 205 | ? "Connecting" | 218 | ? "Environment disconnected" |
| 206 | : isPreparingWorktree | 219 | : isConnecting |
| 207 | ? "Preparing worktree" | 220 | ? "Connecting" |
| 208 | : isSendBusy | 221 | : 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 ? ( |
| @@ -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 | import { memo } from "react"; | 1 | import { memo } from "react"; |
| 2 | import { Alert, AlertAction, AlertDescription } from "../ui/alert"; | ||
| 3 | import { CircleAlertIcon, XIcon } from "lucide-react"; | 2 | import { CircleAlertIcon, XIcon } from "lucide-react"; |
| 4 | 3 | ||
| 4 | import { Alert, AlertAction, AlertDescription } from "../ui/alert"; | ||
| 5 | |||
| 5 | export const ThreadErrorBanner = memo(function ThreadErrorBanner({ | 6 | export 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}> |
| @@ -39,6 +39,7 @@ import { ProviderModelsSection } from "./ProviderModelsSection"; · 38 unmodified lines | |||
| 39 | import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; | 39 | import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; |
| 40 | import { RedactedSensitiveText } from "./RedactedSensitiveText"; | 40 | import { RedactedSensitiveText } from "./RedactedSensitiveText"; |
| 41 | import { | 41 | import { |
| 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 | ||
| 59 | const ENVIRONMENT_VARIABLE_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/; | 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 | let environmentVariableDraftId = 0; | 64 | let environmentVariableDraftId = 0; |
| 62 | const nextEnvironmentVariableDraftId = () => `provider-env-${environmentVariableDraftId++}`; | 65 | const 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 to | 478 | // 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 | <PopoverTrigger | 696 | {compatibilityAdvisory ? ( |
| 688 | render={ | 697 | <span className={ADVISORY_ICON_SLOT_CLASS}> |
| 689 | <Button | 698 | <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 | <p | 719 | <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 | <PopoverTrigger | 735 | <Button |
| 727 | render={ | 736 | type="button" |
| 728 | <Button | 737 | 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 available | 757 | ) : null} |
| 749 | </p> | 758 | {compatibilityUpdateCommand ? ( |
| 750 | <p | 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"> |
| 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 | <Button | 771 | 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 using | 786 | </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 | <TooltipTrigger | 796 | <Button |
| 788 | render={ | 797 | type="button" |
| 789 | <Button | 798 | 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> |
| @@ -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 | ||
| 1160 | describe("SourceControlSettingsPanel discovery states", () => { | 1217 | describe("SourceControlSettingsPanel discovery states", () => { |
| @@ -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"; |
| 14 | import { scopeThreadRef } from "@t3tools/client-runtime"; | 15 | import { scopeThreadRef } from "@t3tools/client-runtime"; |
| 15 | import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; | 16 | import { 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"; |
| 65 | import { ProviderInstanceCard } from "./ProviderInstanceCard"; | 66 | import { ProviderInstanceCard } from "./ProviderInstanceCard"; |
| 67 | import { | ||
| 68 | canRunProviderCompatibilityUpdate, | ||
| 69 | getProviderCompatibilityUpdateCommand, | ||
| 70 | } from "./providerStatus"; | ||
| 66 | import { DRIVER_OPTIONS, getDriverOption } from "./providerDriverMeta"; | 71 | import { DRIVER_OPTIONS, getDriverOption } from "./providerDriverMeta"; |
| 67 | import { | 72 | import { |
| 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 | : undefined | 1388 | : 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 | })} |
| @@ -1,6 +1,9 @@ | |||
| 1 | import { describe, expect, it } from "vitest"; | 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 | describe("getProviderCompatibilityAdvisoryPresentation", () => { | 8 | describe("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 | }); |
| @@ -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 | export function getProviderCompatibilityAdvisoryPresentation( | 175 | export 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 | } |
| @@ -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 | |||
| 523 | export const ServerProviderUpdateInput = Schema.Struct({ | 525 | export 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 | }); |
| 527 | export type ServerProviderUpdateInput = typeof ServerProviderUpdateInput.Type; | 530 | export type ServerProviderUpdateInput = typeof ServerProviderUpdateInput.Type; |
| 528 | 531 | ||
| @@ -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", |