From 96c4bfa0a27f776f7cd17df7c7115c3de79ee399 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 22 Sep 2026 18:38:20 -0700 Subject: [PATCH 01/27] feat(providers): check remote compatibility ranges (#13130) --- .../src/features/threads/ThreadComposer.tsx | 17 ++ .../provider/Layers/ProviderRegistry.test.ts | 3 +- .../src/provider/Layers/ProviderRegistry.ts | 38 ++- .../server/src/provider/ModelManifest.test.ts | 40 +++ apps/server/src/provider/ModelManifest.ts | 2 + apps/server/src/provider/model-manifest.json | 10 +- .../provider/providerCompatibility.test.ts | 258 ++++++++++++++++++ .../src/provider/providerCompatibility.ts | 119 ++++++++ .../src/provider/providerMaintenance.test.ts | 43 +++ .../src/provider/providerMaintenance.ts | 28 ++ .../providerMaintenanceRunner.test.ts | 103 ++++++- .../src/provider/providerMaintenanceRunner.ts | 59 +++- ...iderUpdateLaunchNotification.logic.test.ts | 20 ++ .../ProviderUpdateLaunchNotification.logic.ts | 4 + .../chat/ProviderStatusBanner.test.ts | 72 +++++ .../components/chat/ProviderStatusBanner.tsx | 27 +- .../settings/ProviderInstanceCard.tsx | 141 +++++++--- .../settings/ProviderSettingsPanel.tsx | 28 +- .../settings/providerStatus.test.ts | 72 ++++- .../src/components/settings/providerStatus.ts | 49 +++- docs/user/install.md | 6 + packages/contracts/src/server.ts | 19 ++ 22 files changed, 1099 insertions(+), 59 deletions(-) create mode 100644 apps/server/src/provider/providerCompatibility.test.ts create mode 100644 apps/server/src/provider/providerCompatibility.ts create mode 100644 apps/web/src/components/chat/ProviderStatusBanner.test.ts diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index c747a4ed8171..23ddc65896b9 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -651,6 +651,23 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ) : null} + {selectedProviderStatus?.compatibilityAdvisory?.message && + (selectedProviderStatus.compatibilityAdvisory.status === "unsupported" || + selectedProviderStatus.compatibilityAdvisory.status === "broken") ? ( + + {selectedProviderStatus.compatibilityAdvisory.message} + + ) : null} {modelUnavailable ? ( Model unavailable. Open model settings. diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index caee1981d79f..354484e6d395 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -77,12 +77,13 @@ process.env.T3CODE_CURSOR_ENABLED = "1"; const encoder = new TextEncoder(); const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); +// Provider metadata checks use a bundled manifest and stubbed HTTP. const TestHttpClientLive = Layer.succeed( HttpClient.HttpClient, HttpClient.make((request) => Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ version: "0.0.0" }))), ), -); +).pipe(Layer.provideMerge(ModelManifest.layerTest)); const BackgroundPolicyAlwaysRunLayer = Layer.mock(BackgroundPolicy.BackgroundPolicy)({ reportClientActivity: () => Effect.void, diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index a8e6caf95aa7..a65bc66edf32 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -40,6 +40,8 @@ import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; import * as Semaphore from "effect/Semaphore"; +import * as ModelManifest from "../ModelManifest.ts"; +import { applyProviderCompatibility } from "../providerCompatibility.ts"; import { ServerConfig } from "../../config.ts"; import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; import { ProviderRegistry, type ProviderRegistryShape } from "../Services/ProviderRegistry.ts"; @@ -278,6 +280,8 @@ export const ProviderRegistryLive = Layer.effect( ProviderRegistry, Effect.gen(function* () { const instanceRegistry = yield* ProviderInstanceRegistry; + const manifestService = yield* ModelManifest.ModelManifest; + const serviceScope = yield* Effect.scope; const config = yield* ServerConfig; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -356,7 +360,19 @@ export const ProviderRegistryLive = Layer.effect( ), ), ); - const providersRef = yield* Ref.make>(cachedProviders); + const initialManifest = yield* manifestService.current; + const classifyCompatibility = ( + provider: ServerProvider, + manifest: ModelManifest.ModelManifestData, + ) => + applyProviderCompatibility( + provider, + manifest.compatibility, + ModelManifest.BUNDLED_MODEL_MANIFEST.compatibility, + ); + const providersRef = yield* Ref.make>( + cachedProviders.map((provider) => classifyCompatibility(provider, initialManifest)), + ); const workspaceRefreshesRef = yield* Ref.make< ReadonlyMap> >(new Map()); @@ -424,6 +440,7 @@ export const ProviderRegistryLive = Layer.effect( readonly replace?: boolean; }, ) { + const manifest = yield* manifestService.current; const nextProvidersWithUpdateState = yield* Effect.forEach( nextProviders, applyProviderUpdateState, @@ -450,7 +467,11 @@ export const ProviderRegistryLive = Layer.effect( ); } - const providers = orderProviderSnapshots([...mergedProviders.values()]); + const providers = orderProviderSnapshots( + [...mergedProviders.values()].map((provider) => + classifyCompatibility(provider, manifest), + ), + ); const providersToPersist = providers.filter((provider) => updatedKeys.has(snapshotInstanceKey(provider)), ); @@ -473,13 +494,24 @@ export const ProviderRegistryLive = Layer.effect( return providers; }); + const compatibilityRefreshRunning = yield* Ref.make(false); const syncProvider = Effect.fn("syncProvider")(function* ( provider: ServerProvider, options?: { readonly publish?: boolean; }, ) { - return yield* upsertProviders([provider], options); + const providers = yield* upsertProviders([provider], options); + // Reclassify the current read model after fetching. Never republish the + // probe captured before the fetch: a newer health result may have landed. + if (!(yield* Ref.getAndSet(compatibilityRefreshRunning, true))) { + yield* manifestService.refresh.pipe( + Effect.andThen(upsertProviders([], { persist: false })), + Effect.ensuring(Ref.set(compatibilityRefreshRunning, false)), + Effect.forkIn(serviceScope), + ); + } + return providers; }); const setProviderMaintenanceActionState = Effect.fn("setProviderMaintenanceActionState")( diff --git a/apps/server/src/provider/ModelManifest.test.ts b/apps/server/src/provider/ModelManifest.test.ts index 1f00cb56040a..77e06530f2e5 100644 --- a/apps/server/src/provider/ModelManifest.test.ts +++ b/apps/server/src/provider/ModelManifest.test.ts @@ -549,3 +549,43 @@ describe("ModelManifest service", () => { ), ); }); + +it.effect("caches valid compatibility policies and keeps them after a malformed refresh", () => { + const remote: ModelManifestData = { + ...REMOTE_MANIFEST, + compatibility: [ + { + driver: "codex", + t3CodeRange: ">=0.0.42", + recommendedVersion: "2.0.0", + ranges: [{ range: "=2.0.0", status: "supported" }], + }, + ], + }; + let invalid = false; + return Effect.gen(function* () { + const service = yield* make; + assert.deepStrictEqual((yield* service.refresh).compatibility, remote.compatibility); + invalid = true; + yield* TestClock.adjust("1 hour"); + assert.deepStrictEqual((yield* service.refresh).compatibility, remote.compatibility); + const rebooted = yield* make; + assert.deepStrictEqual((yield* rebooted.current).compatibility, remote.compatibility); + }).pipe( + Effect.scoped, + Effect.provide( + serviceLayers({ + prefix: "model-manifest-compatibility-test", + response: () => + Response.json( + invalid + ? { + ...remote, + compatibility: [{ ...remote.compatibility![0], recommendedVersion: "3.0.0" }], + } + : remote, + ), + }), + ), + ); +}); diff --git a/apps/server/src/provider/ModelManifest.ts b/apps/server/src/provider/ModelManifest.ts index 7f2305097b78..b698ed6c0c82 100644 --- a/apps/server/src/provider/ModelManifest.ts +++ b/apps/server/src/provider/ModelManifest.ts @@ -34,6 +34,7 @@ import { ServerConfig } from "../config.ts"; import * as ServerSettings from "../serverSettings.ts"; import { hasValidClaudeManifestAdapters } from "./ClaudeModelManifest.ts"; import bundledManifestJson from "./model-manifest.json" with { type: "json" }; +import { ProviderCompatibilityPolicy } from "./providerCompatibility.ts"; import type { ServerProviderDraft } from "./providerSnapshot.ts"; const MODEL_MANIFEST_URL = @@ -89,6 +90,7 @@ const ModelManifestEnvelopeSchema = Schema.Struct({ * files still decode; they count as older than any dated bundle. */ updatedAt: Schema.optional(Schema.String), + compatibility: Schema.optional(Schema.Array(ProviderCompatibilityPolicy)), currentModels: Schema.Record(Schema.String, Schema.Array(Schema.String)), providers: Schema.optional(Schema.Record(Schema.String, ManifestProviderCatalog)), }); diff --git a/apps/server/src/provider/model-manifest.json b/apps/server/src/provider/model-manifest.json index a5924f9cadf2..32a103be9d16 100644 --- a/apps/server/src/provider/model-manifest.json +++ b/apps/server/src/provider/model-manifest.json @@ -1,6 +1,14 @@ { "version": 1, - "updatedAt": "2026-09-22T19:42:55Z", + "updatedAt": "2026-09-22T21:40:00Z", + "compatibility": [ + { + "driver": "opencode", + "t3CodeRange": ">=0.0.42", + "recommendedRange": ">=1.14.19", + "ranges": [{ "range": "<1.14.19", "status": "broken" }] + } + ], "currentModels": { "codex": [ "gpt-6-astra", diff --git a/apps/server/src/provider/providerCompatibility.test.ts b/apps/server/src/provider/providerCompatibility.test.ts new file mode 100644 index 000000000000..e6b4e1a608b9 --- /dev/null +++ b/apps/server/src/provider/providerCompatibility.test.ts @@ -0,0 +1,258 @@ +import { assert, describe, it } from "@effect/vitest"; +import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Deferred from "effect/Deferred"; +import * as Fiber from "effect/Fiber"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as PubSub from "effect/PubSub"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; +import * as ServerConfig from "../config.ts"; +import * as ModelManifest from "./ModelManifest.ts"; +import { ProviderRegistryLive } from "./Layers/ProviderRegistry.ts"; +import { ProviderRegistry } from "./Services/ProviderRegistry.ts"; +import { ProviderInstanceRegistry } from "./Services/ProviderInstanceRegistry.ts"; +import type { ProviderInstance } from "./ProviderDriver.ts"; +import { makeManualOnlyProviderMaintenanceCapabilities } from "./providerMaintenance.ts"; +import * as Schema from "effect/Schema"; +import { + applyProviderCompatibility, + ProviderCompatibilityPolicy, + resolveProviderCompatibility, +} from "./providerCompatibility.ts"; + +const driver = ProviderDriverKind.make("codex"); +const policy: ProviderCompatibilityPolicy = { + driver, + t3CodeRange: ">=0.0.42 <0.1.0", + recommendedVersion: "2.0.0", + recommendedRange: ">=2.0.0 <3.0.0", + ranges: [ + { range: "<1.0.0", status: "broken" }, + { range: ">=1.0.0 <1.5.0", status: "unsupported" }, + { range: ">=1.5.0 <2.0.0", status: "graceful" }, + { range: ">=2.0.0 <3.0.0", status: "supported" }, + ], +}; +const provider: ServerProvider = { + driver, + instanceId: ProviderInstanceId.make("codex-work"), + enabled: true, + installed: true, + version: "0.9.0", + status: "error", + message: "Authentication failed", + checkedAt: "2026-09-22T00:00:00Z", + auth: { status: "unauthenticated" }, + models: [], + skills: [], + slashCommands: [], +}; + +describe("provider compatibility", () => { + it("classifies boundaries and treats unlisted versions and release tags as unknown", () => { + for (const [version, expected] of [ + ["0.9.9", "broken"], + ["1.0.0", "unsupported"], + ["1.5.0", "graceful"], + ["2.0.0", "supported"], + ["v2.0.0", "supported"], + ["3.0.0", "unknown"], + ["2.0.0-beta.1", "unknown"], + ["agy_acp_server_20260818_01_RC01", "unknown"], + [null, "unknown"], + ] as const) { + assert.strictEqual(resolveProviderCompatibility([policy], driver, version)?.status, expected); + } + assert.isUndefined(resolveProviderCompatibility([policy], driver, "0.9.0", "0.1.0")); + }); + + it("supports every driver without inventing policies for uncovered adapters", () => { + for (const kind of [ + "codex", + "claudeAgent", + "cursor", + "grok", + "opencode", + "antigravity", + "customDriver", + ]) { + const adapter = ProviderDriverKind.make(kind); + assert.strictEqual( + resolveProviderCompatibility([{ ...policy, driver: adapter }], adapter, "2.0.0")?.status, + "supported", + ); + assert.isUndefined(resolveProviderCompatibility([], adapter, "2.0.0")); + } + }); + + it("relaxes remote policy without losing probe errors, and falls back when a policy is omitted", () => { + const broken = applyProviderCompatibility(provider, [], [policy]); + assert.strictEqual(broken.compatibilityAdvisory?.status, "broken"); + const relaxed = { ...policy, ranges: [{ range: ">=0.0.0", status: "supported" as const }] }; + const supported = applyProviderCompatibility(broken, [relaxed], [policy]); + assert.strictEqual(supported.compatibilityAdvisory?.status, "supported"); + assert.strictEqual(supported.status, "error"); + assert.strictEqual(supported.message, "Authentication failed"); + assert.strictEqual( + applyProviderCompatibility(supported, [{ ...policy, t3CodeRange: ">=9.0.0" }], [policy]) + .compatibilityAdvisory?.status, + "broken", + ); + const removed = applyProviderCompatibility(supported, [], []); + assert.isUndefined(removed.compatibilityAdvisory); + assert.strictEqual(removed.status, "error"); + assert.strictEqual(removed.message, "Authentication failed"); + assert.isUndefined( + applyProviderCompatibility({ ...broken, enabled: false }, [], [policy]).compatibilityAdvisory, + ); + assert.isUndefined( + applyProviderCompatibility({ ...broken, installed: false }, [], [policy]) + .compatibilityAdvisory, + ); + }); + + it("rejects invalid ranges and recommendations outside the first supported match", () => { + const decode = Schema.decodeUnknownSync(ProviderCompatibilityPolicy); + assert.doesNotThrow(() => decode(policy)); + const prefixed = decode({ + ...policy, + t3CodeRange: ">=v0.0.42 =v2.0 =2.0.0 garbage", status: "supported" }] }, + { ...policy, recommendedVersion: "2.0.0; echo unsafe" }, + { ...policy, ranges: [{ range: ">=0.0.0", status: "broken" }, ...policy.ranges] }, + ]) + assert.throws(() => decode(invalid)); + }); +}); + +it.effect("a remote policy refresh preserves a newer health result on the registry stream", () => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + const health = yield* Ref.make({ + ...provider, + status: "ready" as ServerProvider["status"], + message: "Healthy", + }); + const manifest = yield* Ref.make({ + version: 1, + currentModels: {}, + compatibility: [policy], + }); + const instance: ProviderInstance = { + instanceId: provider.instanceId, + driverKind: driver, + enabled: true, + displayName: undefined, + continuationIdentity: { driverKind: driver, continuationKey: "test-codex" }, + snapshot: { + getSnapshot: Ref.get(health), + refresh: Ref.get(health), + streamChanges: Stream.empty, + applyUsageLimits: () => Effect.void, + resolveMaintenance: () => + Effect.succeed( + makeManualOnlyProviderMaintenanceCapabilities({ provider: driver, packageName: null }), + ), + }, + adapter: {} as ProviderInstance["adapter"], + textGeneration: {} as ProviderInstance["textGeneration"], + }; + const refresh = Deferred.succeed(started, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.andThen( + Ref.updateAndGet(manifest, (current) => ({ + ...current, + compatibility: [ + { ...policy, ranges: [{ range: ">=0.0.0", status: "supported" as const }] }, + ], + })), + ), + ); + const dependencies = Layer.mergeAll( + Layer.succeed(ModelManifest.ModelManifest, { + current: Ref.get(manifest), + refresh, + forceRefresh: refresh, + refreshInBackground: Effect.void, + }), + Layer.succeed(ProviderInstanceRegistry, { + getInstance: (id) => Effect.succeed(id === instance.instanceId ? instance : undefined), + listInstances: Effect.succeed([instance]), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.empty, + subscribeChanges: Effect.flatMap(PubSub.unbounded(), PubSub.subscribe), + }), + ServerConfig.layerTest(process.cwd(), { prefix: "compatibility-registry-test" }).pipe( + Layer.provideMerge(NodeServices.layer), + ), + ); + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry; + yield* Deferred.await(started); + assert.strictEqual( + (yield* registry.getProviders)[0]?.compatibilityAdvisory?.status, + "broken", + ); + yield* Ref.set(health, provider); + const latestHealth = yield* registry.refreshInstance(provider.instanceId); + assert.strictEqual(latestHealth[0]?.message, "Authentication failed"); + const supported = yield* Stream.toPull( + registry.streamChanges.pipe( + Stream.filter((snapshots) => snapshots[0]?.compatibilityAdvisory?.status === "supported"), + ), + ); + const subscribed = yield* supported.pipe(Effect.forkScoped({ startImmediately: true })); + yield* Deferred.succeed(release, undefined); + const updated = (yield* Fiber.join(subscribed))[0]?.[0]; + assert.strictEqual(updated?.compatibilityAdvisory?.status, "supported"); + assert.strictEqual(updated?.status, "error"); + assert.strictEqual(updated?.message, "Authentication failed"); + }).pipe(Effect.provide(ProviderRegistryLive.pipe(Layer.provide(dependencies)))); + }).pipe(Effect.scoped), +); + +it("recomputes latest-version compatibility without losing the underlying update advisory", () => { + const snapshot: ServerProvider = { + ...provider, + version: "2.0.0", + versionAdvisory: { + status: "behind_latest", + currentVersion: "2.0.0", + latestVersion: "4.0.0", + canUpdate: true, + updateCommand: "npm install -g fixture@latest", + checkedAt: provider.checkedAt, + message: null, + }, + }; + const blockedPolicy: ProviderCompatibilityPolicy = { + ...policy, + ranges: [...policy.ranges, { range: ">=3.0.0", status: "broken" }], + }; + const blocked = applyProviderCompatibility(snapshot, [blockedPolicy], []); + assert.strictEqual(blocked.compatibilityAdvisory?.latestVersionStatus, "broken"); + const relaxed = applyProviderCompatibility( + blocked, + [{ ...policy, ranges: [{ range: ">=2.0.0", status: "supported" }] }], + [], + ); + assert.strictEqual(relaxed.compatibilityAdvisory?.latestVersionStatus, "supported"); + assert.deepStrictEqual(relaxed.versionAdvisory, snapshot.versionAdvisory); +}); diff --git a/apps/server/src/provider/providerCompatibility.ts b/apps/server/src/provider/providerCompatibility.ts new file mode 100644 index 000000000000..11aedf8dca0d --- /dev/null +++ b/apps/server/src/provider/providerCompatibility.ts @@ -0,0 +1,119 @@ +import { + TrimmedNonEmptyString, + ServerProviderCompatibilityStatus, + type ProviderDriverKind, + type ServerProvider, + type ServerProviderCompatibilityAdvisory, +} from "@t3tools/contracts"; +import { satisfiesSemverRange } from "@t3tools/shared/semver"; +import * as Schema from "effect/Schema"; +import packageJson from "../../package.json" with { type: "json" }; + +// Deliberately uses the shared CLI gate syntax: comparator groups joined by ||. +// Release tags and prereleases remain unknown instead of matching stable ranges. +const StableVersion = TrimmedNonEmptyString.pipe( + Schema.check(Schema.makeFilter((value) => /^\d+\.\d+\.\d+$/.test(value))), +); +const VersionRange = TrimmedNonEmptyString.pipe( + Schema.check( + Schema.makeFilter((value) => + value.split("||").every((group) => { + const tokens = group.trim().split(/\s+/); + return tokens.every((token) => /^(?:\^|>=|>|<=|<|=)?v?\d+(?:\.\d+){0,2}$/.test(token)); + }), + ), + ), +); +const Policy = Schema.Struct({ + driver: TrimmedNonEmptyString, + t3CodeRange: VersionRange, + recommendedRange: Schema.optionalKey(VersionRange), + recommendedVersion: Schema.optionalKey(StableVersion), + ranges: Schema.Array( + Schema.Struct({ + range: VersionRange, + status: ServerProviderCompatibilityStatus, + }), + ), +}); + +export const ProviderCompatibilityPolicy = Policy.pipe( + Schema.check( + Schema.makeFilter( + (policy) => { + const version = policy.recommendedVersion; + if (version === undefined) return true; + return ( + (policy.recommendedRange === undefined || + satisfiesSemverRange(version, policy.recommendedRange)) && + policy.ranges.find((entry) => satisfiesSemverRange(version, entry.range))?.status === + "supported" + ); + }, + { expected: "a recommended version in a supported range" }, + ), + ), +); +export type ProviderCompatibilityPolicy = typeof ProviderCompatibilityPolicy.Type; + +export function resolveProviderCompatibility( + policies: ReadonlyArray | undefined, + driver: ProviderDriverKind, + version: string | null, + t3CodeVersion = packageJson.version, +): ServerProviderCompatibilityAdvisory | undefined { + const policy = policies?.find( + (entry) => entry.driver === driver && satisfiesSemverRange(t3CodeVersion, entry.t3CodeRange), + ); + if (!policy) return undefined; + const stable = version?.replace(/^v/, ""); + const status = + stable && /^\d+\.\d+\.\d+$/.test(stable) + ? (policy.ranges.find((entry) => satisfiesSemverRange(stable, entry.range))?.status ?? + "unknown") + : "unknown"; + const message = + status === "broken" + ? "This provider version is known to be incompatible with this T3 Code release." + : status === "unsupported" + ? "This provider version is outside the supported range for this T3 Code release." + : status === "graceful" + ? "This provider version has limited compatibility with this T3 Code release." + : null; + const recommendedVersion = policy.recommendedVersion ?? null; + const recommendedRange = policy.recommendedRange ?? null; + const recommendation = recommendedVersion ?? recommendedRange; + return { + status, + message: message && recommendation ? `${message} Use ${recommendation}.` : message, + recommendedVersion, + recommendedRange, + }; +} + +/** A remote policy replaces its matching bundled policy; omission keeps the bundle. */ +export function applyProviderCompatibility( + snapshot: ServerProvider, + policies: ReadonlyArray | undefined, + fallback: ReadonlyArray | undefined, +): ServerProvider { + const { compatibilityAdvisory: _previous, ...base } = snapshot; + if (!snapshot.enabled || !snapshot.installed) return base; + const advisory = + resolveProviderCompatibility(policies, snapshot.driver, snapshot.version) ?? + resolveProviderCompatibility(fallback, snapshot.driver, snapshot.version); + const latestVersion = snapshot.versionAdvisory?.latestVersion; + const latestAdvisory = latestVersion + ? (resolveProviderCompatibility(policies, snapshot.driver, latestVersion) ?? + resolveProviderCompatibility(fallback, snapshot.driver, latestVersion)) + : undefined; + return advisory + ? { + ...base, + compatibilityAdvisory: { + ...advisory, + ...(latestAdvisory ? { latestVersionStatus: latestAdvisory.status } : {}), + }, + } + : base; +} diff --git a/apps/server/src/provider/providerMaintenance.test.ts b/apps/server/src/provider/providerMaintenance.test.ts index 3e0810f51b7a..50aee2b08181 100644 --- a/apps/server/src/provider/providerMaintenance.test.ts +++ b/apps/server/src/provider/providerMaintenance.test.ts @@ -15,6 +15,7 @@ import { HttpClient } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; import { createProviderVersionAdvisory, + makeTargetedProviderUpdateAction, enrichProviderSnapshotWithVersionAdvisory, homebrewOwnershipFromCommandPath, makeCachedProviderMaintenanceResolution, @@ -786,3 +787,45 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { }), ); }); + +it("pins only owned package-manager installs and preserves their execution context", () => { + const capabilities = makeProviderMaintenanceCapabilities({ + provider: driver("codex"), + packageName: "@openai/codex", + updateExecutable: "npm", + updateLockKey: "npm-global:/opt/node", + updateArgs: [ + "install", + "-g", + "--prefix", + "/opt/node", + "--allow-scripts=@openai/codex", + "@openai/codex@latest", + ], + env: { PATH: "/opt/node/bin" }, + }); + const pinned = makeTargetedProviderUpdateAction(capabilities, "2.0.0"); + expect(pinned).toMatchObject({ + executable: "npm", + lockKey: capabilities.update?.lockKey, + env: capabilities.update?.env, + args: [ + "install", + "-g", + "--prefix", + "/opt/node", + "--allow-scripts=@openai/codex", + "@openai/codex@2.0.0", + ], + }); + expect(pinned?.command).toContain("@openai/codex@2.0.0"); + for (const lockKey of ["codex-native", "homebrew", "manual"]) { + expect( + makeTargetedProviderUpdateAction( + { ...capabilities, update: { ...capabilities.update!, lockKey } }, + "2.0.0", + ), + ).toBeNull(); + } + expect(makeTargetedProviderUpdateAction(capabilities, "2.0.0; rm -rf /")).toBeNull(); +}); diff --git a/apps/server/src/provider/providerMaintenance.ts b/apps/server/src/provider/providerMaintenance.ts index 6a11b22462ee..ace445b03060 100644 --- a/apps/server/src/provider/providerMaintenance.ts +++ b/apps/server/src/provider/providerMaintenance.ts @@ -186,6 +186,33 @@ export function makeProviderMaintenanceCapabilities(input: { }; } +/** Pin only package-manager actions we own, preserving prefix, scripts, env and lock. */ +export function makeTargetedProviderUpdateAction( + capabilities: ProviderMaintenanceCapabilities, + version: string, +): ProviderMaintenanceCommandAction | null { + if (!/^\d+\.\d+\.\d+$/.test(version)) return null; + const update = capabilities.update; + const packageName = capabilities.packageName; + if (!update || !packageName) return null; + if (!/^(?:npm-global:|bun-global$|pnpm-global$|vite-plus-global$)/.test(update.lockKey)) + return null; + const packageIndex = update.args.findIndex( + (arg) => arg === `${packageName}@latest` || arg === packageName, + ); + if (packageIndex < 0) return null; + const args = update.args.map((arg, index) => + index === packageIndex ? `${packageName}@${version}` : arg, + ); + const previous = update.args[packageIndex]!; + const commandIndex = update.command.lastIndexOf(previous); + const command = + commandIndex < 0 + ? update.command + : `${update.command.slice(0, commandIndex)}${packageName}@${version}${update.command.slice(commandIndex + previous.length)}`; + return { ...update, args, command }; +} + export function makeManualOnlyProviderMaintenanceCapabilities(input: { readonly provider: ProviderDriverKind; readonly packageName: string | null; @@ -627,6 +654,7 @@ export function createProviderVersionAdvisory(input: { latestVersion, updateCommand: capabilities.update?.command ?? null, canUpdate: capabilities.update !== null, + canInstallVersion: makeTargetedProviderUpdateAction(capabilities, "0.0.0") !== null, checkedAt: input.checkedAt ?? null, message: advisory.message, }; diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index 19af22c882ae..2da7c9183365 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -21,6 +21,7 @@ import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hos import { SpawnExecutableResolution } from "@t3tools/shared/shell"; import { ProviderRegistry, type ProviderRegistryShape } from "./Services/ProviderRegistry.ts"; +import * as ModelManifest from "./ModelManifest.ts"; import * as ProviderMaintenanceRunner from "./providerMaintenanceRunner.ts"; import { makeProviderMaintenanceCapabilities, @@ -208,13 +209,22 @@ function makeRegistry( }); } -const makeTestRunner = (registry: ProviderRegistryShape) => +const makeTestRunner = ( + registry: ProviderRegistryShape, + manifest = ModelManifest.BUNDLED_MODEL_MANIFEST, +) => Effect.service(ProviderMaintenanceRunner.ProviderMaintenanceRunner).pipe( Effect.provide( ProviderMaintenanceRunner.layer.pipe( Layer.provide( Layer.mergeAll( Layer.succeed(ProviderRegistry, registry), + Layer.succeed(ModelManifest.ModelManifest, { + current: Effect.succeed(manifest), + refresh: Effect.succeed(manifest), + forceRefresh: Effect.succeed(manifest), + refreshInBackground: Effect.void, + }), // Fresh per runner so a version cached by one test cannot leak into another. Layer.sync(ProviderVersionCache, () => new Map()), ), @@ -743,7 +753,7 @@ describe("providerMaintenanceRunner", () => { Effect.provide( Layer.mergeAll( NonWindowsPlatform, - latestVersionHttpClient("0.0.0"), + latestVersionHttpClient("2.0.0"), mockSpawnerLayer((_command, args) => { calls.push(args.join(" ")); if (calls.length === 1) { @@ -911,3 +921,92 @@ describe("providerMaintenanceRunner", () => { ); }); }); + +it.effect("refuses incompatible latest versions and unapproved or unpinnable targets", () => { + const calls: string[] = []; + const manifest: ModelManifest.ModelManifestData = { + version: 1, + currentModels: {}, + compatibility: [ + { + driver: "codex", + t3CodeRange: ">=0.0.42", + recommendedVersion: "2.0.0", + ranges: [ + { range: "=2.0.0", status: "supported" }, + { range: ">2.0.0", status: "broken" }, + ], + }, + ], + }; + return Effect.gen(function* () { + const { registry, providersRef } = yield* makeRegistry(); + const pinnedCapabilities = makeProviderMaintenanceCapabilities({ + provider: CODEX_DRIVER, + packageName: "@openai/codex", + updateExecutable: "npm", + updateArgs: ["install", "-g", "@openai/codex@latest"], + updateLockKey: "npm-global:/fixture", + }); + const updater = yield* makeTestRunner( + { + ...registry, + getProviderMaintenanceCapabilitiesForInstance: () => Effect.succeed(pinnedCapabilities), + }, + manifest, + ); + for (const targetVersion of [undefined, "", "1.0.0", "2.0.0; echo unsafe"]) { + const result = yield* updater.updateProvider({ + provider: CODEX_DRIVER, + ...(targetVersion !== undefined ? { targetVersion } : {}), + }); + assert.strictEqual(result.providers[0]?.updateState?.status, "failed"); + } + assert.deepStrictEqual(calls, []); + yield* Ref.update(providersRef, (providers) => + providers.map((entry) => ({ ...entry, version: "2.0.0" })), + ); + const installed = yield* updater.updateProvider({ + provider: CODEX_DRIVER, + targetVersion: "2.0.0", + }); + assert.deepStrictEqual(calls, ["install -g @openai/codex@2.0.0"]); + assert.strictEqual(installed.providers[0]?.updateState?.status, "succeeded"); + yield* Ref.update(providersRef, (providers) => + providers.map((entry) => ({ ...entry, version: "1.0.0" })), + ); + const unchanged = yield* updater.updateProvider({ + provider: CODEX_DRIVER, + targetVersion: "2.0.0", + }); + assert.strictEqual(unchanged.providers[0]?.updateState?.status, "unchanged"); + const nativeUpdater = yield* makeTestRunner( + { + ...registry, + getProviderMaintenanceCapabilitiesForInstance: () => + Effect.succeed({ + ...pinnedCapabilities, + update: { ...pinnedCapabilities.update!, lockKey: "codex-native" }, + }), + }, + manifest, + ); + const refused = yield* nativeUpdater.updateProvider({ + provider: CODEX_DRIVER, + targetVersion: "2.0.0", + }); + assert.strictEqual(refused.providers[0]?.updateState?.status, "failed"); + assert.strictEqual(calls.length, 2); + }).pipe( + Effect.provide( + Layer.mergeAll( + NonWindowsPlatform, + latestVersionHttpClient("3.0.0"), + mockSpawnerLayer((_command, args) => { + calls.push(args.join(" ")); + return { stdout: "installed" }; + }), + ), + ), + ); +}); diff --git a/apps/server/src/provider/providerMaintenanceRunner.ts b/apps/server/src/provider/providerMaintenanceRunner.ts index 121e5aeab7d1..17be8eb087f6 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.ts @@ -21,10 +21,14 @@ import * as Schema from "effect/Schema"; import { HttpClient } from "effect/unstable/http"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import * as ModelManifest from "./ModelManifest.ts"; +import { resolveProviderCompatibility } from "./providerCompatibility.ts"; import { ProviderRegistry } from "./Services/ProviderRegistry.ts"; import { makeProviderMaintenanceCommandCoordinator } from "./providerMaintenanceCommandCoordinator.ts"; import { enrichProviderSnapshotWithVersionAdvisory, + makeTargetedProviderUpdateAction, + resolveLatestProviderVersion, type ProviderMaintenanceCommandAction, ProviderVersionCache, } from "./providerMaintenance.ts"; @@ -51,6 +55,7 @@ export interface ProviderMaintenanceRunnerShape { | { readonly provider: ProviderDriverKind; readonly instanceId?: ProviderInstanceId | undefined; + readonly targetVersion?: string | undefined; }, ) => Effect.Effect; } @@ -214,6 +219,7 @@ function makeUpdateState(input: { /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { const providerRegistry = yield* ProviderRegistry; + const manifestService = yield* ModelManifest.ModelManifest; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const httpClient = yield* HttpClient.HttpClient; const versionCache = yield* ProviderVersionCache; @@ -310,6 +316,7 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { typeof target === "string" ? defaultInstanceIdForDriver(provider) : (target.instanceId ?? defaultInstanceIdForDriver(provider)); + const targetVersion = typeof target === "string" ? undefined : target.targetVersion; const targetKey = `instance:${instanceId}`; const capabilities = yield* providerRegistry.getProviderMaintenanceCapabilitiesForInstance( instanceId, @@ -376,7 +383,44 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { ); } - const result = yield* runMaintenanceCommand(fresh.update); + const manifest = yield* manifestService.current; + const candidateVersion = + targetVersion ?? + (yield* resolveLatestProviderVersion(fresh).pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.provideService(ProviderVersionCache, versionCache), + )); + const advisory = + resolveProviderCompatibility(manifest.compatibility, provider, candidateVersion) ?? + resolveProviderCompatibility( + ModelManifest.BUNDLED_MODEL_MANIFEST.compatibility, + provider, + candidateVersion, + ); + const command = + targetVersion !== undefined + ? makeTargetedProviderUpdateAction(fresh, targetVersion) + : fresh.update; + const rejected = + targetVersion !== undefined + ? !command || + advisory?.recommendedVersion !== targetVersion || + advisory.status !== "supported" + : advisory?.status === "broken" || advisory?.status === "unsupported"; + if (rejected || !command) { + return yield* finish( + makeUpdateState({ + status: "failed", + startedAt, + finishedAt: yield* nowIso, + message: + targetVersion !== undefined + ? "This version is no longer recommended or this installer cannot install a specific version. Refresh provider settings." + : "The latest provider version is incompatible with this T3 Code release. Review provider settings.", + }), + ); + } + const result = yield* runMaintenanceCommand(command); const finishedAt = yield* nowIso; if (result.timedOut || result.exitCode !== 0) { return yield* finish( @@ -407,10 +451,15 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { // Cursor's `about` probe can fail transiently on a healthy binary. const couldNotVerify = verifiedProviders.length === 0 || - verifiedProviders.some((verifiedProvider) => !isStillInstalled(verifiedProvider)); - const stillOutdated = verifiedProviders.some((verifiedProvider) => - isOutdatedProvider(verifiedProvider), - ); + verifiedProviders.some( + (verifiedProvider) => + !isStillInstalled(verifiedProvider) || + (targetVersion !== undefined && + verifiedProvider.version?.replace(/^v/, "") !== targetVersion), + ); + const stillOutdated = + targetVersion === undefined && + verifiedProviders.some((verifiedProvider) => isOutdatedProvider(verifiedProvider)); return yield* finish( makeUpdateState({ status: couldNotVerify || stillOutdated ? "unchanged" : "succeeded", diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts index 12f63f3e16fd..c55338cd5c46 100644 --- a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts +++ b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts @@ -24,6 +24,7 @@ import { getProviderUpdateSidebarPillView, hasOneClickUpdateProviderCandidate, isProviderUpdateCandidate, + isProviderSettingsUpdateCandidate, isTerminalProviderUpdatePhase, localEnvironmentUpdateNotificationKey, providerUpdateNotificationKey, @@ -1038,3 +1039,22 @@ describe("provider update launch notification logic", () => { }); }); }); + +it("does not offer incompatible latest versions and restores suggestions after policy relaxation", () => { + const installed = provider({ driver: driver("codex") }); + for (const latestVersionStatus of ["broken", "unsupported", "supported", "unknown"] as const) { + const snapshot: ServerProvider = { + ...installed, + compatibilityAdvisory: { + status: "supported", + latestVersionStatus, + message: null, + recommendedRange: null, + recommendedVersion: null, + }, + }; + const expected = latestVersionStatus === "supported" || latestVersionStatus === "unknown"; + expect(isProviderUpdateCandidate(snapshot)).toBe(expected); + expect(isProviderSettingsUpdateCandidate(snapshot)).toBe(expected); + } +}); diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts index 6da4eaac6dde..dd7b25a62cad 100644 --- a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts +++ b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts @@ -141,6 +141,8 @@ export function isProviderUpdateCandidate( ): provider is ProviderUpdateCandidate { return ( provider.enabled && + provider.compatibilityAdvisory?.latestVersionStatus !== "broken" && + provider.compatibilityAdvisory?.latestVersionStatus !== "unsupported" && provider.versionAdvisory?.status === "behind_latest" && provider.versionAdvisory.latestVersion !== null ); @@ -161,6 +163,8 @@ export function isProviderSettingsUpdateCandidate( ): provider is ProviderSettingsUpdateCandidate { return ( provider.enabled && + provider.compatibilityAdvisory?.latestVersionStatus !== "broken" && + provider.compatibilityAdvisory?.latestVersionStatus !== "unsupported" && provider.versionAdvisory?.status === "behind_latest" && provider.versionAdvisory.canUpdate === true && provider.versionAdvisory.updateCommand !== null diff --git a/apps/web/src/components/chat/ProviderStatusBanner.test.ts b/apps/web/src/components/chat/ProviderStatusBanner.test.ts new file mode 100644 index 000000000000..0ebaf180b51a --- /dev/null +++ b/apps/web/src/components/chat/ProviderStatusBanner.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vite-plus/test"; +import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; +import { + getProviderStatusBannerKey, + getProviderStatusMessage, + shouldShowProviderStatusBanner, +} from "./ProviderStatusBanner"; + +const provider: ServerProvider = { + instanceId: ProviderInstanceId.make("codex-work"), + driver: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-09-22T00:00:00Z", + models: [], + skills: [], + slashCommands: [], + compatibilityAdvisory: { + status: "unsupported", + message: "Unsupported version. Use 2.0.0.", + recommendedVersion: "2.0.0", + recommendedRange: null, + }, +}; + +describe("compatibility banners", () => { + it("shows and dismisses a warning on a healthy provider, then clears it after policy relaxation", () => { + expect(shouldShowProviderStatusBanner(provider, null)).toBe(true); + expect(shouldShowProviderStatusBanner(provider, getProviderStatusBannerKey(provider))).toBe( + false, + ); + expect( + shouldShowProviderStatusBanner( + { ...provider, version: "1.0.1" }, + getProviderStatusBannerKey(provider), + ), + ).toBe(true); + const relaxed: ServerProvider = { + ...provider, + compatibilityAdvisory: { + ...provider.compatibilityAdvisory!, + status: "supported", + message: null, + }, + }; + expect(getProviderStatusBannerKey(relaxed)).toBeNull(); + expect(getProviderStatusBannerKey({ ...provider, status: "disabled" })).toBeNull(); + expect( + getProviderStatusBannerKey({ + ...provider, + compatibilityAdvisory: { ...provider.compatibilityAdvisory!, status: "graceful" }, + }), + ).toBeNull(); + }); + + it("keeps authentication failures ahead of compatibility warnings even without a probe message", () => { + const unauthenticated: ServerProvider = { + ...provider, + status: "error", + auth: { status: "unauthenticated" }, + }; + expect(getProviderStatusMessage(unauthenticated)).toBe( + "Sign in via the CLI to authenticate again.", + ); + expect(getProviderStatusMessage({ ...unauthenticated, message: "Credentials expired" })).toBe( + "Credentials expired", + ); + }); +}); diff --git a/apps/web/src/components/chat/ProviderStatusBanner.tsx b/apps/web/src/components/chat/ProviderStatusBanner.tsx index a35be6ed0951..58d723c9a92a 100644 --- a/apps/web/src/components/chat/ProviderStatusBanner.tsx +++ b/apps/web/src/components/chat/ProviderStatusBanner.tsx @@ -6,8 +6,22 @@ import { Button, InlineButton } from "../ui/button"; import { formatProviderDriverKindLabel } from "../../providerModels"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +/** Unsupported and broken versions fail mid-turn, so they warn even when ready. */ +function getIncompatibleVersion(status: ServerProvider) { + const compatibility = status.compatibilityAdvisory; + return compatibility?.status === "unsupported" || compatibility?.status === "broken" + ? compatibility + : null; +} + export function getProviderStatusBannerKey(status: ServerProvider | null): string | null { - if (!status || status.status === "ready" || status.status === "disabled") return null; + if (!status || status.status === "disabled") return null; + if (status.status === "ready") { + const incompatible = getIncompatibleVersion(status); + return incompatible + ? [status.instanceId, incompatible.status, status.version ?? ""].join("\u0000") + : null; + } // Antigravity checks saved credentials when a session starts. Its local // health check leaves auth unknown after a restart, which is not a failure. if ( @@ -76,15 +90,20 @@ export const ProviderStatusBanner = memo(function ProviderStatusBanner({ const providerName = status.displayName?.trim() || formatProviderDriverKindLabel(status.driver); const isUnauthenticated = status.status === "error" && status.auth.status === "unauthenticated"; + const incompatible = status.status === "ready" ? getIncompatibleVersion(status) : null; const title = isUnauthenticated ? `${providerName} is unauthenticated` - : `${providerName} provider status`; - const message = getProviderStatusMessage(status); + : incompatible + ? `${providerName} ${status.version ?? ""} is ${incompatible.status === "broken" ? "known to be broken" : "unsupported"}` + : `${providerName} provider status`; + const message = incompatible?.message ?? getProviderStatusMessage(status); + const isWarning = status.status === "warning" || incompatible !== null; return (
diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index 2aa765df06b7..58ca11b0808c 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -3,6 +3,7 @@ import { Spinner } from "~/components/ui/spinner"; import { + AlertTriangleIcon, ArrowUpCircleIcon, CopyIcon, DownloadIcon, @@ -14,7 +15,7 @@ import { } from "lucide-react"; import * as Arr from "effect/Array"; import * as Result from "effect/Result"; -import { useEffect, useRef, useState, type ReactNode } from "react"; +import { useEffect, useRef, useState, type ReactElement, type ReactNode } from "react"; import { isProviderDriverKind, resolveProviderInstanceEnabled, @@ -58,6 +59,22 @@ import { const ENVIRONMENT_VARIABLE_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/; +function ProviderStatusDiagnostic({ + detail, + children, +}: { + detail: string | null; + children: ReactElement; +}) { + if (!detail) return children; + return ( + + + {detail} + + ); +} + let environmentVariableDraftId = 0; const nextEnvironmentVariableDraftId = () => `provider-env-${environmentVariableDraftId++}`; @@ -377,6 +394,7 @@ interface ProviderInstanceCardProps { readonly onFavoriteModelsChange: (next: ReadonlyArray) => void; readonly onModelOrderChange: (next: ReadonlyArray) => void; readonly onRunUpdate?: (() => void) | undefined; + readonly onInstallRecommended?: (() => void) | undefined; readonly isUpdating?: boolean | undefined; } @@ -419,9 +437,11 @@ export function ProviderInstanceCard({ onFavoriteModelsChange, onModelOrderChange, onRunUpdate, + onInstallRecommended, isUpdating = false, }: ProviderInstanceCardProps) { const enabled = resolveProviderInstanceEnabled(instance); + const compatibility = enabled ? liveProvider?.compatibilityAdvisory : undefined; // A locally disabled provider reads "Disabled" with a muted dot even if its // last server status is stale. Enabled providers use the server status. const statusKey: ProviderStatusKey = enabled @@ -438,8 +458,18 @@ export function ProviderInstanceCard({ ? (liveProvider.auth.label ?? liveProvider.auth.type ?? null) : null; const versionLabel = getProviderVersionLabel(liveProvider?.version); - const versionAdvisory = getProviderVersionAdvisoryPresentation(liveProvider?.versionAdvisory); + const versionAdvisory = getProviderVersionAdvisoryPresentation( + liveProvider?.versionAdvisory, + liveProvider?.compatibilityAdvisory, + enabled, + ); const updateCommand = versionAdvisory?.updateCommand ?? null; + const hasCompatibilityWarning = + compatibility !== undefined && + compatibility.status !== "supported" && + compatibility.status !== "unknown"; + const VersionAdvisoryIcon = hasCompatibilityWarning ? AlertTriangleIcon : ArrowUpCircleIcon; + const onRunVersionAction = versionAdvisory?.targetVersion ? onInstallRecommended : onRunUpdate; const FallbackIconComponent = driverOption?.icon; const displayName = instance.displayName?.trim() || driverOption?.label || String(instance.driver); @@ -568,10 +598,16 @@ export function ProviderInstanceCard({ statusKey === "warning" || statusKey === "error" ? ( ) : null; - // Trouble states carry the server's explanation (a failed probe, a shadow - // home entry that is not a symlink, a missing binary). Show it wherever the - // headline shows so the user can act without opening the editor. const needsAttention = statusKey === "warning" || statusKey === "error"; + const statusDiagnostic = hasCompatibilityWarning && needsAttention ? summary.detail : null; + // Keep compatibility copy compact; the version popover carries the explanation. + const inlineStatusDetail = hasCompatibilityWarning + ? compatibility?.status === "broken" + ? "Incompatible" + : compatibility?.status === "unsupported" + ? "Unsupported" + : "Limited support" + : summary.detail; const editorStatusNode = isAuthenticated && authEmail ? ( <> @@ -579,16 +615,16 @@ export function ProviderInstanceCard({ Authenticated as {authLabel ? · {authLabel} : null} - {summary.detail ? ( - · {summary.detail} + {inlineStatusDetail ? ( + · {inlineStatusDetail} ) : null} ) : ( <> {statusDotNode} {summary.headline} - {summary.detail ? ( - · {summary.detail} + {inlineStatusDetail ? ( + · {inlineStatusDetail} ) : null} ); @@ -629,7 +665,23 @@ export function ProviderInstanceCard({ ) : null} {versionAdvisory ? ( - updateCommand ? ( + hasCompatibilityWarning ? ( + + + + + } + /> + {versionAdvisory.detail} + + ) : updateCommand ? ( {statusDotNode} ) : null} - - {summary.headline} - {needsAttention && summary.detail ? ` · ${summary.detail}` : null} - + + + {summary.headline} + {needsAttention && inlineStatusDetail ? ` · ${inlineStatusDetail}` : null} + +
@@ -694,25 +751,32 @@ export function ProviderInstanceCard({ > {versionAdvisory ? ( - - + + + + } /> - - } - /> + } + /> + {versionAdvisory.title} +

- Update available + {versionAdvisory.title}

- {onRunUpdate ? ( + {onRunVersionAction ? ( ) : null} - {onRunUpdate && updateCommand ? ( + {onRunVersionAction && updateCommand ? (
or, update manually using @@ -798,7 +866,14 @@ export function ProviderInstanceCard({ {editorStatusNode}
+ +
+ {editorStatusNode} +
+
} control={
{ + async ( + candidate: Pick, + targetVersion?: string, + ) => { // Ref-based re-entry guard, mirroring refreshProviders: a state updater // may run after this function returns, so it cannot gate the dispatch. if (updatingInstanceIdsRef.current.has(candidate.instanceId)) { @@ -663,6 +666,7 @@ export function EnvironmentProviderSettings({ input: { provider: candidate.driver, instanceId: candidate.instanceId, + ...(targetVersion ? { targetVersion } : {}), }, }); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { @@ -887,9 +891,8 @@ export function EnvironmentProviderSettings({ ); const updateCandidate = providerUpdateCandidateByInstanceId.get(row.instanceId); const isInstanceUpdateRunning = - updateCandidate !== undefined && - (updatingProviderInstanceIds.has(updateCandidate.instanceId) || - isProviderUpdateActive(updateCandidate)); + updatingProviderInstanceIds.has(row.instanceId) || + (liveProvider !== undefined && isProviderUpdateActive(liveProvider)); const showInlineUpdateButton = updateCandidate !== undefined; const canRunInlineUpdate = updateCandidate !== undefined && !isInstanceUpdateRunning; const modelPreferences = settings.providerModelPreferences?.[row.instanceId] ?? { @@ -971,6 +974,19 @@ export function EnvironmentProviderSettings({ modelOrder, }) } + onInstallRecommended={ + mode === "editor" && + liveProvider?.compatibilityAdvisory?.message && + liveProvider.compatibilityAdvisory.recommendedVersion && + liveProvider.versionAdvisory?.canInstallVersion + ? () => { + void runProviderUpdate( + liveProvider, + liveProvider.compatibilityAdvisory?.recommendedVersion ?? undefined, + ); + } + : undefined + } onRunUpdate={ mode === "editor" && showInlineUpdateButton && updateCandidate ? () => { @@ -978,9 +994,7 @@ export function EnvironmentProviderSettings({ } : undefined } - isUpdating={ - mode === "editor" && showInlineUpdateButton ? isInstanceUpdateRunning : undefined - } + isUpdating={mode === "editor" ? isInstanceUpdateRunning : undefined} /> ); }; diff --git a/apps/web/src/components/settings/providerStatus.test.ts b/apps/web/src/components/settings/providerStatus.test.ts index 46dc7e262512..5e2a93f48ca9 100644 --- a/apps/web/src/components/settings/providerStatus.test.ts +++ b/apps/web/src/components/settings/providerStatus.test.ts @@ -1,7 +1,7 @@ import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { getProviderSummary } from "./providerStatus"; +import { getProviderSummary, getProviderVersionAdvisoryPresentation } from "./providerStatus"; const provider: ServerProvider = { instanceId: ProviderInstanceId.make("codex"), @@ -69,3 +69,73 @@ describe("getProviderSummary", () => { expect(getProviderSummary({ ...provider, status: "disabled" }).headline).toBe("Disabled"); }); }); + +it("does not suggest copying a command that installs an incompatible latest version", () => { + const advisory = { + status: "behind_latest" as const, + currentVersion: "1.0.0", + latestVersion: "2.0.0", + updateCommand: "npm install -g fixture@latest", + canUpdate: true, + checkedAt: provider.checkedAt, + message: null, + }; + const compatibility = { + status: "supported" as const, + latestVersionStatus: "broken" as const, + message: null, + recommendedRange: null, + recommendedVersion: null, + }; + expect(getProviderVersionAdvisoryPresentation(advisory, compatibility)).toBeNull(); + expect( + getProviderVersionAdvisoryPresentation(advisory, { ...compatibility, status: "broken" }, false), + ).toBeNull(); + expect( + getProviderVersionAdvisoryPresentation(advisory, { + ...compatibility, + latestVersionStatus: "supported", + }), + ).not.toBeNull(); +}); + +it("shows compatibility in the version popover even when the installed version is current", () => { + const advisory = { + status: "current" as const, + currentVersion: "2.0.0", + latestVersion: "2.0.0", + updateCommand: "npm install -g fixture@latest", + canUpdate: true, + checkedAt: provider.checkedAt, + message: null, + }; + const compatibility = { + status: "broken" as const, + latestVersionStatus: "broken" as const, + message: "This version drops turns. Use 1.9.0.", + recommendedRange: null, + recommendedVersion: "1.9.0", + }; + expect(getProviderVersionAdvisoryPresentation(advisory, compatibility)).toEqual({ + title: "Known broken version", + detail: compatibility.message, + updateCommand: null, + emphasis: "strong", + targetVersion: "1.9.0", + }); + expect( + getProviderVersionAdvisoryPresentation(undefined, { + ...compatibility, + status: "graceful", + recommendedVersion: null, + recommendedRange: ">=2.1.0", + message: null, + }), + ).toEqual({ + title: "Limited support", + detail: "Use >=2.1.0 for full support.", + updateCommand: null, + emphasis: "normal", + targetVersion: null, + }); +}); diff --git a/apps/web/src/components/settings/providerStatus.ts b/apps/web/src/components/settings/providerStatus.ts index 90c618f5daa7..8b6b95479686 100644 --- a/apps/web/src/components/settings/providerStatus.ts +++ b/apps/web/src/components/settings/providerStatus.ts @@ -1,4 +1,8 @@ -import type { ServerProvider, ServerProviderVersionAdvisory } from "@t3tools/contracts"; +import type { + ServerProvider, + ServerProviderVersionAdvisory, + ServerProviderCompatibilityAdvisory, +} from "@t3tools/contracts"; /** * Visual treatment for each server-reported provider status. Centralized so @@ -99,14 +103,53 @@ export function getProviderVersionLabel(version: string | null | undefined) { return /^\d/.test(version) ? `v${version}` : version; } +const COMPATIBILITY_TITLES = { + graceful: "Limited support", + unsupported: "Unsupported version", + broken: "Known broken version", +} as const; + +/** Compatibility guidance shares the version popover, with safe install actions. */ export function getProviderVersionAdvisoryPresentation( advisory: ServerProviderVersionAdvisory | undefined, + compatibility?: ServerProviderCompatibilityAdvisory | undefined, + showCompatibility = true, ): { + readonly title: string; readonly detail: string; readonly updateCommand: string | null; readonly emphasis: "normal" | "strong"; + readonly targetVersion: string | null; } | null { - if (!advisory || advisory.status === "current" || advisory.status === "unknown") { + const latestIsIncompatible = + compatibility?.latestVersionStatus === "broken" || + compatibility?.latestVersionStatus === "unsupported"; + if ( + showCompatibility && + compatibility && + (compatibility.status === "graceful" || + compatibility.status === "unsupported" || + compatibility.status === "broken") + ) { + const targetVersion = compatibility.recommendedVersion; + const recommendation = getProviderVersionLabel(targetVersion) ?? compatibility.recommendedRange; + return { + title: COMPATIBILITY_TITLES[compatibility.status], + detail: + compatibility.message ?? + (recommendation ? `Use ${recommendation} for full support.` : "Update for full support."), + updateCommand: + targetVersion || latestIsIncompatible ? null : (advisory?.updateCommand ?? null), + emphasis: compatibility.status === "graceful" ? "normal" : "strong", + targetVersion, + }; + } + if ( + !advisory || + advisory.status === "current" || + advisory.status === "unknown" || + latestIsIncompatible + ) { return null; } @@ -115,6 +158,7 @@ export function getProviderVersionAdvisoryPresentation( const versionLabel = getProviderVersionLabel(version); return { + title: label, detail: advisory.message ?? (versionLabel @@ -122,5 +166,6 @@ export function getProviderVersionAdvisoryPresentation( : `${label}: install the latest provider version.`), updateCommand: advisory.updateCommand, emphasis: "normal" as const, + targetVersion: null, }; } diff --git a/docs/user/install.md b/docs/user/install.md index 2a7e7dc7e749..773979e77302 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -120,6 +120,12 @@ Provider CLIs must be on the server's `PATH`. If T3 Code cannot find one, set it Cursor's executable is `cursor-agent`, although its login command is `agent login`. Antigravity can use its managed runtime without a `PATH` entry. +T3 Code warns when a provider version has known compatibility problems with your +release. Check **Settings → Providers** on that environment for the recommended +version or range. When its package manager supports installing a specific version, +you can install the recommendation there. Otherwise use the provider's installer +on the environment's machine. An unlisted version is unverified. + When a provider CLI is behind its latest release, its provider card shows the available version. **Update now** appears only when T3 Code can tell which installer owns the CLI (its own update command, Homebrew, or a global npm, pnpm, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 49a377208799..c137cac9ac7a 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -148,6 +148,22 @@ export const ServerProviderContinuation = Schema.Struct({ }); export type ServerProviderContinuation = typeof ServerProviderContinuation.Type; +export const ServerProviderCompatibilityStatus = Schema.Literals([ + "unknown", + "supported", + "graceful", + "unsupported", + "broken", +]); +export const ServerProviderCompatibilityAdvisory = Schema.Struct({ + status: ServerProviderCompatibilityStatus, + latestVersionStatus: Schema.optionalKey(ServerProviderCompatibilityStatus), + message: Schema.NullOr(TrimmedNonEmptyString), + recommendedVersion: Schema.NullOr(TrimmedNonEmptyString), + recommendedRange: Schema.NullOr(TrimmedNonEmptyString), +}); +export type ServerProviderCompatibilityAdvisory = typeof ServerProviderCompatibilityAdvisory.Type; + export const ServerProviderVersionAdvisoryStatus = Schema.Literals([ "unknown", "current", @@ -161,6 +177,7 @@ export const ServerProviderVersionAdvisory = Schema.Struct({ latestVersion: Schema.NullOr(TrimmedNonEmptyString), updateCommand: Schema.NullOr(TrimmedNonEmptyString), canUpdate: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + canInstallVersion: Schema.optionalKey(Schema.Boolean), checkedAt: Schema.NullOr(IsoDateTime), message: Schema.NullOr(TrimmedNonEmptyString), }); @@ -234,6 +251,7 @@ export const ServerProvider = Schema.Struct({ // Absent when the driver has no notion of subscription usage. usageLimits: Schema.optional(ServerProviderUsageLimits), versionAdvisory: Schema.optionalKey(ServerProviderVersionAdvisory), + compatibilityAdvisory: Schema.optionalKey(ServerProviderCompatibilityAdvisory), updateState: Schema.optionalKey(ServerProviderUpdateState), }); export type ServerProvider = typeof ServerProvider.Type; @@ -799,6 +817,7 @@ export type ServerProviderUpdatedPayload = typeof ServerProviderUpdatedPayload.T export const ServerProviderUpdateInput = Schema.Struct({ provider: ProviderDriverKind, + targetVersion: Schema.optionalKey(TrimmedNonEmptyString), instanceId: Schema.optionalKey(ProviderInstanceId), }); export type ServerProviderUpdateInput = typeof ServerProviderUpdateInput.Type; From 2eaff08241a51dadfd9ef5ad53cba74e6d0fceb4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 22 Sep 2026 18:48:17 -0700 Subject: [PATCH 02/27] chore(lint): keep mobile theme escape-hatch allowlist honest (#13146) --- .../mobile/src/features/files/ThreadFilesRouteScreen.tsx | 9 +-------- .../appearance/components/AppearancePreviews.tsx | 9 ++------- vite.config.ts | 2 -- 3 files changed, 3 insertions(+), 17 deletions(-) diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx index 06ef8f6ba4d9..06b6664431b5 100644 --- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -417,7 +417,6 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { const { fileInspector, layout, showAuxiliaryPane } = useAdaptiveWorkspaceLayout(); const [searchQuery, setSearchQuery] = useState(""); const { themeAppearance: highlightTheme } = useAppearancePreferences(); - const headerColor = useUniwindTheme()["--color-header"]; const { cwd, environmentId, projectName, selectedThread, threadId } = useThreadFilesWorkspace( props.route.params, ); @@ -554,13 +553,7 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { ); - return Platform.OS === "android" ? ( - - {content} - - ) : ( - content - ); + return Platform.OS === "android" ? {content} : content; } export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { diff --git a/apps/mobile/src/features/settings/appearance/components/AppearancePreviews.tsx b/apps/mobile/src/features/settings/appearance/components/AppearancePreviews.tsx index 55bd661a64ef..386ac3e2c39f 100644 --- a/apps/mobile/src/features/settings/appearance/components/AppearancePreviews.tsx +++ b/apps/mobile/src/features/settings/appearance/components/AppearancePreviews.tsx @@ -5,7 +5,6 @@ import { resolveMarkdownFontSizes, resolveMobileCodeSurface, } from "../../../../lib/appearancePreferences"; -import { useUniwindTheme } from "../../../../lib/useUniwindTheme"; import { getMobileTerminalTheme } from "../../../terminal/terminalTheme"; import { useAppearancePreferences } from "../AppearancePreferencesProvider"; @@ -138,16 +137,12 @@ export function CodeAppearancePreview(props: { readonly wordBreak: boolean; }) { const surface = resolveMobileCodeSurface(props.fontSize); - const theme = useUniwindTheme(); - const lineNumberColor = theme["--color-icon-subtle"]; - const keywordColor = theme["--color-md-link"]; const lineNumber = (line: CodePreviewLine, index: number) => ( ( Date: Tue, 22 Sep 2026 19:00:08 -0700 Subject: [PATCH 03/27] fix(web): the pull request badge reads at the meta size again (#13175) Co-authored-by: Claude Opus 5.5 (1M context) --- .../BranchToolbarBranchSelector.tsx | 3 +- apps/web/src/components/Sidebar.tsx | 4 +- .../src/components/ThreadStatusIndicators.tsx | 103 ++++++++++-------- scripts/lint-restyle-ceiling.ts | 2 +- 4 files changed, 65 insertions(+), 47 deletions(-) diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 3bab1315ee8b..9ca02389b192 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -59,6 +59,7 @@ import { useLinkedThreadPullRequest, } from "./ThreadStatusIndicators"; import { Button } from "./ui/button"; +import { ComposerControl } from "./chat/ComposerControl"; import { Switch } from "./ui/switch"; import { getVirtualizedScrollFadeClassName } from "./ui/scroll-area"; import { @@ -790,7 +791,7 @@ export function BranchToolbarBranchSelector({ data-composer-context-control > } badge={prBadge} number={prNumber} url={prUrl} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 95f0e062d8e0..3ec0f981d386 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -221,7 +221,7 @@ import { } from "../providerInstances"; import { useThreadRunningTerminalIds } from "../state/terminalSessions"; import { stackedThreadToast, toastManager } from "./ui/toast"; -import { Button } from "./ui/button"; +import { Button, InlineButton } from "./ui/button"; import { Combobox, ComboboxEmpty, @@ -1510,7 +1510,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { const prBadge = prBadgeShape?.kind === "stack" || pr || currentLinkedPr ? ( } badge={prBadgeShape} number={pr?.number ?? currentLinkedPr?.number} url={pr?.url ?? currentLinkedPr?.url} diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 8b27b74fd0a5..29020f4975a3 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -15,8 +15,8 @@ import { type ThreadPullRequestBadge, } from "@t3tools/shared/threadPullRequests"; import { FolderGit2Icon, TerminalIcon } from "lucide-react"; -import { useMemo, type MouseEvent } from "react"; -import { Button, InlineButton } from "./ui/button"; +import { useRender } from "@base-ui/react/use-render"; +import { useMemo, type MouseEvent, type ReactElement } from "react"; import { cn } from "../lib/utils"; import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments"; import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; @@ -191,9 +191,14 @@ export function resolveThreadPullRequestBadgePresentation({ }; } -/** The complete linked-PR control shared by the sidebar and composer footer. */ +/** + * The linked-PR badge shared by the sidebar and composer footer. The badge owns what it shows: + * the state glyph and number at the meta size, in the state's color. The caller owns the control + * it sits in through `render` (an inline link in a sidebar row, a toolbar control in the + * composer), and the badge fills in the link or stack button behavior. + */ export function ThreadPullRequestBadgeControl({ - variant, + render, badge, number, url, @@ -201,7 +206,7 @@ export function ThreadPullRequestBadgeControl({ onOpenStack, onOpenPullRequest, }: { - variant: "underline" | "ghost"; + render: ReactElement<{ render?: useRender.RenderProp }>; badge: ThreadPullRequestBadge | null; number?: number | undefined; url?: string | undefined; @@ -211,53 +216,65 @@ export function ThreadPullRequestBadgeControl({ }) { const presentation = resolveThreadPullRequestBadgePresentation({ badge, number, url, status }); if (presentation === null) return null; - const isStack = badge?.kind === "stack"; - const content = ( - <> - - {presentation.text} - + return ( + ); - const linkProps = isStack - ? { - onClick: (event: MouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - onOpenStack(); - }, +} + +function PullRequestBadge({ + render, + presentation, + isStack, + url, + onOpenStack, + onOpenPullRequest, +}: { + render: ReactElement<{ render?: useRender.RenderProp }>; + presentation: NonNullable>; + isStack: boolean; + url: string | undefined; + onOpenStack: () => void; + onOpenPullRequest: (event: MouseEvent) => void; +}) { + const onClick = isStack + ? (event: MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + onOpenStack(); } - : { onClick: onOpenPullRequest }; + : onOpenPullRequest; const element = isStack ? (
) : null} diff --git a/apps/web/src/components/chat/ComposerBanner.tsx b/apps/web/src/components/chat/ComposerBanner.tsx index bdd28ce555a9..4e52dfa51b69 100644 --- a/apps/web/src/components/chat/ComposerBanner.tsx +++ b/apps/web/src/components/chat/ComposerBanner.tsx @@ -4,7 +4,7 @@ import { ChevronDownIcon, XIcon } from "lucide-react"; import type { ComponentProps } from "react"; import { cn } from "~/lib/utils"; -import { Button, buttonVariants } from "../ui/button"; +import { Button } from "../ui/button"; import { ScrollArea } from "../ui/scroll-area"; export type ComposerBannerVariant = "default" | "error" | "info" | "success" | "warning"; @@ -323,18 +323,18 @@ function Dot({ className, ...props }: ComponentProps<"span">) { ); } -function ToggleIcon({ expanded, className }: { expanded: boolean; className?: string }) { +// Decorative: the row itself is the control, so this only matches Dismiss's box. +function ToggleIcon({ expanded }: { expanded: boolean }) { return ( - } + size="icon-xs" + variant="ghost" + tabIndex={-1} + className="pointer-events-none" > - + ); } diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index 2d1b147fd9bd..7ccf4f93ba6c 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -5,9 +5,8 @@ import { type ResolvedKeybindingsConfig, } from "@t3tools/contracts"; import { memo, useEffect, useMemo, useState } from "react"; -import type { VariantProps } from "class-variance-authority"; import { Badge } from "../ui/badge"; -import { buttonVariants } from "../ui/button"; +import type { ButtonVariant } from "../ui/button"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { cn } from "~/lib/utils"; @@ -49,7 +48,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { disabled?: boolean; terminalOpen?: boolean; open?: boolean; - triggerVariant?: VariantProps["variant"]; + triggerVariant?: ButtonVariant; triggerClassName?: string; /** Aggregate settings can show a neutral value without claiming one provider is selected. */ triggerLabel?: string; diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index 07ca60cedcc7..c0740c73a2df 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -16,9 +16,8 @@ import { normalizeModelSlug, } from "@t3tools/shared/model"; import { memo, useCallback } from "react"; -import type { VariantProps } from "class-variance-authority"; import { GaugeIcon, ZapIcon } from "lucide-react"; -import { buttonVariants } from "../ui/button"; +import type { ButtonVariant } from "../ui/button"; import { Menu, MenuGroup, @@ -277,7 +276,7 @@ export interface TraitsMenuContentProps { modelOptions?: ProviderOptions | null | undefined; allowPromptInjectedEffort?: boolean; planModeEnabled: boolean; - triggerVariant?: VariantProps["variant"]; + triggerVariant?: ButtonVariant; triggerClassName?: string; isComposerOwned?: boolean; } diff --git a/apps/web/src/components/chat/composerProviderState.tsx b/apps/web/src/components/chat/composerProviderState.tsx index c6837700cdfa..96fd01a22380 100644 --- a/apps/web/src/components/chat/composerProviderState.tsx +++ b/apps/web/src/components/chat/composerProviderState.tsx @@ -13,10 +13,9 @@ import { isClaudeUltrathinkPrompt, normalizeModelSlug, } from "@t3tools/shared/model"; -import type { VariantProps } from "class-variance-authority"; import type { ReactNode } from "react"; -import type { buttonVariants } from "../ui/button"; +import type { ButtonVariant } from "../ui/button"; import type { DraftId } from "../../composerDraftStore"; import { getProviderModelCapabilities } from "../../providerModels"; import type { ComposerControlSize } from "./ComposerControl"; @@ -55,7 +54,7 @@ type TraitsRenderInput = { planModeEnabled: boolean; size?: ComposerControlSize; hidden?: boolean; - triggerVariant?: VariantProps["variant"]; + triggerVariant?: ButtonVariant; triggerClassName?: string; isComposerOwned?: boolean; }; diff --git a/apps/web/src/components/settings/FontFamilyPicker.tsx b/apps/web/src/components/settings/FontFamilyPicker.tsx index 1981b852c03d..f9084ca027a6 100644 --- a/apps/web/src/components/settings/FontFamilyPicker.tsx +++ b/apps/web/src/components/settings/FontFamilyPicker.tsx @@ -1,5 +1,5 @@ import { LegendList, type LegendListRef } from "@legendapp/list/react"; -import { CheckIcon, ChevronDownIcon } from "lucide-react"; +import { CheckIcon } from "lucide-react"; import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import { isMonospaceFamily, queryInstalledFontFamilies } from "../../appearanceFonts"; import { @@ -11,7 +11,7 @@ import { ComboboxPopup, ComboboxTrigger, } from "../ui/combobox"; -import { selectTriggerVariants } from "../ui/select"; +import { SelectButton } from "../ui/select"; const DEFAULT_FONT_VALUE = "__default__"; @@ -204,11 +204,8 @@ export function FontFamilyPicker({ void listRef.current?.scrollIndexIntoView?.({ index: eventDetails.index, animated: false }); }} > - - - {selectedFamily.length === 0 ? defaultFamily : selectedFamily} - - + }> + {selectedFamily.length === 0 ? defaultFamily : selectedFamily} } + className="w-auto min-w-0" disabled={!captureAvailable} > - - {soundSelection === "off" ? ( - "Off" - ) : soundSelection === "soft-pop" ? ( - <> - Whoosh (Default) - - ) : ( - "Click" - )} - - + {soundSelection === "off" ? ( + "Off" + ) : soundSelection === "soft-pop" ? ( + <> + Whoosh (Default) + + ) : ( + "Click" + )} ["variant"]>; +type ButtonSize = NonNullable["size"]>; + interface ButtonProps extends useRender.ComponentProps<"button"> { - variant?: VariantProps["variant"]; - size?: VariantProps["size"]; + variant?: ButtonVariant; + size?: ButtonSize; } function Button({ className, variant, size, render, ...props }: ButtonProps) { @@ -95,7 +98,9 @@ function Button({ className, variant, size, render, ...props }: ButtonProps) { }); } -export { Button, buttonVariants }; +// buttonVariants is exported for other components/ui modules only; app code +// renders a Button (with `render` for other elements) instead. +export { Button, buttonVariants, type ButtonSize, type ButtonVariant }; const inlineButtonVariants = cva( "inline-flex shrink-0 cursor-pointer items-center gap-0.5 whitespace-nowrap font-medium underline-offset-2 hover:underline focus-visible:outline-2 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-64", diff --git a/apps/web/src/components/ui/select.tsx b/apps/web/src/components/ui/select.tsx index a7ab17eb9289..6fd85f510a23 100644 --- a/apps/web/src/components/ui/select.tsx +++ b/apps/web/src/components/ui/select.tsx @@ -1,5 +1,7 @@ "use client"; +import { mergeProps } from "@base-ui/react/merge-props"; import { Select as SelectPrimitive } from "@base-ui/react/select"; +import { useRender } from "@base-ui/react/use-render"; import { cva, type VariantProps } from "class-variance-authority"; import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"; import type * as React from "react"; @@ -57,6 +59,37 @@ function SelectTrigger({ ); } +/** + * The select-field look for a picker that is not a Select, such as a Menu or + * Combobox trigger. Render it as that trigger: `}>`. + */ +function SelectButton({ + className, + size = "default", + children, + render, + ...props +}: useRender.ComponentProps<"button"> & Pick, "size">) { + const defaultProps = { + className: cn(selectTriggerVariants({ size }), className), + "data-slot": "select-trigger", + type: render ? undefined : ("button" as const), + }; + return useRender({ + defaultTagName: "button", + props: { + ...mergeProps<"button">(defaultProps, props), + children: ( + <> + {children} + + + ), + }, + render, + }); +} + function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) { return ( }`, or `SelectButton` for a picker trigger). + */ +const RESTRICTED_UI_VARIANT_PATTERNS = [ + { + group: ["**/components/ui/*", "**/ui/*", "./ui/*"], + importNames: ["buttonVariants", "toggleVariants", "badgeVariants", "selectTriggerVariants"], + message: + "Render the components/ui export instead of borrowing its class recipe (render={ + + ) } /> @@ -2242,8 +2233,7 @@ function areMarkdownFileLinkPropsEqual( previous.onOpenInBrowser === next.onOpenInBrowser && previous.onOpenMedia === next.onOpenMedia && previous.onReveal === next.onReveal && - previous.revealLabel === next.revealLabel && - previous.className === next.className + previous.revealLabel === next.revealLabel ); } @@ -2571,12 +2561,7 @@ function useChatMarkdownState({ [cwd, findWorkspaceBasenameMatch, revealFileInFileManager], ); const fileLinkChip = useCallback( - ( - fileLinkMeta: MarkdownFileLinkMeta, - copyMarkdown: string, - className?: string, - mediaSource?: string, - ) => { + (fileLinkMeta: MarkdownFileLinkMeta, copyMarkdown: string, mediaSource?: string) => { const parentSuffix = fileLinkParentSuffixByPath.get( fileLinkMeta.filePath.replaceAll("\\", "/"), ); @@ -2633,7 +2618,6 @@ function useChatMarkdownState({ ? () => openMarkdownFileInPreview(fileLinkMeta.filePath) : undefined } - className={className} /> ); }, @@ -3068,7 +3052,6 @@ const CHAT_MARKDOWN_COMPONENTS = { return fileLinkChip( fileLinkMeta, `[${fileLinkMeta.basename}](${normalizedHref})`, - props.className, normalizedHref, ); }, @@ -3085,7 +3068,6 @@ const CHAT_MARKDOWN_COMPONENTS = { return fileLinkChip( fileLinkMeta, `\`${codeText}\``, - undefined, inlineCodeFilePathCandidate(codeText) ?? codeText.trim(), ); } diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index ec9b9e07e2fb..8af2d18b310e 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -58,18 +58,13 @@ import { import { collectInlineContextIds } from "~/lib/composerContextReferences"; import { cn, isMacPlatform } from "~/lib/utils"; import { basenameOfPath } from "~/pierre-icons"; -import { - COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME, - COMPOSER_INLINE_CHIP_ICON_CLASS_NAME, - COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME, - COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME, - SKILL_CHIP_ICON_SVG, -} from "./composerInlineChip"; -import { FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip"; +import { FileTagChipContent } from "./chat/FileTagChip"; +import { SkillChipIcon } from "./chat/SkillInlineText"; import { AssistantCitationChip } from "./chat/AssistantCitationChip"; import { getTimelinePageScrollKey } from "./chat/pageScrollController"; import { ContextChipPopover } from "./contextChipParts"; import { Button } from "./ui/button"; +import { ContextChip } from "./ContextChip"; import { ComposerContextActionsContext, ComposerContextReferenceChip, @@ -190,6 +185,13 @@ function resolvedThemeFromDocument(): "light" | "dark" { // ── Inline atom nodes (chips) ───────────────────────────────────────────── +/** + * Wraps an inline chip node view: keeps the caret and text selection out of the chip and + * paints the editor's node selection over it. + */ +const CHIP_NODE_SELECTION_CLASS_NAME = + "relative inline-flex select-none items-center align-middle leading-none data-[composer-chip-selected]:after:pointer-events-none data-[composer-chip-selected]:after:absolute data-[composer-chip-selected]:after:inset-0 data-[composer-chip-selected]:after:rounded-[6px] data-[composer-chip-selected]:after:bg-[Highlight] data-[composer-chip-selected]:after:opacity-30 data-[composer-chip-selected]:after:content-['']"; + const ComposerMentionExtension = Node.create({ name: "composer-mention", group: "inline", @@ -217,11 +219,11 @@ function ComposerMentionNodeView({ node }: NodeViewProps) { const actions = use(ComposerContextActionsContext); const path = (node.attrs.path as string) ?? ""; const chip = ( - + ); return ( - + {path} @@ -275,20 +277,12 @@ function ComposerSkillNodeView({ node }: NodeViewProps) { const skillDescription = (node.attrs.skillDescription as string | null) ?? null; const skill = skills.find((candidate) => candidate.name === skillName); return ( - + } + label={skillLabel} accessibleLabel={`Skill ${skillLabel}`} - triggerClassName={COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME} - chip={ - <> -