History

fix: reconcile main's round-20 features after the rebase

Renumbers the v2 migrations 044-052 to 045-053 behind main's 044_ClearAutomaticProjectModelDefaults, and adopts main's sticky new-thread selection (#9164), local-only worktree bases in the v2 launch path (#8751), the PR summary read for settlement (#9176), Claude per-cwd skills (#9210), the provider editor redesign with the branch's dedicated environment fields re-grafted (#8508), and the client half of continue-threads-across-restart (#9167). The server-side continuation markers stay unported: they live in the v1 session directory, and v2 recovery terminalizes running runs on restart, so the capability is withheld until the v2 runtime carries them.

Julius Marminge committed 2 months ago

Commit d36b2bc1 parent 1f285d932 files · +452 −380Browse files

Unified · Split

32 files
  1. apps/mobile/src/features/threads/ThreadComposer.tsx +8 −3
  2. apps/server/src/cli/project.ts +0 −2
  3. apps/server/src/environment/ServerEnvironment.test.ts +3 −1
  4. apps/server/src/environment/ServerEnvironment.ts +5 −4
  5. apps/server/src/orchestration-v2/ThreadLaunchService.ts +24 −8
  6. apps/server/src/orchestration-v2/ThreadSettlementService.ts +12 −6
  7. apps/server/src/orchestration/decider.ts +4 −1
  8. apps/server/src/persistence/Migrations.ts +20 −18
  9. apps/server/src/persistence/Migrations/045_046_OrchestrationV2.test.ts +27 −26
  10. apps/server/src/persistence/Migrations/045_OrchestrationV2.ts +0 −0
  11. apps/server/src/persistence/Migrations/046_OrchestrationV2Subagents.ts +0 −0
  12. apps/server/src/persistence/Migrations/047_OrchestrationV2Foundation.test.ts +2 −2
  13. apps/server/src/persistence/Migrations/047_OrchestrationV2Foundation.ts +0 −0
  14. apps/server/src/persistence/Migrations/048_OrchestrationV2ProviderSessionBindings.ts +0 −0
  15. apps/server/src/persistence/Migrations/049_OrchestrationV2ThreadLaunchWorkflows.ts +0 −0
  16. apps/server/src/persistence/Migrations/050_ApplicationEventSource.test.ts +3 −3
  17. apps/server/src/persistence/Migrations/050_ApplicationEventSource.ts +0 −0
  18. apps/server/src/persistence/Migrations/051_OrchestrationV2EffectCancellation.test.ts +3 −3
  19. apps/server/src/persistence/Migrations/051_OrchestrationV2EffectCancellation.ts +0 −0
  20. apps/server/src/persistence/Migrations/052_ScheduledTasks.ts +0 −0
  21. apps/server/src/persistence/Migrations/053_LegacyV1ImportState.ts +0 −0
  22. apps/server/src/provider/Drivers/ClaudeDriver.ts +12 −0
  23. apps/server/src/serverRuntimeStartup.test.ts +1 −1
  24. apps/server/src/serverRuntimeStartup.ts +5 −4
  25. apps/server/src/ws.ts +4 −2
  26. apps/web/src/components/ChatView.tsx +4 −0
  27. apps/web/src/components/chat/ChatComposer.tsx +3 −1
  28. apps/web/src/components/settings/ProviderInstanceCard.tsx +280 −288
  29. apps/web/src/index.css +3 −3
  30. packages/contracts/src/settings.ts +8 −0
  31. packages/shared/src/model.test.ts +17 −0
  32. pnpm-lock.yaml +4 −4
apps/mobile/src/features/threads/ThreadComposer.tsx+8−3
@@ -153,6 +153,11 @@ export const COMPOSER_LAYOUT_TRANSITION = · 152 unmodified lines
153 ? undefined153 ? undefined
154 : LinearTransition.duration(COMPOSER_TRANSITION_DURATION_MS).reduceMotion(ReduceMotion.System);154 : LinearTransition.duration(COMPOSER_TRANSITION_DURATION_MS).reduceMotion(ReduceMotion.System);
155 155
156const COMPOSER_ATTACHMENT_ENTERING =
157 Platform.OS === "android"
158 ? FadeIn.duration(160)
159 : FadeIn.delay(COMPOSER_TRANSITION_DURATION_MS).duration(160).reduceMotion(ReduceMotion.System);
160
156const AnimatedGlassSurface = Animated.createAnimatedComponent(GlassSurface);161const AnimatedGlassSurface = Animated.createAnimatedComponent(GlassSurface);
157 162
158export function ComposerSurface(props: {163export function ComposerSurface(props: {
@@ -622,10 +627,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 463 unmodified lines
622 onPickFiles={props.onPickDraftFiles}627 onPickFiles={props.onPickDraftFiles}
623 />628 />
624 ) : null}629 ) : null}
625 {isExpanded ? (630 {isExpanded && props.draftAttachments.length > 0 ? (
626 <Animated.View631 <Animated.View
627 className={props.draftAttachments.length > 0 ? "px-[14px] pb-2.5" : undefined}632 className="px-[14px] pb-2.5"
628 entering={FadeIn.duration(160)}633 entering={COMPOSER_ATTACHMENT_ENTERING}
629 exiting={FadeOut.duration(120)}634 exiting={FadeOut.duration(120)}
630 >635 >
631 <ComposerAttachmentStrip636 <ComposerAttachmentStrip
apps/server/src/cli/project.ts+0−2
@@ -31,7 +31,6 @@ import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; · 30 unmodified lines
31import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts";31import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts";
32import * as ProjectService from "../project/ProjectService.ts";32import * as ProjectService from "../project/ProjectService.ts";
33import * as T3ProjectFileLoader from "../project/T3ProjectFileLoader.ts";33import * as T3ProjectFileLoader from "../project/T3ProjectFileLoader.ts";
34import * as ServerRuntimeStartup from "../serverRuntimeStartup.ts";
35import {34import {
36 clearPersistedServerRuntimeState,35 clearPersistedServerRuntimeState,
37 readPersistedServerRuntimeState,36 readPersistedServerRuntimeState,
@@ -513,7 +512,6 @@ const projectAddCommand = Command.make("add", { · 475 unmodified lines
513 projectId,512 projectId,
514 title,513 title,
515 workspaceRoot,514 workspaceRoot,
516 defaultModelSelection: ServerRuntimeStartup.getAutoBootstrapDefaultModelSelection(),
517 });515 });
518 return `Added project ${projectId} (${title}) at ${workspaceRoot}.`;516 return `Added project ${projectId} (${title}) at ${workspaceRoot}.`;
519 }),517 }),
apps/server/src/environment/ServerEnvironment.test.ts+3−1
@@ -245,7 +245,9 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { · 244 unmodified lines
245 expect(withFd.capabilities.serverSelfUpdate).toBe("desktop-managed");245 expect(withFd.capabilities.serverSelfUpdate).toBe("desktop-managed");
246 expect(withFd.capabilities.desktopAppUpdate).toBe(true);246 expect(withFd.capabilities.desktopAppUpdate).toBe(true);
247 expect(withFd.capabilities.serverSelfUpdateProgress).toBe(true);247 expect(withFd.capabilities.serverSelfUpdateProgress).toBe(true);
248 expect(withFd.capabilities.serverUpdateThreadContinuation).toBe(true);248 // v2 recovery terminalizes running runs on restart, so continuation
249 // stays unadvertised until the v2 runtime carries the markers.
250 expect(withFd.capabilities.serverUpdateThreadContinuation).toBeUndefined();
249 251
250 const withoutFd = yield* describeWith({ mode: "desktop" });252 const withoutFd = yield* describeWith({ mode: "desktop" });
251 expect(withoutFd.capabilities.serverSelfUpdate).toBe("desktop-managed");253 expect(withoutFd.capabilities.serverSelfUpdate).toBe("desktop-managed");
apps/server/src/environment/ServerEnvironment.ts+5−4
@@ -226,11 +226,12 @@ export const make = Effect.gen(function* () { · 225 unmodified lines
226 threadVisitedTracking: true,226 threadVisitedTracking: true,
227 threadPullRequestLinking: true,227 threadPullRequestLinking: true,
228 ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }),228 ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }),
229 // Running-thread continuation across a self-update (#9167) marks v1
230 // provider session bindings; the v2 recovery path terminalizes running
231 // runs on restart instead, so the capability stays unadvertised until
232 // ProviderRuntimeRecoveryService grows an equivalent.
229 ...(serverSelfUpdate === "boot-service" || desktopAppUpdate233 ...(serverSelfUpdate === "boot-service" || desktopAppUpdate
230 ? {234 ? { serverSelfUpdateProgress: true }
231 serverSelfUpdateProgress: true,
232 serverUpdateThreadContinuation: true,
233 }
234 : {}),235 : {}),
235 ...(desktopAppUpdate ? { desktopAppUpdate: true } : {}),236 ...(desktopAppUpdate ? { desktopAppUpdate: true } : {}),
236 },237 },
apps/server/src/orchestration-v2/ThreadLaunchService.ts+24−8
@@ -240,20 +240,36 @@ export const make = Effect.gen(function* () { · 239 unmodified lines
240 .pipe(Effect.mapError(mapError(input, "update-thread", threadId)));240 .pipe(Effect.mapError(mapError(input, "update-thread", threadId)));
241 }241 }
242 let startRef = input.workspaceStrategy.baseRef;242 let startRef = input.workspaceStrategy.baseRef;
243 if (input.workspaceStrategy.startFromOrigin === true) {243 // "Start from origin" is a stored default; repos without the requested
244 // remote branch fall back to the local base branch.
245 const startFromOrigin =
246 input.workspaceStrategy.startFromOrigin === true &&
247 (yield* git
248 .remoteExists({ cwd: project.workspaceRoot, remoteName: "origin" })
249 .pipe(Effect.mapError(mapError(input, "provision-worktree", threadId))));
250 if (startFromOrigin) {
244 yield* git251 yield* git
245 .fetchRemote({ cwd: project.workspaceRoot, remoteName: "origin" })252 .fetchRemote({ cwd: project.workspaceRoot, remoteName: "origin" })
246 .pipe(Effect.mapError(mapError(input, "provision-worktree", threadId)));253 .pipe(Effect.mapError(mapError(input, "provision-worktree", threadId)));
247 startRef = yield* git254 const remoteBaseExists = yield* git
248 .resolveRemoteTrackingCommit({255 .remoteBranchExists({
249 cwd: project.workspaceRoot,256 cwd: project.workspaceRoot,
250 refName: input.workspaceStrategy.baseRef,257 refName: input.workspaceStrategy.baseRef,
251 fallbackRemoteName: "origin",258 remoteName: "origin",
252 })259 })
253 .pipe(260 .pipe(Effect.mapError(mapError(input, "provision-worktree", threadId)));
254 Effect.map((resolved) => resolved.commitSha),261 if (remoteBaseExists) {
255 Effect.mapError(mapError(input, "provision-worktree", threadId)),262 startRef = yield* git
256 );263 .resolveRemoteTrackingCommit({
264 cwd: project.workspaceRoot,
265 refName: input.workspaceStrategy.baseRef,
266 fallbackRemoteName: "origin",
267 })
268 .pipe(
269 Effect.map((resolved) => resolved.commitSha),
270 Effect.mapError(mapError(input, "provision-worktree", threadId)),
271 );
272 }
257 }273 }
258 const worktree = yield* git274 const worktree = yield* git
259 .createWorktree({275 .createWorktree({
apps/server/src/orchestration-v2/ThreadSettlementService.ts+12−6
@@ -192,12 +192,18 @@ export const make = Effect.gen(function* () { · 191 unmodified lines
192 thread: (typeof candidates)[number],192 thread: (typeof candidates)[number],
193 ) {193 ) {
194 if (thread.linkedPullRequest != null) {194 if (thread.linkedPullRequest != null) {
195 const detail = yield* pullRequests.detail({195 const summary = yield* pullRequests.summary(
196 projectId: thread.linkedPullRequest.projectId,196 {
197 repository: thread.linkedPullRequest.repository,197 projectId: thread.linkedPullRequest.projectId,
198 number: thread.linkedPullRequest.number,198 repository: thread.linkedPullRequest.repository,
199 });199 number: thread.linkedPullRequest.number,
200 return { state: detail.state, updatedAt: detail.updatedAt } satisfies SettlementPullRequest;200 },
201 { recoverTransientFailure: false },
202 );
203 return {
204 state: summary.state,
205 updatedAt: summary.updatedAt,
206 } satisfies SettlementPullRequest;
201 }207 }
202 if (thread.branch === null) return null;208 if (thread.branch === null) return null;
203 const workspaceRoot = workspaceRootByProjectId.get(thread.projectId);209 const workspaceRoot = workspaceRootByProjectId.get(thread.projectId);
apps/server/src/orchestration/decider.ts+4−1
@@ -219,7 +219,10 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" · 218 unmodified lines
219 projectId: command.projectId,219 projectId: command.projectId,
220 title: command.title,220 title: command.title,
221 workspaceRoot: command.workspaceRoot,221 workspaceRoot: command.workspaceRoot,
222 defaultModelSelection: command.defaultModelSelection ?? null,222 // Project creation has no user model choice. Older clients sent an
223 // automatic seed here, but only a metadata update records an
224 // explicit project default.
225 defaultModelSelection: null,
223 faviconPath: null,226 faviconPath: null,
224 scripts: command.scripts ?? [],227 scripts: command.scripts ?? [],
225 createdAt: command.createdAt,228 createdAt: command.createdAt,
apps/server/src/persistence/Migrations.ts+20−18
@@ -55,15 +55,16 @@ import Migration0040 from "./Migrations/040_ProjectionProjectFaviconPath.ts"; · 54 unmodified lines
55import Migration0041 from "./Migrations/041_AuthSessionClientConnection.ts";55import Migration0041 from "./Migrations/041_AuthSessionClientConnection.ts";
56import Migration0042 from "./Migrations/042_ProjectionThreadLinkedPullRequest.ts";56import Migration0042 from "./Migrations/042_ProjectionThreadLinkedPullRequest.ts";
57import Migration0043 from "./Migrations/043_ProjectionThreadsUnsettledAt.ts";57import Migration0043 from "./Migrations/043_ProjectionThreadsUnsettledAt.ts";
58import Migration0044 from "./Migrations/044_OrchestrationV2.ts";58import Migration0044 from "./Migrations/044_ClearAutomaticProjectModelDefaults.ts";
59import Migration0045 from "./Migrations/045_OrchestrationV2Subagents.ts";59import Migration0045 from "./Migrations/045_OrchestrationV2.ts";
60import Migration0046 from "./Migrations/046_OrchestrationV2Foundation.ts";60import Migration0046 from "./Migrations/046_OrchestrationV2Subagents.ts";
61import Migration0047 from "./Migrations/047_OrchestrationV2ProviderSessionBindings.ts";61import Migration0047 from "./Migrations/047_OrchestrationV2Foundation.ts";
62import Migration0048 from "./Migrations/048_OrchestrationV2ThreadLaunchWorkflows.ts";62import Migration0048 from "./Migrations/048_OrchestrationV2ProviderSessionBindings.ts";
63import Migration0049 from "./Migrations/049_ApplicationEventSource.ts";63import Migration0049 from "./Migrations/049_OrchestrationV2ThreadLaunchWorkflows.ts";
64import Migration0050 from "./Migrations/050_OrchestrationV2EffectCancellation.ts";64import Migration0050 from "./Migrations/050_ApplicationEventSource.ts";
65import Migration0051 from "./Migrations/051_ScheduledTasks.ts";65import Migration0051 from "./Migrations/051_OrchestrationV2EffectCancellation.ts";
66import Migration0052 from "./Migrations/052_LegacyV1ImportState.ts";66import Migration0052 from "./Migrations/052_ScheduledTasks.ts";
67import Migration0053 from "./Migrations/053_LegacyV1ImportState.ts";
67 68
68/**69/**
69 * Migration loader with all migrations defined inline.70 * Migration loader with all migrations defined inline.
@@ -119,15 +120,16 @@ export const migrationEntries = [ · 49 unmodified lines
119 [41, "AuthSessionClientConnection", Migration0041],120 [41, "AuthSessionClientConnection", Migration0041],
120 [42, "ProjectionThreadLinkedPullRequest", Migration0042],121 [42, "ProjectionThreadLinkedPullRequest", Migration0042],
121 [43, "ProjectionThreadsUnsettledAt", Migration0043],122 [43, "ProjectionThreadsUnsettledAt", Migration0043],
122 [44, "OrchestrationV2", Migration0044],123 [44, "ClearAutomaticProjectModelDefaults", Migration0044],
123 [45, "OrchestrationV2Subagents", Migration0045],124 [45, "OrchestrationV2", Migration0045],
124 [46, "OrchestrationV2Foundation", Migration0046],125 [46, "OrchestrationV2Subagents", Migration0046],
125 [47, "OrchestrationV2ProviderSessionBindings", Migration0047],126 [47, "OrchestrationV2Foundation", Migration0047],
126 [48, "OrchestrationV2ThreadLaunchWorkflows", Migration0048],127 [48, "OrchestrationV2ProviderSessionBindings", Migration0048],
127 [49, "ApplicationEventSource", Migration0049],128 [49, "OrchestrationV2ThreadLaunchWorkflows", Migration0049],
128 [50, "OrchestrationV2EffectCancellation", Migration0050],129 [50, "ApplicationEventSource", Migration0050],
129 [51, "ScheduledTasks", Migration0051],130 [51, "OrchestrationV2EffectCancellation", Migration0051],
130 [52, "LegacyV1ImportState", Migration0052],131 [52, "ScheduledTasks", Migration0052],
132 [53, "LegacyV1ImportState", Migration0053],
131] as const;133] as const;
132 134
133export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const);135export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const);
apps/server/src/persistence/Migrations/045_046_OrchestrationV2.test.tsrenamed from apps/server/src/persistence/Migrations/044_045_OrchestrationV2.test.ts+27−26
@@ -8,12 +8,12 @@ import * as NodeSqliteClient from "../NodeSqliteClient.ts"; · 7 unmodified lines
8 8
9const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));9const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));
10 10
11layer("038_039_OrchestrationV2", (it) => {11layer("045_046_OrchestrationV2", (it) => {
12 it.effect("keeps released and private migration ids contiguous", () =>12 it.effect("keeps released and private migration ids contiguous", () =>
13 Effect.sync(() => {13 Effect.sync(() => {
14 assert.deepStrictEqual(14 assert.deepStrictEqual(
15 migrationEntries.map(([id]) => id),15 migrationEntries.map(([id]) => id),
16 Array.from({ length: 52 }, (_, index) => index + 1),16 Array.from({ length: 53 }, (_, index) => index + 1),
17 );17 );
18 }),18 }),
19 );19 );
@@ -22,7 +22,7 @@ layer("038_039_OrchestrationV2", (it) => { · 2 unmodified lines
22 Effect.gen(function* () {22 Effect.gen(function* () {
23 const sql = yield* SqlClient.SqlClient;23 const sql = yield* SqlClient.SqlClient;
24 24
25 yield* runMigrations({ toMigrationInclusive: 45 });25 yield* runMigrations({ toMigrationInclusive: 46 });
26 26
27 const migrations = yield* sql<{27 const migrations = yield* sql<{
28 readonly migration_id: number;28 readonly migration_id: number;
@@ -30,24 +30,24 @@ layer("038_039_OrchestrationV2", (it) => { · 1 unmodified line
30 }>`30 }>`
31 SELECT migration_id, name31 SELECT migration_id, name
32 FROM effect_sql_migrations32 FROM effect_sql_migrations
33 WHERE migration_id IN (42, 43, 44, 45)33 WHERE migration_id IN (43, 44, 45, 46)
34 ORDER BY migration_id34 ORDER BY migration_id
35 `;35 `;
36 assert.deepStrictEqual(migrations, [36 assert.deepStrictEqual(migrations, [
37 {
38 migration_id: 42,
39 name: "ProjectionThreadLinkedPullRequest",
40 },
41 {37 {
42 migration_id: 43,38 migration_id: 43,
43 name: "ProjectionThreadsUnsettledAt",39 name: "ProjectionThreadsUnsettledAt",
44 },40 },
45 {41 {
46 migration_id: 44,42 migration_id: 44,
47 name: "OrchestrationV2",43 name: "ClearAutomaticProjectModelDefaults",
48 },44 },
49 {45 {
50 migration_id: 45,46 migration_id: 45,
47 name: "OrchestrationV2",
48 },
49 {
50 migration_id: 46,
51 name: "OrchestrationV2Subagents",51 name: "OrchestrationV2Subagents",
52 },52 },
53 ]);53 ]);
@@ -64,10 +64,10 @@ layer("038_039_OrchestrationV2", (it) => { · 10 unmodified lines
64 }),64 }),
65 );65 );
66 66
67 it.effect("backfills provider-session thread bindings in migration 047", () =>67 it.effect("backfills provider-session thread bindings in migration 048", () =>
68 Effect.gen(function* () {68 Effect.gen(function* () {
69 const sql = yield* SqlClient.SqlClient;69 const sql = yield* SqlClient.SqlClient;
70 yield* runMigrations({ toMigrationInclusive: 46 });70 yield* runMigrations({ toMigrationInclusive: 47 });
71 yield* sql`71 yield* sql`
72 INSERT INTO orchestration_v2_projection_provider_sessions (72 INSERT INTO orchestration_v2_projection_provider_sessions (
73 provider_session_id,73 provider_session_id,
@@ -92,7 +92,7 @@ layer("038_039_OrchestrationV2", (it) => { · 18 unmodified lines
92 )92 )
93 `;93 `;
94 94
95 yield* runMigrations({ toMigrationInclusive: 47 });95 yield* runMigrations({ toMigrationInclusive: 48 });
96 96
97 const bindings = yield* sql<{97 const bindings = yield* sql<{
98 readonly provider_session_id: string;98 readonly provider_session_id: string;
@@ -110,10 +110,10 @@ layer("038_039_OrchestrationV2", (it) => { · 11 unmodified lines
110 }),110 }),
111 );111 );
112 112
113 it.effect("preserves turn items with colliding ordinals in migration 046", () =>113 it.effect("preserves turn items with colliding ordinals in migration 047", () =>
114 Effect.gen(function* () {114 Effect.gen(function* () {
115 const sql = yield* SqlClient.SqlClient;115 const sql = yield* SqlClient.SqlClient;
116 yield* runMigrations({ toMigrationInclusive: 45 });116 yield* runMigrations({ toMigrationInclusive: 46 });
117 yield* sql`117 yield* sql`
118 INSERT INTO orchestration_v2_projection_runs (118 INSERT INTO orchestration_v2_projection_runs (
119 run_id,119 run_id,
@@ -150,7 +150,7 @@ layer("038_039_OrchestrationV2", (it) => { · 30 unmodified lines
150 ('turn-item:d', 'thread:two', NULL, 42, 'assistant_message', 'completed', '2026-01-01T00:00:00.000Z', '{}')150 ('turn-item:d', 'thread:two', NULL, 42, 'assistant_message', 'completed', '2026-01-01T00:00:00.000Z', '{}')
151 `;151 `;
152 152
153 yield* runMigrations({ toMigrationInclusive: 46 });153 yield* runMigrations({ toMigrationInclusive: 47 });
154 154
155 const positions = yield* sql<{155 const positions = yield* sql<{
156 readonly thread_id: string;156 readonly thread_id: string;
@@ -182,7 +182,7 @@ it.effect("upgrades a database already at released main migration 043", () => · 25 unmodified lines
182 assert.ok(snoozeColumns.some((column) => column.name === "snoozed_until"));182 assert.ok(snoozeColumns.some((column) => column.name === "snoozed_until"));
183 assert.ok(snoozeColumns.some((column) => column.name === "snoozed_at"));183 assert.ok(snoozeColumns.some((column) => column.name === "snoozed_at"));
184 184
185 yield* runMigrations({ toMigrationInclusive: 52 });185 yield* runMigrations({ toMigrationInclusive: 53 });
186 186
187 const migrations = yield* sql<{187 const migrations = yield* sql<{
188 readonly migration_id: number;188 readonly migration_id: number;
@@ -190,7 +190,7 @@ it.effect("upgrades a database already at released main migration 043", () => · 1 unmodified line
190 }>`190 }>`
191 SELECT migration_id, name191 SELECT migration_id, name
192 FROM effect_sql_migrations192 FROM effect_sql_migrations
193 WHERE migration_id BETWEEN 39 AND 52193 WHERE migration_id BETWEEN 39 AND 53
194 ORDER BY migration_id194 ORDER BY migration_id
195 `;195 `;
196 assert.deepStrictEqual(196 assert.deepStrictEqual(
@@ -201,15 +201,16 @@ it.effect("upgrades a database already at released main migration 043", () => · 4 unmodified lines
201 [41, "AuthSessionClientConnection"],201 [41, "AuthSessionClientConnection"],
202 [42, "ProjectionThreadLinkedPullRequest"],202 [42, "ProjectionThreadLinkedPullRequest"],
203 [43, "ProjectionThreadsUnsettledAt"],203 [43, "ProjectionThreadsUnsettledAt"],
204 [44, "OrchestrationV2"],204 [44, "ClearAutomaticProjectModelDefaults"],
205 [45, "OrchestrationV2Subagents"],205 [45, "OrchestrationV2"],
206 [46, "OrchestrationV2Foundation"],206 [46, "OrchestrationV2Subagents"],
207 [47, "OrchestrationV2ProviderSessionBindings"],207 [47, "OrchestrationV2Foundation"],
208 [48, "OrchestrationV2ThreadLaunchWorkflows"],208 [48, "OrchestrationV2ProviderSessionBindings"],
209 [49, "ApplicationEventSource"],209 [49, "OrchestrationV2ThreadLaunchWorkflows"],
210 [50, "OrchestrationV2EffectCancellation"],210 [50, "ApplicationEventSource"],
211 [51, "ScheduledTasks"],211 [51, "OrchestrationV2EffectCancellation"],
212 [52, "LegacyV1ImportState"],212 [52, "ScheduledTasks"],
213 [53, "LegacyV1ImportState"],
213 ],214 ],
214 );215 );
215 216
apps/server/src/persistence/Migrations/045_OrchestrationV2.tsrenamed from apps/server/src/persistence/Migrations/044_OrchestrationV2.ts+0−0

File renamed without changes.

apps/server/src/persistence/Migrations/046_OrchestrationV2Subagents.tsrenamed from apps/server/src/persistence/Migrations/045_OrchestrationV2Subagents.ts+0−0

File renamed without changes.

apps/server/src/persistence/Migrations/047_OrchestrationV2Foundation.test.tsrenamed from apps/server/src/persistence/Migrations/046_OrchestrationV2Foundation.test.ts+2−2
@@ -8,7 +8,7 @@ import * as NodeSqliteClient from "../NodeSqliteClient.ts"; · 7 unmodified lines
8it.effect("backfills the V2 event driver from payload before the legacy provider column", () =>8it.effect("backfills the V2 event driver from payload before the legacy provider column", () =>
9 Effect.gen(function* () {9 Effect.gen(function* () {
10 const sql = yield* SqlClient.SqlClient;10 const sql = yield* SqlClient.SqlClient;
11 yield* runMigrations({ toMigrationInclusive: 45 });11 yield* runMigrations({ toMigrationInclusive: 46 });
12 12
13 yield* sql`13 yield* sql`
14 INSERT INTO orchestration_v2_events (14 INSERT INTO orchestration_v2_events (
@@ -37,7 +37,7 @@ it.effect("backfills the V2 event driver from payload before the legacy provider · 22 unmodified lines
37 )37 )
38 `;38 `;
39 39
40 yield* runMigrations({ toMigrationInclusive: 46 });40 yield* runMigrations({ toMigrationInclusive: 47 });
41 41
42 const events = yield* sql<{42 const events = yield* sql<{
43 readonly event_id: string;43 readonly event_id: string;
apps/server/src/persistence/Migrations/047_OrchestrationV2Foundation.tsrenamed from apps/server/src/persistence/Migrations/046_OrchestrationV2Foundation.ts+0−0

File renamed without changes.

apps/server/src/persistence/Migrations/048_OrchestrationV2ProviderSessionBindings.tsrenamed from apps/server/src/persistence/Migrations/047_OrchestrationV2ProviderSessionBindings.ts+0−0

File renamed without changes.

apps/server/src/persistence/Migrations/049_OrchestrationV2ThreadLaunchWorkflows.tsrenamed from apps/server/src/persistence/Migrations/048_OrchestrationV2ThreadLaunchWorkflows.ts+0−0

File renamed without changes.

apps/server/src/persistence/Migrations/050_ApplicationEventSource.test.tsrenamed from apps/server/src/persistence/Migrations/049_ApplicationEventSource.test.ts+3−3
@@ -8,11 +8,11 @@ import * as NodeSqliteClient from "../NodeSqliteClient.ts"; · 7 unmodified lines
8 8
9const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));9const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));
10 10
11layer("043_ApplicationEventSource", (it) => {11layer("050_ApplicationEventSource", (it) => {
12 it.effect("moves V2 events and current project state behind one global sequence", () =>12 it.effect("moves V2 events and current project state behind one global sequence", () =>
13 Effect.gen(function* () {13 Effect.gen(function* () {
14 const sql = yield* SqlClient.SqlClient;14 const sql = yield* SqlClient.SqlClient;
15 yield* runMigrations({ toMigrationInclusive: 48 });15 yield* runMigrations({ toMigrationInclusive: 49 });
16 16
17 yield* sql`17 yield* sql`
18 INSERT INTO orchestration_v2_events (18 INSERT INTO orchestration_v2_events (
@@ -84,7 +84,7 @@ layer("043_ApplicationEventSource", (it) => { · 65 unmodified lines
84 )84 )
85 `;85 `;
86 86
87 yield* runMigrations({ toMigrationInclusive: 49 });87 yield* runMigrations({ toMigrationInclusive: 50 });
88 88
89 const events = yield* sql<{89 const events = yield* sql<{
90 readonly sequence: number;90 readonly sequence: number;
apps/server/src/persistence/Migrations/050_ApplicationEventSource.tsrenamed from apps/server/src/persistence/Migrations/049_ApplicationEventSource.ts+0−0

File renamed without changes.

apps/server/src/persistence/Migrations/051_OrchestrationV2EffectCancellation.test.tsrenamed from apps/server/src/persistence/Migrations/050_OrchestrationV2EffectCancellation.test.ts+3−3
@@ -8,11 +8,11 @@ import * as NodeSqliteClient from "../NodeSqliteClient.ts"; · 7 unmodified lines
8 8
9const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));9const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));
10 10
11layer("050_OrchestrationV2EffectCancellation", (it) => {11layer("051_OrchestrationV2EffectCancellation", (it) => {
12 it.effect("preserves existing effects and adds the cancelled terminal status", () =>12 it.effect("preserves existing effects and adds the cancelled terminal status", () =>
13 Effect.gen(function* () {13 Effect.gen(function* () {
14 const sql = yield* SqlClient.SqlClient;14 const sql = yield* SqlClient.SqlClient;
15 yield* runMigrations({ toMigrationInclusive: 49 });15 yield* runMigrations({ toMigrationInclusive: 50 });
16 yield* sql`16 yield* sql`
17 INSERT INTO orchestration_v2_effect_outbox (17 INSERT INTO orchestration_v2_effect_outbox (
18 effect_id,18 effect_id,
@@ -47,7 +47,7 @@ layer("050_OrchestrationV2EffectCancellation", (it) => { · 28 unmodified lines
47 )47 )
48 `;48 `;
49 49
50 yield* runMigrations({ toMigrationInclusive: 50 });50 yield* runMigrations({ toMigrationInclusive: 51 });
51 yield* sql`51 yield* sql`
52 UPDATE orchestration_v2_effect_outbox52 UPDATE orchestration_v2_effect_outbox
53 SET53 SET
apps/server/src/persistence/Migrations/051_OrchestrationV2EffectCancellation.tsrenamed from apps/server/src/persistence/Migrations/050_OrchestrationV2EffectCancellation.ts+0−0

File renamed without changes.

apps/server/src/persistence/Migrations/052_ScheduledTasks.tsrenamed from apps/server/src/persistence/Migrations/051_ScheduledTasks.ts+0−0

File renamed without changes.

apps/server/src/persistence/Migrations/053_LegacyV1ImportState.tsrenamed from apps/server/src/persistence/Migrations/052_LegacyV1ImportState.ts+0−0

File renamed without changes.

apps/server/src/provider/Drivers/ClaudeDriver.ts+12−0
@@ -61,6 +61,7 @@ import { · 60 unmodified lines
61 type ProviderSnapshotSettings,61 type ProviderSnapshotSettings,
62} from "../providerUpdateSettings.ts";62} from "../providerUpdateSettings.ts";
63import { makeClaudeCapabilitiesCacheKey, makeClaudeContinuationGroupKey } from "./ClaudeHome.ts";63import { makeClaudeCapabilitiesCacheKey, makeClaudeContinuationGroupKey } from "./ClaudeHome.ts";
64import { discoverClaudeSkills } from "./ClaudeSkills.ts";
64const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings);65const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings);
65 66
66const DRIVER_KIND = ProviderDriverKind.make("claudeAgent");67const DRIVER_KIND = ProviderDriverKind.make("claudeAgent");
@@ -246,6 +247,17 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = { · 179 unmodified lines
246 accentColor,247 accentColor,
247 enabled,248 enabled,
248 snapshot,249 snapshot,
250 snapshotForCwd: (cwd: string) =>
251 !effectiveConfig.enabled
252 ? snapshot.getSnapshot
253 : Effect.all([
254 snapshot.getSnapshot,
255 discoverClaudeSkills(effectiveConfig, cwd, processEnv),
256 ]).pipe(
257 Effect.map(([machineSnapshot, skills]) => ({ ...machineSnapshot, skills })),
258 Effect.provideService(FileSystem.FileSystem, fileSystem),
259 Effect.provideService(Path.Path, path),
260 ),
249 orchestrationAdapter,261 orchestrationAdapter,
250 textGeneration,262 textGeneration,
251 } satisfies ProviderInstance;263 } satisfies ProviderInstance;
apps/server/src/serverRuntimeStartup.test.ts+1−1
@@ -10,7 +10,7 @@ import * as ServerConfig from "./config.ts"; · 9 unmodified lines
10import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts";10import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts";
11 11
12it("uses the canonical Codex model for auto-bootstrap", () => {12it("uses the canonical Codex model for auto-bootstrap", () => {
13 assert.deepEqual(ServerRuntimeStartup.getAutoBootstrapDefaultModelSelection(), {13 assert.deepEqual(ServerRuntimeStartup.getAutoBootstrapThreadModelSelection(), {
14 instanceId: ProviderInstanceId.make("codex"),14 instanceId: ProviderInstanceId.make("codex"),
15 model: DEFAULT_MODEL,15 model: DEFAULT_MODEL,
16 });16 });
apps/server/src/serverRuntimeStartup.ts+5−4
@@ -176,7 +176,7 @@ export const launchStartupHeartbeat = recordStartupHeartbeat.pipe( · 175 unmodified lines
176 Effect.asVoid,176 Effect.asVoid,
177);177);
178 178
179export const getAutoBootstrapDefaultModelSelection = (): ModelSelection => ({179export const getAutoBootstrapThreadModelSelection = (): ModelSelection => ({
180 instanceId: ProviderInstanceId.make("codex"),180 instanceId: ProviderInstanceId.make("codex"),
181 model: DEFAULT_MODEL,181 model: DEFAULT_MODEL,
182});182});
@@ -210,13 +210,14 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () { · 27 unmodified lines
210 let bootstrapThreadId: ThreadId | undefined;210 let bootstrapThreadId: ThreadId | undefined;
211 211
212 if (serverConfig.autoBootstrapProjectFromCwd) {212 if (serverConfig.autoBootstrapProjectFromCwd) {
213 const defaultModelSelection = getAutoBootstrapDefaultModelSelection();213 // Project creation has no user model choice; only the bootstrap thread
214 // gets an automatic selection, and an explicit project default wins.
215 const threadModelSelection = getAutoBootstrapThreadModelSelection();
214 const { project } = yield* projects.bootstrap({216 const { project } = yield* projects.bootstrap({
215 commandId: CommandId.make(yield* randomUUID),217 commandId: CommandId.make(yield* randomUUID),
216 projectId: ProjectId.make(yield* randomUUID),218 projectId: ProjectId.make(yield* randomUUID),
217 title: path.basename(serverConfig.cwd) || "project",219 title: path.basename(serverConfig.cwd) || "project",
218 workspaceRoot: serverConfig.cwd,220 workspaceRoot: serverConfig.cwd,
219 defaultModelSelection,
220 });221 });
221 const shell = yield* threads.getShellSnapshot();222 const shell = yield* threads.getShellSnapshot();
222 const existingThread = shell.threads.find(223 const existingThread = shell.threads.find(
@@ -228,7 +229,7 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () { · 5 unmodified lines
228 commandId: CommandId.make(yield* randomUUID),229 commandId: CommandId.make(yield* randomUUID),
229 projectId: project.id,230 projectId: project.id,
230 title: "New thread",231 title: "New thread",
231 modelSelection: project.defaultModelSelection ?? defaultModelSelection,232 modelSelection: project.defaultModelSelection ?? threadModelSelection,
232 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,233 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
233 runtimeMode: "full-access",234 runtimeMode: "full-access",
234 workspaceStrategy: { type: "root" },235 workspaceStrategy: { type: "root" },
apps/server/src/ws.ts+4−2
@@ -176,7 +176,6 @@ import * as SourceControlProviderRegistry from "./sourceControl/SourceControlPro · 175 unmodified lines
176import * as GitVcsDriver from "./vcs/GitVcsDriver.ts";176import * as GitVcsDriver from "./vcs/GitVcsDriver.ts";
177import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts";177import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts";
178import * as VcsProjectConfig from "./vcs/VcsProjectConfig.ts";178import * as VcsProjectConfig from "./vcs/VcsProjectConfig.ts";
179import * as VcsProcess from "./vcs/VcsProcess.ts";
180import * as PairingGrantStore from "./auth/PairingGrantStore.ts";179import * as PairingGrantStore from "./auth/PairingGrantStore.ts";
181import * as SessionStore from "./auth/SessionStore.ts";180import * as SessionStore from "./auth/SessionStore.ts";
182import { failEnvironmentAuthInvalid, failEnvironmentInternal } from "./auth/http.ts";181import { failEnvironmentAuthInvalid, failEnvironmentInternal } from "./auth/http.ts";
@@ -1840,6 +1839,10 @@ const makeWsRpcLayer = ( · 1657 unmodified lines
1840 observeRpcEffect(WS_METHODS.pullRequestsListStats, pullRequests.listStats(input), {1839 observeRpcEffect(WS_METHODS.pullRequestsListStats, pullRequests.listStats(input), {
1841 "rpc.aggregate": "pull-requests",1840 "rpc.aggregate": "pull-requests",
1842 }),1841 }),
1842 [WS_METHODS.pullRequestsSummary]: (input) =>
1843 observeRpcEffect(WS_METHODS.pullRequestsSummary, pullRequests.summary(input), {
1844 "rpc.aggregate": "pull-requests",
1845 }),
1843 [WS_METHODS.pullRequestsDetail]: (input) =>1846 [WS_METHODS.pullRequestsDetail]: (input) =>
1844 observeRpcEffect(WS_METHODS.pullRequestsDetail, pullRequests.detail(input), {1847 observeRpcEffect(WS_METHODS.pullRequestsDetail, pullRequests.detail(input), {
1845 "rpc.aggregate": "pull-requests",1848 "rpc.aggregate": "pull-requests",
@@ -2612,7 +2615,6 @@ export const websocketRpcRouteLayer = Layer.unwrap( · 766 unmodified lines
2612 ),2615 ),
2613 ),2616 ),
2614 ),2617 ),
2615 Layer.provide(VcsProcess.layer),
2616 ),2618 ),
2617 ),2619 ),
2618 ),2620 ),
apps/web/src/components/ChatView.tsx+4−0
@@ -406,6 +406,7 @@ import { · 405 unmodified lines
406 resolveServerSelfUpdateCapability,406 resolveServerSelfUpdateCapability,
407 serverUpdateGuidance,407 serverUpdateGuidance,
408 supportsDesktopAppUpdate,408 supportsDesktopAppUpdate,
409 supportsServerUpdateThreadContinuation,
409} from "../versionSkew";410} from "../versionSkew";
410import { useAssetUrls } from "../assets/assetUrls";411import { useAssetUrls } from "../assets/assetUrls";
411import type { CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates";412import type { CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates";
@@ -2394,6 +2395,7 @@ function ChatViewContent(props: ChatViewProps) { · 1982 unmodified lines
2394 const serverUpdateEnvironmentId = activeThread?.environmentId ?? null;2395 const serverUpdateEnvironmentId = activeThread?.environmentId ?? null;
2395 const versionMismatchSelfUpdate = resolveServerSelfUpdateCapability(serverConfig);2396 const versionMismatchSelfUpdate = resolveServerSelfUpdateCapability(serverConfig);
2396 const versionMismatchDesktopAppUpdate = supportsDesktopAppUpdate(serverConfig);2397 const versionMismatchDesktopAppUpdate = supportsDesktopAppUpdate(serverConfig);
2398 const versionMismatchThreadContinuation = supportsServerUpdateThreadContinuation(serverConfig);
2397 const serverUpdateState = useAtomValue(2399 const serverUpdateState = useAtomValue(
2398 serverEnvironment.updateStateAtom(serverUpdateEnvironmentId),2400 serverEnvironment.updateStateAtom(serverUpdateEnvironmentId),
2399 );2401 );
@@ -2527,6 +2529,7 @@ function ChatViewContent(props: ChatViewProps) { · 127 unmodified lines
2527 serverLabel={versionMismatchServerLabel}2529 serverLabel={versionMismatchServerLabel}
2528 selfUpdate={versionMismatchSelfUpdate}2530 selfUpdate={versionMismatchSelfUpdate}
2529 desktopAppUpdate={versionMismatchDesktopAppUpdate}2531 desktopAppUpdate={versionMismatchDesktopAppUpdate}
2532 threadContinuation={versionMismatchThreadContinuation}
2530 targetVersion={versionMismatch.clientVersion}2533 targetVersion={versionMismatch.clientVersion}
2531 label={updateFailed ? "Retry" : "Update"}2534 label={updateFailed ? "Retry" : "Update"}
2532 variant="ghost"2535 variant="ghost"
@@ -2561,6 +2564,7 @@ function ChatViewContent(props: ChatViewProps) { · 28 unmodified lines
2561 serverUpdateEnvironmentId,2564 serverUpdateEnvironmentId,
2562 versionMismatchSelfUpdate,2565 versionMismatchSelfUpdate,
2563 versionMismatchDesktopAppUpdate,2566 versionMismatchDesktopAppUpdate,
2567 versionMismatchThreadContinuation,
2564 versionMismatchServerLabel,2568 versionMismatchServerLabel,
2565 ]);2569 ]);
2566 const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS;2570 const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS;
apps/web/src/components/chat/ChatComposer.tsx+3−1
@@ -4429,7 +4429,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 4428 unmodified lines
4429 ) : null}4429 ) : null}
4430 <ComposerFooterPrimaryActions4430 <ComposerFooterPrimaryActions
4431 compact={isComposerPrimaryActionsCompact}4431 compact={isComposerPrimaryActionsCompact}
4432 activeContextWindow={activeContextWindow}4432 activeContextWindow={
4433 settings.contextWindowMeterEnabled ? activeContextWindow : null
4434 }
4433 activeThreadModelDisplayName={activeThreadModelDisplayName}4435 activeThreadModelDisplayName={activeThreadModelDisplayName}
4434 pendingAction={pendingPrimaryAction}4436 pendingAction={pendingPrimaryAction}
4435 isRunning={phase === "running"}4437 isRunning={phase === "running"}
apps/web/src/components/settings/ProviderInstanceCard.tsx+280−288
@@ -5,6 +5,8 @@ import { · 4 unmodified lines
5 CopyIcon,5 CopyIcon,
6 DownloadIcon,6 DownloadIcon,
7 LoaderIcon,7 LoaderIcon,
8 LockIcon,
9 LockOpenIcon,
8 PlusIcon,10 PlusIcon,
9 Trash2Icon,11 Trash2Icon,
10 XIcon,12 XIcon,
@@ -33,12 +35,10 @@ import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; · 22 unmodified lines
33import { normalizeProviderAccentColor } from "../../providerInstances";35import { normalizeProviderAccentColor } from "../../providerInstances";
34import { Badge } from "../ui/badge";36import { Badge } from "../ui/badge";
35import { Button } from "../ui/button";37import { Button } from "../ui/button";
36import { Checkbox } from "../ui/checkbox";
37import { DraftInput } from "../ui/draft-input";38import { DraftInput } from "../ui/draft-input";
38import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";39import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
39import { ScrollArea } from "../ui/scroll-area";40import { ScrollArea } from "../ui/scroll-area";
40import { Switch } from "../ui/switch";41import { Switch } from "../ui/switch";
41import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../ui/table";
42import { stackedThreadToast, toastManager } from "../ui/toast";42import { stackedThreadToast, toastManager } from "../ui/toast";
43import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";43import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
44import type { DriverOption, ProviderEnvironmentFieldDefinition } from "./providerDriverMeta";44import type { DriverOption, ProviderEnvironmentFieldDefinition } from "./providerDriverMeta";
@@ -58,6 +58,13 @@ import { · 13 unmodified lines
58 58
59const ENVIRONMENT_VARIABLE_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/;59const ENVIRONMENT_VARIABLE_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
60 60
61/** Label-left field grid for the Configuration tab: one row per field. */
62const PROVIDER_FIELD_GRID_CLASS_NAME =
63 "grid gap-x-4 gap-y-2.5 sm:grid-cols-[8rem_minmax(0,1fr)] sm:items-start";
64/** Full-width divider row that names the group of fields below it. */
65const PROVIDER_FIELD_GROUP_LABEL_CLASS_NAME =
66 "col-span-full mt-1 border-t border-border/60 pt-2.5 text-[11px] text-muted-foreground";
67
61let environmentVariableDraftId = 0;68let environmentVariableDraftId = 0;
62const nextEnvironmentVariableDraftId = () => `provider-env-${environmentVariableDraftId++}`;69const nextEnvironmentVariableDraftId = () => `provider-env-${environmentVariableDraftId++}`;
63 70
@@ -130,6 +137,43 @@ function nextConfigBlobWithValue( · 66 unmodified lines
130 return base;137 return base;
131}138}
132 139
140export function deriveProviderModelsForDisplay(input: {
141 readonly liveModels: ReadonlyArray<ServerProviderModel> | undefined;
142 readonly customModels: ReadonlyArray<string>;
143}): ReadonlyArray<ServerProviderModel> {
144 const liveCustomModelsBySlug = new Map(
145 Arr.filterMap(input.liveModels ?? [], (model) =>
146 model.isCustom ? Result.succeed([model.slug, model] as const) : Result.failVoid,
147 ),
148 );
149 const serverModels = input.liveModels?.filter((model) => !model.isCustom) ?? [];
150 const customModels = input.customModels.map(
151 (slug) =>
152 liveCustomModelsBySlug.get(slug) ?? {
153 slug,
154 name: slug,
155 isCustom: true,
156 capabilities: null,
157 },
158 );
159 return [...serverModels, ...customModels];
160}
161
162function ProviderAuthEmail(props: { readonly email: string | undefined }) {
163 const email = props.email?.trim();
164 if (!email) return null;
165
166 return (
167 <RedactedSensitiveText
168 value={email}
169 ariaLabel="Toggle account email visibility"
170 revealTooltip="Click to reveal email"
171 hideTooltip="Click to hide email"
172 className="max-w-full truncate"
173 />
174 );
175}
176
133export function readProviderEnvironmentVariable(177export function readProviderEnvironmentVariable(
134 environment: ReadonlyArray<ProviderInstanceEnvironmentVariable> | undefined,178 environment: ReadonlyArray<ProviderInstanceEnvironmentVariable> | undefined,
135 name: string,179 name: string,
@@ -179,38 +223,50 @@ export function nextProviderEnvironmentWithFieldValue( · 43 unmodified lines
179 return next;223 return next;
180}224}
181 225
182export function deriveProviderModelsForDisplay(input: {226function ProviderEnvironmentFieldRow(props: {
183 readonly liveModels: ReadonlyArray<ServerProviderModel> | undefined;227 readonly field: ProviderEnvironmentFieldDefinition;
184 readonly customModels: ReadonlyArray<CustomModelDefinition>;228 readonly variable: ProviderInstanceEnvironmentVariable | undefined;
185}): ReadonlyArray<ServerProviderModel> {229 readonly idPrefix: string;
186 const liveCustomModelsBySlug = new Map(230 readonly onCommit: (field: ProviderEnvironmentFieldDefinition, value: string) => void;
187 Arr.filterMap(input.liveModels ?? [], (model) =>231 readonly onRemove: (field: ProviderEnvironmentFieldDefinition) => void;
188 model.isCustom ? Result.succeed([model.slug, model] as const) : Result.failVoid,232}) {
189 ),233 const inputId = `${props.idPrefix}-environment-${props.field.name}`;
190 );234 const value = props.variable?.valueRedacted ? "" : (props.variable?.value ?? "");
191 const serverModels = input.liveModels?.filter((model) => !model.isCustom) ?? [];235 const placeholder = props.variable?.valueRedacted
192 const customModels = input.customModels.map((entry) => ({236 ? "Stored secret - enter a new value to replace"
193 slug: entry.slug,237 : props.field.placeholder;
194 name: entry.name,
195 isCustom: true,
196 capabilities:
197 entry.capabilities ?? liveCustomModelsBySlug.get(entry.slug)?.capabilities ?? null,
198 }));
199 return [...serverModels, ...customModels];
200}
201
202function ProviderAuthEmail(props: { readonly email: string | undefined }) {
203 const email = props.email?.trim();
204 if (!email) return null;
205 238
206 return (239 return (
207 <RedactedSensitiveText240 <label htmlFor={inputId} className="block">
208 value={email}241 <span className="text-xs font-medium text-foreground">{props.field.label}</span>
209 ariaLabel="Toggle account email visibility"242 <div className="mt-1.5 flex min-w-0 items-center gap-2">
210 revealTooltip="Click to reveal email"243 <DraftInput
211 hideTooltip="Click to hide email"244 id={inputId}
212 className="max-w-full truncate"245 className="min-w-0 flex-1"
213 />246 type={props.field.sensitive === false ? undefined : "password"}
247 autoComplete="off"
248 value={value}
249 onCommit={(next) => props.onCommit(props.field, next)}
250 placeholder={placeholder}
251 spellCheck={false}
252 />
253 {props.variable ? (
254 <Button
255 type="button"
256 size="icon-sm"
257 variant="ghost"
258 className="size-8 shrink-0 text-muted-foreground hover:text-destructive"
259 onClick={() => props.onRemove(props.field)}
260 aria-label={`Clear ${props.field.label}`}
261 >
262 <XIcon className="size-3.5" />
263 </Button>
264 ) : null}
265 </div>
266 {props.field.description ? (
267 <span className="mt-1 block text-xs text-muted-foreground">{props.field.description}</span>
268 ) : null}
269 </label>
214 );270 );
215}271}
216 272
@@ -284,166 +340,105 @@ function ProviderEnvironmentSection(props: { · 67 unmodified lines
284 publishRows(nextRows);340 publishRows(nextRows);
285 };341 };
286 342
287 return (343 const addVariable = () =>
288 <div className="grid gap-2">344 setRows([
289 <div className="flex items-center justify-between gap-3">345 ...rows,
290 <span className="text-xs font-medium text-foreground">Environment variables</span>346 {
291 <Button347 id: nextEnvironmentVariableDraftId(),
292 type="button"348 name: "",
293 size="sm"349 value: "",
294 variant="outline"350 sensitive: true,
295 className="h-7 gap-1.5 px-2 text-xs"351 },
296 onClick={() =>352 ]);
297 setRows([
298 ...rows,
299 {
300 id: nextEnvironmentVariableDraftId(),
301 name: "",
302 value: "",
303 sensitive: true,
304 },
305 ])
306 }
307 >
308 <PlusIcon className="size-3" />
309 Add
310 </Button>
311 </div>
312 {rows.length === 0 ? (
313 <p className="text-xs text-muted-foreground">
314 Add variables to pass API keys, base URLs, or other per-instance CLI settings.
315 </p>
316 ) : (
317 <div className="overflow-hidden rounded-md border border-border/70">
318 <Table>
319 <TableHeader className="bg-muted/25 text-[11px] text-muted-foreground">
320 <TableRow className="hover:bg-transparent">
321 <TableHead>Variable</TableHead>
322 <TableHead>Value</TableHead>
323 <TableHead className="w-20">Sensitive</TableHead>
324 <TableHead className="w-12 text-right">
325 <span className="sr-only">Options</span>
326 </TableHead>
327 </TableRow>
328 </TableHeader>
329 <TableBody>
330 {rows.map((variable, index) => (
331 <TableRow
332 key={variable.id}
333 className="border-border/60 odd:bg-muted/20 even:bg-background/20"
334 >
335 <TableCell>
336 <DraftInput
337 value={variable.name}
338 onCommit={(name) => updateVariable(variable.id, { name: name.trim() })}
339 placeholder="VARIABLE_NAME"
340 spellCheck={false}
341 aria-label={`Environment variable name ${index + 1}`}
342 />
343 </TableCell>
344 <TableCell>
345 <DraftInput
346 value={variable.valueRedacted ? "" : variable.value}
347 onCommit={(value) => updateVariable(variable.id, { value })}
348 type={variable.sensitive ? "password" : undefined}
349 autoComplete="off"
350 placeholder={
351 variable.valueRedacted
352 ? "Stored secret - enter a new value to replace"
353 : "Value"
354 }
355 spellCheck={false}
356 aria-label={`Environment variable value ${index + 1}`}
357 />
358 </TableCell>
359 <TableCell className="w-20">
360 <div className="flex h-8 items-center justify-center">
361 <Checkbox
362 checked={variable.sensitive}
363 onCheckedChange={(checked) => {
364 const sensitive = Boolean(checked);
365 updateVariable(variable.id, {
366 sensitive,
367 ...(sensitive && variable.valueRedacted === undefined
368 ? {}
369 : { valueRedacted: sensitive ? variable.valueRedacted : false }),
370 });
371 }}
372 aria-label={`Mark environment variable ${variable.name || index + 1} as sensitive`}
373 />
374 </div>
375 </TableCell>
376 <TableCell className="w-12">
377 <div className="flex justify-end">
378 <Button
379 type="button"
380 size="icon-sm"
381 variant="ghost"
382 className="size-8 text-muted-foreground hover:text-destructive"
383 onClick={() => removeVariable(variable.id)}
384 aria-label={`Remove environment variable ${variable.name || index + 1}`}
385 >
386 <XIcon className="size-3.5" />
387 </Button>
388 </div>
389 </TableCell>
390 </TableRow>
391 ))}
392 </TableBody>
393 </Table>
394 </div>
395 )}
396 <span className="text-xs text-muted-foreground">
397 Sensitive values are stored separately and are not returned to the app after saving.
398 </span>
399 </div>
400 );
401}
402
403function ProviderEnvironmentFieldRow(props: {
404 readonly field: ProviderEnvironmentFieldDefinition;
405 readonly variable: ProviderInstanceEnvironmentVariable | undefined;
406 readonly idPrefix: string;
407 readonly onCommit: (field: ProviderEnvironmentFieldDefinition, value: string) => void;
408 readonly onRemove: (field: ProviderEnvironmentFieldDefinition) => void;
409}) {
410 const inputId = `${props.idPrefix}-environment-${props.field.name}`;
411 const value = props.variable?.valueRedacted ? "" : (props.variable?.value ?? "");
412 const placeholder = props.variable?.valueRedacted
413 ? "Stored secret - enter a new value to replace"
414 : props.field.placeholder;
415 353
416 return (354 return (
417 <label htmlFor={inputId} className="block">355 <div className="min-w-0 space-y-1.5">
418 <span className="text-xs font-medium text-foreground">{props.field.label}</span>356 {rows.map((variable, index) => (
419 <div className="mt-1.5 flex min-w-0 items-center gap-2">357 <div key={variable.id} className="flex min-w-0 items-center gap-1.5">
420 <DraftInput358 <DraftInput
421 id={inputId}359 size="compact"
422 className="min-w-0 flex-1"360 className="w-44 shrink-0 font-mono"
423 type={props.field.sensitive === false ? undefined : "password"}361 value={variable.name}
424 autoComplete="off"362 onCommit={(name) => updateVariable(variable.id, { name: name.trim() })}
425 value={value}363 placeholder="VARIABLE_NAME"
426 onCommit={(next) => props.onCommit(props.field, next)}364 spellCheck={false}
427 placeholder={placeholder}365 aria-label={`Environment variable name ${index + 1}`}
428 spellCheck={false}366 />
429 />367 <span className="text-xs text-muted-foreground" aria-hidden>
430 {props.variable ? (368 =
369 </span>
370 <DraftInput
371 size="compact"
372 className="min-w-0 flex-1 font-mono"
373 value={variable.valueRedacted ? "" : variable.value}
374 onCommit={(value) => updateVariable(variable.id, { value })}
375 type={variable.sensitive ? "password" : undefined}
376 autoComplete="off"
377 placeholder={
378 variable.valueRedacted ? "Stored secret, enter a new value to replace" : "value"
379 }
380 spellCheck={false}
381 aria-label={`Environment variable value ${index + 1}`}
382 />
383 <Tooltip>
384 <TooltipTrigger
385 render={
386 <Button
387 type="button"
388 size="icon-micro"
389 variant="ghost-muted"
390 className={cn(
391 "[--control-icon-color:currentColor]",
392 variable.sensitive && "text-foreground",
393 )}
394 onClick={() => {
395 const sensitive = !variable.sensitive;
396 updateVariable(variable.id, {
397 sensitive,
398 ...(sensitive && variable.valueRedacted === undefined
399 ? {}
400 : { valueRedacted: sensitive ? variable.valueRedacted : false }),
401 });
402 }}
403 aria-pressed={variable.sensitive}
404 aria-label={`Mark environment variable ${variable.name || index + 1} as sensitive`}
405 >
406 {variable.sensitive ? (
407 <LockIcon className="size-3" />
408 ) : (
409 <LockOpenIcon className="size-3" />
410 )}
411 </Button>
412 }
413 />
414 <TooltipPopup side="top">
415 {variable.sensitive ? "Sensitive, stored separately" : "Plain text"}
416 </TooltipPopup>
417 </Tooltip>
431 <Button418 <Button
432 type="button"419 type="button"
433 size="icon-sm"420 size="icon-micro"
434 variant="ghost"421 variant="ghost-muted"
435 className="size-8 shrink-0 text-muted-foreground hover:text-destructive"422 className="[--control-icon-color:currentColor] hover:text-destructive"
436 onClick={() => props.onRemove(props.field)}423 onClick={() => removeVariable(variable.id)}
437 aria-label={`Clear ${props.field.label}`}424 aria-label={`Remove environment variable ${variable.name || index + 1}`}
438 >425 >
439 <XIcon className="size-3.5" />426 <XIcon className="size-3" />
440 </Button>427 </Button>
441 ) : null}428 </div>
429 ))}
430 <div className="flex min-h-[1.875rem] flex-wrap items-center gap-x-3 gap-y-1">
431 <Button type="button" size="xs" variant="ghost-muted" onClick={addVariable}>
432 <PlusIcon className="size-3" />
433 Add variable
434 </Button>
435 <span className="text-xs text-muted-foreground">
436 {rows.length === 0
437 ? "API keys, base URLs, or other per-instance CLI settings."
438 : "Sensitive values are stored separately and never returned to the app."}
439 </span>
442 </div>440 </div>
443 {props.field.description ? (441 </div>
444 <span className="mt-1 block text-xs text-muted-foreground">{props.field.description}</span>
445 ) : null}
446 </label>
447 );442 );
448}443}
449 444
@@ -458,7 +453,7 @@ interface ProviderInstanceCardProps { · 8 unmodified lines
458 readonly readOnly?: boolean | undefined;453 readonly readOnly?: boolean | undefined;
459 readonly onUpdate: (nextInstance: ProviderInstanceConfig) => void;454 readonly onUpdate: (nextInstance: ProviderInstanceConfig) => void;
460 /**455 /**
461 * Pass `undefined` to hide the delete button entirely. Built-in default456 * Pass `undefined` to hide the delete footer entirely. Built-in default
462 * instance slots use `undefined` — they can't be deleted without losing457 * instance slots use `undefined` — they can't be deleted without losing
463 * the slot, and their "reset to defaults" affordance lives on an outer458 * the slot, and their "reset to defaults" affordance lives on an outer
464 * reset button instead. Explicit `| undefined` in the type accommodates459 * reset button instead. Explicit `| undefined` in the type accommodates
@@ -578,12 +573,6 @@ export function ProviderInstanceCard({ · 113 unmodified lines
578 const visibleTab = driverOption === undefined ? "configuration" : activeTab;573 const visibleTab = driverOption === undefined ? "configuration" : activeTab;
579 574
580 const customModels = readConfigStringArray(instance.config, "customModels");575 const customModels = readConfigStringArray(instance.config, "customModels");
581 const environmentFields = driverOption?.environmentFields ?? [];
582 const environmentFieldNames = new Set(environmentFields.map((field) => field.name));
583 const genericEnvironment = providerEnvironmentWithoutNames(
584 instance.environment,
585 environmentFieldNames,
586 );
587 // Server-returned models may lag behind settings writes. Treat probe576 // Server-returned models may lag behind settings writes. Treat probe
588 // models as the source for built-ins only; custom rows come directly577 // models as the source for built-ins only; custom rows come directly
589 // from the current instance config so add/remove reflects immediately.578 // from the current instance config so add/remove reflects immediately.
@@ -591,6 +580,9 @@ export function ProviderInstanceCard({ · 1 unmodified line
591 liveModels: liveProvider?.models,580 liveModels: liveProvider?.models,
592 customModels,581 customModels,
593 });582 });
583 const hiddenModelCount = modelsForDisplay.filter(
584 (model) => !model.isCustom && hiddenModels.includes(model.slug),
585 ).length;
594 586
595 const updateDisplayName = (value: string) => {587 const updateDisplayName = (value: string) => {
596 const trimmed = value.trim();588 const trimmed = value.trim();
@@ -644,7 +636,14 @@ export function ProviderInstanceCard({ · 47 unmodified lines
644 : (rest as ProviderInstanceConfig),636 : (rest as ProviderInstanceConfig),
645 );637 );
646 };638 };
647 639 // Drivers that need a named secret (Cursor's API key) get a dedicated field;
640 // the generic editor only shows the remaining variables.
641 const environmentFields = driverOption?.environmentFields ?? [];
642 const environmentFieldNames = new Set(environmentFields.map((field) => field.name));
643 const genericEnvironment = providerEnvironmentWithoutNames(
644 instance.environment,
645 environmentFieldNames,
646 );
648 const updateGenericEnvironment = (647 const updateGenericEnvironment = (
649 environment: ReadonlyArray<ProviderInstanceEnvironmentVariable>,648 environment: ReadonlyArray<ProviderInstanceEnvironmentVariable>,
650 ) => {649 ) => {
@@ -653,11 +652,9 @@ export function ProviderInstanceCard({ · 2 unmodified lines
653 );652 );
654 updateEnvironment([...dedicatedEnvironment, ...environment]);653 updateEnvironment([...dedicatedEnvironment, ...environment]);
655 };654 };
656
657 const updateEnvironmentField = (field: ProviderEnvironmentFieldDefinition, value: string) => {655 const updateEnvironmentField = (field: ProviderEnvironmentFieldDefinition, value: string) => {
658 updateEnvironment(nextProviderEnvironmentWithFieldValue(instance.environment, field, value));656 updateEnvironment(nextProviderEnvironmentWithFieldValue(instance.environment, field, value));
659 };657 };
660
661 const removeEnvironmentField = (field: ProviderEnvironmentFieldDefinition) => {658 const removeEnvironmentField = (field: ProviderEnvironmentFieldDefinition) => {
662 updateEnvironment(providerEnvironmentWithoutNames(instance.environment, new Set([field.name])));659 updateEnvironment(providerEnvironmentWithoutNames(instance.environment, new Set([field.name])));
663 };660 };
@@ -704,35 +701,9 @@ export function ProviderInstanceCard({ · 40 unmodified lines
704 </>701 </>
705 );702 );
706 703
707 const titleTailNode = (704 const titleTailNode = headerAction ? (
708 <>705 <span className="inline-flex h-5 w-5 shrink-0 items-center justify-center">{headerAction}</span>
709 {headerAction ? (706 ) : null;
710 <span className="inline-flex h-5 w-5 shrink-0 items-center justify-center">
711 {headerAction}
712 </span>
713 ) : null}
714 {onDelete ? (
715 <span className="inline-flex h-5 w-5 shrink-0 items-center justify-center">
716 <Tooltip>
717 <TooltipTrigger
718 render={
719 <Button
720 size="icon-micro"
721 variant="ghost"
722 className="text-muted-foreground hover:text-destructive"
723 onClick={onDelete}
724 aria-label={`Delete provider instance ${instanceId}`}
725 >
726 <Trash2Icon className="size-3" />
727 </Button>
728 }
729 />
730 <TooltipPopup side="top">Delete instance</TooltipPopup>
731 </Tooltip>
732 </span>
733 ) : null}
734 </>
735 );
736 707
737 const versionCodeNode = versionLabel ? (708 const versionCodeNode = versionLabel ? (
738 <code className="text-xs text-muted-foreground">{versionLabel}</code>709 <code className="text-xs text-muted-foreground">{versionLabel}</code>
@@ -757,7 +728,7 @@ export function ProviderInstanceCard({ · 18 unmodified lines
757 className={cn(728 className={cn(
758 // Sidebar-style selection with a fixed row height so the list stays729 // Sidebar-style selection with a fixed row height so the list stays
759 // even; the status line clamps to two lines instead of growing.730 // even; the status line clamps to two lines instead of growing.
760 "group flex h-19 items-start gap-3 rounded-md px-3 py-2 transition-colors",731 "group flex min-h-19 items-start gap-3 rounded-md px-3 py-2 transition-colors",
761 // Foreground-alpha tint so the fill reads the same in light and dark themes.732 // Foreground-alpha tint so the fill reads the same in light and dark themes.
762 selected ? "bg-foreground/8" : "hover:bg-foreground/4",733 selected ? "bg-foreground/8" : "hover:bg-foreground/4",
763 )}734 )}
@@ -839,7 +810,7 @@ export function ProviderInstanceCard({ · 75 unmodified lines
839 size="icon-xs"810 size="icon-xs"
840 variant="ghost"811 variant="ghost"
841 className={cn(812 className={cn(
842 "size-5 rounded-sm p-0",813 "size-5 rounded-sm p-0 [--control-icon-color:currentColor]",
843 versionAdvisory.emphasis === "strong"814 versionAdvisory.emphasis === "strong"
844 ? "text-warning hover:text-warning"815 ? "text-warning hover:text-warning"
845 : "text-muted-foreground hover:text-foreground",816 : "text-muted-foreground hover:text-foreground",
@@ -947,6 +918,25 @@ export function ProviderInstanceCard({ · 101 unmodified lines
947 </p>918 </p>
948 ) : null}919 ) : null}
949 </div>920 </div>
921 {onDelete ? (
922 <span
923 inert={readOnly}
924 aria-disabled={readOnly || undefined}
925 className={cn("shrink-0", readOnly && "opacity-50")}
926 >
927 <Button
928 type="button"
929 size="xs"
930 variant="ghost"
931 className="[--control-icon-color:currentColor] text-muted-foreground hover:text-destructive"
932 onClick={onDelete}
933 aria-label={`Delete instance ${instanceId}`}
934 >
935 <Trash2Icon className="size-3" />
936 Delete instance
937 </Button>
938 </span>
939 ) : null}
950 </div>940 </div>
951 941
952 <div className="flex h-11 shrink-0 border-b border-border/70 px-1">942 <div className="flex h-11 shrink-0 border-b border-border/70 px-1">
@@ -962,10 +952,14 @@ export function ProviderInstanceCard({ · 9 unmodified lines
962 <button952 <button
963 type="button"953 type="button"
964 aria-pressed={visibleTab === "models"}954 aria-pressed={visibleTab === "models"}
965 className={providerSettingsTabClassName(visibleTab === "models")}955 className={cn(providerSettingsTabClassName(visibleTab === "models"), "gap-1.5")}
966 onClick={() => setActiveTab("models")}956 onClick={() => setActiveTab("models")}
967 >957 >
968 Models958 Models
959 <span className="font-normal text-muted-foreground/70">
960 {modelsForDisplay.length}
961 {hiddenModelCount > 0 ? ` · ${hiddenModelCount} hidden` : ""}
962 </span>
969 </button>963 </button>
970 ) : null}964 ) : null}
971 </div>965 </div>
@@ -980,76 +974,74 @@ export function ProviderInstanceCard({ · 8 unmodified lines
980 <div974 <div
981 inert={readOnly}975 inert={readOnly}
982 aria-disabled={readOnly || undefined}976 aria-disabled={readOnly || undefined}
983 className={cn("space-y-5 px-4 py-5", readOnly && "opacity-50 select-none")}977 className={cn("px-4 py-4", readOnly && "opacity-50 select-none")}
984 >978 >
985 <div>979 <div className={PROVIDER_FIELD_GRID_CLASS_NAME}>
986 <label htmlFor={`provider-instance-${instanceId}-display-name`} className="block">980 <label
987 <span className="text-xs font-medium text-foreground">Display name</span>981 htmlFor={`provider-instance-${instanceId}-display-name`}
982 className="pt-1.5 text-xs font-medium text-foreground"
983 >
984 Display name
985 </label>
986 <div className="flex min-w-0 flex-wrap items-center gap-3">
988 <DraftInput987 <DraftInput
989 id={`provider-instance-${instanceId}-display-name`}988 id={`provider-instance-${instanceId}-display-name`}
990 className="mt-1.5"989 className="w-44"
991 value={instance.displayName ?? ""}990 value={instance.displayName ?? ""}
992 onCommit={updateDisplayName}991 onCommit={updateDisplayName}
993 placeholder={driverOption?.label ?? "Instance label"}992 placeholder={driverOption?.label ?? "Instance label"}
994 spellCheck={false}993 spellCheck={false}
995 />994 />
996 <span className="mt-1 block text-xs text-muted-foreground">995 <ProviderAccentColorPicker
997 Optional label shown in the provider list.996 layout="inline"
998 </span>997 displayName={displayName}
999 </label>998 value={accentColor}
1000 </div>999 onCommit={updateAccentColor}
1001 1000 commitDelayMs={120}
1002 <div>1001 />
1003 <ProviderAccentColorPicker
1004 displayName={displayName}
1005 value={accentColor}
1006 onCommit={updateAccentColor}
1007 commitDelayMs={120}
1008 description="Used to distinguish this instance in picker rails and model lists."
1009 />
1010 </div>
1011
1012 <div>
1013 {environmentFields.length > 0 ? (
1014 <div className="mb-4 grid gap-3">
1015 {environmentFields.map((field) => (
1016 <ProviderEnvironmentFieldRow
1017 key={field.name}
1018 field={field}
1019 variable={readProviderEnvironmentVariable(instance.environment, field.name)}
1020 idPrefix={`provider-instance-${instanceId}`}
1021 onCommit={updateEnvironmentField}
1022 onRemove={removeEnvironmentField}
1023 />
1024 ))}
1025 </div>
1026 ) : null}
1027 <ProviderEnvironmentSection
1028 environment={genericEnvironment}
1029 onChange={updateGenericEnvironment}
1030 />
1031 </div>
1032
1033 {driverOption ? (
1034 <ProviderSettingsForm
1035 definition={driverOption}
1036 value={instance.config}
1037 idPrefix={`provider-instance-${instanceId}`}
1038 variant="card"
1039 onChange={updateConfig}
1040 />
1041 ) : null}
1042
1043 {driverOption === undefined ? (
1044 <div>
1045 <p className="text-xs text-muted-foreground">
1046 This instance uses a driver (
1047 <code className="text-foreground">{String(instance.driver)}</code>) that is not
1048 shipped with the current build. Configuration values are preserved but cannot be
1049 edited from this surface.
1050 </p>
1051 </div>1002 </div>
1052 ) : null}1003 <div className={PROVIDER_FIELD_GROUP_LABEL_CLASS_NAME}>Runtime</div>
1004 {driverOption ? (
1005 <ProviderSettingsForm
1006 definition={driverOption}
1007 value={instance.config}
1008 idPrefix={`provider-instance-${instanceId}`}
1009 variant="grid"
1010 onChange={updateConfig}
1011 />
1012 ) : (
1013 <>
1014 <span className="pt-1.5 text-xs font-medium text-foreground">Driver</span>
1015 <p className="text-xs text-muted-foreground">
1016 This instance uses a driver (
1017 <code className="text-foreground">{String(instance.driver)}</code>) that is not
1018 shipped with the current build. Configuration values are preserved but cannot be
1019 edited from this surface.
1020 </p>
1021 </>
1022 )}
1023 <div className={PROVIDER_FIELD_GROUP_LABEL_CLASS_NAME}>Environment</div>
1024 <div className="col-span-full">
1025 {environmentFields.length > 0 ? (
1026 <div className="mb-4 grid gap-3">
1027 {environmentFields.map((field) => (
1028 <ProviderEnvironmentFieldRow
1029 key={field.name}
1030 field={field}
1031 variable={readProviderEnvironmentVariable(instance.environment, field.name)}
1032 idPrefix={`provider-instance-${instanceId}`}
1033 onCommit={updateEnvironmentField}
1034 onRemove={removeEnvironmentField}
1035 />
1036 ))}
1037 </div>
1038 ) : null}
1039 <ProviderEnvironmentSection
1040 environment={genericEnvironment}
1041 onChange={updateGenericEnvironment}
1042 />
1043 </div>
1044 </div>
1053 </div>1045 </div>
1054 </ScrollArea>1046 </ScrollArea>
1055 {driverOption !== undefined ? (1047 {driverOption !== undefined ? (
apps/web/src/index.css+3−3
@@ -1821,8 +1821,8 @@ code { · 1820 unmodified lines
1821}1821}
1822 1822
1823.chat-markdown ul {1823.chat-markdown ul {
1824 /* Reset for nested uls under a widened ol — --list-gutter is an inherited1824 /* Reset for nested uls under a widened ol. --list-gutter is an inherited
1825 custom property, so without this a task-list under a 3+ digit ordered1825 custom property, so without this a task-list under a multi-digit ordered
1826 list would inherit the outer gutter instead of its own default. */1826 list would inherit the outer gutter instead of its own default. */
1827 --list-gutter: 1.25rem;1827 --list-gutter: 1.25rem;
1828 padding-left: 1.25rem;1828 padding-left: 1.25rem;
@@ -1831,7 +1831,7 @@ code { · 2 unmodified lines
1831 1831
1832/* --list-gutter defaults to the same 1.25rem as .chat-markdown ul, but1832/* --list-gutter defaults to the same 1.25rem as .chat-markdown ul, but
1833 ChatMarkdown's `ol` renderer widens it (via inline style) for lists whose1833 ChatMarkdown's `ol` renderer widens it (via inline style) for lists whose
1834 widest marker is 3+ characters, so item 100+ isn't clipped by list-style-position:1834 widest marker has multiple characters so it isn't clipped by list-style-position:
1835 outside painting the marker past the padding box. Reset it here too so a1835 outside painting the marker past the padding box. Reset it here too so a
1836 nested ol without its own widened marker doesn't inherit the outer one. */1836 nested ol without its own widened marker doesn't inherit the outer one. */
1837.chat-markdown ol {1837.chat-markdown ol {
packages/contracts/src/settings.ts+8−0
@@ -208,6 +208,9 @@ export const ClientSettingsSchema = Schema.Struct({ · 207 unmodified lines
208 confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),208 confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
209 confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),209 confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
210 confirmThreadUnpin: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),210 confirmThreadUnpin: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
211 continueThreadsAfterServerUpdate: Schema.Boolean.pipe(
212 Schema.withDecodingDefault(Effect.succeed(false)),
213 ),
211 dismissedProviderUpdateNotificationKeys: Schema.Array(TrimmedNonEmptyString).pipe(214 dismissedProviderUpdateNotificationKeys: Schema.Array(TrimmedNonEmptyString).pipe(
212 Schema.withDecodingDefault(Effect.succeed([])),215 Schema.withDecodingDefault(Effect.succeed([])),
213 ),216 ),
@@ -269,6 +272,9 @@ export const ClientSettingsSchema = Schema.Struct({ · 55 unmodified lines
269 // default UI; this beta flag restores it (plus the /plan and /default slash272 // default UI; this beta flag restores it (plus the /plan and /default slash
270 // commands) for users who still rely on the old workflow.273 // commands) for users who still rely on the old workflow.
271 planModeEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),274 planModeEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
275 // Legacy context window meter. The composer hides it by default; users who
276 // still want the old usage indicator can restore it from Settings.
277 contextWindowMeterEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
272 showSkillsInSlashMenu: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),278 showSkillsInSlashMenu: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
273 // Legacy sidebar (the original per-project tree). Deliberately a fresh key279 // Legacy sidebar (the original per-project tree). Deliberately a fresh key
274 // (was `sidebarV2Enabled` + `sidebarV2ConfiguredByUser`): decoding drops the280 // (was `sidebarV2Enabled` + `sidebarV2ConfiguredByUser`): decoding drops the
@@ -1009,6 +1015,7 @@ export const ClientSettingsPatch = Schema.Struct({ · 734 unmodified lines
1009 confirmThreadArchive: Schema.optionalKey(Schema.Boolean),1015 confirmThreadArchive: Schema.optionalKey(Schema.Boolean),
1010 confirmThreadDelete: Schema.optionalKey(Schema.Boolean),1016 confirmThreadDelete: Schema.optionalKey(Schema.Boolean),
1011 confirmThreadUnpin: Schema.optionalKey(Schema.Boolean),1017 confirmThreadUnpin: Schema.optionalKey(Schema.Boolean),
1018 continueThreadsAfterServerUpdate: Schema.optionalKey(Schema.Boolean),
1012 diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean),1019 diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean),
1013 environmentIdentificationMode: Schema.optionalKey(EnvironmentIdentificationMode),1020 environmentIdentificationMode: Schema.optionalKey(EnvironmentIdentificationMode),
1014 glassOpacity: Schema.optionalKey(GlassOpacity),1021 glassOpacity: Schema.optionalKey(GlassOpacity),
@@ -1044,6 +1051,7 @@ export const ClientSettingsPatch = Schema.Struct({ · 29 unmodified lines
1044 ),1051 ),
1045 ),1052 ),
1046 planModeEnabled: Schema.optionalKey(Schema.Boolean),1053 planModeEnabled: Schema.optionalKey(Schema.Boolean),
1054 contextWindowMeterEnabled: Schema.optionalKey(Schema.Boolean),
1047 showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean),1055 showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean),
1048 legacySidebarEnabled: Schema.optionalKey(Schema.Boolean),1056 legacySidebarEnabled: Schema.optionalKey(Schema.Boolean),
1049 sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode),1057 sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode),
packages/shared/src/model.test.ts+17−0
@@ -3,6 +3,7 @@ import { ProviderDriverKind, ProviderInstanceId, type ModelCapabilities } from " · 2 unmodified lines
3 3
4import {4import {
5 applyClaudePromptEffortPrefix,5 applyClaudePromptEffortPrefix,
6 buildExplicitProviderOptionSelectionsFromDescriptors,
6 buildProviderOptionSelectionsFromDescriptors,7 buildProviderOptionSelectionsFromDescriptors,
7 createModelCapabilities,8 createModelCapabilities,
8 createModelSelection,9 createModelSelection,
@@ -127,6 +128,22 @@ describe("descriptor helpers", () => { · 118 unmodified lines
127 ]);128 ]);
128 });129 });
129 130
131 it("builds dispatch options only from explicit selections", () => {
132 const descriptors = getProviderOptionDescriptors({
133 caps: codexCaps,
134 selections: [{ id: "fastMode", value: true }],
135 });
136
137 expect(buildExplicitProviderOptionSelectionsFromDescriptors(descriptors, undefined)).toBe(
138 undefined,
139 );
140 expect(
141 buildExplicitProviderOptionSelectionsFromDescriptors(descriptors, [
142 { id: "fastMode", value: true },
143 ]),
144 ).toEqual([{ id: "fastMode", value: true }]);
145 });
146
130 it("stores option selection arrays in model selections", () => {147 it("stores option selection arrays in model selections", () => {
131 expect(148 expect(
132 createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.4", [149 createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.4", [
pnpm-lock.yaml+4−4
@@ -472,7 +472,7 @@ importers: · 471 unmodified lines
472 dependencies:472 dependencies:
473 '@anthropic-ai/claude-agent-sdk':473 '@anthropic-ai/claude-agent-sdk':
474 specifier: ^0.3.205474 specifier: ^0.3.205
475 version: 0.3.252(@anthropic-ai/sdk@0.93.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3)475 version: 0.3.257(@anthropic-ai/sdk@0.93.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3)
476 '@connectrpc/connect':476 '@connectrpc/connect':
477 specifier: 1.7.0477 specifier: 1.7.0
478 version: 1.7.0(@bufbuild/protobuf@1.10.0)478 version: 1.7.0(@bufbuild/protobuf@1.10.0)
@@ -1012,8 +1012,8 @@ packages: · 533 unmodified lines
1012 '@alchemy.run/node-utils@0.0.5':1012 '@alchemy.run/node-utils@0.0.5':
1013 resolution: {integrity: sha512-5agdhQxWBodxa5hDRyjnpx91RTU3g+qd5fxYB7uNDCaOzB0XC47UU/KHR6zf6jhz/NXf61gJS+vhYwn8NHeRoQ==}1013 resolution: {integrity: sha512-5agdhQxWBodxa5hDRyjnpx91RTU3g+qd5fxYB7uNDCaOzB0XC47UU/KHR6zf6jhz/NXf61gJS+vhYwn8NHeRoQ==}
1014 1014
1015 '@anthropic-ai/claude-agent-sdk@0.3.252':1015 '@anthropic-ai/claude-agent-sdk@0.3.257':
1016 resolution: {integrity: sha512-hCkyZFn3J46aAMNqS6AZbYz91FaLUmX5VvJOzYZqzlVBJN47OxXQugqOzqa6b6GOZRmwiqW2ck8J8TE7bQZswQ==}1016 resolution: {integrity: sha512-Se55zXv48IYLg/WzoXzpbPLcq86suwDSbRUoNb69l4dkovorqS/47Xuy7MUo/gPNwwcPB4a+aqbXbshU33dcdQ==}
1017 engines: {node: '>=18.0.0'}1017 engines: {node: '>=18.0.0'}
1018 peerDependencies:1018 peerDependencies:
1019 '@anthropic-ai/sdk': '>=0.93.0'1019 '@anthropic-ai/sdk': '>=0.93.0'
@@ -10540,7 +10540,7 @@ snapshots: · 9520 unmodified lines
10540 10540
10541 '@alchemy.run/node-utils@0.0.5': {}10541 '@alchemy.run/node-utils@0.0.5': {}
10542 10542
10543 '@anthropic-ai/claude-agent-sdk@0.3.252(@anthropic-ai/sdk@0.93.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3)':10543 '@anthropic-ai/claude-agent-sdk@0.3.257(@anthropic-ai/sdk@0.93.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3)':
10544 dependencies:10544 dependencies:
10545 '@anthropic-ai/sdk': 0.93.0(zod@4.4.3)10545 '@anthropic-ai/sdk': 0.93.0(zod@4.4.3)
10546 '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3)10546 '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3)