authorgravatar for anirudh@gosupernova.liveAnirudh Coontoor <anirudh@gosupernova.live> 2026-08-29 01:52:13+05:30
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-29 01:52:13+05:30
log84b9d9bc2d8a13a8b13d59b81f1012f5cb57ebd2
treec6e50c3f9a8f22537d02cbc4401e9743c0872df0
parentacb599d2dc5b28d5bad82fa2a280ac2abe771ec9
signature Signed by PGP key B5690EEEBB952194

fix(clients): honor project default models in new threads (#6011)

Co-authored-by: Shivam Sharma <91240327+shivamhwp@users.noreply.github.com>

12 files changed, 917 insertions(+), 160 deletions(-)

apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+10-5
...@@ -712,10 +712,12 @@ export function NewTaskDraftScreen(props: {...@@ -712,10 +712,12 @@ export function NewTaskDraftScreen(props: {
712 if (editingPendingTask) {712 if (editingPendingTask) {
713 flow.finishEditingPendingTask();713 flow.finishEditingPendingTask();
714 } else {714 } else {
715 // Drop the workspace selection with the content: the next task should715 // Drop draft-local model/workspace selections with the content. The
716 // re-resolve mode/branch/origin from the server's configured defaults716 // next task re-resolves project defaults before sticky app defaults.
717 // instead of resurrecting this task's picks.717 clearComposerDraftContent(draftKey, {
718 clearComposerDraftContent(draftKey, { clearWorkspaceSelection: true });718 clearModelSelection: true,
719 clearWorkspaceSelection: true,
720 });
719 }721 }
720 navigation.getParent()?.goBack();722 navigation.getParent()?.goBack();
721 return;723 return;
...@@ -779,7 +781,10 @@ export function NewTaskDraftScreen(props: {...@@ -779,7 +781,10 @@ export function NewTaskDraftScreen(props: {
779 }781 }
780 flow.finishEditingPendingTask();782 flow.finishEditingPendingTask();
781 } else {783 } else {
782 clearComposerDraftContent(draftKey, { clearWorkspaceSelection: true });784 clearComposerDraftContent(draftKey, {
785 clearModelSelection: true,
786 clearWorkspaceSelection: true,
787 });
783 }788 }
784 navigation.dispatch(789 navigation.dispatch(
785 StackActions.replace("Thread", {790 StackActions.replace("Thread", {
apps/mobile/src/features/threads/new-task-flow-provider.tsx+27-11
...@@ -33,6 +33,7 @@ import {...@@ -33,6 +33,7 @@ import {
33 buildModelOptions,33 buildModelOptions,
34 groupByProvider,34 groupByProvider,
35 resolveDefaultableModelSelection,35 resolveDefaultableModelSelection,
36 resolveNewTaskModelSelection,
36 resolveSelectableModelSelection,37 resolveSelectableModelSelection,
37} from "../../lib/modelOptions";38} from "../../lib/modelOptions";
38import { scopedProjectKey } from "../../lib/scopedEntities";39import { scopedProjectKey } from "../../lib/scopedEntities";
...@@ -48,8 +49,10 @@ import {...@@ -48,8 +49,10 @@ import {
48 removeComposerDraftAttachment,49 removeComposerDraftAttachment,
49 replaceComposerDraftAttachments,50 replaceComposerDraftAttachments,
50 setComposerDraftText,51 setComposerDraftText,
52 setStickyComposerModelSelection,
51 updateComposerDraftSettings,53 updateComposerDraftSettings,
52 useComposerDraft,54 useComposerDraft,
55 useStickyComposerModelSelection,
53} from "../../state/use-composer-drafts";56} from "../../state/use-composer-drafts";
54import { useDebouncedValue, usePaginatedBranches } from "../../state/queries";57import { useDebouncedValue, usePaginatedBranches } from "../../state/queries";
55import { vcsEnvironment } from "../../state/vcs";58import { vcsEnvironment } from "../../state/vcs";
...@@ -418,21 +421,33 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -418,21 +421,33 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
418 selectedEnvironmentServerConfig,421 selectedEnvironmentServerConfig,
419 selectedProject?.defaultModelSelection ?? null,422 selectedProject?.defaultModelSelection ?? null,
420 );423 );
424 const storedStickyModelSelection = useStickyComposerModelSelection();
425 const stickyModelSelection = resolveDefaultableModelSelection(
426 selectedEnvironmentServerConfig,
427 storedStickyModelSelection,
428 );
421 const modelOptions = useMemo(429 const modelOptions = useMemo(
422 () =>430 () =>
423 buildModelOptions(431 buildModelOptions(
424 selectedEnvironmentServerConfig,432 selectedEnvironmentServerConfig,
425 draftModelSelection ?? projectDefaultModelSelection,433 draftModelSelection ?? projectDefaultModelSelection ?? stickyModelSelection,
426 ),434 ),
427 [selectedEnvironmentServerConfig, draftModelSelection, projectDefaultModelSelection],435 [
436 selectedEnvironmentServerConfig,
437 draftModelSelection,
438 projectDefaultModelSelection,
439 stickyModelSelection,
440 ],
428 );441 );
429442
430 const selectedModel =443 // An unsent draft keeps its explicit pick. Fresh drafts resolve the project
431 draftModelSelection ??444 // default before the last manual app-wide selection and provider default.
432 projectDefaultModelSelection ??445 const selectedModel = resolveNewTaskModelSelection({
433 modelOptions.find((option) => option.isDefault)?.selection ??446 draftSelection: draftModelSelection,
434 modelOptions[0]?.selection ??447 projectDefaultSelection: projectDefaultModelSelection,
435 null;448 stickySelection: stickyModelSelection,
449 modelOptions,
450 });
436 const selectedModelKey = selectedModel451 const selectedModelKey = selectedModel
437 ? `${selectedModel.instanceId}:${selectedModel.model}`452 ? `${selectedModel.instanceId}:${selectedModel.model}`
438 : null;453 : null;
...@@ -462,9 +477,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -462,9 +477,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
462 if (!option) {477 if (!option) {
463 return;478 return;
464 }479 }
465 updateComposerDraftSettings(selectedProjectDraftKey, {480 const selection = options ? { ...option.selection, options } : option.selection;
466 modelSelection: options ? { ...option.selection, options } : option.selection,481 updateComposerDraftSettings(selectedProjectDraftKey, { modelSelection: selection });
467 });482 setStickyComposerModelSelection(selection);
468 },483 },
469 [modelOptions, selectedProjectDraftKey],484 [modelOptions, selectedProjectDraftKey],
470 );485 );
...@@ -482,6 +497,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -482,6 +497,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
482 updateComposerDraftSettings(selectedProjectDraftKey, {497 updateComposerDraftSettings(selectedProjectDraftKey, {
483 modelSelection: nextSelection,498 modelSelection: nextSelection,
484 });499 });
500 setStickyComposerModelSelection(nextSelection);
485 },501 },
486 [selectedModel, selectedProjectDraftKey],502 [selectedModel, selectedProjectDraftKey],
487 );503 );
apps/mobile/src/lib/modelOptions.test.ts+29-1
...@@ -1,12 +1,14 @@...@@ -1,12 +1,14 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
22
3import { ProviderInstanceId, type ServerConfig } from "@t3tools/contracts";3import { ProviderInstanceId, type ModelSelection, type ServerConfig } from "@t3tools/contracts";
44
5import {5import {
6 buildModelOptions,6 buildModelOptions,
7 groupByProvider,7 groupByProvider,
8 resolveDefaultableModelSelection,8 resolveDefaultableModelSelection,
9 resolveNewTaskModelSelection,
9 resolveSelectableModelSelection,10 resolveSelectableModelSelection,
11 type ModelOption,
10} from "./modelOptions";12} from "./modelOptions";
1113
12describe("mobile model options", () => {14describe("mobile model options", () => {
...@@ -220,4 +222,30 @@ describe("mobile model options", () => {...@@ -220,4 +222,30 @@ describe("mobile model options", () => {
220 // Offline: nothing to validate against, selection passes through.222 // Offline: nothing to validate against, selection passes through.
221 expect(resolveDefaultableModelSelection(null, legacy)).toBe(legacy);223 expect(resolveDefaultableModelSelection(null, legacy)).toBe(legacy);
222 });224 });
225
226 it("resolves new tasks from draft, project, sticky, then provider defaults", () => {
227 const draft = { instanceId: ProviderInstanceId.make("codex"), model: "draft" };
228 const project = { instanceId: ProviderInstanceId.make("codex"), model: "project" };
229 const sticky = { instanceId: ProviderInstanceId.make("codex"), model: "sticky" };
230 const providerDefault = {
231 selection: { instanceId: ProviderInstanceId.make("codex"), model: "default" },
232 isDefault: true,
233 } as ModelOption;
234 const resolve = (
235 draftSelection: ModelSelection | null,
236 projectDefaultSelection: ModelSelection | null,
237 stickySelection: ModelSelection | null,
238 ) =>
239 resolveNewTaskModelSelection({
240 draftSelection,
241 projectDefaultSelection,
242 stickySelection,
243 modelOptions: [providerDefault],
244 });
245
246 expect(resolve(draft, project, sticky)).toBe(draft);
247 expect(resolve(null, project, sticky)).toBe(project);
248 expect(resolve(null, null, sticky)).toBe(sticky);
249 expect(resolve(null, null, null)).toBe(providerDefault.selection);
250 });
223});251});
apps/mobile/src/lib/modelOptions.ts+16
...@@ -104,6 +104,22 @@ export function resolveDefaultableModelSelection(...@@ -104,6 +104,22 @@ export function resolveDefaultableModelSelection(
104 return model?.isLegacy === true ? null : usable;104 return model?.isLegacy === true ? null : usable;
105}105}
106106
107export function resolveNewTaskModelSelection(input: {
108 readonly draftSelection: ModelSelection | null;
109 readonly projectDefaultSelection: ModelSelection | null;
110 readonly stickySelection: ModelSelection | null;
111 readonly modelOptions: ReadonlyArray<ModelOption>;
112}): ModelSelection | null {
113 return (
114 input.draftSelection ??
115 input.projectDefaultSelection ??
116 input.stickySelection ??
117 input.modelOptions.find((option) => option.isDefault)?.selection ??
118 input.modelOptions[0]?.selection ??
119 null
120 );
121}
122
107export function buildModelOptions(123export function buildModelOptions(
108 config: T3ServerConfig | null | undefined,124 config: T3ServerConfig | null | undefined,
109 fallbackModelSelection: ModelSelection | null,125 fallbackModelSelection: ModelSelection | null,
apps/mobile/src/state/use-composer-drafts.test.ts+232-8
...@@ -7,6 +7,9 @@ const composerDraftFileMocks = vi.hoisted(() => {...@@ -7,6 +7,9 @@ const composerDraftFileMocks = vi.hoisted(() => {
7 let writeError: Error | null = null;7 let writeError: Error | null = null;
8 let releaseRead: (() => void) | null = null;8 let releaseRead: (() => void) | null = null;
9 let readBarrier = Promise.resolve();9 let readBarrier = Promise.resolve();
10 let nextWriteBarrier: Promise<void> | null = null;
11 let onWrite: (() => void) | null = null;
12 const writes: string[] = [];
1013
11 return {14 return {
12 blockRead() {15 blockRead() {
...@@ -27,6 +30,18 @@ const composerDraftFileMocks = vi.hoisted(() => {...@@ -27,6 +30,18 @@ const composerDraftFileMocks = vi.hoisted(() => {
27 setWriteError(error: Error | null) {30 setWriteError(error: Error | null) {
28 writeError = error;31 writeError = error;
29 },32 },
33 setNextWriteBarrier(barrier: Promise<void> | null) {
34 nextWriteBarrier = barrier;
35 },
36 setOnWrite(callback: (() => void) | null) {
37 onWrite = callback;
38 },
39 getWrites(): ReadonlyArray<string> {
40 return writes;
41 },
42 resetWrites() {
43 writes.length = 0;
44 },
30 Directory: class {45 Directory: class {
31 create() {}46 create() {}
32 },47 },
...@@ -47,7 +62,18 @@ const composerDraftFileMocks = vi.hoisted(() => {...@@ -47,7 +62,18 @@ const composerDraftFileMocks = vi.hoisted(() => {
47 if (writeError) {62 if (writeError) {
48 throw writeError;63 throw writeError;
49 }64 }
65 if (nextWriteBarrier) {
66 const barrier = nextWriteBarrier;
67 nextWriteBarrier = null;
68 return barrier.then(() => {
69 document = value;
70 writes.push(value);
71 onWrite?.();
72 });
73 }
50 document = value;74 document = value;
75 writes.push(value);
76 onWrite?.();
51 }77 }
52 },78 },
53 };79 };
...@@ -62,18 +88,24 @@ vi.mock("expo-file-system", () => ({...@@ -62,18 +88,24 @@ vi.mock("expo-file-system", () => ({
62import { appAtomRegistry } from "./atom-registry";88import { appAtomRegistry } from "./atom-registry";
63import {89import {
64 clearComposerDraftContentState,90 clearComposerDraftContentState,
91 clearComposerDraftsEnvironment,
65 ComposerDraftPersistenceError,92 ComposerDraftPersistenceError,
66 composerDraftsAtom,93 composerDraftsAtom,
67 copyComposerDraftContentIfEmpty,94 copyComposerDraftContentIfEmpty,
68 copyComposerDraftContentState,95 copyComposerDraftContentState,
96 decodePersistedComposerState,
69 decodePersistedComposerDrafts,97 decodePersistedComposerDrafts,
98 ensureComposerDraftsLoaded,
70 type ComposerDraft,99 type ComposerDraft,
71 flushComposerDrafts,100 flushComposerDrafts,
72 getComposerDraftSnapshot,101 getComposerDraftSnapshot,
73 mergeComposerDraftContentState,102 mergeComposerDraftContentState,
74 removeComposerDraftsForEnvironment,103 removeComposerDraftsForEnvironment,
104 resetComposerDraftsLoadState,
75 restoreComposerDraftSnapshotState,105 restoreComposerDraftSnapshotState,
76 setComposerDraftText,106 setComposerDraftText,
107 setStickyComposerModelSelection,
108 stickyComposerModelSelectionAtom,
77} from "./use-composer-drafts";109} from "./use-composer-drafts";
78110
79const DRAFT: ComposerDraft = {111const DRAFT: ComposerDraft = {
...@@ -82,7 +114,15 @@ const DRAFT: ComposerDraft = {...@@ -82,7 +114,15 @@ const DRAFT: ComposerDraft = {
82};114};
83115
84afterEach(() => {116afterEach(() => {
117 vi.useRealTimers();
118 resetComposerDraftsLoadState();
119 composerDraftFileMocks.setDocument("");
120 composerDraftFileMocks.setWriteError(null);
121 composerDraftFileMocks.setNextWriteBarrier(null);
122 composerDraftFileMocks.setOnWrite(null);
123 composerDraftFileMocks.resetWrites();
85 appAtomRegistry.set(composerDraftsAtom, {});124 appAtomRegistry.set(composerDraftsAtom, {});
125 appAtomRegistry.set(stickyComposerModelSelectionAtom, null);
86});126});
87127
88describe("mobile composer drafts", () => {128describe("mobile composer drafts", () => {
...@@ -154,6 +194,195 @@ describe("mobile composer drafts", () => {...@@ -154,6 +194,195 @@ describe("mobile composer drafts", () => {
154 ).toThrow();194 ).toThrow();
155 });195 });
156196
197 it("keeps share-import receipts on otherwise contentless new-task drafts", () => {
198 const receiptDraft: ComposerDraft = {
199 text: "",
200 attachments: [],
201 importedShareIds: ["share-1"],
202 };
203 // The stale-model strip must not touch receipt-bearing drafts, and the
204 // empty filter must keep them — or the same share would re-import after
205 // restart.
206 expect(
207 decodePersistedComposerState({
208 schemaVersion: 1,
209 drafts: {
210 "new-task:environment-1:project-1": {
211 ...receiptDraft,
212 modelSelection: {
213 instanceId: "codex",
214 model: "gpt-5.4",
215 },
216 },
217 },
218 }).drafts,
219 ).toEqual({
220 "new-task:environment-1:project-1": {
221 text: "",
222 attachments: [],
223 importedShareIds: ["share-1"],
224 },
225 });
226
227 expect(
228 decodePersistedComposerState({
229 schemaVersion: 1,
230 drafts: { "new-task:environment-1:project-1": receiptDraft },
231 }).drafts,
232 ).toEqual({ "new-task:environment-1:project-1": receiptDraft });
233 });
234
235 it("hydrates the global sticky model selection", () => {
236 expect(
237 decodePersistedComposerState({
238 schemaVersion: 1,
239 drafts: {},
240 stickyModelSelection: {
241 instanceId: "codex",
242 model: "gpt-5.6-sol",
243 },
244 }).stickyModelSelection,
245 ).toEqual({
246 instanceId: "codex",
247 model: "gpt-5.6-sol",
248 });
249 });
250
251 it("waits for hydration before persisting the latest composer state", async () => {
252 vi.useFakeTimers();
253 composerDraftFileMocks.setDocument({
254 schemaVersion: 1,
255 drafts: {
256 "environment-1:thread-1": DRAFT,
257 },
258 stickyModelSelection: {
259 instanceId: "codex",
260 model: "gpt-5.6-sol",
261 },
262 });
263 composerDraftFileMocks.blockRead();
264 composerDraftFileMocks.resetWrites();
265
266 ensureComposerDraftsLoaded();
267 await Promise.resolve();
268 // The read is blocked, hydration is pending.
269 setComposerDraftText("new-task:environment-1:project-1", "New prompt");
270 await vi.advanceTimersByTimeAsync(200);
271
272 // Write should still be deferred — hydration has not resolved.
273 expect(composerDraftFileMocks.getWrites()).toHaveLength(0);
274
275 composerDraftFileMocks.releaseRead();
276 // Let the loadPromise settle and chain into the deferred persist.
277 await vi.runAllTimersAsync();
278
279 expect(JSON.parse(composerDraftFileMocks.getWrites()[0]!)).toEqual({
280 schemaVersion: 1,
281 drafts: {
282 "environment-1:thread-1": DRAFT,
283 "new-task:environment-1:project-1": {
284 text: "New prompt",
285 attachments: [],
286 },
287 },
288 stickyModelSelection: {
289 instanceId: "codex",
290 model: "gpt-5.6-sol",
291 },
292 });
293 });
294
295 it("flush waits for pending hydration instead of clobbering disk", async () => {
296 vi.useFakeTimers();
297 composerDraftFileMocks.setDocument({
298 schemaVersion: 1,
299 drafts: {
300 "environment-1:thread-1": DRAFT,
301 },
302 stickyModelSelection: {
303 instanceId: "codex",
304 model: "gpt-5.6-sol",
305 },
306 });
307 composerDraftFileMocks.blockRead();
308 composerDraftFileMocks.resetWrites();
309
310 ensureComposerDraftsLoaded();
311 await Promise.resolve();
312 // An edit lands before hydration finishes; its debounced write is gated
313 // behind the blocked read.
314 setComposerDraftText("new-task:environment-1:project-1", "New prompt");
315
316 const flush = flushComposerDrafts();
317 await vi.advanceTimersByTimeAsync(200);
318 // The flush must not have written the pre-hydration snapshot over disk.
319 expect(composerDraftFileMocks.getWrites()).toHaveLength(0);
320
321 composerDraftFileMocks.releaseRead();
322 await flush;
323
324 const written = JSON.parse(composerDraftFileMocks.getDocument());
325 expect(written.drafts["environment-1:thread-1"]).toEqual(DRAFT);
326 expect(written.drafts["new-task:environment-1:project-1"]).toEqual({
327 text: "New prompt",
328 attachments: [],
329 });
330 expect(written.stickyModelSelection).toEqual({
331 instanceId: "codex",
332 model: "gpt-5.6-sol",
333 });
334 });
335
336 it("serializes environment cleanup after an older queued write", async () => {
337 vi.useFakeTimers();
338 composerDraftFileMocks.setDocument(JSON.stringify({ schemaVersion: 1, drafts: {} }));
339 composerDraftFileMocks.resetWrites();
340 let releaseFirstWrite!: () => void;
341 const firstWriteBarrier = new Promise<void>((resolve) => {
342 releaseFirstWrite = resolve;
343 });
344 composerDraftFileMocks.setNextWriteBarrier(firstWriteBarrier);
345 let writeCount = 0;
346 const bothWritesCommitted = new Promise<void>((resolve) => {
347 composerDraftFileMocks.setOnWrite(() => {
348 writeCount += 1;
349 if (writeCount === 2) {
350 resolve();
351 }
352 });
353 });
354
355 appAtomRegistry.set(composerDraftsAtom, {
356 "environment-1:thread-1": DRAFT,
357 "environment-2:thread-2": { text: "keep", attachments: [] },
358 });
359 setStickyComposerModelSelection({
360 instanceId: ProviderInstanceId.make("codex"),
361 model: "gpt-5.6-sol",
362 });
363 await vi.advanceTimersByTimeAsync(200);
364
365 const clear = clearComposerDraftsEnvironment(EnvironmentId.make("environment-1"));
366 await Promise.resolve();
367 // Cleanup write is queued behind the still-blocked debounced write.
368 expect(composerDraftFileMocks.getWrites()).toHaveLength(0);
369
370 releaseFirstWrite();
371 await clear;
372 await bothWritesCommitted;
373
374 expect(JSON.parse(composerDraftFileMocks.getDocument())).toEqual({
375 schemaVersion: 1,
376 drafts: {
377 "environment-2:thread-2": { text: "keep", attachments: [] },
378 },
379 stickyModelSelection: {
380 instanceId: "codex",
381 model: "gpt-5.6-sol",
382 },
383 });
384 });
385
157 it("clears sent content without clearing the selected model or workspace", () => {386 it("clears sent content without clearing the selected model or workspace", () => {
158 const draftKey = "environment-1:thread-1";387 const draftKey = "environment-1:thread-1";
159 const draft: ComposerDraft = {388 const draft: ComposerDraft = {
...@@ -182,7 +411,7 @@ describe("mobile composer drafts", () => {...@@ -182,7 +411,7 @@ describe("mobile composer drafts", () => {
182 });411 });
183 });412 });
184413
185 it("drops the workspace selection when clearing a sent new-task draft", () => {414 it("drops draft-local model and workspace selections after sending a new task", () => {
186 const draftKey = "new-task:environment-1:project-1";415 const draftKey = "new-task:environment-1:project-1";
187 const draft: ComposerDraft = {416 const draft: ComposerDraft = {
188 text: "send this",417 text: "send this",
...@@ -201,15 +430,10 @@ describe("mobile composer drafts", () => {...@@ -201,15 +430,10 @@ describe("mobile composer drafts", () => {
201430
202 expect(431 expect(
203 clearComposerDraftContentState({ [draftKey]: draft }, draftKey, {432 clearComposerDraftContentState({ [draftKey]: draft }, draftKey, {
433 clearModelSelection: true,
204 clearWorkspaceSelection: true,434 clearWorkspaceSelection: true,
205 }),435 }),
206 ).toEqual({436 ).toEqual({});
207 [draftKey]: {
208 modelSelection: draft.modelSelection,
209 text: "",
210 attachments: [],
211 },
212 });
213 });437 });
214438
215 it("reads the latest selector state synchronously for send", () => {439 it("reads the latest selector state synchronously for send", () => {
apps/mobile/src/state/use-composer-drafts.ts+144-37
...@@ -86,6 +86,7 @@ const ComposerDraftSchema = Schema.Struct({...@@ -86,6 +86,7 @@ const ComposerDraftSchema = Schema.Struct({
86const PersistedComposerDraftsSchema = Schema.Struct({86const PersistedComposerDraftsSchema = Schema.Struct({
87 schemaVersion: Schema.Literal(COMPOSER_DRAFTS_SCHEMA_VERSION),87 schemaVersion: Schema.Literal(COMPOSER_DRAFTS_SCHEMA_VERSION),
88 drafts: Schema.Record(Schema.String, ComposerDraftSchema),88 drafts: Schema.Record(Schema.String, ComposerDraftSchema),
89 stickyModelSelection: Schema.optional(ModelSelectionSchema),
89});90});
9091
91const decodePersistedComposerDraftsDocument = Schema.decodeUnknownSync(92const decodePersistedComposerDraftsDocument = Schema.decodeUnknownSync(
...@@ -102,10 +103,20 @@ export const composerDraftsAtom = Atom.make<Record<string, ComposerDraft>>({}).p...@@ -102,10 +103,20 @@ export const composerDraftsAtom = Atom.make<Record<string, ComposerDraft>>({}).p
102 Atom.withLabel("mobile:composer-drafts"),103 Atom.withLabel("mobile:composer-drafts"),
103);104);
104105
106export const stickyComposerModelSelectionAtom = Atom.make<ModelSelection | null>(null).pipe(
107 Atom.keepAlive,
108 Atom.withLabel("mobile:sticky-composer-model-selection"),
109);
110
105let loadPromise: Promise<void> | null = null;111let loadPromise: Promise<void> | null = null;
106let persistTimer: ReturnType<typeof setTimeout> | null = null;112let persistTimer: ReturnType<typeof setTimeout> | null = null;
107const persistenceQueue = new SerializedAsyncQueue();113const persistenceQueue = new SerializedAsyncQueue();
108114
115/** Resets module-level state between test runs. */
116export function resetComposerDraftsLoadState(): void {
117 loadPromise = null;
118}
119
109function normalizeDraft(draft: ComposerDraft | undefined): ComposerDraft {120function normalizeDraft(draft: ComposerDraft | undefined): ComposerDraft {
110 if (!draft) {121 if (!draft) {
111 return EMPTY_DRAFT;122 return EMPTY_DRAFT;
...@@ -136,11 +147,46 @@ function isEmptyDraft(draft: ComposerDraft): boolean {...@@ -136,11 +147,46 @@ function isEmptyDraft(draft: ComposerDraft): boolean {
136 );147 );
137}148}
138149
139export function decodePersistedComposerDrafts(value: unknown): Record<string, ComposerDraft> {150export function decodePersistedComposerState(value: unknown): {
151 readonly drafts: Record<string, ComposerDraft>;
152 readonly stickyModelSelection: ModelSelection | null;
153} {
140 const parsed = decodePersistedComposerDraftsDocument(value);154 const parsed = decodePersistedComposerDraftsDocument(value);
141 return Object.fromEntries(155 return {
142 Object.entries(parsed.drafts).filter(([, draft]) => !isEmptyDraft(draft)),156 drafts: Object.fromEntries(
143 );157 Object.entries(parsed.drafts)
158 .map(
159 ([key, draft]) =>
160 [
161 key,
162 // Stale new-task drafts left on disk by builds before the
163 // model-precedence fix carry a bare modelSelection with no
164 // other selector settings. Strip it so the next compose pass
165 // re-resolves project → sticky → provider defaults. Drafts
166 // with runtime/interaction/workspace settings or actual text /
167 // attachments were deliberately configured and are left alone.
168 key.startsWith("new-task:") &&
169 draft.modelSelection &&
170 draft.text.length === 0 &&
171 draft.attachments.length === 0 &&
172 draft.runtimeMode === undefined &&
173 draft.interactionMode === undefined &&
174 draft.workspaceSelection === undefined
175 ? { ...draft, modelSelection: undefined }
176 : draft,
177 ] as const,
178 )
179 // importedShareIds are share-import receipts: a contentless draft
180 // carrying one is not empty, or the same native share would be
181 // re-imported after restart.
182 .filter(([, draft]) => !isEmptyDraft(draft) || (draft.importedShareIds?.length ?? 0) > 0),
183 ),
184 stickyModelSelection: parsed.stickyModelSelection ?? null,
185 };
186}
187
188export function decodePersistedComposerDrafts(value: unknown): Record<string, ComposerDraft> {
189 return decodePersistedComposerState(value).drafts;
144}190}
145191
146async function getComposerDraftsFile() {192async function getComposerDraftsFile() {
...@@ -150,17 +196,20 @@ async function getComposerDraftsFile() {...@@ -150,17 +196,20 @@ async function getComposerDraftsFile() {
150 return new File(directory, COMPOSER_DRAFTS_FILE);196 return new File(directory, COMPOSER_DRAFTS_FILE);
151}197}
152198
153async function loadPersistedComposerDrafts(): Promise<Record<string, ComposerDraft>> {199async function loadPersistedComposerState(): Promise<{
200 readonly drafts: Record<string, ComposerDraft>;
201 readonly stickyModelSelection: ModelSelection | null;
202}> {
154 let operation: ComposerDraftPersistenceError["operation"] = "open";203 let operation: ComposerDraftPersistenceError["operation"] = "open";
155 try {204 try {
156 const file = await getComposerDraftsFile();205 const file = await getComposerDraftsFile();
157 if (!file.exists) {206 if (!file.exists) {
158 return {};207 return { drafts: {}, stickyModelSelection: null };
159 }208 }
160 operation = "read";209 operation = "read";
161 const raw = await file.text();210 const raw = await file.text();
162 operation = "decode";211 operation = "decode";
163 return decodePersistedComposerDrafts(JSON.parse(raw) as unknown);212 return decodePersistedComposerState(JSON.parse(raw) as unknown);
164 } catch (cause) {213 } catch (cause) {
165 console.warn(214 console.warn(
166 "[composer-drafts] ignored persisted draft failure",215 "[composer-drafts] ignored persisted draft failure",
...@@ -171,11 +220,14 @@ async function loadPersistedComposerDrafts(): Promise<Record<string, ComposerDra...@@ -171,11 +220,14 @@ async function loadPersistedComposerDrafts(): Promise<Record<string, ComposerDra
171 cause,220 cause,
172 }),221 }),
173 );222 );
174 return {};223 return { drafts: {}, stickyModelSelection: null };
175 }224 }
176}225}
177226
178async function writePersistedComposerDrafts(drafts: Record<string, ComposerDraft>): Promise<void> {227async function writePersistedComposerState(
228 drafts: Record<string, ComposerDraft>,
229 stickyModelSelection: ModelSelection | null,
230): Promise<void> {
179 let operation: ComposerDraftPersistenceError["operation"] = "open";231 let operation: ComposerDraftPersistenceError["operation"] = "open";
180 try {232 try {
181 const file = await getComposerDraftsFile();233 const file = await getComposerDraftsFile();
...@@ -186,6 +238,7 @@ async function writePersistedComposerDrafts(drafts: Record<string, ComposerDraft...@@ -186,6 +238,7 @@ async function writePersistedComposerDrafts(drafts: Record<string, ComposerDraft
186 const document = {238 const document = {
187 schemaVersion: COMPOSER_DRAFTS_SCHEMA_VERSION,239 schemaVersion: COMPOSER_DRAFTS_SCHEMA_VERSION,
188 drafts: nonEmptyDrafts,240 drafts: nonEmptyDrafts,
241 ...(stickyModelSelection ? { stickyModelSelection } : {}),
189 } as const;242 } as const;
190 const encoded = JSON.stringify(document);243 const encoded = JSON.stringify(document);
191 operation = "write";244 operation = "write";
...@@ -200,21 +253,18 @@ async function writePersistedComposerDrafts(drafts: Record<string, ComposerDraft...@@ -200,21 +253,18 @@ async function writePersistedComposerDrafts(drafts: Record<string, ComposerDraft
200 }253 }
201}254}
202255
203async function savePersistedComposerDrafts(drafts: Record<string, ComposerDraft>): Promise<void> {
204 try {
205 await persistenceQueue.run(() => writePersistedComposerDrafts(drafts));
206 } catch (error) {
207 console.warn("[composer-drafts] failed to persist drafts", error);
208 // Draft persistence is best-effort; in-memory drafts still keep working.
209 }
210}
211
212/**256/**
213 * Lands any debounced or in-flight draft write before the JS runtime is torn257 * Lands any debounced or in-flight draft write before the JS runtime is torn
214 * down (app update restart), so the freshest draft state survives it. A write258 * down (app update restart), so the freshest draft state survives it. A write
215 * failure propagates so the caller can decide whether the restart may proceed.259 * failure propagates so the caller can decide whether the restart may proceed.
216 */260 */
217export async function flushComposerDrafts(): Promise<void> {261export async function flushComposerDrafts(): Promise<void> {
262 // Never land a pre-hydration snapshot: persisted state must merge into the
263 // atoms first, or this write would clobber disk with partial data.
264 ensureComposerDraftsLoaded();
265 if (loadPromise !== null) {
266 await loadPromise;
267 }
218 // An edit during an awaited write schedules another debounced write, so268 // An edit during an awaited write schedules another debounced write, so
219 // keep landing snapshots until no debounce is pending after a queue drain.269 // keep landing snapshots until no debounce is pending after a queue drain.
220 do {270 do {
...@@ -222,20 +272,41 @@ export async function flushComposerDrafts(): Promise<void> {...@@ -222,20 +272,41 @@ export async function flushComposerDrafts(): Promise<void> {
222 clearTimeout(persistTimer);272 clearTimeout(persistTimer);
223 persistTimer = null;273 persistTimer = null;
224 await persistenceQueue.run(() =>274 await persistenceQueue.run(() =>
225 writePersistedComposerDrafts(appAtomRegistry.get(composerDraftsAtom)),275 writePersistedComposerState(
276 appAtomRegistry.get(composerDraftsAtom),
277 appAtomRegistry.get(stickyComposerModelSelectionAtom),
278 ),
226 );279 );
227 }280 }
281 // Draining also waits for an already-fired debounce whose write is still
282 // gated behind its own hydration await inside the queue.
228 await persistenceQueue.run(() => Promise.resolve());283 await persistenceQueue.run(() => Promise.resolve());
229 } while (persistTimer !== null);284 } while (persistTimer !== null);
230}285}
231286
232function schedulePersistComposerDrafts(drafts: Record<string, ComposerDraft>): void {287function schedulePersistComposerState(): void {
233 if (persistTimer !== null) {288 if (persistTimer !== null) {
234 clearTimeout(persistTimer);289 clearTimeout(persistTimer);
235 }290 }
236 persistTimer = setTimeout(() => {291 persistTimer = setTimeout(() => {
237 persistTimer = null;292 persistTimer = null;
238 void savePersistedComposerDrafts(drafts);293 ensureComposerDraftsLoaded();
294 // The write enters the serialization queue before waiting on hydration,
295 // so flushComposerDrafts' queue drain cannot resolve ahead of it.
296 void persistenceQueue.run(async () => {
297 if (loadPromise !== null) {
298 await loadPromise;
299 }
300 try {
301 await writePersistedComposerState(
302 appAtomRegistry.get(composerDraftsAtom),
303 appAtomRegistry.get(stickyComposerModelSelectionAtom),
304 );
305 } catch (error) {
306 console.warn("[composer-drafts] failed to persist drafts", error);
307 // Draft persistence is best-effort; in-memory drafts still keep working.
308 }
309 });
239 }, PERSIST_DEBOUNCE_MS);310 }, PERSIST_DEBOUNCE_MS);
240}311}
241312
...@@ -243,16 +314,21 @@ export function ensureComposerDraftsLoaded(): void {...@@ -243,16 +314,21 @@ export function ensureComposerDraftsLoaded(): void {
243 if (loadPromise !== null) {314 if (loadPromise !== null) {
244 return;315 return;
245 }316 }
246 loadPromise = loadPersistedComposerDrafts()317 loadPromise = loadPersistedComposerState()
247 .then((persistedDrafts) => {318 .then((persisted) => {
248 if (Object.keys(persistedDrafts).length === 0) {319 if (Object.keys(persisted.drafts).length > 0) {
249 return;320 const current = appAtomRegistry.get(composerDraftsAtom);
321 appAtomRegistry.set(composerDraftsAtom, {
322 ...persisted.drafts,
323 ...current,
324 });
325 }
326 if (
327 persisted.stickyModelSelection !== null &&
328 appAtomRegistry.get(stickyComposerModelSelectionAtom) === null
329 ) {
330 appAtomRegistry.set(stickyComposerModelSelectionAtom, persisted.stickyModelSelection);
250 }331 }
251 const current = appAtomRegistry.get(composerDraftsAtom);
252 appAtomRegistry.set(composerDraftsAtom, {
253 ...persistedDrafts,
254 ...current,
255 });
256 })332 })
257 .catch((cause) => {333 .catch((cause) => {
258 console.warn(334 console.warn(
...@@ -277,7 +353,12 @@ function updateComposerDrafts(...@@ -277,7 +353,12 @@ function updateComposerDrafts(
277 return;353 return;
278 }354 }
279 appAtomRegistry.set(composerDraftsAtom, next);355 appAtomRegistry.set(composerDraftsAtom, next);
280 schedulePersistComposerDrafts(next);356 schedulePersistComposerState();
357}
358
359export function setStickyComposerModelSelection(modelSelection: ModelSelection): void {
360 appAtomRegistry.set(stickyComposerModelSelectionAtom, modelSelection);
361 schedulePersistComposerState();
281}362}
282363
283export function setComposerDraftText(draftKey: string, value: string): void {364export function setComposerDraftText(draftKey: string, value: string): void {
...@@ -394,15 +475,24 @@ export function updateComposerDraftSettings(...@@ -394,15 +475,24 @@ export function updateComposerDraftSettings(
394export function clearComposerDraftContentState(475export function clearComposerDraftContentState(
395 current: Record<string, ComposerDraft>,476 current: Record<string, ComposerDraft>,
396 draftKey: string,477 draftKey: string,
397 options?: { readonly clearWorkspaceSelection?: boolean },478 options?: {
479 readonly clearModelSelection?: boolean;
480 readonly clearWorkspaceSelection?: boolean;
481 },
398): Record<string, ComposerDraft> {482): Record<string, ComposerDraft> {
399 const existing = current[draftKey];483 const existing = current[draftKey];
400 if (!existing) {484 if (!existing) {
401 return current;485 return current;
402 }486 }
403 const { importedShareIds: _importedShareIds, workspaceSelection, ...retained } = existing;487 const {
488 importedShareIds: _importedShareIds,
489 modelSelection,
490 workspaceSelection,
491 ...retained
492 } = existing;
404 const draft = {493 const draft = {
405 ...retained,494 ...retained,
495 ...(options?.clearModelSelection || modelSelection === undefined ? {} : { modelSelection }),
406 ...(options?.clearWorkspaceSelection || workspaceSelection === undefined496 ...(options?.clearWorkspaceSelection || workspaceSelection === undefined
407 ? {}497 ? {}
408 : { workspaceSelection }),498 : { workspaceSelection }),
...@@ -571,7 +661,9 @@ export async function mergeComposerDraftContent(...@@ -571,7 +661,9 @@ export async function mergeComposerDraftContent(
571 if (next !== current) {661 if (next !== current) {
572 appAtomRegistry.set(composerDraftsAtom, next);662 appAtomRegistry.set(composerDraftsAtom, next);
573 }663 }
574 await persistenceQueue.run(() => writePersistedComposerDrafts(next));664 await persistenceQueue.run(() =>
665 writePersistedComposerState(next, appAtomRegistry.get(stickyComposerModelSelectionAtom)),
666 );
575 return { skippedAttachmentCount };667 return { skippedAttachmentCount };
576}668}
577669
...@@ -594,12 +686,17 @@ export async function restoreComposerDraftSnapshot(...@@ -594,12 +686,17 @@ export async function restoreComposerDraftSnapshot(
594 snapshot,686 snapshot,
595 );687 );
596 appAtomRegistry.set(composerDraftsAtom, next);688 appAtomRegistry.set(composerDraftsAtom, next);
597 await persistenceQueue.run(() => writePersistedComposerDrafts(next));689 await persistenceQueue.run(() =>
690 writePersistedComposerState(next, appAtomRegistry.get(stickyComposerModelSelectionAtom)),
691 );
598}692}
599693
600export function clearComposerDraftContent(694export function clearComposerDraftContent(
601 draftKey: string,695 draftKey: string,
602 options?: { readonly clearWorkspaceSelection?: boolean },696 options?: {
697 readonly clearModelSelection?: boolean;
698 readonly clearWorkspaceSelection?: boolean;
699 },
603): void {700): void {
604 updateComposerDrafts((current) => clearComposerDraftContentState(current, draftKey, options));701 updateComposerDrafts((current) => clearComposerDraftContentState(current, draftKey, options));
605}702}
...@@ -645,7 +742,9 @@ export async function clearComposerDraftsEnvironment(environmentId: EnvironmentI...@@ -645,7 +742,9 @@ export async function clearComposerDraftsEnvironment(environmentId: EnvironmentI
645 persistTimer = null;742 persistTimer = null;
646 }743 }
647 appAtomRegistry.set(composerDraftsAtom, next);744 appAtomRegistry.set(composerDraftsAtom, next);
648 await persistenceQueue.run(() => writePersistedComposerDrafts(next));745 await persistenceQueue.run(() =>
746 writePersistedComposerState(next, appAtomRegistry.get(stickyComposerModelSelectionAtom)),
747 );
649}748}
650749
651export function useComposerDraft(draftKey: string | null): ComposerDraft {750export function useComposerDraft(draftKey: string | null): ComposerDraft {
...@@ -655,3 +754,11 @@ export function useComposerDraft(draftKey: string | null): ComposerDraft {...@@ -655,3 +754,11 @@ export function useComposerDraft(draftKey: string | null): ComposerDraft {
655 }, []);754 }, []);
656 return draftKey ? normalizeDraft(drafts[draftKey]) : EMPTY_DRAFT;755 return draftKey ? normalizeDraft(drafts[draftKey]) : EMPTY_DRAFT;
657}756}
757
758export function useStickyComposerModelSelection(): ModelSelection | null {
759 const selection = useAtomValue(stickyComposerModelSelectionAtom);
760 useEffect(() => {
761 ensureComposerDraftsLoaded();
762 }, []);
763 return selection;
764}
apps/web/src/components/ChatView.tsx+1
...@@ -6598,6 +6598,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -6598,6 +6598,7 @@ function ChatViewContent(props: ChatViewProps) {
6598 setComposerDraftModelSelection(6598 setComposerDraftModelSelection(
6599 scopeThreadRef(activeThread.environmentId, activeThread.id),6599 scopeThreadRef(activeThread.environmentId, activeThread.id),
6600 nextModelSelection,6600 nextModelSelection,
6601 { explicit: true },
6601 );6602 );
6602 setStickyComposerModelSelection(nextModelSelection);6603 setStickyComposerModelSelection(nextModelSelection);
6603 scheduleComposerFocus();6604 scheduleComposerFocus();
apps/web/src/composerDraftStore.test.ts+253
...@@ -1314,6 +1314,47 @@ describe("composerDraftStore modelSelection", () => {...@@ -1314,6 +1314,47 @@ describe("composerDraftStore modelSelection", () => {
1314 ).toEqual(modelSelection(CODEX_DRIVER, "gpt-5.4"));1314 ).toEqual(modelSelection(CODEX_DRIVER, "gpt-5.4"));
1315 });1315 });
13161316
1317 it("marks picker writes explicit and seeding writes non-explicit", () => {
1318 const store = useComposerDraftStore.getState();
1319 store.setModelSelection(threadRef, modelSelection(CODEX_DRIVER, "gpt-5.4"));
1320 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionExplicit).toBeUndefined();
1321
1322 store.setModelSelection(threadRef, modelSelection(CODEX_DRIVER, "gpt-5.4"), {
1323 explicit: true,
1324 });
1325 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionExplicit).toBe(true);
1326
1327 // Last writer defines intent: a later seed clears the marker.
1328 store.setModelSelection(threadRef, modelSelection(CODEX_DRIVER, "gpt-5.4"), {
1329 replaceOptions: true,
1330 });
1331 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionExplicit).toBeUndefined();
1332 });
1333
1334 it("persists the explicit marker through storage round-trips", async () => {
1335 vi.useFakeTimers();
1336 try {
1337 useComposerDraftStore
1338 .getState()
1339 .setModelSelection(threadRef, modelSelection(CODEX_DRIVER, "gpt-5.4"), {
1340 explicit: true,
1341 });
1342 // Land the debounced persist write.
1343 await vi.advanceTimersByTimeAsync(300);
1344
1345 // Hydrate from the same storage the store persists into and verify the
1346 // marker survives the partialize → decode → merge path.
1347 resetComposerDraftStore();
1348 await useComposerDraftStore.persist.rehydrate();
1349 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionExplicit).toBe(true);
1350 expect(
1351 draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider[CODEX_INSTANCE],
1352 ).toEqual(modelSelection(CODEX_DRIVER, "gpt-5.4"));
1353 } finally {
1354 vi.useRealTimers();
1355 }
1356 });
1357
1317 it("replaces only the targeted provider options on the current model selection", () => {1358 it("replaces only the targeted provider options on the current model selection", () => {
1318 const store = useComposerDraftStore.getState();1359 const store = useComposerDraftStore.getState();
13191360
...@@ -1356,6 +1397,23 @@ describe("composerDraftStore modelSelection", () => {...@@ -1356,6 +1397,23 @@ describe("composerDraftStore modelSelection", () => {
1356 );1397 );
1357 });1398 });
13581399
1400 it("marks trait edits as explicit model intent", () => {
1401 const store = useComposerDraftStore.getState();
1402 store.setModelSelection(threadRef, modelSelection(CODEX_DRIVER, "gpt-5.4"));
1403 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionExplicit).toBeUndefined();
1404
1405 store.setProviderModelOptions(
1406 threadRef,
1407 CODEX_DRIVER,
1408 toSelections({ reasoningEffort: "xhigh" }),
1409 );
1410
1411 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionExplicit).toBe(true);
1412 expect(
1413 draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider[CODEX_INSTANCE],
1414 ).toEqual(modelSelection(CODEX_DRIVER, "gpt-5.4", { reasoningEffort: "xhigh" }));
1415 });
1416
1359 it("keeps explicit default-state overrides on the selection", () => {1417 it("keeps explicit default-state overrides on the selection", () => {
1360 const store = useComposerDraftStore.getState();1418 const store = useComposerDraftStore.getState();
13611419
...@@ -1706,6 +1764,201 @@ describe("composerDraftStore sticky composer settings", () => {...@@ -1706,6 +1764,201 @@ describe("composerDraftStore sticky composer settings", () => {
1706 activeProvider: "claudeAgent",1764 activeProvider: "claudeAgent",
1707 });1765 });
1708 });1766 });
1767
1768 it("replaces a non-explicit stale model and its options with sticky state", () => {
1769 const store = useComposerDraftStore.getState();
1770 const draftId = DraftId.make("draft-stale-sticky-seed");
1771
1772 store.setModelSelection(
1773 draftId,
1774 modelSelection(CODEX_DRIVER, "stale-model", { reasoningEffort: "low" }),
1775 );
1776 store.setStickyModelSelection(
1777 modelSelection(CODEX_DRIVER, "sticky-model", { reasoningEffort: "xhigh" }),
1778 );
1779 store.applyStickyState(draftId);
1780
1781 expect(draftByKey(draftId)).toMatchObject({
1782 activeProvider: CODEX_INSTANCE,
1783 modelSelectionByProvider: {
1784 [CODEX_INSTANCE]: modelSelection(CODEX_DRIVER, "sticky-model", {
1785 reasoningEffort: "xhigh",
1786 }),
1787 },
1788 });
1789 });
1790
1791 it("clears a non-explicit stale model when there is no sticky state", () => {
1792 const store = useComposerDraftStore.getState();
1793 const draftId = DraftId.make("draft-stale-without-sticky");
1794
1795 store.setModelSelection(draftId, modelSelection(CODEX_DRIVER, "stale-model"));
1796 store.applyStickyState(draftId);
1797
1798 expect(draftByKey(draftId)).toBeUndefined();
1799 });
1800});
1801
1802describe("composerDraftStore model seed migration", () => {
1803 const staleDraftId = DraftId.make("draft-legacy-stale-model");
1804 const explicitDraftId = DraftId.make("draft-legacy-explicit-model");
1805 const typedDraftId = DraftId.make("draft-legacy-typed-model");
1806 const staleThreadId = ThreadId.make("thread-legacy-stale-model");
1807 const explicitThreadId = ThreadId.make("thread-legacy-explicit-model");
1808 const typedThreadId = ThreadId.make("thread-legacy-typed-model");
1809 const serverThreadId = ThreadId.make("thread-server-model");
1810 const serverThreadKey = scopedThreadKey(scopeThreadRef(TEST_ENVIRONMENT_ID, serverThreadId));
1811 const projectId = ProjectId.make("project-model-migration");
1812 const logicalProjectKey = `${TEST_ENVIRONMENT_ID}:/tmp/project-model-migration`;
1813
1814 const draftThread = (threadId: ThreadId) => ({
1815 threadId,
1816 environmentId: TEST_ENVIRONMENT_ID,
1817 projectId,
1818 logicalProjectKey,
1819 createdAt: "2026-08-01T00:00:00.000Z",
1820 runtimeMode: "full-access",
1821 interactionMode: "default",
1822 branch: null,
1823 worktreePath: null,
1824 envMode: "local",
1825 startFromOrigin: false,
1826 promotedTo: null,
1827 });
1828
1829 beforeEach(async () => {
1830 resetComposerDraftStore();
1831 await useComposerDraftStore.persist.clearStorage();
1832 });
1833
1834 afterEach(async () => {
1835 await useComposerDraftStore.persist.clearStorage();
1836 });
1837
1838 it.each([1, 2])(
1839 "keeps the legacy sticky Codex selection when v%s storage omitted the provider",
1840 async (version) => {
1841 vi.useFakeTimers();
1842 try {
1843 const stickySelection = modelSelection(CODEX_DRIVER, "gpt-5.6-terra", {
1844 reasoningEffort: "xhigh",
1845 });
1846 const storage = useComposerDraftStore.persist.getOptions().storage;
1847 expect(storage).toBeDefined();
1848 storage?.setItem(COMPOSER_DRAFT_STORAGE_KEY, {
1849 version,
1850 state: {
1851 draftsByThreadId: {},
1852 draftThreadsByThreadId: {},
1853 projectDraftThreadIdByProjectId: {},
1854 stickyModel: stickySelection.model,
1855 stickyModelOptions: providerModelOptions({
1856 [CODEX_DRIVER]: { reasoningEffort: "xhigh" },
1857 }),
1858 },
1859 } as never);
1860 await vi.advanceTimersByTimeAsync(300);
1861
1862 await useComposerDraftStore.persist.rehydrate();
1863
1864 expect(useComposerDraftStore.getState()).toMatchObject({
1865 stickyModelSelectionByProvider: { [CODEX_INSTANCE]: stickySelection },
1866 stickyActiveProvider: null,
1867 });
1868 } finally {
1869 vi.useRealTimers();
1870 }
1871 },
1872 );
1873
1874 it("strips seeded models only from empty draft sessions when upgrading storage", async () => {
1875 vi.useFakeTimers();
1876 try {
1877 const staleSelection = modelSelection(CODEX_DRIVER, "gpt-5.4");
1878 const stickySelection = modelSelection(CODEX_DRIVER, "gpt-5.6-terra", {
1879 reasoningEffort: "xhigh",
1880 });
1881 const storage = useComposerDraftStore.persist.getOptions().storage;
1882 expect(storage).toBeDefined();
1883 storage?.setItem(COMPOSER_DRAFT_STORAGE_KEY, {
1884 version: 8,
1885 state: {
1886 draftsByThreadKey: {
1887 [staleDraftId]: {
1888 prompt: "",
1889 attachments: [],
1890 modelSelectionByProvider: { [CODEX_INSTANCE]: staleSelection },
1891 activeProvider: CODEX_INSTANCE,
1892 runtimeMode: "approval-required",
1893 },
1894 [typedDraftId]: {
1895 prompt: "keep this prompt",
1896 attachments: [],
1897 modelSelectionByProvider: { [CODEX_INSTANCE]: staleSelection },
1898 activeProvider: CODEX_INSTANCE,
1899 },
1900 [explicitDraftId]: {
1901 prompt: "",
1902 attachments: [],
1903 modelSelectionByProvider: { [CODEX_INSTANCE]: staleSelection },
1904 activeProvider: CODEX_INSTANCE,
1905 modelSelectionExplicit: true,
1906 },
1907 [serverThreadKey]: {
1908 prompt: "",
1909 attachments: [],
1910 modelSelectionByProvider: { [CODEX_INSTANCE]: staleSelection },
1911 activeProvider: CODEX_INSTANCE,
1912 },
1913 },
1914 draftThreadsByThreadKey: {
1915 [staleDraftId]: draftThread(staleThreadId),
1916 [explicitDraftId]: draftThread(explicitThreadId),
1917 [typedDraftId]: draftThread(typedThreadId),
1918 },
1919 logicalProjectDraftThreadKeyByLogicalProjectKey: {
1920 [logicalProjectKey]: staleDraftId,
1921 },
1922 stickyModelSelectionByProvider: { [CODEX_INSTANCE]: stickySelection },
1923 stickyActiveProvider: CODEX_INSTANCE,
1924 },
1925 } as never);
1926 await vi.advanceTimersByTimeAsync(300);
1927
1928 await useComposerDraftStore.persist.rehydrate();
1929
1930 expect(draftByKey(staleDraftId)).toMatchObject({
1931 modelSelectionByProvider: {},
1932 activeProvider: null,
1933 runtimeMode: "approval-required",
1934 });
1935 expect(draftByKey(typedDraftId)).toMatchObject({
1936 prompt: "keep this prompt",
1937 modelSelectionByProvider: { [CODEX_INSTANCE]: staleSelection },
1938 activeProvider: CODEX_INSTANCE,
1939 });
1940 expect(draftByKey(explicitDraftId)).toMatchObject({
1941 modelSelectionByProvider: { [CODEX_INSTANCE]: staleSelection },
1942 activeProvider: CODEX_INSTANCE,
1943 modelSelectionExplicit: true,
1944 });
1945 expect(draftByKey(serverThreadKey)).toMatchObject({
1946 modelSelectionByProvider: { [CODEX_INSTANCE]: staleSelection },
1947 activeProvider: CODEX_INSTANCE,
1948 });
1949 expect(useComposerDraftStore.getState().draftThreadsByThreadKey[staleDraftId]).toMatchObject({
1950 environmentId: TEST_ENVIRONMENT_ID,
1951 projectId,
1952 logicalProjectKey,
1953 });
1954 expect(useComposerDraftStore.getState()).toMatchObject({
1955 stickyModelSelectionByProvider: { [CODEX_INSTANCE]: stickySelection },
1956 stickyActiveProvider: CODEX_INSTANCE,
1957 });
1958 } finally {
1959 vi.useRealTimers();
1960 }
1961 });
1709});1962});
17101963
1711describe("composerDraftStore provider-scoped option updates", () => {1964describe("composerDraftStore provider-scoped option updates", () => {
apps/web/src/composerDraftStore.ts+91-76
...@@ -58,7 +58,7 @@ const isProviderDriverKind = Schema.is(ProviderDriverKind);...@@ -58,7 +58,7 @@ const isProviderDriverKind = Schema.is(ProviderDriverKind);
58const isReviewCommentContext = Schema.is(ReviewCommentContextSchema);58const isReviewCommentContext = Schema.is(ReviewCommentContextSchema);
5959
60export const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1";60export const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1";
61const COMPOSER_DRAFT_STORAGE_VERSION = 8;61const COMPOSER_DRAFT_STORAGE_VERSION = 9;
62const DraftThreadEnvModeSchema = Schema.Literals(["local", "worktree"]);62const DraftThreadEnvModeSchema = Schema.Literals(["local", "worktree"]);
63export type DraftThreadEnvMode = typeof DraftThreadEnvModeSchema.Type;63export type DraftThreadEnvMode = typeof DraftThreadEnvModeSchema.Type;
6464
...@@ -146,6 +146,10 @@ const PersistedComposerThreadDraftState = Schema.Struct({...@@ -146,6 +146,10 @@ const PersistedComposerThreadDraftState = Schema.Struct({
146 // an entry already encodes "no selection for this instance".146 // an entry already encodes "no selection for this instance".
147 modelSelectionByProvider: Schema.optionalKey(Schema.Record(ProviderInstanceId, ModelSelection)),147 modelSelectionByProvider: Schema.optionalKey(Schema.Record(ProviderInstanceId, ModelSelection)),
148 activeProvider: Schema.optionalKey(Schema.NullOr(ProviderInstanceId)),148 activeProvider: Schema.optionalKey(Schema.NullOr(ProviderInstanceId)),
149 // True only when a human picked this selection in the composer. Seeded
150 // selections (project default / sticky) leave it unset so later seeds can
151 // replace them; legacy entries predate the flag and read as seeded too.
152 modelSelectionExplicit: Schema.optionalKey(Schema.Boolean),
149 runtimeMode: Schema.optionalKey(RuntimeMode),153 runtimeMode: Schema.optionalKey(RuntimeMode),
150 interactionMode: Schema.optionalKey(ProviderInteractionMode),154 interactionMode: Schema.optionalKey(ProviderInteractionMode),
151});155});
...@@ -274,6 +278,12 @@ export interface ComposerThreadDraftState {...@@ -274,6 +278,12 @@ export interface ComposerThreadDraftState {
274 modelSelectionByProvider: Partial<Record<ProviderInstanceId, ModelSelection>>;278 modelSelectionByProvider: Partial<Record<ProviderInstanceId, ModelSelection>>;
275 /** Routing key of the last picked instance (see `modelSelectionByProvider`). */279 /** Routing key of the last picked instance (see `modelSelectionByProvider`). */
276 activeProvider: ProviderInstanceId | null;280 activeProvider: ProviderInstanceId | null;
281 /**
282 * True only when a human picked the active selection in the composer.
283 * Absent/false means seeded (project default / sticky), so later seeds
284 * may replace it. Legacy entries predate the flag and read as seeded.
285 */
286 modelSelectionExplicit?: boolean;
277 runtimeMode: RuntimeMode | null;287 runtimeMode: RuntimeMode | null;
278 interactionMode: ProviderInteractionMode | null;288 interactionMode: ProviderInteractionMode | null;
279}289}
...@@ -434,6 +444,7 @@ interface ComposerDraftStoreState {...@@ -434,6 +444,7 @@ interface ComposerDraftStoreState {
434 threadRef: ComposerThreadTarget,444 threadRef: ComposerThreadTarget,
435 modelSelection: ModelSelection | null | undefined,445 modelSelection: ModelSelection | null | undefined,
436 opts?: {446 opts?: {
447 explicit?: boolean;
437 /**448 /**
438 * Replace the stored entry outright instead of preserving its449 * Replace the stored entry outright instead of preserving its
439 * existing options when the incoming selection has none. Used when450 * existing options when the incoming selection has none. Used when
...@@ -1609,12 +1620,18 @@ function normalizePersistedDraftThreads(...@@ -1609,12 +1620,18 @@ function normalizePersistedDraftThreads(
1609 const parsedThreadRef = parseScopedThreadKey(threadKeyOrId);1620 const parsedThreadRef = parseScopedThreadKey(threadKeyOrId);
1610 const threadKey = normalizeLegacyComposerStorageKey(threadKeyOrId);1621 const threadKey = normalizeLegacyComposerStorageKey(threadKeyOrId);
1611 logicalProjectDraftThreadKeyByLogicalProjectKey[logicalProjectKey] = threadKey;1622 logicalProjectDraftThreadKeyByLogicalProjectKey[logicalProjectKey] = threadKey;
1623 const existingDraftThread = draftThreadsByThreadKey[threadKey];
1612 if (parsedThreadRef) {1624 if (parsedThreadRef) {
1613 environmentIdByThreadId.set(parsedThreadRef.threadId, parsedThreadRef.environmentId);1625 environmentIdByThreadId.set(parsedThreadRef.threadId, parsedThreadRef.environmentId);
1614 }1626 }
1627 // Logical project keys may contain a workspace path after the
1628 // environment prefix. When the persisted draft already names that
1629 // logical key, its concrete project id remains authoritative.
1630 if (existingDraftThread?.logicalProjectKey === logicalProjectKey) {
1631 continue;
1632 }
1615 if (!projectRef) {1633 if (!projectRef) {
1616 const existingDraftThread = draftThreadsByThreadKey[threadKey];1634 if (existingDraftThread) {
1617 if (existingDraftThread && !existingDraftThread.logicalProjectKey) {
1618 draftThreadsByThreadKey[threadKey] = {1635 draftThreadsByThreadKey[threadKey] = {
1619 ...existingDraftThread,1636 ...existingDraftThread,
1620 logicalProjectKey,1637 logicalProjectKey,
...@@ -1622,7 +1639,7 @@ function normalizePersistedDraftThreads(...@@ -1622,7 +1639,7 @@ function normalizePersistedDraftThreads(
1622 }1639 }
1623 continue;1640 continue;
1624 }1641 }
1625 if (!draftThreadsByThreadKey[threadKey]) {1642 if (!existingDraftThread) {
1626 draftThreadsByThreadKey[threadKey] = {1643 draftThreadsByThreadKey[threadKey] = {
1627 threadId: parsedThreadRef?.threadId ?? (threadKey as ThreadId),1644 threadId: parsedThreadRef?.threadId ?? (threadKey as ThreadId),
1628 environmentId: projectRef.environmentId,1645 environmentId: projectRef.environmentId,
...@@ -1638,12 +1655,12 @@ function normalizePersistedDraftThreads(...@@ -1638,12 +1655,12 @@ function normalizePersistedDraftThreads(
1638 promotedTo: null,1655 promotedTo: null,
1639 };1656 };
1640 } else if (1657 } else if (
1641 draftThreadsByThreadKey[threadKey]?.projectId !== projectRef.projectId ||1658 existingDraftThread.projectId !== projectRef.projectId ||
1642 draftThreadsByThreadKey[threadKey]?.environmentId !== projectRef.environmentId1659 existingDraftThread.environmentId !== projectRef.environmentId
1643 ) {1660 ) {
1644 draftThreadsByThreadKey[threadKey] = {1661 draftThreadsByThreadKey[threadKey] = {
1645 ...draftThreadsByThreadKey[threadKey]!,1662 ...existingDraftThread,
1646 threadId: draftThreadsByThreadKey[threadKey]!.threadId,1663 threadId: existingDraftThread.threadId,
1647 environmentId: projectRef.environmentId,1664 environmentId: projectRef.environmentId,
1648 projectId: projectRef.projectId,1665 projectId: projectRef.projectId,
1649 logicalProjectKey,1666 logicalProjectKey,
...@@ -1724,6 +1741,7 @@ function normalizePersistedDraftsByThreadId(...@@ -1724,6 +1741,7 @@ function normalizePersistedDraftsByThreadId(
1724 const legacyDraftCandidate = draftValue as LegacyPersistedComposerThreadDraftState;1741 const legacyDraftCandidate = draftValue as LegacyPersistedComposerThreadDraftState;
1725 let modelSelectionByProvider: Partial<Record<ProviderInstanceId, ModelSelection>> = {};1742 let modelSelectionByProvider: Partial<Record<ProviderInstanceId, ModelSelection>> = {};
1726 let activeProvider: ProviderInstanceId | null = null;1743 let activeProvider: ProviderInstanceId | null = null;
1744 let modelSelectionExplicit: true | undefined = undefined;
17271745
1728 if (1746 if (
1729 draftCandidate.modelSelectionByProvider &&1747 draftCandidate.modelSelectionByProvider &&
...@@ -1734,6 +1752,7 @@ function normalizePersistedDraftsByThreadId(...@@ -1734,6 +1752,7 @@ function normalizePersistedDraftsByThreadId(
1734 Record<ProviderInstanceId, ModelSelection>1752 Record<ProviderInstanceId, ModelSelection>
1735 >;1753 >;
1736 activeProvider = normalizeProviderInstanceId(draftCandidate.activeProvider);1754 activeProvider = normalizeProviderInstanceId(draftCandidate.activeProvider);
1755 modelSelectionExplicit = draftCandidate.modelSelectionExplicit === true ? true : undefined;
1737 } else {1756 } else {
1738 // v2 or legacy format: migrate1757 // v2 or legacy format: migrate
1739 const normalizedModelOptions =1758 const normalizedModelOptions =
...@@ -1802,6 +1821,7 @@ function normalizePersistedDraftsByThreadId(...@@ -1802,6 +1821,7 @@ function normalizePersistedDraftsByThreadId(
1802 ? {1821 ? {
1803 modelSelectionByProvider: compactModelSelectionByProvider(modelSelectionByProvider),1822 modelSelectionByProvider: compactModelSelectionByProvider(modelSelectionByProvider),
1804 activeProvider,1823 activeProvider,
1824 ...(modelSelectionExplicit ? { modelSelectionExplicit: true } : {}),
1805 }1825 }
1806 : {}),1826 : {}),
1807 ...(runtimeMode ? { runtimeMode } : {}),1827 ...(runtimeMode ? { runtimeMode } : {}),
...@@ -1812,55 +1832,52 @@ function normalizePersistedDraftsByThreadId(...@@ -1812,55 +1832,52 @@ function normalizePersistedDraftsByThreadId(
1812 return nextDraftsByThreadKey;1832 return nextDraftsByThreadKey;
1813}1833}
18141834
1815function migratePersistedComposerDraftStoreState(1835function persistedComposerDraftHasUserContent(draft: PersistedComposerThreadDraftState): boolean {
1816 persistedState: unknown,1836 return (
1817): PersistedComposerDraftStoreState {1837 draft.prompt.trim().length > 0 ||
1818 if (!persistedState || typeof persistedState !== "object") {1838 draft.attachments.length > 0 ||
1819 return EMPTY_PERSISTED_DRAFT_STORE_STATE;1839 (draft.terminalContexts?.length ?? 0) > 0 ||
1820 }1840 (draft.elementContexts?.length ?? 0) > 0 ||
1821 const candidate = persistedState as LegacyPersistedComposerDraftStoreState;1841 (draft.previewAnnotations?.length ?? 0) > 0 ||
1822 const rawDraftMap = candidate.draftsByThreadKey ?? candidate.draftsByThreadId;1842 (draft.reviewComments?.length ?? 0) > 0
1823 const rawDraftThreadsByThreadId =
1824 candidate.draftThreadsByThreadKey ?? candidate.draftThreadsByThreadId;
1825 const rawProjectDraftThreadIdByProjectKey =
1826 candidate.logicalProjectDraftThreadKeyByLogicalProjectKey ??
1827 candidate.projectDraftThreadKeyByProjectKey ??
1828 candidate.projectDraftThreadIdByProjectKey ??
1829 candidate.projectDraftThreadIdByProjectId;
1830
1831 // Migrate sticky state from v2 (dual) to v3 (consolidated)
1832 const stickyModelOptions = normalizeProviderModelOptions(candidate.stickyModelOptions) ?? {};
1833 const normalizedStickyModelSelection = normalizeModelSelection(candidate.stickyModelSelection, {
1834 provider: candidate.stickyProvider ?? "codex",
1835 model: candidate.stickyModel,
1836 modelOptions: stickyModelOptions,
1837 });
1838 const nextStickyModelOptions = legacyMergeModelSelectionIntoProviderModelOptions(
1839 normalizedStickyModelSelection,
1840 stickyModelOptions,
1841 );
1842 const stickyModelSelection = legacySyncModelSelectionOptions(
1843 normalizedStickyModelSelection,
1844 nextStickyModelOptions,
1845 );
1846 const stickyModelSelectionByProvider = legacyToModelSelectionByProvider(
1847 stickyModelSelection,
1848 nextStickyModelOptions,
1849 );1843 );
1850 const stickyActiveProvider = normalizeProviderInstanceId(candidate.stickyProvider) ?? null;1844}
18511845
1852 const { draftThreadsByThreadKey, logicalProjectDraftThreadKeyByLogicalProjectKey } =1846function stripLegacyModelSeedsFromEmptyDraftSessions(
1853 normalizePersistedDraftThreads(rawDraftThreadsByThreadId, rawProjectDraftThreadIdByProjectKey);1847 draftsByThreadKey: PersistedComposerDraftStoreState["draftsByThreadKey"],
1854 const draftsByThreadKey = normalizePersistedDraftsByThreadId(1848 draftThreadsByThreadKey: PersistedComposerDraftStoreState["draftThreadsByThreadKey"],
1855 rawDraftMap,1849): PersistedComposerDraftStoreState["draftsByThreadKey"] {
1856 draftThreadsByThreadKey,1850 return Object.fromEntries(
1851 Object.entries(draftsByThreadKey).flatMap(([threadKey, draft]) => {
1852 if (
1853 draftThreadsByThreadKey[threadKey] === undefined ||
1854 draft.modelSelectionExplicit === true ||
1855 persistedComposerDraftHasUserContent(draft)
1856 ) {
1857 return [[threadKey, draft]];
1858 }
1859
1860 const {
1861 activeProvider: _activeProvider,
1862 modelSelectionByProvider: _modelSelectionByProvider,
1863 modelSelectionExplicit: _modelSelectionExplicit,
1864 ...retained
1865 } = draft;
1866 return retained.runtimeMode || retained.interactionMode ? [[threadKey, retained]] : [];
1867 }),
1857 );1868 );
1869}
1870
1871function migratePersistedComposerDraftStoreState(
1872 persistedState: unknown,
1873): PersistedComposerDraftStoreState {
1874 const normalized = normalizeCurrentPersistedComposerDraftStoreState(persistedState);
1858 return {1875 return {
1859 draftsByThreadKey,1876 ...normalized,
1860 draftThreadsByThreadKey,1877 draftsByThreadKey: stripLegacyModelSeedsFromEmptyDraftSessions(
1861 logicalProjectDraftThreadKeyByLogicalProjectKey,1878 normalized.draftsByThreadKey,
1862 stickyModelSelectionByProvider: compactModelSelectionByProvider(stickyModelSelectionByProvider),1879 normalized.draftThreadsByThreadKey,
1863 stickyActiveProvider,1880 ),
1864 };1881 };
1865}1882}
18661883
...@@ -1963,6 +1980,7 @@ function partializeComposerDraftStoreState(...@@ -1963,6 +1980,7 @@ function partializeComposerDraftStoreState(
1963 draft.modelSelectionByProvider,1980 draft.modelSelectionByProvider,
1964 ),1981 ),
1965 activeProvider: draft.activeProvider,1982 activeProvider: draft.activeProvider,
1983 ...(draft.modelSelectionExplicit ? { modelSelectionExplicit: true } : {}),
1966 }1984 }
1967 : {}),1985 : {}),
1968 ...(draft.runtimeMode ? { runtimeMode: draft.runtimeMode } : {}),1986 ...(draft.runtimeMode ? { runtimeMode: draft.runtimeMode } : {}),
...@@ -2029,7 +2047,7 @@ function normalizeCurrentPersistedComposerDraftStoreState(...@@ -2029,7 +2047,7 @@ function normalizeCurrentPersistedComposerDraftStoreState(
2029 const normalizedStickyModelSelection = normalizeModelSelection(2047 const normalizedStickyModelSelection = normalizeModelSelection(
2030 normalizedPersistedState.stickyModelSelection,2048 normalizedPersistedState.stickyModelSelection,
2031 {2049 {
2032 provider: normalizedPersistedState.stickyProvider,2050 provider: normalizedPersistedState.stickyProvider ?? "codex",
2033 model: normalizedPersistedState.stickyModel,2051 model: normalizedPersistedState.stickyModel,
2034 modelOptions: stickyModelOptions,2052 modelOptions: stickyModelOptions,
2035 },2053 },
...@@ -2211,6 +2229,7 @@ function toHydratedThreadDraft(...@@ -2211,6 +2229,7 @@ function toHydratedThreadDraft(
2211 reviewComments: persistedDraft.reviewComments?.map((comment) => ({ ...comment })) ?? [],2229 reviewComments: persistedDraft.reviewComments?.map((comment) => ({ ...comment })) ?? [],
2212 modelSelectionByProvider,2230 modelSelectionByProvider,
2213 activeProvider,2231 activeProvider,
2232 ...(persistedDraft.modelSelectionExplicit ? { modelSelectionExplicit: true } : {}),
2214 runtimeMode: persistedDraft.runtimeMode ?? null,2233 runtimeMode: persistedDraft.runtimeMode ?? null,
2215 interactionMode: persistedDraft.interactionMode ?? null,2234 interactionMode: persistedDraft.interactionMode ?? null,
2216 };2235 };
...@@ -2628,33 +2647,19 @@ const composerDraftStore = create<ComposerDraftStoreState>()(...@@ -2628,33 +2647,19 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
2628 set((state) => {2647 set((state) => {
2629 const stickyMap = state.stickyModelSelectionByProvider;2648 const stickyMap = state.stickyModelSelectionByProvider;
2630 const stickyActiveProvider = state.stickyActiveProvider;2649 const stickyActiveProvider = state.stickyActiveProvider;
2631 if (Object.keys(stickyMap).length === 0 && stickyActiveProvider === null) {
2632 return state;
2633 }
2634 const existing = state.draftsByThreadKey[threadKey];2650 const existing = state.draftsByThreadKey[threadKey];
2635 const base = existing ?? createEmptyThreadDraft();2651 const base = existing ?? createEmptyThreadDraft();
2636 const nextMap = { ...base.modelSelectionByProvider };2652 const nextMap = compactModelSelectionByProvider(stickyMap);
2637 for (const [provider, selection] of Object.entries(stickyMap)) {
2638 if (selection) {
2639 // Iteration key comes from the instance-keyed sticky map,
2640 // so coerce the string back to `ProviderInstanceId` for
2641 // the typed lookup.
2642 const instanceKey = provider as ProviderInstanceId;
2643 const current = nextMap[instanceKey];
2644 nextMap[instanceKey] = {
2645 ...selection,
2646 model: current?.model ?? selection.model,
2647 };
2648 }
2649 }
2650 if (2653 if (
2651 Equal.equals(base.modelSelectionByProvider, nextMap) &&2654 Equal.equals(base.modelSelectionByProvider, nextMap) &&
2652 base.activeProvider === stickyActiveProvider2655 base.activeProvider === stickyActiveProvider &&
2656 base.modelSelectionExplicit === undefined
2653 ) {2657 ) {
2654 return state;2658 return state;
2655 }2659 }
2660 const { modelSelectionExplicit: _modelSelectionExplicit, ...retained } = base;
2656 const nextDraft: ComposerThreadDraftState = {2661 const nextDraft: ComposerThreadDraftState = {
2657 ...base,2662 ...retained,
2658 modelSelectionByProvider: nextMap,2663 modelSelectionByProvider: nextMap,
2659 activeProvider: stickyActiveProvider,2664 activeProvider: stickyActiveProvider,
2660 };2665 };
...@@ -2745,14 +2750,20 @@ const composerDraftStore = create<ComposerDraftStoreState>()(...@@ -2745,14 +2750,20 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
2745 const nextActiveProvider = normalized?.instanceId ?? base.activeProvider;2750 const nextActiveProvider = normalized?.instanceId ?? base.activeProvider;
2746 if (2751 if (
2747 Equal.equals(base.modelSelectionByProvider, nextMap) &&2752 Equal.equals(base.modelSelectionByProvider, nextMap) &&
2748 base.activeProvider === nextActiveProvider2753 base.activeProvider === nextActiveProvider &&
2754 (base.modelSelectionExplicit ?? false) === (opts?.explicit === true)
2749 ) {2755 ) {
2750 return state;2756 return state;
2751 }2757 }
2758 // Last writer defines intent: picker writes mark the selection
2759 // explicit; seeding writes leave it unset so future seeds can
2760 // replace it.
2761 const { modelSelectionExplicit: _previousExplicit, ...restBase } = base;
2752 const nextDraft: ComposerThreadDraftState = {2762 const nextDraft: ComposerThreadDraftState = {
2753 ...base,2763 ...restBase,
2754 modelSelectionByProvider: nextMap,2764 modelSelectionByProvider: nextMap,
2755 activeProvider: nextActiveProvider,2765 activeProvider: nextActiveProvider,
2766 ...(opts?.explicit === true ? { modelSelectionExplicit: true as const } : {}),
2756 };2767 };
2757 const nextDraftsByThreadKey = { ...state.draftsByThreadKey };2768 const nextDraftsByThreadKey = { ...state.draftsByThreadKey };
2758 if (shouldRemoveDraft(nextDraft)) {2769 if (shouldRemoveDraft(nextDraft)) {
...@@ -2875,10 +2886,14 @@ const composerDraftStore = create<ComposerDraftStoreState>()(...@@ -2875,10 +2886,14 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
2875 return state;2886 return state;
2876 }2887 }
28772888
2889 // Trait edits are user-driven intent: mark the selection explicit
2890 // so later seeds cannot silently replace the chosen options.
2891 const { modelSelectionExplicit: _previousExplicit, ...restBase } = base;
2878 const nextDraft: ComposerThreadDraftState = {2892 const nextDraft: ComposerThreadDraftState = {
2879 ...base,2893 ...restBase,
2880 ...(options?.instanceId ? { activeProvider: instanceKey } : {}),2894 ...(options?.instanceId ? { activeProvider: instanceKey } : {}),
2881 modelSelectionByProvider: nextMap,2895 modelSelectionByProvider: nextMap,
2896 modelSelectionExplicit: true,
2882 };2897 };
2883 const nextDraftsByThreadKey = { ...state.draftsByThreadKey };2898 const nextDraftsByThreadKey = { ...state.draftsByThreadKey };
2884 if (shouldRemoveDraft(nextDraft)) {2899 if (shouldRemoveDraft(nextDraft)) {
apps/web/src/hooks/useHandleNewThread.ts+48-20
...@@ -24,7 +24,10 @@ import {...@@ -24,7 +24,10 @@ import {
24} from "../logicalProject";24} from "../logicalProject";
25import { resolveDefaultThreadEnvMode } from "@t3tools/shared/threadEnvMode";25import { resolveDefaultThreadEnvMode } from "@t3tools/shared/threadEnvMode";
26import { readThreadShell, useProjects, useThread } from "../state/entities";26import { readThreadShell, useProjects, useThread } from "../state/entities";
27import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions";27import {
28 resolveNewDraftStartFromOrigin,
29 resolveNewThreadModelSelectionOverride,
30} from "../lib/chatThreadActions";
28import { readT3ProjectFileDefaultThreadEnvMode } from "../lib/t3ProjectFileDefaults";31import { readT3ProjectFileDefaultThreadEnvMode } from "../lib/t3ProjectFileDefaults";
29import { primaryServerSettingsAtom } from "../state/server";32import { primaryServerSettingsAtom } from "../state/server";
30import { resolveThreadRouteTarget } from "../threadRoutes";33import { resolveThreadRouteTarget } from "../threadRoutes";
...@@ -99,11 +102,10 @@ export function useNewThreadHandler() {...@@ -99,11 +102,10 @@ export function useNewThreadHandler() {
99 setModelSelection,102 setModelSelection,
100 } = useComposerDraftStore.getState();103 } = useComposerDraftStore.getState();
101 const currentRouteTarget = getCurrentRouteTarget();104 const currentRouteTarget = getCurrentRouteTarget();
102 // A new thread carries the user's *working mode* from the thread being105 // A new thread carries the user's working mode from the thread being
103 // viewed: model (including options like reasoning effort and context106 // viewed. The target project's configured model still wins; runtime and
104 // window), permission mode, and interaction mode. Branch, worktree, and107 // interaction modes carry independently. Branch, worktree, and env mode
105 // env mode never carry implicitly — those come from the configured108 // come from configured defaults unless the caller passes them explicitly.
106 // defaults unless the caller passes them explicitly.
107 const carrySourceShell =109 const carrySourceShell =
108 currentRouteTarget?.kind === "server"110 currentRouteTarget?.kind === "server"
109 ? readThreadShell(currentRouteTarget.threadRef)111 ? readThreadShell(currentRouteTarget.threadRef)
...@@ -161,6 +163,14 @@ export function useNewThreadHandler() {...@@ -161,6 +163,14 @@ export function useNewThreadHandler() {
161 candidate.id === projectRef.projectId &&163 candidate.id === projectRef.projectId &&
162 candidate.environmentId === projectRef.environmentId,164 candidate.environmentId === projectRef.environmentId,
163 );165 );
166 const resolveModelSelectionOverride = (destinationDraftId: DraftId) =>
167 resolveNewThreadModelSelectionOverride({
168 projectDefaultSelection: project?.defaultModelSelection ?? null,
169 carrySelection: carryModelSelection,
170 carrySourceDraftId:
171 currentRouteTarget?.kind === "draft" ? currentRouteTarget.draftId : null,
172 destinationDraftId,
173 });
164 // The shared resolver owns the priority order. The t3.json read is174 // The shared resolver owns the priority order. The t3.json read is
165 // skipped entirely when a higher-priority source decides, and its175 // skipped entirely when a higher-priority source decides, and its
166 // query atom caches per project after the first call.176 // query atom caches per project after the first call.
...@@ -229,8 +239,10 @@ export function useNewThreadHandler() {...@@ -229,8 +239,10 @@ export function useNewThreadHandler() {
229 // env context resets to the configured defaults so drafts seeded239 // env context resets to the configured defaults so drafts seeded
230 // before a defaults change (or by the old carry-over behavior) stop240 // before a defaults change (or by the old carry-over behavior) stop
231 // landing on "current checkout" branches forever. When the draft is241 // landing on "current checkout" branches forever. When the draft is
232 // already open and no options were passed, leave it alone entirely —242 // already open and no options were passed, leave its workspace
233 // the user may have just picked a branch in the composer.243 // context alone entirely — the user may have just picked a branch
244 // in the composer. Model selection has its own explicit-pick rule
245 // below and does not follow this guard.
234 let workspaceContext: NewThreadWorkspaceOptions | null = null;246 let workspaceContext: NewThreadWorkspaceOptions | null = null;
235 if (hasExplicitWorkspaceOption) {247 if (hasExplicitWorkspaceOption) {
236 workspaceContext = pickExplicitWorkspaceOptions(options);248 workspaceContext = pickExplicitWorkspaceOptions(options);
...@@ -276,11 +288,29 @@ export function useNewThreadHandler() {...@@ -276,11 +288,29 @@ export function useNewThreadHandler() {
276 ...(carryRuntimeMode ? { runtimeMode: carryRuntimeMode } : {}),288 ...(carryRuntimeMode ? { runtimeMode: carryRuntimeMode } : {}),
277 ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}),289 ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}),
278 });290 });
279 if (carryModelSelection) {291 }
280 // The carried selection is a complete snapshot of the viewed292 // Model intent: an explicit human pick always stands. Seeds and
281 // thread's model state: absent options mean "no options", not293 // legacy entries alike re-resolve here — sticky first, mirroring
282 // "keep the stale draft's options".294 // the mint-fresh path, then the project default or carried
283 setModelSelection(emptyStoredDraftThread.draftId, carryModelSelection, {295 // selection on top. This runs even when the draft is already open:
296 // without it, a changed pin could never reach the draft the user
297 // is looking at, because explicit picks are the only thing the
298 // flag protects.
299 const storedDraft = getComposerDraft(emptyStoredDraftThread.draftId);
300 const storedActiveSelection = storedDraft?.activeProvider
301 ? storedDraft.modelSelectionByProvider[storedDraft.activeProvider]
302 : undefined;
303 const storedDraftHasExplicitModelPick =
304 Boolean(storedActiveSelection) && storedDraft?.modelSelectionExplicit === true;
305 if (!storedDraftHasExplicitModelPick) {
306 applyStickyState(emptyStoredDraftThread.draftId);
307 const modelSelectionOverride = resolveModelSelectionOverride(
308 emptyStoredDraftThread.draftId,
309 );
310 if (modelSelectionOverride) {
311 // This is a complete snapshot: absent options mean "no options",
312 // not "keep the stale draft's options".
313 setModelSelection(emptyStoredDraftThread.draftId, modelSelectionOverride, {
284 replaceOptions: true,314 replaceOptions: true,
285 });315 });
286 }316 }
...@@ -412,13 +442,11 @@ export function useNewThreadHandler() {...@@ -412,13 +442,11 @@ export function useNewThreadHandler() {
412 ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}),442 ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}),
413 });443 });
414 applyStickyState(draftId);444 applyStickyState(draftId);
415 if (carryModelSelection) {445 const modelSelectionOverride = resolveModelSelectionOverride(draftId);
416 // After sticky state so the viewed thread's exact selection446 if (modelSelectionOverride) {
417 // (model + options like effort and context window) wins over the447 // Project defaults and carried selections both outrank global sticky
418 // globally sticky one. replaceOptions: the carried selection is a448 // state. The project default wins when both are present.
419 // complete snapshot — absent options mean "no options", not "keep449 setModelSelection(draftId, modelSelectionOverride, { replaceOptions: true });
420 // whatever sticky state just wrote".
421 setModelSelection(draftId, carryModelSelection, { replaceOptions: true });
422 }450 }
423 carryComposerContentTo(draftId);451 carryComposerContentTo(draftId);
424452
apps/web/src/lib/chatThreadActions.test.ts+48-1
...@@ -1,9 +1,15 @@...@@ -1,9 +1,15 @@
1import { scopeProjectRef } from "@t3tools/client-runtime/environment";1import { scopeProjectRef } from "@t3tools/client-runtime/environment";
2import { EnvironmentId, ProjectId } from "@t3tools/contracts";2import {
3 EnvironmentId,
4 ProjectId,
5 ProviderInstanceId,
6 type ModelSelection,
7} from "@t3tools/contracts";
3import { describe, expect, it, vi } from "vite-plus/test";8import { describe, expect, it, vi } from "vite-plus/test";
4import {9import {
5 resolveThreadActionProjectRef,10 resolveThreadActionProjectRef,
6 resolveNewDraftStartFromOrigin,11 resolveNewDraftStartFromOrigin,
12 resolveNewThreadModelSelectionOverride,
7 startNewThreadFromContext,13 startNewThreadFromContext,
8 type ChatThreadActionContext,14 type ChatThreadActionContext,
9} from "./chatThreadActions";15} from "./chatThreadActions";
...@@ -11,6 +17,14 @@ import {...@@ -11,6 +17,14 @@ import {
11const ENVIRONMENT_ID = EnvironmentId.make("environment-1");17const ENVIRONMENT_ID = EnvironmentId.make("environment-1");
12const PROJECT_ID = ProjectId.make("project-1");18const PROJECT_ID = ProjectId.make("project-1");
13const FALLBACK_PROJECT_ID = ProjectId.make("project-2");19const FALLBACK_PROJECT_ID = ProjectId.make("project-2");
20const PROJECT_DEFAULT_SELECTION: ModelSelection = {
21 instanceId: ProviderInstanceId.make("codex"),
22 model: "project-default",
23};
24const CARRIED_SELECTION: ModelSelection = {
25 instanceId: ProviderInstanceId.make("codex"),
26 model: "carried-model",
27};
1428
15function createContext(overrides: Partial<ChatThreadActionContext> = {}): ChatThreadActionContext {29function createContext(overrides: Partial<ChatThreadActionContext> = {}): ChatThreadActionContext {
16 return {30 return {
...@@ -23,6 +37,39 @@ function createContext(overrides: Partial<ChatThreadActionContext> = {}): ChatTh...@@ -23,6 +37,39 @@ function createContext(overrides: Partial<ChatThreadActionContext> = {}): ChatTh
23}37}
2438
25describe("chatThreadActions", () => {39describe("chatThreadActions", () => {
40 it("does not carry a non-explicit model from the destination draft back into itself", () => {
41 expect(
42 resolveNewThreadModelSelectionOverride({
43 projectDefaultSelection: null,
44 carrySelection: CARRIED_SELECTION,
45 carrySourceDraftId: "draft-a",
46 destinationDraftId: "draft-a",
47 }),
48 ).toBeNull();
49 });
50
51 it("still carries models between different threads when the project has no default", () => {
52 expect(
53 resolveNewThreadModelSelectionOverride({
54 projectDefaultSelection: null,
55 carrySelection: CARRIED_SELECTION,
56 carrySourceDraftId: "draft-a",
57 destinationDraftId: "draft-b",
58 }),
59 ).toEqual(CARRIED_SELECTION);
60 });
61
62 it("keeps the project default above any carried selection", () => {
63 expect(
64 resolveNewThreadModelSelectionOverride({
65 projectDefaultSelection: PROJECT_DEFAULT_SELECTION,
66 carrySelection: CARRIED_SELECTION,
67 carrySourceDraftId: "draft-a",
68 destinationDraftId: "draft-b",
69 }),
70 ).toEqual(PROJECT_DEFAULT_SELECTION);
71 });
72
26 it("only applies the start-from-origin default to new worktree drafts", () => {73 it("only applies the start-from-origin default to new worktree drafts", () => {
27 expect(74 expect(
28 resolveNewDraftStartFromOrigin({75 resolveNewDraftStartFromOrigin({
apps/web/src/lib/chatThreadActions.ts+18-1
...@@ -1,5 +1,10 @@...@@ -1,5 +1,10 @@
1import { scopeProjectRef } from "@t3tools/client-runtime/environment";1import { scopeProjectRef } from "@t3tools/client-runtime/environment";
2import type { EnvironmentId, ProjectId, ScopedProjectRef } from "@t3tools/contracts";2import type {
3 EnvironmentId,
4 ModelSelection,
5 ProjectId,
6 ScopedProjectRef,
7} from "@t3tools/contracts";
3import type { DraftThreadEnvMode } from "../composerDraftStore";8import type { DraftThreadEnvMode } from "../composerDraftStore";
49
5interface ThreadContextLike {10interface ThreadContextLike {
...@@ -34,6 +39,18 @@ export function resolveNewDraftStartFromOrigin(input: {...@@ -34,6 +39,18 @@ export function resolveNewDraftStartFromOrigin(input: {
34 return input.envMode === "worktree" && input.newWorktreesStartFromOrigin;39 return input.envMode === "worktree" && input.newWorktreesStartFromOrigin;
35}40}
3641
42export function resolveNewThreadModelSelectionOverride(input: {
43 readonly projectDefaultSelection: ModelSelection | null;
44 readonly carrySelection: ModelSelection | null;
45 readonly carrySourceDraftId: string | null;
46 readonly destinationDraftId: string;
47}): ModelSelection | null {
48 return (
49 input.projectDefaultSelection ??
50 (input.carrySourceDraftId === input.destinationDraftId ? null : input.carrySelection)
51 );
52}
53
37export function resolveThreadActionProjectRef(54export function resolveThreadActionProjectRef(
38 context: ChatThreadActionContext,55 context: ChatThreadActionContext,
39): ScopedProjectRef | null {56): ScopedProjectRef | null {