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
153153 ? undefined
154154 : LinearTransition.duration(COMPOSER_TRANSITION_DURATION_MS).reduceMotion(ReduceMotion.System);
155155
156+const COMPOSER_ATTACHMENT_ENTERING =
157+ Platform.OS === "android"
158+ ? FadeIn.duration(160)
159+ : FadeIn.delay(COMPOSER_TRANSITION_DURATION_MS).duration(160).reduceMotion(ReduceMotion.System);
160+
156161 const AnimatedGlassSurface = Animated.createAnimatedComponent(GlassSurface);
157162
158163 export function ComposerSurface(props: {
@@ -622,10 +627,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 463 unmodified lines
622627 onPickFiles={props.onPickDraftFiles}
623628 />
624629 ) : null}
625− {isExpanded ? (
630+ {isExpanded && props.draftAttachments.length > 0 ? (
626631 <Animated.View
627− className={props.draftAttachments.length > 0 ? "px-[14px] pb-2.5" : undefined}
628− entering={FadeIn.duration(160)}
632+ className="px-[14px] pb-2.5"
633+ entering={COMPOSER_ATTACHMENT_ENTERING}
629634 exiting={FadeOut.duration(120)}
630635 >
631636 <ComposerAttachmentStrip
apps/server/src/cli/project.ts+0−2
@@ -31,7 +31,6 @@ import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; · 30 unmodified lines
3131 import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts";
3232 import * as ProjectService from "../project/ProjectService.ts";
3333 import * as T3ProjectFileLoader from "../project/T3ProjectFileLoader.ts";
34−import * as ServerRuntimeStartup from "../serverRuntimeStartup.ts";
3534 import {
3635 clearPersistedServerRuntimeState,
3736 readPersistedServerRuntimeState,
@@ -513,7 +512,6 @@ const projectAddCommand = Command.make("add", { · 475 unmodified lines
513512 projectId,
514513 title,
515514 workspaceRoot,
516− defaultModelSelection: ServerRuntimeStartup.getAutoBootstrapDefaultModelSelection(),
517515 });
518516 return `Added project ${projectId} (${title}) at ${workspaceRoot}.`;
519517 }),
apps/server/src/environment/ServerEnvironment.test.ts+3−1
@@ -245,7 +245,9 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { · 244 unmodified lines
245245 expect(withFd.capabilities.serverSelfUpdate).toBe("desktop-managed");
246246 expect(withFd.capabilities.desktopAppUpdate).toBe(true);
247247 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();
249251
250252 const withoutFd = yield* describeWith({ mode: "desktop" });
251253 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
226226 threadVisitedTracking: true,
227227 threadPullRequestLinking: true,
228228 ...(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.
229233 ...(serverSelfUpdate === "boot-service" || desktopAppUpdate
230− ? {
231− serverSelfUpdateProgress: true,
232− serverUpdateThreadContinuation: true,
233− }
234+ ? { serverSelfUpdateProgress: true }
234235 : {}),
235236 ...(desktopAppUpdate ? { desktopAppUpdate: true } : {}),
236237 },
apps/server/src/orchestration-v2/ThreadLaunchService.ts+24−8
@@ -240,20 +240,36 @@ export const make = Effect.gen(function* () { · 239 unmodified lines
240240 .pipe(Effect.mapError(mapError(input, "update-thread", threadId)));
241241 }
242242 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) {
244251 yield* git
245252 .fetchRemote({ cwd: project.workspaceRoot, remoteName: "origin" })
246253 .pipe(Effect.mapError(mapError(input, "provision-worktree", threadId)));
247− startRef = yield* git
248− .resolveRemoteTrackingCommit({
254+ const remoteBaseExists = yield* git
255+ .remoteBranchExists({
249256 cwd: project.workspaceRoot,
250257 refName: input.workspaceStrategy.baseRef,
251− fallbackRemoteName: "origin",
258+ remoteName: "origin",
252259 })
253− .pipe(
254− Effect.map((resolved) => resolved.commitSha),
255− Effect.mapError(mapError(input, "provision-worktree", threadId)),
256− );
260+ .pipe(Effect.mapError(mapError(input, "provision-worktree", threadId)));
261+ if (remoteBaseExists) {
262+ startRef = yield* git
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+ }
257273 }
258274 const worktree = yield* git
259275 .createWorktree({
apps/server/src/orchestration-v2/ThreadSettlementService.ts+12−6
@@ -192,12 +192,18 @@ export const make = Effect.gen(function* () { · 191 unmodified lines
192192 thread: (typeof candidates)[number],
193193 ) {
194194 if (thread.linkedPullRequest != null) {
195− const detail = yield* pullRequests.detail({
196− projectId: thread.linkedPullRequest.projectId,
197− repository: thread.linkedPullRequest.repository,
198− number: thread.linkedPullRequest.number,
199− });
200− return { state: detail.state, updatedAt: detail.updatedAt } satisfies SettlementPullRequest;
195+ const summary = yield* pullRequests.summary(
196+ {
197+ projectId: thread.linkedPullRequest.projectId,
198+ repository: thread.linkedPullRequest.repository,
199+ number: thread.linkedPullRequest.number,
200+ },
201+ { recoverTransientFailure: false },
202+ );
203+ return {
204+ state: summary.state,
205+ updatedAt: summary.updatedAt,
206+ } satisfies SettlementPullRequest;
201207 }
202208 if (thread.branch === null) return null;
203209 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
219219 projectId: command.projectId,
220220 title: command.title,
221221 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,
223226 faviconPath: null,
224227 scripts: command.scripts ?? [],
225228 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
5555 import Migration0041 from "./Migrations/041_AuthSessionClientConnection.ts";
5656 import Migration0042 from "./Migrations/042_ProjectionThreadLinkedPullRequest.ts";
5757 import Migration0043 from "./Migrations/043_ProjectionThreadsUnsettledAt.ts";
58−import Migration0044 from "./Migrations/044_OrchestrationV2.ts";
59−import Migration0045 from "./Migrations/045_OrchestrationV2Subagents.ts";
60−import Migration0046 from "./Migrations/046_OrchestrationV2Foundation.ts";
61−import Migration0047 from "./Migrations/047_OrchestrationV2ProviderSessionBindings.ts";
62−import Migration0048 from "./Migrations/048_OrchestrationV2ThreadLaunchWorkflows.ts";
63−import Migration0049 from "./Migrations/049_ApplicationEventSource.ts";
64−import Migration0050 from "./Migrations/050_OrchestrationV2EffectCancellation.ts";
65−import Migration0051 from "./Migrations/051_ScheduledTasks.ts";
66−import Migration0052 from "./Migrations/052_LegacyV1ImportState.ts";
58+import Migration0044 from "./Migrations/044_ClearAutomaticProjectModelDefaults.ts";
59+import Migration0045 from "./Migrations/045_OrchestrationV2.ts";
60+import Migration0046 from "./Migrations/046_OrchestrationV2Subagents.ts";
61+import Migration0047 from "./Migrations/047_OrchestrationV2Foundation.ts";
62+import Migration0048 from "./Migrations/048_OrchestrationV2ProviderSessionBindings.ts";
63+import Migration0049 from "./Migrations/049_OrchestrationV2ThreadLaunchWorkflows.ts";
64+import Migration0050 from "./Migrations/050_ApplicationEventSource.ts";
65+import Migration0051 from "./Migrations/051_OrchestrationV2EffectCancellation.ts";
66+import Migration0052 from "./Migrations/052_ScheduledTasks.ts";
67+import Migration0053 from "./Migrations/053_LegacyV1ImportState.ts";
6768
6869 /**
6970 * Migration loader with all migrations defined inline.
@@ -119,15 +120,16 @@ export const migrationEntries = [ · 49 unmodified lines
119120 [41, "AuthSessionClientConnection", Migration0041],
120121 [42, "ProjectionThreadLinkedPullRequest", Migration0042],
121122 [43, "ProjectionThreadsUnsettledAt", Migration0043],
122− [44, "OrchestrationV2", Migration0044],
123− [45, "OrchestrationV2Subagents", Migration0045],
124− [46, "OrchestrationV2Foundation", Migration0046],
125− [47, "OrchestrationV2ProviderSessionBindings", Migration0047],
126− [48, "OrchestrationV2ThreadLaunchWorkflows", Migration0048],
127− [49, "ApplicationEventSource", Migration0049],
128− [50, "OrchestrationV2EffectCancellation", Migration0050],
129− [51, "ScheduledTasks", Migration0051],
130− [52, "LegacyV1ImportState", Migration0052],
123+ [44, "ClearAutomaticProjectModelDefaults", Migration0044],
124+ [45, "OrchestrationV2", Migration0045],
125+ [46, "OrchestrationV2Subagents", Migration0046],
126+ [47, "OrchestrationV2Foundation", Migration0047],
127+ [48, "OrchestrationV2ProviderSessionBindings", Migration0048],
128+ [49, "OrchestrationV2ThreadLaunchWorkflows", Migration0049],
129+ [50, "ApplicationEventSource", Migration0050],
130+ [51, "OrchestrationV2EffectCancellation", Migration0051],
131+ [52, "ScheduledTasks", Migration0052],
132+ [53, "LegacyV1ImportState", Migration0053],
131133 ] as const;
132134
133135 export 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
88
99 const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));
1010
11−layer("038_039_OrchestrationV2", (it) => {
11+layer("045_046_OrchestrationV2", (it) => {
1212 it.effect("keeps released and private migration ids contiguous", () =>
1313 Effect.sync(() => {
1414 assert.deepStrictEqual(
1515 migrationEntries.map(([id]) => id),
16− Array.from({ length: 52 }, (_, index) => index + 1),
16+ Array.from({ length: 53 }, (_, index) => index + 1),
1717 );
1818 }),
1919 );
@@ -22,7 +22,7 @@ layer("038_039_OrchestrationV2", (it) => { · 2 unmodified lines
2222 Effect.gen(function* () {
2323 const sql = yield* SqlClient.SqlClient;
2424
25− yield* runMigrations({ toMigrationInclusive: 45 });
25+ yield* runMigrations({ toMigrationInclusive: 46 });
2626
2727 const migrations = yield* sql<{
2828 readonly migration_id: number;
@@ -30,24 +30,24 @@ layer("038_039_OrchestrationV2", (it) => { · 1 unmodified line
3030 }>`
3131 SELECT migration_id, name
3232 FROM effect_sql_migrations
33− WHERE migration_id IN (42, 43, 44, 45)
33+ WHERE migration_id IN (43, 44, 45, 46)
3434 ORDER BY migration_id
3535 `;
3636 assert.deepStrictEqual(migrations, [
37− {
38− migration_id: 42,
39− name: "ProjectionThreadLinkedPullRequest",
40− },
4137 {
4238 migration_id: 43,
4339 name: "ProjectionThreadsUnsettledAt",
4440 },
4541 {
4642 migration_id: 44,
47− name: "OrchestrationV2",
43+ name: "ClearAutomaticProjectModelDefaults",
4844 },
4945 {
5046 migration_id: 45,
47+ name: "OrchestrationV2",
48+ },
49+ {
50+ migration_id: 46,
5151 name: "OrchestrationV2Subagents",
5252 },
5353 ]);
@@ -64,10 +64,10 @@ layer("038_039_OrchestrationV2", (it) => { · 10 unmodified lines
6464 }),
6565 );
6666
67− it.effect("backfills provider-session thread bindings in migration 047", () =>
67+ it.effect("backfills provider-session thread bindings in migration 048", () =>
6868 Effect.gen(function* () {
6969 const sql = yield* SqlClient.SqlClient;
70− yield* runMigrations({ toMigrationInclusive: 46 });
70+ yield* runMigrations({ toMigrationInclusive: 47 });
7171 yield* sql`
7272 INSERT INTO orchestration_v2_projection_provider_sessions (
7373 provider_session_id,
@@ -92,7 +92,7 @@ layer("038_039_OrchestrationV2", (it) => { · 18 unmodified lines
9292 )
9393 `;
9494
95− yield* runMigrations({ toMigrationInclusive: 47 });
95+ yield* runMigrations({ toMigrationInclusive: 48 });
9696
9797 const bindings = yield* sql<{
9898 readonly provider_session_id: string;
@@ -110,10 +110,10 @@ layer("038_039_OrchestrationV2", (it) => { · 11 unmodified lines
110110 }),
111111 );
112112
113− it.effect("preserves turn items with colliding ordinals in migration 046", () =>
113+ it.effect("preserves turn items with colliding ordinals in migration 047", () =>
114114 Effect.gen(function* () {
115115 const sql = yield* SqlClient.SqlClient;
116− yield* runMigrations({ toMigrationInclusive: 45 });
116+ yield* runMigrations({ toMigrationInclusive: 46 });
117117 yield* sql`
118118 INSERT INTO orchestration_v2_projection_runs (
119119 run_id,
@@ -150,7 +150,7 @@ layer("038_039_OrchestrationV2", (it) => { · 30 unmodified lines
150150 ('turn-item:d', 'thread:two', NULL, 42, 'assistant_message', 'completed', '2026-01-01T00:00:00.000Z', '{}')
151151 `;
152152
153− yield* runMigrations({ toMigrationInclusive: 46 });
153+ yield* runMigrations({ toMigrationInclusive: 47 });
154154
155155 const positions = yield* sql<{
156156 readonly thread_id: string;
@@ -182,7 +182,7 @@ it.effect("upgrades a database already at released main migration 043", () => · 25 unmodified lines
182182 assert.ok(snoozeColumns.some((column) => column.name === "snoozed_until"));
183183 assert.ok(snoozeColumns.some((column) => column.name === "snoozed_at"));
184184
185− yield* runMigrations({ toMigrationInclusive: 52 });
185+ yield* runMigrations({ toMigrationInclusive: 53 });
186186
187187 const migrations = yield* sql<{
188188 readonly migration_id: number;
@@ -190,7 +190,7 @@ it.effect("upgrades a database already at released main migration 043", () => · 1 unmodified line
190190 }>`
191191 SELECT migration_id, name
192192 FROM effect_sql_migrations
193− WHERE migration_id BETWEEN 39 AND 52
193+ WHERE migration_id BETWEEN 39 AND 53
194194 ORDER BY migration_id
195195 `;
196196 assert.deepStrictEqual(
@@ -201,15 +201,16 @@ it.effect("upgrades a database already at released main migration 043", () => · 4 unmodified lines
201201 [41, "AuthSessionClientConnection"],
202202 [42, "ProjectionThreadLinkedPullRequest"],
203203 [43, "ProjectionThreadsUnsettledAt"],
204− [44, "OrchestrationV2"],
205− [45, "OrchestrationV2Subagents"],
206− [46, "OrchestrationV2Foundation"],
207− [47, "OrchestrationV2ProviderSessionBindings"],
208− [48, "OrchestrationV2ThreadLaunchWorkflows"],
209− [49, "ApplicationEventSource"],
210− [50, "OrchestrationV2EffectCancellation"],
211− [51, "ScheduledTasks"],
212− [52, "LegacyV1ImportState"],
204+ [44, "ClearAutomaticProjectModelDefaults"],
205+ [45, "OrchestrationV2"],
206+ [46, "OrchestrationV2Subagents"],
207+ [47, "OrchestrationV2Foundation"],
208+ [48, "OrchestrationV2ProviderSessionBindings"],
209+ [49, "OrchestrationV2ThreadLaunchWorkflows"],
210+ [50, "ApplicationEventSource"],
211+ [51, "OrchestrationV2EffectCancellation"],
212+ [52, "ScheduledTasks"],
213+ [53, "LegacyV1ImportState"],
213214 ],
214215 );
215216
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
88 it.effect("backfills the V2 event driver from payload before the legacy provider column", () =>
99 Effect.gen(function* () {
1010 const sql = yield* SqlClient.SqlClient;
11− yield* runMigrations({ toMigrationInclusive: 45 });
11+ yield* runMigrations({ toMigrationInclusive: 46 });
1212
1313 yield* sql`
1414 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
3737 )
3838 `;
3939
40− yield* runMigrations({ toMigrationInclusive: 46 });
40+ yield* runMigrations({ toMigrationInclusive: 47 });
4141
4242 const events = yield* sql<{
4343 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
88
99 const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));
1010
11−layer("043_ApplicationEventSource", (it) => {
11+layer("050_ApplicationEventSource", (it) => {
1212 it.effect("moves V2 events and current project state behind one global sequence", () =>
1313 Effect.gen(function* () {
1414 const sql = yield* SqlClient.SqlClient;
15− yield* runMigrations({ toMigrationInclusive: 48 });
15+ yield* runMigrations({ toMigrationInclusive: 49 });
1616
1717 yield* sql`
1818 INSERT INTO orchestration_v2_events (
@@ -84,7 +84,7 @@ layer("043_ApplicationEventSource", (it) => { · 65 unmodified lines
8484 )
8585 `;
8686
87− yield* runMigrations({ toMigrationInclusive: 49 });
87+ yield* runMigrations({ toMigrationInclusive: 50 });
8888
8989 const events = yield* sql<{
9090 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
88
99 const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));
1010
11−layer("050_OrchestrationV2EffectCancellation", (it) => {
11+layer("051_OrchestrationV2EffectCancellation", (it) => {
1212 it.effect("preserves existing effects and adds the cancelled terminal status", () =>
1313 Effect.gen(function* () {
1414 const sql = yield* SqlClient.SqlClient;
15− yield* runMigrations({ toMigrationInclusive: 49 });
15+ yield* runMigrations({ toMigrationInclusive: 50 });
1616 yield* sql`
1717 INSERT INTO orchestration_v2_effect_outbox (
1818 effect_id,
@@ -47,7 +47,7 @@ layer("050_OrchestrationV2EffectCancellation", (it) => { · 28 unmodified lines
4747 )
4848 `;
4949
50− yield* runMigrations({ toMigrationInclusive: 50 });
50+ yield* runMigrations({ toMigrationInclusive: 51 });
5151 yield* sql`
5252 UPDATE orchestration_v2_effect_outbox
5353 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
6161 type ProviderSnapshotSettings,
6262 } from "../providerUpdateSettings.ts";
6363 import { makeClaudeCapabilitiesCacheKey, makeClaudeContinuationGroupKey } from "./ClaudeHome.ts";
64+import { discoverClaudeSkills } from "./ClaudeSkills.ts";
6465 const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings);
6566
6667 const DRIVER_KIND = ProviderDriverKind.make("claudeAgent");
@@ -246,6 +247,17 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = { · 179 unmodified lines
246247 accentColor,
247248 enabled,
248249 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+ ),
249261 orchestrationAdapter,
250262 textGeneration,
251263 } satisfies ProviderInstance;
apps/server/src/serverRuntimeStartup.test.ts+1−1
@@ -10,7 +10,7 @@ import * as ServerConfig from "./config.ts"; · 9 unmodified lines
1010 import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts";
1111
1212 it("uses the canonical Codex model for auto-bootstrap", () => {
13− assert.deepEqual(ServerRuntimeStartup.getAutoBootstrapDefaultModelSelection(), {
13+ assert.deepEqual(ServerRuntimeStartup.getAutoBootstrapThreadModelSelection(), {
1414 instanceId: ProviderInstanceId.make("codex"),
1515 model: DEFAULT_MODEL,
1616 });
apps/server/src/serverRuntimeStartup.ts+5−4
@@ -176,7 +176,7 @@ export const launchStartupHeartbeat = recordStartupHeartbeat.pipe( · 175 unmodified lines
176176 Effect.asVoid,
177177 );
178178
179−export const getAutoBootstrapDefaultModelSelection = (): ModelSelection => ({
179+export const getAutoBootstrapThreadModelSelection = (): ModelSelection => ({
180180 instanceId: ProviderInstanceId.make("codex"),
181181 model: DEFAULT_MODEL,
182182 });
@@ -210,13 +210,14 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () { · 27 unmodified lines
210210 let bootstrapThreadId: ThreadId | undefined;
211211
212212 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();
214216 const { project } = yield* projects.bootstrap({
215217 commandId: CommandId.make(yield* randomUUID),
216218 projectId: ProjectId.make(yield* randomUUID),
217219 title: path.basename(serverConfig.cwd) || "project",
218220 workspaceRoot: serverConfig.cwd,
219− defaultModelSelection,
220221 });
221222 const shell = yield* threads.getShellSnapshot();
222223 const existingThread = shell.threads.find(
@@ -228,7 +229,7 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () { · 5 unmodified lines
228229 commandId: CommandId.make(yield* randomUUID),
229230 projectId: project.id,
230231 title: "New thread",
231− modelSelection: project.defaultModelSelection ?? defaultModelSelection,
232+ modelSelection: project.defaultModelSelection ?? threadModelSelection,
232233 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
233234 runtimeMode: "full-access",
234235 workspaceStrategy: { type: "root" },
apps/server/src/ws.ts+4−2
@@ -176,7 +176,6 @@ import * as SourceControlProviderRegistry from "./sourceControl/SourceControlPro · 175 unmodified lines
176176 import * as GitVcsDriver from "./vcs/GitVcsDriver.ts";
177177 import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts";
178178 import * as VcsProjectConfig from "./vcs/VcsProjectConfig.ts";
179−import * as VcsProcess from "./vcs/VcsProcess.ts";
180179 import * as PairingGrantStore from "./auth/PairingGrantStore.ts";
181180 import * as SessionStore from "./auth/SessionStore.ts";
182181 import { failEnvironmentAuthInvalid, failEnvironmentInternal } from "./auth/http.ts";
@@ -1840,6 +1839,10 @@ const makeWsRpcLayer = ( · 1657 unmodified lines
18401839 observeRpcEffect(WS_METHODS.pullRequestsListStats, pullRequests.listStats(input), {
18411840 "rpc.aggregate": "pull-requests",
18421841 }),
1842+ [WS_METHODS.pullRequestsSummary]: (input) =>
1843+ observeRpcEffect(WS_METHODS.pullRequestsSummary, pullRequests.summary(input), {
1844+ "rpc.aggregate": "pull-requests",
1845+ }),
18431846 [WS_METHODS.pullRequestsDetail]: (input) =>
18441847 observeRpcEffect(WS_METHODS.pullRequestsDetail, pullRequests.detail(input), {
18451848 "rpc.aggregate": "pull-requests",
@@ -2612,7 +2615,6 @@ export const websocketRpcRouteLayer = Layer.unwrap( · 766 unmodified lines
26122615 ),
26132616 ),
26142617 ),
2615− Layer.provide(VcsProcess.layer),
26162618 ),
26172619 ),
26182620 ),
apps/web/src/components/ChatView.tsx+4−0
@@ -406,6 +406,7 @@ import { · 405 unmodified lines
406406 resolveServerSelfUpdateCapability,
407407 serverUpdateGuidance,
408408 supportsDesktopAppUpdate,
409+ supportsServerUpdateThreadContinuation,
409410 } from "../versionSkew";
410411 import { useAssetUrls } from "../assets/assetUrls";
411412 import type { CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates";
@@ -2394,6 +2395,7 @@ function ChatViewContent(props: ChatViewProps) { · 1982 unmodified lines
23942395 const serverUpdateEnvironmentId = activeThread?.environmentId ?? null;
23952396 const versionMismatchSelfUpdate = resolveServerSelfUpdateCapability(serverConfig);
23962397 const versionMismatchDesktopAppUpdate = supportsDesktopAppUpdate(serverConfig);
2398+ const versionMismatchThreadContinuation = supportsServerUpdateThreadContinuation(serverConfig);
23972399 const serverUpdateState = useAtomValue(
23982400 serverEnvironment.updateStateAtom(serverUpdateEnvironmentId),
23992401 );
@@ -2527,6 +2529,7 @@ function ChatViewContent(props: ChatViewProps) { · 127 unmodified lines
25272529 serverLabel={versionMismatchServerLabel}
25282530 selfUpdate={versionMismatchSelfUpdate}
25292531 desktopAppUpdate={versionMismatchDesktopAppUpdate}
2532+ threadContinuation={versionMismatchThreadContinuation}
25302533 targetVersion={versionMismatch.clientVersion}
25312534 label={updateFailed ? "Retry" : "Update"}
25322535 variant="ghost"
@@ -2561,6 +2564,7 @@ function ChatViewContent(props: ChatViewProps) { · 28 unmodified lines
25612564 serverUpdateEnvironmentId,
25622565 versionMismatchSelfUpdate,
25632566 versionMismatchDesktopAppUpdate,
2567+ versionMismatchThreadContinuation,
25642568 versionMismatchServerLabel,
25652569 ]);
25662570 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
44294429 ) : null}
44304430 <ComposerFooterPrimaryActions
44314431 compact={isComposerPrimaryActionsCompact}
4432− activeContextWindow={activeContextWindow}
4432+ activeContextWindow={
4433+ settings.contextWindowMeterEnabled ? activeContextWindow : null
4434+ }
44334435 activeThreadModelDisplayName={activeThreadModelDisplayName}
44344436 pendingAction={pendingPrimaryAction}
44354437 isRunning={phase === "running"}
apps/web/src/components/settings/ProviderInstanceCard.tsx+280−288
@@ -5,6 +5,8 @@ import { · 4 unmodified lines
55 CopyIcon,
66 DownloadIcon,
77 LoaderIcon,
8+ LockIcon,
9+ LockOpenIcon,
810 PlusIcon,
911 Trash2Icon,
1012 XIcon,
@@ -33,12 +35,10 @@ import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; · 22 unmodified lines
3335 import { normalizeProviderAccentColor } from "../../providerInstances";
3436 import { Badge } from "../ui/badge";
3537 import { Button } from "../ui/button";
36−import { Checkbox } from "../ui/checkbox";
3738 import { DraftInput } from "../ui/draft-input";
3839 import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
3940 import { ScrollArea } from "../ui/scroll-area";
4041 import { Switch } from "../ui/switch";
41−import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../ui/table";
4242 import { stackedThreadToast, toastManager } from "../ui/toast";
4343 import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
4444 import type { DriverOption, ProviderEnvironmentFieldDefinition } from "./providerDriverMeta";
@@ -58,6 +58,13 @@ import { · 13 unmodified lines
5858
5959 const ENVIRONMENT_VARIABLE_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
6060
61+/** Label-left field grid for the Configuration tab: one row per field. */
62+const 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. */
65+const 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+
6168 let environmentVariableDraftId = 0;
6269 const nextEnvironmentVariableDraftId = () => `provider-env-${environmentVariableDraftId++}`;
6370
@@ -130,6 +137,43 @@ function nextConfigBlobWithValue( · 66 unmodified lines
130137 return base;
131138 }
132139
140+export 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+
162+function 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+
133177 export function readProviderEnvironmentVariable(
134178 environment: ReadonlyArray<ProviderInstanceEnvironmentVariable> | undefined,
135179 name: string,
@@ -179,38 +223,50 @@ export function nextProviderEnvironmentWithFieldValue( · 43 unmodified lines
179223 return next;
180224 }
181225
182−export function deriveProviderModelsForDisplay(input: {
183− readonly liveModels: ReadonlyArray<ServerProviderModel> | undefined;
184− readonly customModels: ReadonlyArray<CustomModelDefinition>;
185−}): ReadonlyArray<ServerProviderModel> {
186− const liveCustomModelsBySlug = new Map(
187− Arr.filterMap(input.liveModels ?? [], (model) =>
188− model.isCustom ? Result.succeed([model.slug, model] as const) : Result.failVoid,
189− ),
190− );
191− const serverModels = input.liveModels?.filter((model) => !model.isCustom) ?? [];
192− const customModels = input.customModels.map((entry) => ({
193− slug: entry.slug,
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−
202−function ProviderAuthEmail(props: { readonly email: string | undefined }) {
203− const email = props.email?.trim();
204− if (!email) return null;
226+function ProviderEnvironmentFieldRow(props: {
227+ readonly field: ProviderEnvironmentFieldDefinition;
228+ readonly variable: ProviderInstanceEnvironmentVariable | undefined;
229+ readonly idPrefix: string;
230+ readonly onCommit: (field: ProviderEnvironmentFieldDefinition, value: string) => void;
231+ readonly onRemove: (field: ProviderEnvironmentFieldDefinition) => void;
232+}) {
233+ const inputId = `${props.idPrefix}-environment-${props.field.name}`;
234+ const value = props.variable?.valueRedacted ? "" : (props.variable?.value ?? "");
235+ const placeholder = props.variable?.valueRedacted
236+ ? "Stored secret - enter a new value to replace"
237+ : props.field.placeholder;
205238
206239 return (
207− <RedactedSensitiveText
208− value={email}
209− ariaLabel="Toggle account email visibility"
210− revealTooltip="Click to reveal email"
211− hideTooltip="Click to hide email"
212− className="max-w-full truncate"
213− />
240+ <label htmlFor={inputId} className="block">
241+ <span className="text-xs font-medium text-foreground">{props.field.label}</span>
242+ <div className="mt-1.5 flex min-w-0 items-center gap-2">
243+ <DraftInput
244+ id={inputId}
245+ className="min-w-0 flex-1"
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>
214270 );
215271 }
216272
@@ -284,166 +340,105 @@ function ProviderEnvironmentSection(props: { · 67 unmodified lines
284340 publishRows(nextRows);
285341 };
286342
287− return (
288− <div className="grid gap-2">
289− <div className="flex items-center justify-between gap-3">
290− <span className="text-xs font-medium text-foreground">Environment variables</span>
291− <Button
292− type="button"
293− size="sm"
294− variant="outline"
295− className="h-7 gap-1.5 px-2 text-xs"
296− onClick={() =>
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−
403−function 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;
343+ const addVariable = () =>
344+ setRows([
345+ ...rows,
346+ {
347+ id: nextEnvironmentVariableDraftId(),
348+ name: "",
349+ value: "",
350+ sensitive: true,
351+ },
352+ ]);
415353
416354 return (
417− <label htmlFor={inputId} className="block">
418− <span className="text-xs font-medium text-foreground">{props.field.label}</span>
419− <div className="mt-1.5 flex min-w-0 items-center gap-2">
420− <DraftInput
421− id={inputId}
422− className="min-w-0 flex-1"
423− type={props.field.sensitive === false ? undefined : "password"}
424− autoComplete="off"
425− value={value}
426− onCommit={(next) => props.onCommit(props.field, next)}
427− placeholder={placeholder}
428− spellCheck={false}
429− />
430− {props.variable ? (
355+ <div className="min-w-0 space-y-1.5">
356+ {rows.map((variable, index) => (
357+ <div key={variable.id} className="flex min-w-0 items-center gap-1.5">
358+ <DraftInput
359+ size="compact"
360+ className="w-44 shrink-0 font-mono"
361+ value={variable.name}
362+ onCommit={(name) => updateVariable(variable.id, { name: name.trim() })}
363+ placeholder="VARIABLE_NAME"
364+ spellCheck={false}
365+ aria-label={`Environment variable name ${index + 1}`}
366+ />
367+ <span className="text-xs text-muted-foreground" aria-hidden>
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>
431418 <Button
432419 type="button"
433− size="icon-sm"
434− variant="ghost"
435− className="size-8 shrink-0 text-muted-foreground hover:text-destructive"
436− onClick={() => props.onRemove(props.field)}
437− aria-label={`Clear ${props.field.label}`}
420+ size="icon-micro"
421+ variant="ghost-muted"
422+ className="[--control-icon-color:currentColor] hover:text-destructive"
423+ onClick={() => removeVariable(variable.id)}
424+ aria-label={`Remove environment variable ${variable.name || index + 1}`}
438425 >
439− <XIcon className="size-3.5" />
426+ <XIcon className="size-3" />
440427 </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>
442440 </div>
443− {props.field.description ? (
444− <span className="mt-1 block text-xs text-muted-foreground">{props.field.description}</span>
445− ) : null}
446− </label>
441+ </div>
447442 );
448443 }
449444
@@ -458,7 +453,7 @@ interface ProviderInstanceCardProps { · 8 unmodified lines
458453 readonly readOnly?: boolean | undefined;
459454 readonly onUpdate: (nextInstance: ProviderInstanceConfig) => void;
460455 /**
461− * Pass `undefined` to hide the delete button entirely. Built-in default
456+ * Pass `undefined` to hide the delete footer entirely. Built-in default
462457 * instance slots use `undefined` — they can't be deleted without losing
463458 * the slot, and their "reset to defaults" affordance lives on an outer
464459 * reset button instead. Explicit `| undefined` in the type accommodates
@@ -578,12 +573,6 @@ export function ProviderInstanceCard({ · 113 unmodified lines
578573 const visibleTab = driverOption === undefined ? "configuration" : activeTab;
579574
580575 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− );
587576 // Server-returned models may lag behind settings writes. Treat probe
588577 // models as the source for built-ins only; custom rows come directly
589578 // from the current instance config so add/remove reflects immediately.
@@ -591,6 +580,9 @@ export function ProviderInstanceCard({ · 1 unmodified line
591580 liveModels: liveProvider?.models,
592581 customModels,
593582 });
583+ const hiddenModelCount = modelsForDisplay.filter(
584+ (model) => !model.isCustom && hiddenModels.includes(model.slug),
585+ ).length;
594586
595587 const updateDisplayName = (value: string) => {
596588 const trimmed = value.trim();
@@ -644,7 +636,14 @@ export function ProviderInstanceCard({ · 47 unmodified lines
644636 : (rest as ProviderInstanceConfig),
645637 );
646638 };
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+ );
648647 const updateGenericEnvironment = (
649648 environment: ReadonlyArray<ProviderInstanceEnvironmentVariable>,
650649 ) => {
@@ -653,11 +652,9 @@ export function ProviderInstanceCard({ · 2 unmodified lines
653652 );
654653 updateEnvironment([...dedicatedEnvironment, ...environment]);
655654 };
656−
657655 const updateEnvironmentField = (field: ProviderEnvironmentFieldDefinition, value: string) => {
658656 updateEnvironment(nextProviderEnvironmentWithFieldValue(instance.environment, field, value));
659657 };
660−
661658 const removeEnvironmentField = (field: ProviderEnvironmentFieldDefinition) => {
662659 updateEnvironment(providerEnvironmentWithoutNames(instance.environment, new Set([field.name])));
663660 };
@@ -704,35 +701,9 @@ export function ProviderInstanceCard({ · 40 unmodified lines
704701 </>
705702 );
706703
707− const titleTailNode = (
708− <>
709− {headerAction ? (
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− );
704+ const titleTailNode = headerAction ? (
705+ <span className="inline-flex h-5 w-5 shrink-0 items-center justify-center">{headerAction}</span>
706+ ) : null;
736707
737708 const versionCodeNode = versionLabel ? (
738709 <code className="text-xs text-muted-foreground">{versionLabel}</code>
@@ -757,7 +728,7 @@ export function ProviderInstanceCard({ · 18 unmodified lines
757728 className={cn(
758729 // Sidebar-style selection with a fixed row height so the list stays
759730 // 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",
761732 // Foreground-alpha tint so the fill reads the same in light and dark themes.
762733 selected ? "bg-foreground/8" : "hover:bg-foreground/4",
763734 )}
@@ -839,7 +810,7 @@ export function ProviderInstanceCard({ · 75 unmodified lines
839810 size="icon-xs"
840811 variant="ghost"
841812 className={cn(
842− "size-5 rounded-sm p-0",
813+ "size-5 rounded-sm p-0 [--control-icon-color:currentColor]",
843814 versionAdvisory.emphasis === "strong"
844815 ? "text-warning hover:text-warning"
845816 : "text-muted-foreground hover:text-foreground",
@@ -947,6 +918,25 @@ export function ProviderInstanceCard({ · 101 unmodified lines
947918 </p>
948919 ) : null}
949920 </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}
950940 </div>
951941
952942 <div className="flex h-11 shrink-0 border-b border-border/70 px-1">
@@ -962,10 +952,14 @@ export function ProviderInstanceCard({ · 9 unmodified lines
962952 <button
963953 type="button"
964954 aria-pressed={visibleTab === "models"}
965− className={providerSettingsTabClassName(visibleTab === "models")}
955+ className={cn(providerSettingsTabClassName(visibleTab === "models"), "gap-1.5")}
966956 onClick={() => setActiveTab("models")}
967957 >
968958 Models
959+ <span className="font-normal text-muted-foreground/70">
960+ {modelsForDisplay.length}
961+ {hiddenModelCount > 0 ? ` · ${hiddenModelCount} hidden` : ""}
962+ </span>
969963 </button>
970964 ) : null}
971965 </div>
@@ -980,76 +974,74 @@ export function ProviderInstanceCard({ · 8 unmodified lines
980974 <div
981975 inert={readOnly}
982976 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")}
984978 >
985− <div>
986− <label htmlFor={`provider-instance-${instanceId}-display-name`} className="block">
987− <span className="text-xs font-medium text-foreground">Display name</span>
979+ <div className={PROVIDER_FIELD_GRID_CLASS_NAME}>
980+ <label
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">
988987 <DraftInput
989988 id={`provider-instance-${instanceId}-display-name`}
990− className="mt-1.5"
989+ className="w-44"
991990 value={instance.displayName ?? ""}
992991 onCommit={updateDisplayName}
993992 placeholder={driverOption?.label ?? "Instance label"}
994993 spellCheck={false}
995994 />
996− <span className="mt-1 block text-xs text-muted-foreground">
997− Optional label shown in the provider list.
998− </span>
999− </label>
1000− </div>
1001−
1002− <div>
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>
995+ <ProviderAccentColorPicker
996+ layout="inline"
997+ displayName={displayName}
998+ value={accentColor}
999+ onCommit={updateAccentColor}
1000+ commitDelayMs={120}
1001+ />
10511002 </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>
10531045 </div>
10541046 </ScrollArea>
10551047 {driverOption !== undefined ? (
apps/web/src/index.css+3−3
@@ -1821,8 +1821,8 @@ code { · 1820 unmodified lines
18211821 }
18221822
18231823 .chat-markdown ul {
1824− /* 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 ordered
1824+ /* Reset for nested uls under a widened ol. --list-gutter is an inherited
1825+ custom property, so without this a task-list under a multi-digit ordered
18261826 list would inherit the outer gutter instead of its own default. */
18271827 --list-gutter: 1.25rem;
18281828 padding-left: 1.25rem;
@@ -1831,7 +1831,7 @@ code { · 2 unmodified lines
18311831
18321832 /* --list-gutter defaults to the same 1.25rem as .chat-markdown ul, but
18331833 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:
18351835 outside painting the marker past the padding box. Reset it here too so a
18361836 nested ol without its own widened marker doesn't inherit the outer one. */
18371837 .chat-markdown ol {
packages/contracts/src/settings.ts+8−0
@@ -208,6 +208,9 @@ export const ClientSettingsSchema = Schema.Struct({ · 207 unmodified lines
208208 confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
209209 confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
210210 confirmThreadUnpin: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
211+ continueThreadsAfterServerUpdate: Schema.Boolean.pipe(
212+ Schema.withDecodingDefault(Effect.succeed(false)),
213+ ),
211214 dismissedProviderUpdateNotificationKeys: Schema.Array(TrimmedNonEmptyString).pipe(
212215 Schema.withDecodingDefault(Effect.succeed([])),
213216 ),
@@ -269,6 +272,9 @@ export const ClientSettingsSchema = Schema.Struct({ · 55 unmodified lines
269272 // default UI; this beta flag restores it (plus the /plan and /default slash
270273 // commands) for users who still rely on the old workflow.
271274 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))),
272278 showSkillsInSlashMenu: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
273279 // Legacy sidebar (the original per-project tree). Deliberately a fresh key
274280 // (was `sidebarV2Enabled` + `sidebarV2ConfiguredByUser`): decoding drops the
@@ -1009,6 +1015,7 @@ export const ClientSettingsPatch = Schema.Struct({ · 734 unmodified lines
10091015 confirmThreadArchive: Schema.optionalKey(Schema.Boolean),
10101016 confirmThreadDelete: Schema.optionalKey(Schema.Boolean),
10111017 confirmThreadUnpin: Schema.optionalKey(Schema.Boolean),
1018+ continueThreadsAfterServerUpdate: Schema.optionalKey(Schema.Boolean),
10121019 diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean),
10131020 environmentIdentificationMode: Schema.optionalKey(EnvironmentIdentificationMode),
10141021 glassOpacity: Schema.optionalKey(GlassOpacity),
@@ -1044,6 +1051,7 @@ export const ClientSettingsPatch = Schema.Struct({ · 29 unmodified lines
10441051 ),
10451052 ),
10461053 planModeEnabled: Schema.optionalKey(Schema.Boolean),
1054+ contextWindowMeterEnabled: Schema.optionalKey(Schema.Boolean),
10471055 showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean),
10481056 legacySidebarEnabled: Schema.optionalKey(Schema.Boolean),
10491057 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
33
44 import {
55 applyClaudePromptEffortPrefix,
6+ buildExplicitProviderOptionSelectionsFromDescriptors,
67 buildProviderOptionSelectionsFromDescriptors,
78 createModelCapabilities,
89 createModelSelection,
@@ -127,6 +128,22 @@ describe("descriptor helpers", () => { · 118 unmodified lines
127128 ]);
128129 });
129130
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+
130147 it("stores option selection arrays in model selections", () => {
131148 expect(
132149 createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.4", [
pnpm-lock.yaml+4−4
@@ -472,7 +472,7 @@ importers: · 471 unmodified lines
472472 dependencies:
473473 '@anthropic-ai/claude-agent-sdk':
474474 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)
476476 '@connectrpc/connect':
477477 specifier: 1.7.0
478478 version: 1.7.0(@bufbuild/protobuf@1.10.0)
@@ -1012,8 +1012,8 @@ packages: · 533 unmodified lines
10121012 '@alchemy.run/node-utils@0.0.5':
10131013 resolution: {integrity: sha512-5agdhQxWBodxa5hDRyjnpx91RTU3g+qd5fxYB7uNDCaOzB0XC47UU/KHR6zf6jhz/NXf61gJS+vhYwn8NHeRoQ==}
10141014
1015− '@anthropic-ai/claude-agent-sdk@0.3.252':
1016− resolution: {integrity: sha512-hCkyZFn3J46aAMNqS6AZbYz91FaLUmX5VvJOzYZqzlVBJN47OxXQugqOzqa6b6GOZRmwiqW2ck8J8TE7bQZswQ==}
1015+ '@anthropic-ai/claude-agent-sdk@0.3.257':
1016+ resolution: {integrity: sha512-Se55zXv48IYLg/WzoXzpbPLcq86suwDSbRUoNb69l4dkovorqS/47Xuy7MUo/gPNwwcPB4a+aqbXbshU33dcdQ==}
10171017 engines: {node: '>=18.0.0'}
10181018 peerDependencies:
10191019 '@anthropic-ai/sdk': '>=0.93.0'
@@ -10540,7 +10540,7 @@ snapshots: · 9520 unmodified lines
1054010540
1054110541 '@alchemy.run/node-utils@0.0.5': {}
1054210542
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)':
1054410544 dependencies:
1054510545 '@anthropic-ai/sdk': 0.93.0(zod@4.4.3)
1054610546 '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3)