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: {
712712 if (editingPendingTask) {
713713 flow.finishEditingPendingTask();
714714 } else {
715 // Drop the workspace selection with the content: the next task should
716 // re-resolve mode/branch/origin from the server's configured defaults
717 // instead of resurrecting this task's picks.
718 clearComposerDraftContent(draftKey, { clearWorkspaceSelection: true });
715 // Drop draft-local model/workspace selections with the content. The
716 // next task re-resolves project defaults before sticky app defaults.
717 clearComposerDraftContent(draftKey, {
718 clearModelSelection: true,
719 clearWorkspaceSelection: true,
720 });
719721 }
720722 navigation.getParent()?.goBack();
721723 return;
......@@ -779,7 +781,10 @@ export function NewTaskDraftScreen(props: {
779781 }
780782 flow.finishEditingPendingTask();
781783 } else {
782 clearComposerDraftContent(draftKey, { clearWorkspaceSelection: true });
784 clearComposerDraftContent(draftKey, {
785 clearModelSelection: true,
786 clearWorkspaceSelection: true,
787 });
783788 }
784789 navigation.dispatch(
785790 StackActions.replace("Thread", {
apps/mobile/src/features/threads/new-task-flow-provider.tsx+27-11
......@@ -33,6 +33,7 @@ import {
3333 buildModelOptions,
3434 groupByProvider,
3535 resolveDefaultableModelSelection,
36 resolveNewTaskModelSelection,
3637 resolveSelectableModelSelection,
3738} from "../../lib/modelOptions";
3839import { scopedProjectKey } from "../../lib/scopedEntities";
......@@ -48,8 +49,10 @@ import {
4849 removeComposerDraftAttachment,
4950 replaceComposerDraftAttachments,
5051 setComposerDraftText,
52 setStickyComposerModelSelection,
5153 updateComposerDraftSettings,
5254 useComposerDraft,
55 useStickyComposerModelSelection,
5356} from "../../state/use-composer-drafts";
5457import { useDebouncedValue, usePaginatedBranches } from "../../state/queries";
5558import { vcsEnvironment } from "../../state/vcs";
......@@ -418,21 +421,33 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
418421 selectedEnvironmentServerConfig,
419422 selectedProject?.defaultModelSelection ?? null,
420423 );
424 const storedStickyModelSelection = useStickyComposerModelSelection();
425 const stickyModelSelection = resolveDefaultableModelSelection(
426 selectedEnvironmentServerConfig,
427 storedStickyModelSelection,
428 );
421429 const modelOptions = useMemo(
422430 () =>
423431 buildModelOptions(
424432 selectedEnvironmentServerConfig,
425 draftModelSelection ?? projectDefaultModelSelection,
433 draftModelSelection ?? projectDefaultModelSelection ?? stickyModelSelection,
426434 ),
427 [selectedEnvironmentServerConfig, draftModelSelection, projectDefaultModelSelection],
435 [
436 selectedEnvironmentServerConfig,
437 draftModelSelection,
438 projectDefaultModelSelection,
439 stickyModelSelection,
440 ],
428441 );
429442
430 const selectedModel =
431 draftModelSelection ??
432 projectDefaultModelSelection ??
433 modelOptions.find((option) => option.isDefault)?.selection ??
434 modelOptions[0]?.selection ??
435 null;
443 // An unsent draft keeps its explicit pick. Fresh drafts resolve the project
444 // default before the last manual app-wide selection and provider default.
445 const selectedModel = resolveNewTaskModelSelection({
446 draftSelection: draftModelSelection,
447 projectDefaultSelection: projectDefaultModelSelection,
448 stickySelection: stickyModelSelection,
449 modelOptions,
450 });
436451 const selectedModelKey = selectedModel
437452 ? `${selectedModel.instanceId}:${selectedModel.model}`
438453 : null;
......@@ -462,9 +477,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
462477 if (!option) {
463478 return;
464479 }
465 updateComposerDraftSettings(selectedProjectDraftKey, {
466 modelSelection: options ? { ...option.selection, options } : option.selection,
467 });
480 const selection = options ? { ...option.selection, options } : option.selection;
481 updateComposerDraftSettings(selectedProjectDraftKey, { modelSelection: selection });
482 setStickyComposerModelSelection(selection);
468483 },
469484 [modelOptions, selectedProjectDraftKey],
470485 );
......@@ -482,6 +497,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
482497 updateComposerDraftSettings(selectedProjectDraftKey, {
483498 modelSelection: nextSelection,
484499 });
500 setStickyComposerModelSelection(nextSelection);
485501 },
486502 [selectedModel, selectedProjectDraftKey],
487503 );
apps/mobile/src/lib/modelOptions.test.ts+29-1
......@@ -1,12 +1,14 @@
11import { 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
55import {
66 buildModelOptions,
77 groupByProvider,
88 resolveDefaultableModelSelection,
9 resolveNewTaskModelSelection,
910 resolveSelectableModelSelection,
11 type ModelOption,
1012} from "./modelOptions";
1113
1214describe("mobile model options", () => {
......@@ -220,4 +222,30 @@ describe("mobile model options", () => {
220222 // Offline: nothing to validate against, selection passes through.
221223 expect(resolveDefaultableModelSelection(null, legacy)).toBe(legacy);
222224 });
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 });
223251});
apps/mobile/src/lib/modelOptions.ts+16
......@@ -104,6 +104,22 @@ export function resolveDefaultableModelSelection(
104104 return model?.isLegacy === true ? null : usable;
105105}
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
107123export function buildModelOptions(
108124 config: T3ServerConfig | null | undefined,
109125 fallbackModelSelection: ModelSelection | null,
apps/mobile/src/state/use-composer-drafts.test.ts+232-8
......@@ -7,6 +7,9 @@ const composerDraftFileMocks = vi.hoisted(() => {
77 let writeError: Error | null = null;
88 let releaseRead: (() => void) | null = null;
99 let readBarrier = Promise.resolve();
10 let nextWriteBarrier: Promise<void> | null = null;
11 let onWrite: (() => void) | null = null;
12 const writes: string[] = [];
1013
1114 return {
1215 blockRead() {
......@@ -27,6 +30,18 @@ const composerDraftFileMocks = vi.hoisted(() => {
2730 setWriteError(error: Error | null) {
2831 writeError = error;
2932 },
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 },
3045 Directory: class {
3146 create() {}
3247 },
......@@ -47,7 +62,18 @@ const composerDraftFileMocks = vi.hoisted(() => {
4762 if (writeError) {
4863 throw writeError;
4964 }
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 }
5074 document = value;
75 writes.push(value);
76 onWrite?.();
5177 }
5278 },
5379 };
......@@ -62,18 +88,24 @@ vi.mock("expo-file-system", () => ({
6288import { appAtomRegistry } from "./atom-registry";
6389import {
6490 clearComposerDraftContentState,
91 clearComposerDraftsEnvironment,
6592 ComposerDraftPersistenceError,
6693 composerDraftsAtom,
6794 copyComposerDraftContentIfEmpty,
6895 copyComposerDraftContentState,
96 decodePersistedComposerState,
6997 decodePersistedComposerDrafts,
98 ensureComposerDraftsLoaded,
7099 type ComposerDraft,
71100 flushComposerDrafts,
72101 getComposerDraftSnapshot,
73102 mergeComposerDraftContentState,
74103 removeComposerDraftsForEnvironment,
104 resetComposerDraftsLoadState,
75105 restoreComposerDraftSnapshotState,
76106 setComposerDraftText,
107 setStickyComposerModelSelection,
108 stickyComposerModelSelectionAtom,
77109} from "./use-composer-drafts";
78110
79111const DRAFT: ComposerDraft = {
......@@ -82,7 +114,15 @@ const DRAFT: ComposerDraft = {
82114};
83115
84116afterEach(() => {
117 vi.useRealTimers();
118 resetComposerDraftsLoadState();
119 composerDraftFileMocks.setDocument("");
120 composerDraftFileMocks.setWriteError(null);
121 composerDraftFileMocks.setNextWriteBarrier(null);
122 composerDraftFileMocks.setOnWrite(null);
123 composerDraftFileMocks.resetWrites();
85124 appAtomRegistry.set(composerDraftsAtom, {});
125 appAtomRegistry.set(stickyComposerModelSelectionAtom, null);
86126});
87127
88128describe("mobile composer drafts", () => {
......@@ -154,6 +194,195 @@ describe("mobile composer drafts", () => {
154194 ).toThrow();
155195 });
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
157386 it("clears sent content without clearing the selected model or workspace", () => {
158387 const draftKey = "environment-1:thread-1";
159388 const draft: ComposerDraft = {
......@@ -182,7 +411,7 @@ describe("mobile composer drafts", () => {
182411 });
183412 });
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", () => {
186415 const draftKey = "new-task:environment-1:project-1";
187416 const draft: ComposerDraft = {
188417 text: "send this",
......@@ -201,15 +430,10 @@ describe("mobile composer drafts", () => {
201430
202431 expect(
203432 clearComposerDraftContentState({ [draftKey]: draft }, draftKey, {
433 clearModelSelection: true,
204434 clearWorkspaceSelection: true,
205435 }),
206 ).toEqual({
207 [draftKey]: {
208 modelSelection: draft.modelSelection,
209 text: "",
210 attachments: [],
211 },
212 });
436 ).toEqual({});
213437 });
214438
215439 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({
8686const PersistedComposerDraftsSchema = Schema.Struct({
8787 schemaVersion: Schema.Literal(COMPOSER_DRAFTS_SCHEMA_VERSION),
8888 drafts: Schema.Record(Schema.String, ComposerDraftSchema),
89 stickyModelSelection: Schema.optional(ModelSelectionSchema),
8990});
9091
9192const decodePersistedComposerDraftsDocument = Schema.decodeUnknownSync(
......@@ -102,10 +103,20 @@ export const composerDraftsAtom = Atom.make<Record<string, ComposerDraft>>({}).p
102103 Atom.withLabel("mobile:composer-drafts"),
103104);
104105
106export const stickyComposerModelSelectionAtom = Atom.make<ModelSelection | null>(null).pipe(
107 Atom.keepAlive,
108 Atom.withLabel("mobile:sticky-composer-model-selection"),
109);
110
105111let loadPromise: Promise<void> | null = null;
106112let persistTimer: ReturnType<typeof setTimeout> | null = null;
107113const persistenceQueue = new SerializedAsyncQueue();
108114
115/** Resets module-level state between test runs. */
116export function resetComposerDraftsLoadState(): void {
117 loadPromise = null;
118}
119
109120function normalizeDraft(draft: ComposerDraft | undefined): ComposerDraft {
110121 if (!draft) {
111122 return EMPTY_DRAFT;
......@@ -136,11 +147,46 @@ function isEmptyDraft(draft: ComposerDraft): boolean {
136147 );
137148}
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} {
140154 const parsed = decodePersistedComposerDraftsDocument(value);
141 return Object.fromEntries(
142 Object.entries(parsed.drafts).filter(([, draft]) => !isEmptyDraft(draft)),
143 );
155 return {
156 drafts: Object.fromEntries(
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;
144190}
145191
146192async function getComposerDraftsFile() {
......@@ -150,17 +196,20 @@ async function getComposerDraftsFile() {
150196 return new File(directory, COMPOSER_DRAFTS_FILE);
151197}
152198
153async function loadPersistedComposerDrafts(): Promise<Record<string, ComposerDraft>> {
199async function loadPersistedComposerState(): Promise<{
200 readonly drafts: Record<string, ComposerDraft>;
201 readonly stickyModelSelection: ModelSelection | null;
202}> {
154203 let operation: ComposerDraftPersistenceError["operation"] = "open";
155204 try {
156205 const file = await getComposerDraftsFile();
157206 if (!file.exists) {
158 return {};
207 return { drafts: {}, stickyModelSelection: null };
159208 }
160209 operation = "read";
161210 const raw = await file.text();
162211 operation = "decode";
163 return decodePersistedComposerDrafts(JSON.parse(raw) as unknown);
212 return decodePersistedComposerState(JSON.parse(raw) as unknown);
164213 } catch (cause) {
165214 console.warn(
166215 "[composer-drafts] ignored persisted draft failure",
......@@ -171,11 +220,14 @@ async function loadPersistedComposerDrafts(): Promise<Record<string, ComposerDra
171220 cause,
172221 }),
173222 );
174 return {};
223 return { drafts: {}, stickyModelSelection: null };
175224 }
176225}
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> {
179231 let operation: ComposerDraftPersistenceError["operation"] = "open";
180232 try {
181233 const file = await getComposerDraftsFile();
......@@ -186,6 +238,7 @@ async function writePersistedComposerDrafts(drafts: Record<string, ComposerDraft
186238 const document = {
187239 schemaVersion: COMPOSER_DRAFTS_SCHEMA_VERSION,
188240 drafts: nonEmptyDrafts,
241 ...(stickyModelSelection ? { stickyModelSelection } : {}),
189242 } as const;
190243 const encoded = JSON.stringify(document);
191244 operation = "write";
......@@ -200,21 +253,18 @@ async function writePersistedComposerDrafts(drafts: Record<string, ComposerDraft
200253 }
201254}
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
212256/**
213257 * Lands any debounced or in-flight draft write before the JS runtime is torn
214258 * down (app update restart), so the freshest draft state survives it. A write
215259 * failure propagates so the caller can decide whether the restart may proceed.
216260 */
217261export 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 }
218268 // An edit during an awaited write schedules another debounced write, so
219269 // keep landing snapshots until no debounce is pending after a queue drain.
220270 do {
......@@ -222,20 +272,41 @@ export async function flushComposerDrafts(): Promise<void> {
222272 clearTimeout(persistTimer);
223273 persistTimer = null;
224274 await persistenceQueue.run(() =>
225 writePersistedComposerDrafts(appAtomRegistry.get(composerDraftsAtom)),
275 writePersistedComposerState(
276 appAtomRegistry.get(composerDraftsAtom),
277 appAtomRegistry.get(stickyComposerModelSelectionAtom),
278 ),
226279 );
227280 }
281 // Draining also waits for an already-fired debounce whose write is still
282 // gated behind its own hydration await inside the queue.
228283 await persistenceQueue.run(() => Promise.resolve());
229284 } while (persistTimer !== null);
230285}
231286
232function schedulePersistComposerDrafts(drafts: Record<string, ComposerDraft>): void {
287function schedulePersistComposerState(): void {
233288 if (persistTimer !== null) {
234289 clearTimeout(persistTimer);
235290 }
236291 persistTimer = setTimeout(() => {
237292 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 });
239310 }, PERSIST_DEBOUNCE_MS);
240311}
241312
......@@ -243,16 +314,21 @@ export function ensureComposerDraftsLoaded(): void {
243314 if (loadPromise !== null) {
244315 return;
245316 }
246 loadPromise = loadPersistedComposerDrafts()
247 .then((persistedDrafts) => {
248 if (Object.keys(persistedDrafts).length === 0) {
249 return;
317 loadPromise = loadPersistedComposerState()
318 .then((persisted) => {
319 if (Object.keys(persisted.drafts).length > 0) {
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);
250331 }
251 const current = appAtomRegistry.get(composerDraftsAtom);
252 appAtomRegistry.set(composerDraftsAtom, {
253 ...persistedDrafts,
254 ...current,
255 });
256332 })
257333 .catch((cause) => {
258334 console.warn(
......@@ -277,7 +353,12 @@ function updateComposerDrafts(
277353 return;
278354 }
279355 appAtomRegistry.set(composerDraftsAtom, next);
280 schedulePersistComposerDrafts(next);
356 schedulePersistComposerState();
357}
358
359export function setStickyComposerModelSelection(modelSelection: ModelSelection): void {
360 appAtomRegistry.set(stickyComposerModelSelectionAtom, modelSelection);
361 schedulePersistComposerState();
281362}
282363
283364export function setComposerDraftText(draftKey: string, value: string): void {
......@@ -394,15 +475,24 @@ export function updateComposerDraftSettings(
394475export function clearComposerDraftContentState(
395476 current: Record<string, ComposerDraft>,
396477 draftKey: string,
397 options?: { readonly clearWorkspaceSelection?: boolean },
478 options?: {
479 readonly clearModelSelection?: boolean;
480 readonly clearWorkspaceSelection?: boolean;
481 },
398482): Record<string, ComposerDraft> {
399483 const existing = current[draftKey];
400484 if (!existing) {
401485 return current;
402486 }
403 const { importedShareIds: _importedShareIds, workspaceSelection, ...retained } = existing;
487 const {
488 importedShareIds: _importedShareIds,
489 modelSelection,
490 workspaceSelection,
491 ...retained
492 } = existing;
404493 const draft = {
405494 ...retained,
495 ...(options?.clearModelSelection || modelSelection === undefined ? {} : { modelSelection }),
406496 ...(options?.clearWorkspaceSelection || workspaceSelection === undefined
407497 ? {}
408498 : { workspaceSelection }),
......@@ -571,7 +661,9 @@ export async function mergeComposerDraftContent(
571661 if (next !== current) {
572662 appAtomRegistry.set(composerDraftsAtom, next);
573663 }
574 await persistenceQueue.run(() => writePersistedComposerDrafts(next));
664 await persistenceQueue.run(() =>
665 writePersistedComposerState(next, appAtomRegistry.get(stickyComposerModelSelectionAtom)),
666 );
575667 return { skippedAttachmentCount };
576668}
577669
......@@ -594,12 +686,17 @@ export async function restoreComposerDraftSnapshot(
594686 snapshot,
595687 );
596688 appAtomRegistry.set(composerDraftsAtom, next);
597 await persistenceQueue.run(() => writePersistedComposerDrafts(next));
689 await persistenceQueue.run(() =>
690 writePersistedComposerState(next, appAtomRegistry.get(stickyComposerModelSelectionAtom)),
691 );
598692}
599693
600694export function clearComposerDraftContent(
601695 draftKey: string,
602 options?: { readonly clearWorkspaceSelection?: boolean },
696 options?: {
697 readonly clearModelSelection?: boolean;
698 readonly clearWorkspaceSelection?: boolean;
699 },
603700): void {
604701 updateComposerDrafts((current) => clearComposerDraftContentState(current, draftKey, options));
605702}
......@@ -645,7 +742,9 @@ export async function clearComposerDraftsEnvironment(environmentId: EnvironmentI
645742 persistTimer = null;
646743 }
647744 appAtomRegistry.set(composerDraftsAtom, next);
648 await persistenceQueue.run(() => writePersistedComposerDrafts(next));
745 await persistenceQueue.run(() =>
746 writePersistedComposerState(next, appAtomRegistry.get(stickyComposerModelSelectionAtom)),
747 );
649748}
650749
651750export function useComposerDraft(draftKey: string | null): ComposerDraft {
......@@ -655,3 +754,11 @@ export function useComposerDraft(draftKey: string | null): ComposerDraft {
655754 }, []);
656755 return draftKey ? normalizeDraft(drafts[draftKey]) : EMPTY_DRAFT;
657756}
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) {
65986598 setComposerDraftModelSelection(
65996599 scopeThreadRef(activeThread.environmentId, activeThread.id),
66006600 nextModelSelection,
6601 { explicit: true },
66016602 );
66026603 setStickyComposerModelSelection(nextModelSelection);
66036604 scheduleComposerFocus();
apps/web/src/composerDraftStore.test.ts+253
......@@ -1314,6 +1314,47 @@ describe("composerDraftStore modelSelection", () => {
13141314 ).toEqual(modelSelection(CODEX_DRIVER, "gpt-5.4"));
13151315 });
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
13171358 it("replaces only the targeted provider options on the current model selection", () => {
13181359 const store = useComposerDraftStore.getState();
13191360
......@@ -1356,6 +1397,23 @@ describe("composerDraftStore modelSelection", () => {
13561397 );
13571398 });
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
13591417 it("keeps explicit default-state overrides on the selection", () => {
13601418 const store = useComposerDraftStore.getState();
13611419
......@@ -1706,6 +1764,201 @@ describe("composerDraftStore sticky composer settings", () => {
17061764 activeProvider: "claudeAgent",
17071765 });
17081766 });
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 });
17091962});
17101963
17111964describe("composerDraftStore provider-scoped option updates", () => {
apps/web/src/composerDraftStore.ts+91-76
......@@ -58,7 +58,7 @@ const isProviderDriverKind = Schema.is(ProviderDriverKind);
5858const isReviewCommentContext = Schema.is(ReviewCommentContextSchema);
5959
6060export const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1";
61const COMPOSER_DRAFT_STORAGE_VERSION = 8;
61const COMPOSER_DRAFT_STORAGE_VERSION = 9;
6262const DraftThreadEnvModeSchema = Schema.Literals(["local", "worktree"]);
6363export type DraftThreadEnvMode = typeof DraftThreadEnvModeSchema.Type;
6464
......@@ -146,6 +146,10 @@ const PersistedComposerThreadDraftState = Schema.Struct({
146146 // an entry already encodes "no selection for this instance".
147147 modelSelectionByProvider: Schema.optionalKey(Schema.Record(ProviderInstanceId, ModelSelection)),
148148 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),
149153 runtimeMode: Schema.optionalKey(RuntimeMode),
150154 interactionMode: Schema.optionalKey(ProviderInteractionMode),
151155});
......@@ -274,6 +278,12 @@ export interface ComposerThreadDraftState {
274278 modelSelectionByProvider: Partial<Record<ProviderInstanceId, ModelSelection>>;
275279 /** Routing key of the last picked instance (see `modelSelectionByProvider`). */
276280 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;
277287 runtimeMode: RuntimeMode | null;
278288 interactionMode: ProviderInteractionMode | null;
279289}
......@@ -434,6 +444,7 @@ interface ComposerDraftStoreState {
434444 threadRef: ComposerThreadTarget,
435445 modelSelection: ModelSelection | null | undefined,
436446 opts?: {
447 explicit?: boolean;
437448 /**
438449 * Replace the stored entry outright instead of preserving its
439450 * existing options when the incoming selection has none. Used when
......@@ -1609,12 +1620,18 @@ function normalizePersistedDraftThreads(
16091620 const parsedThreadRef = parseScopedThreadKey(threadKeyOrId);
16101621 const threadKey = normalizeLegacyComposerStorageKey(threadKeyOrId);
16111622 logicalProjectDraftThreadKeyByLogicalProjectKey[logicalProjectKey] = threadKey;
1623 const existingDraftThread = draftThreadsByThreadKey[threadKey];
16121624 if (parsedThreadRef) {
16131625 environmentIdByThreadId.set(parsedThreadRef.threadId, parsedThreadRef.environmentId);
16141626 }
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 }
16151633 if (!projectRef) {
1616 const existingDraftThread = draftThreadsByThreadKey[threadKey];
1617 if (existingDraftThread && !existingDraftThread.logicalProjectKey) {
1634 if (existingDraftThread) {
16181635 draftThreadsByThreadKey[threadKey] = {
16191636 ...existingDraftThread,
16201637 logicalProjectKey,
......@@ -1622,7 +1639,7 @@ function normalizePersistedDraftThreads(
16221639 }
16231640 continue;
16241641 }
1625 if (!draftThreadsByThreadKey[threadKey]) {
1642 if (!existingDraftThread) {
16261643 draftThreadsByThreadKey[threadKey] = {
16271644 threadId: parsedThreadRef?.threadId ?? (threadKey as ThreadId),
16281645 environmentId: projectRef.environmentId,
......@@ -1638,12 +1655,12 @@ function normalizePersistedDraftThreads(
16381655 promotedTo: null,
16391656 };
16401657 } else if (
1641 draftThreadsByThreadKey[threadKey]?.projectId !== projectRef.projectId ||
1642 draftThreadsByThreadKey[threadKey]?.environmentId !== projectRef.environmentId
1658 existingDraftThread.projectId !== projectRef.projectId ||
1659 existingDraftThread.environmentId !== projectRef.environmentId
16431660 ) {
16441661 draftThreadsByThreadKey[threadKey] = {
1645 ...draftThreadsByThreadKey[threadKey]!,
1646 threadId: draftThreadsByThreadKey[threadKey]!.threadId,
1662 ...existingDraftThread,
1663 threadId: existingDraftThread.threadId,
16471664 environmentId: projectRef.environmentId,
16481665 projectId: projectRef.projectId,
16491666 logicalProjectKey,
......@@ -1724,6 +1741,7 @@ function normalizePersistedDraftsByThreadId(
17241741 const legacyDraftCandidate = draftValue as LegacyPersistedComposerThreadDraftState;
17251742 let modelSelectionByProvider: Partial<Record<ProviderInstanceId, ModelSelection>> = {};
17261743 let activeProvider: ProviderInstanceId | null = null;
1744 let modelSelectionExplicit: true | undefined = undefined;
17271745
17281746 if (
17291747 draftCandidate.modelSelectionByProvider &&
......@@ -1734,6 +1752,7 @@ function normalizePersistedDraftsByThreadId(
17341752 Record<ProviderInstanceId, ModelSelection>
17351753 >;
17361754 activeProvider = normalizeProviderInstanceId(draftCandidate.activeProvider);
1755 modelSelectionExplicit = draftCandidate.modelSelectionExplicit === true ? true : undefined;
17371756 } else {
17381757 // v2 or legacy format: migrate
17391758 const normalizedModelOptions =
......@@ -1802,6 +1821,7 @@ function normalizePersistedDraftsByThreadId(
18021821 ? {
18031822 modelSelectionByProvider: compactModelSelectionByProvider(modelSelectionByProvider),
18041823 activeProvider,
1824 ...(modelSelectionExplicit ? { modelSelectionExplicit: true } : {}),
18051825 }
18061826 : {}),
18071827 ...(runtimeMode ? { runtimeMode } : {}),
......@@ -1812,55 +1832,52 @@ function normalizePersistedDraftsByThreadId(
18121832 return nextDraftsByThreadKey;
18131833}
18141834
1815function migratePersistedComposerDraftStoreState(
1816 persistedState: unknown,
1817): PersistedComposerDraftStoreState {
1818 if (!persistedState || typeof persistedState !== "object") {
1819 return EMPTY_PERSISTED_DRAFT_STORE_STATE;
1820 }
1821 const candidate = persistedState as LegacyPersistedComposerDraftStoreState;
1822 const rawDraftMap = candidate.draftsByThreadKey ?? candidate.draftsByThreadId;
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,
1835function persistedComposerDraftHasUserContent(draft: PersistedComposerThreadDraftState): boolean {
1836 return (
1837 draft.prompt.trim().length > 0 ||
1838 draft.attachments.length > 0 ||
1839 (draft.terminalContexts?.length ?? 0) > 0 ||
1840 (draft.elementContexts?.length ?? 0) > 0 ||
1841 (draft.previewAnnotations?.length ?? 0) > 0 ||
1842 (draft.reviewComments?.length ?? 0) > 0
18491843 );
1850 const stickyActiveProvider = normalizeProviderInstanceId(candidate.stickyProvider) ?? null;
1844}
18511845
1852 const { draftThreadsByThreadKey, logicalProjectDraftThreadKeyByLogicalProjectKey } =
1853 normalizePersistedDraftThreads(rawDraftThreadsByThreadId, rawProjectDraftThreadIdByProjectKey);
1854 const draftsByThreadKey = normalizePersistedDraftsByThreadId(
1855 rawDraftMap,
1856 draftThreadsByThreadKey,
1846function stripLegacyModelSeedsFromEmptyDraftSessions(
1847 draftsByThreadKey: PersistedComposerDraftStoreState["draftsByThreadKey"],
1848 draftThreadsByThreadKey: PersistedComposerDraftStoreState["draftThreadsByThreadKey"],
1849): PersistedComposerDraftStoreState["draftsByThreadKey"] {
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 }),
18571868 );
1869}
1870
1871function migratePersistedComposerDraftStoreState(
1872 persistedState: unknown,
1873): PersistedComposerDraftStoreState {
1874 const normalized = normalizeCurrentPersistedComposerDraftStoreState(persistedState);
18581875 return {
1859 draftsByThreadKey,
1860 draftThreadsByThreadKey,
1861 logicalProjectDraftThreadKeyByLogicalProjectKey,
1862 stickyModelSelectionByProvider: compactModelSelectionByProvider(stickyModelSelectionByProvider),
1863 stickyActiveProvider,
1876 ...normalized,
1877 draftsByThreadKey: stripLegacyModelSeedsFromEmptyDraftSessions(
1878 normalized.draftsByThreadKey,
1879 normalized.draftThreadsByThreadKey,
1880 ),
18641881 };
18651882}
18661883
......@@ -1963,6 +1980,7 @@ function partializeComposerDraftStoreState(
19631980 draft.modelSelectionByProvider,
19641981 ),
19651982 activeProvider: draft.activeProvider,
1983 ...(draft.modelSelectionExplicit ? { modelSelectionExplicit: true } : {}),
19661984 }
19671985 : {}),
19681986 ...(draft.runtimeMode ? { runtimeMode: draft.runtimeMode } : {}),
......@@ -2029,7 +2047,7 @@ function normalizeCurrentPersistedComposerDraftStoreState(
20292047 const normalizedStickyModelSelection = normalizeModelSelection(
20302048 normalizedPersistedState.stickyModelSelection,
20312049 {
2032 provider: normalizedPersistedState.stickyProvider,
2050 provider: normalizedPersistedState.stickyProvider ?? "codex",
20332051 model: normalizedPersistedState.stickyModel,
20342052 modelOptions: stickyModelOptions,
20352053 },
......@@ -2211,6 +2229,7 @@ function toHydratedThreadDraft(
22112229 reviewComments: persistedDraft.reviewComments?.map((comment) => ({ ...comment })) ?? [],
22122230 modelSelectionByProvider,
22132231 activeProvider,
2232 ...(persistedDraft.modelSelectionExplicit ? { modelSelectionExplicit: true } : {}),
22142233 runtimeMode: persistedDraft.runtimeMode ?? null,
22152234 interactionMode: persistedDraft.interactionMode ?? null,
22162235 };
......@@ -2628,33 +2647,19 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
26282647 set((state) => {
26292648 const stickyMap = state.stickyModelSelectionByProvider;
26302649 const stickyActiveProvider = state.stickyActiveProvider;
2631 if (Object.keys(stickyMap).length === 0 && stickyActiveProvider === null) {
2632 return state;
2633 }
26342650 const existing = state.draftsByThreadKey[threadKey];
26352651 const base = existing ?? createEmptyThreadDraft();
2636 const nextMap = { ...base.modelSelectionByProvider };
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 }
2652 const nextMap = compactModelSelectionByProvider(stickyMap);
26502653 if (
26512654 Equal.equals(base.modelSelectionByProvider, nextMap) &&
2652 base.activeProvider === stickyActiveProvider
2655 base.activeProvider === stickyActiveProvider &&
2656 base.modelSelectionExplicit === undefined
26532657 ) {
26542658 return state;
26552659 }
2660 const { modelSelectionExplicit: _modelSelectionExplicit, ...retained } = base;
26562661 const nextDraft: ComposerThreadDraftState = {
2657 ...base,
2662 ...retained,
26582663 modelSelectionByProvider: nextMap,
26592664 activeProvider: stickyActiveProvider,
26602665 };
......@@ -2745,14 +2750,20 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
27452750 const nextActiveProvider = normalized?.instanceId ?? base.activeProvider;
27462751 if (
27472752 Equal.equals(base.modelSelectionByProvider, nextMap) &&
2748 base.activeProvider === nextActiveProvider
2753 base.activeProvider === nextActiveProvider &&
2754 (base.modelSelectionExplicit ?? false) === (opts?.explicit === true)
27492755 ) {
27502756 return state;
27512757 }
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;
27522762 const nextDraft: ComposerThreadDraftState = {
2753 ...base,
2763 ...restBase,
27542764 modelSelectionByProvider: nextMap,
27552765 activeProvider: nextActiveProvider,
2766 ...(opts?.explicit === true ? { modelSelectionExplicit: true as const } : {}),
27562767 };
27572768 const nextDraftsByThreadKey = { ...state.draftsByThreadKey };
27582769 if (shouldRemoveDraft(nextDraft)) {
......@@ -2875,10 +2886,14 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
28752886 return state;
28762887 }
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;
28782892 const nextDraft: ComposerThreadDraftState = {
2879 ...base,
2893 ...restBase,
28802894 ...(options?.instanceId ? { activeProvider: instanceKey } : {}),
28812895 modelSelectionByProvider: nextMap,
2896 modelSelectionExplicit: true,
28822897 };
28832898 const nextDraftsByThreadKey = { ...state.draftsByThreadKey };
28842899 if (shouldRemoveDraft(nextDraft)) {
apps/web/src/hooks/useHandleNewThread.ts+48-20
......@@ -24,7 +24,10 @@ import {
2424} from "../logicalProject";
2525import { resolveDefaultThreadEnvMode } from "@t3tools/shared/threadEnvMode";
2626import { readThreadShell, useProjects, useThread } from "../state/entities";
27import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions";
27import {
28 resolveNewDraftStartFromOrigin,
29 resolveNewThreadModelSelectionOverride,
30} from "../lib/chatThreadActions";
2831import { readT3ProjectFileDefaultThreadEnvMode } from "../lib/t3ProjectFileDefaults";
2932import { primaryServerSettingsAtom } from "../state/server";
3033import { resolveThreadRouteTarget } from "../threadRoutes";
......@@ -99,11 +102,10 @@ export function useNewThreadHandler() {
99102 setModelSelection,
100103 } = useComposerDraftStore.getState();
101104 const currentRouteTarget = getCurrentRouteTarget();
102 // A new thread carries the user's *working mode* from the thread being
103 // viewed: model (including options like reasoning effort and context
104 // window), permission mode, and interaction mode. Branch, worktree, and
105 // env mode never carry implicitly — those come from the configured
106 // defaults unless the caller passes them explicitly.
105 // A new thread carries the user's working mode from the thread being
106 // viewed. The target project's configured model still wins; runtime and
107 // interaction modes carry independently. Branch, worktree, and env mode
108 // come from configured defaults unless the caller passes them explicitly.
107109 const carrySourceShell =
108110 currentRouteTarget?.kind === "server"
109111 ? readThreadShell(currentRouteTarget.threadRef)
......@@ -161,6 +163,14 @@ export function useNewThreadHandler() {
161163 candidate.id === projectRef.projectId &&
162164 candidate.environmentId === projectRef.environmentId,
163165 );
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 });
164174 // The shared resolver owns the priority order. The t3.json read is
165175 // skipped entirely when a higher-priority source decides, and its
166176 // query atom caches per project after the first call.
......@@ -229,8 +239,10 @@ export function useNewThreadHandler() {
229239 // env context resets to the configured defaults so drafts seeded
230240 // before a defaults change (or by the old carry-over behavior) stop
231241 // landing on "current checkout" branches forever. When the draft is
232 // already open and no options were passed, leave it alone entirely —
233 // the user may have just picked a branch in the composer.
242 // already open and no options were passed, leave its workspace
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.
234246 let workspaceContext: NewThreadWorkspaceOptions | null = null;
235247 if (hasExplicitWorkspaceOption) {
236248 workspaceContext = pickExplicitWorkspaceOptions(options);
......@@ -276,11 +288,29 @@ export function useNewThreadHandler() {
276288 ...(carryRuntimeMode ? { runtimeMode: carryRuntimeMode } : {}),
277289 ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}),
278290 });
279 if (carryModelSelection) {
280 // The carried selection is a complete snapshot of the viewed
281 // thread's model state: absent options mean "no options", not
282 // "keep the stale draft's options".
283 setModelSelection(emptyStoredDraftThread.draftId, carryModelSelection, {
291 }
292 // Model intent: an explicit human pick always stands. Seeds and
293 // legacy entries alike re-resolve here — sticky first, mirroring
294 // the mint-fresh path, then the project default or carried
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, {
284314 replaceOptions: true,
285315 });
286316 }
......@@ -412,13 +442,11 @@ export function useNewThreadHandler() {
412442 ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}),
413443 });
414444 applyStickyState(draftId);
415 if (carryModelSelection) {
416 // After sticky state so the viewed thread's exact selection
417 // (model + options like effort and context window) wins over the
418 // globally sticky one. replaceOptions: the carried selection is a
419 // complete snapshot — absent options mean "no options", not "keep
420 // whatever sticky state just wrote".
421 setModelSelection(draftId, carryModelSelection, { replaceOptions: true });
445 const modelSelectionOverride = resolveModelSelectionOverride(draftId);
446 if (modelSelectionOverride) {
447 // Project defaults and carried selections both outrank global sticky
448 // state. The project default wins when both are present.
449 setModelSelection(draftId, modelSelectionOverride, { replaceOptions: true });
422450 }
423451 carryComposerContentTo(draftId);
424452
apps/web/src/lib/chatThreadActions.test.ts+48-1
......@@ -1,9 +1,15 @@
11import { 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";
38import { describe, expect, it, vi } from "vite-plus/test";
49import {
510 resolveThreadActionProjectRef,
611 resolveNewDraftStartFromOrigin,
12 resolveNewThreadModelSelectionOverride,
713 startNewThreadFromContext,
814 type ChatThreadActionContext,
915} from "./chatThreadActions";
......@@ -11,6 +17,14 @@ import {
1117const ENVIRONMENT_ID = EnvironmentId.make("environment-1");
1218const PROJECT_ID = ProjectId.make("project-1");
1319const 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
1529function createContext(overrides: Partial<ChatThreadActionContext> = {}): ChatThreadActionContext {
1630 return {
......@@ -23,6 +37,39 @@ function createContext(overrides: Partial<ChatThreadActionContext> = {}): ChatTh
2337}
2438
2539describe("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
2673 it("only applies the start-from-origin default to new worktree drafts", () => {
2774 expect(
2875 resolveNewDraftStartFromOrigin({
apps/web/src/lib/chatThreadActions.ts+18-1
......@@ -1,5 +1,10 @@
11import { 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";
38import type { DraftThreadEnvMode } from "../composerDraftStore";
49
510interface ThreadContextLike {
......@@ -34,6 +39,18 @@ export function resolveNewDraftStartFromOrigin(input: {
3439 return input.envMode === "worktree" && input.newWorktreesStartFromOrigin;
3540}
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
3754export function resolveThreadActionProjectRef(
3855 context: ChatThreadActionContext,
3956): ScopedProjectRef | null {