diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 8f5beb69c938..dae8c2d2e471 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -704,10 +704,12 @@ export function NewTaskDraftScreen(props: { if (editingPendingTask) { flow.finishEditingPendingTask(); } else { - // Drop the workspace selection with the content: the next task should - // re-resolve mode/branch/origin from the server's configured defaults - // instead of resurrecting this task's picks. - clearComposerDraftContent(draftKey, { clearWorkspaceSelection: true }); + // Drop draft-local model/workspace selections with the content. The + // next task re-resolves project defaults before sticky app defaults. + clearComposerDraftContent(draftKey, { + clearModelSelection: true, + clearWorkspaceSelection: true, + }); } navigation.getParent()?.goBack(); return; @@ -771,7 +773,10 @@ export function NewTaskDraftScreen(props: { } flow.finishEditingPendingTask(); } else { - clearComposerDraftContent(draftKey, { clearWorkspaceSelection: true }); + clearComposerDraftContent(draftKey, { + clearModelSelection: true, + clearWorkspaceSelection: true, + }); } navigation.dispatch( StackActions.replace("Thread", { diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 14f0fcc95a22..2edeae214421 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -33,6 +33,7 @@ import { buildModelOptions, groupByProvider, resolveDefaultableModelSelection, + resolveNewTaskModelSelection, resolveSelectableModelSelection, } from "../../lib/modelOptions"; import { scopedProjectKey } from "../../lib/scopedEntities"; @@ -48,8 +49,10 @@ import { removeComposerDraftAttachment, replaceComposerDraftAttachments, setComposerDraftText, + setStickyComposerModelSelection, updateComposerDraftSettings, useComposerDraft, + useStickyComposerModelSelection, } from "../../state/use-composer-drafts"; import { useDebouncedValue, usePaginatedBranches } from "../../state/queries"; import { vcsEnvironment } from "../../state/vcs"; @@ -418,21 +421,33 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { selectedEnvironmentServerConfig, selectedProject?.defaultModelSelection ?? null, ); + const storedStickyModelSelection = useStickyComposerModelSelection(); + const stickyModelSelection = resolveDefaultableModelSelection( + selectedEnvironmentServerConfig, + storedStickyModelSelection, + ); const modelOptions = useMemo( () => buildModelOptions( selectedEnvironmentServerConfig, - draftModelSelection ?? projectDefaultModelSelection, + draftModelSelection ?? projectDefaultModelSelection ?? stickyModelSelection, ), - [selectedEnvironmentServerConfig, draftModelSelection, projectDefaultModelSelection], + [ + selectedEnvironmentServerConfig, + draftModelSelection, + projectDefaultModelSelection, + stickyModelSelection, + ], ); - const selectedModel = - draftModelSelection ?? - projectDefaultModelSelection ?? - modelOptions.find((option) => option.isDefault)?.selection ?? - modelOptions[0]?.selection ?? - null; + // An unsent draft keeps its explicit pick. Fresh drafts resolve the project + // default before the last manual app-wide selection and provider default. + const selectedModel = resolveNewTaskModelSelection({ + draftSelection: draftModelSelection, + projectDefaultSelection: projectDefaultModelSelection, + stickySelection: stickyModelSelection, + modelOptions, + }); const selectedModelKey = selectedModel ? `${selectedModel.instanceId}:${selectedModel.model}` : null; @@ -462,9 +477,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { if (!option) { return; } - updateComposerDraftSettings(selectedProjectDraftKey, { - modelSelection: options ? { ...option.selection, options } : option.selection, - }); + const selection = options ? { ...option.selection, options } : option.selection; + updateComposerDraftSettings(selectedProjectDraftKey, { modelSelection: selection }); + setStickyComposerModelSelection(selection); }, [modelOptions, selectedProjectDraftKey], ); @@ -482,6 +497,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { updateComposerDraftSettings(selectedProjectDraftKey, { modelSelection: nextSelection, }); + setStickyComposerModelSelection(nextSelection); }, [selectedModel, selectedProjectDraftKey], ); diff --git a/apps/mobile/src/lib/modelOptions.test.ts b/apps/mobile/src/lib/modelOptions.test.ts index 8a9dabbe034f..7cd8f6c4a3c9 100644 --- a/apps/mobile/src/lib/modelOptions.test.ts +++ b/apps/mobile/src/lib/modelOptions.test.ts @@ -1,12 +1,14 @@ import { describe, expect, it } from "vite-plus/test"; -import { ProviderInstanceId, type ServerConfig } from "@t3tools/contracts"; +import { ProviderInstanceId, type ModelSelection, type ServerConfig } from "@t3tools/contracts"; import { buildModelOptions, groupByProvider, resolveDefaultableModelSelection, + resolveNewTaskModelSelection, resolveSelectableModelSelection, + type ModelOption, } from "./modelOptions"; describe("mobile model options", () => { @@ -171,4 +173,30 @@ describe("mobile model options", () => { // Offline: nothing to validate against, selection passes through. expect(resolveDefaultableModelSelection(null, legacy)).toBe(legacy); }); + + it("resolves new tasks from draft, project, sticky, then provider defaults", () => { + const draft = { instanceId: ProviderInstanceId.make("codex"), model: "draft" }; + const project = { instanceId: ProviderInstanceId.make("codex"), model: "project" }; + const sticky = { instanceId: ProviderInstanceId.make("codex"), model: "sticky" }; + const providerDefault = { + selection: { instanceId: ProviderInstanceId.make("codex"), model: "default" }, + isDefault: true, + } as ModelOption; + const resolve = ( + draftSelection: ModelSelection | null, + projectDefaultSelection: ModelSelection | null, + stickySelection: ModelSelection | null, + ) => + resolveNewTaskModelSelection({ + draftSelection, + projectDefaultSelection, + stickySelection, + modelOptions: [providerDefault], + }); + + expect(resolve(draft, project, sticky)).toBe(draft); + expect(resolve(null, project, sticky)).toBe(project); + expect(resolve(null, null, sticky)).toBe(sticky); + expect(resolve(null, null, null)).toBe(providerDefault.selection); + }); }); diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index cb7a8c4198ec..2ace8e11fdf9 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -104,6 +104,22 @@ export function resolveDefaultableModelSelection( return model?.isLegacy === true ? null : usable; } +export function resolveNewTaskModelSelection(input: { + readonly draftSelection: ModelSelection | null; + readonly projectDefaultSelection: ModelSelection | null; + readonly stickySelection: ModelSelection | null; + readonly modelOptions: ReadonlyArray; +}): ModelSelection | null { + return ( + input.draftSelection ?? + input.projectDefaultSelection ?? + input.stickySelection ?? + input.modelOptions.find((option) => option.isDefault)?.selection ?? + input.modelOptions[0]?.selection ?? + null + ); +} + export function buildModelOptions( config: T3ServerConfig | null | undefined, fallbackModelSelection: ModelSelection | null, diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index 8dbddfe1fece..571309054310 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -7,6 +7,9 @@ const composerDraftFileMocks = vi.hoisted(() => { let writeError: Error | null = null; let releaseRead: (() => void) | null = null; let readBarrier = Promise.resolve(); + let nextWriteBarrier: Promise | null = null; + let onWrite: (() => void) | null = null; + const writes: string[] = []; return { blockRead() { @@ -27,6 +30,18 @@ const composerDraftFileMocks = vi.hoisted(() => { setWriteError(error: Error | null) { writeError = error; }, + setNextWriteBarrier(barrier: Promise | null) { + nextWriteBarrier = barrier; + }, + setOnWrite(callback: (() => void) | null) { + onWrite = callback; + }, + getWrites(): ReadonlyArray { + return writes; + }, + resetWrites() { + writes.length = 0; + }, Directory: class { create() {} }, @@ -47,7 +62,18 @@ const composerDraftFileMocks = vi.hoisted(() => { if (writeError) { throw writeError; } + if (nextWriteBarrier) { + const barrier = nextWriteBarrier; + nextWriteBarrier = null; + return barrier.then(() => { + document = value; + writes.push(value); + onWrite?.(); + }); + } document = value; + writes.push(value); + onWrite?.(); } }, }; @@ -62,18 +88,24 @@ vi.mock("expo-file-system", () => ({ import { appAtomRegistry } from "./atom-registry"; import { clearComposerDraftContentState, + clearComposerDraftsEnvironment, ComposerDraftPersistenceError, composerDraftsAtom, copyComposerDraftContentIfEmpty, copyComposerDraftContentState, + decodePersistedComposerState, decodePersistedComposerDrafts, + ensureComposerDraftsLoaded, type ComposerDraft, flushComposerDrafts, getComposerDraftSnapshot, mergeComposerDraftContentState, removeComposerDraftsForEnvironment, + resetComposerDraftsLoadState, restoreComposerDraftSnapshotState, setComposerDraftText, + setStickyComposerModelSelection, + stickyComposerModelSelectionAtom, } from "./use-composer-drafts"; const DRAFT: ComposerDraft = { @@ -82,7 +114,15 @@ const DRAFT: ComposerDraft = { }; afterEach(() => { + vi.useRealTimers(); + resetComposerDraftsLoadState(); + composerDraftFileMocks.setDocument(""); + composerDraftFileMocks.setWriteError(null); + composerDraftFileMocks.setNextWriteBarrier(null); + composerDraftFileMocks.setOnWrite(null); + composerDraftFileMocks.resetWrites(); appAtomRegistry.set(composerDraftsAtom, {}); + appAtomRegistry.set(stickyComposerModelSelectionAtom, null); }); describe("mobile composer drafts", () => { @@ -154,6 +194,195 @@ describe("mobile composer drafts", () => { ).toThrow(); }); + it("keeps share-import receipts on otherwise contentless new-task drafts", () => { + const receiptDraft: ComposerDraft = { + text: "", + attachments: [], + importedShareIds: ["share-1"], + }; + // The stale-model strip must not touch receipt-bearing drafts, and the + // empty filter must keep them — or the same share would re-import after + // restart. + expect( + decodePersistedComposerState({ + schemaVersion: 1, + drafts: { + "new-task:environment-1:project-1": { + ...receiptDraft, + modelSelection: { + instanceId: "codex", + model: "gpt-5.4", + }, + }, + }, + }).drafts, + ).toEqual({ + "new-task:environment-1:project-1": { + text: "", + attachments: [], + importedShareIds: ["share-1"], + }, + }); + + expect( + decodePersistedComposerState({ + schemaVersion: 1, + drafts: { "new-task:environment-1:project-1": receiptDraft }, + }).drafts, + ).toEqual({ "new-task:environment-1:project-1": receiptDraft }); + }); + + it("hydrates the global sticky model selection", () => { + expect( + decodePersistedComposerState({ + schemaVersion: 1, + drafts: {}, + stickyModelSelection: { + instanceId: "codex", + model: "gpt-5.6-sol", + }, + }).stickyModelSelection, + ).toEqual({ + instanceId: "codex", + model: "gpt-5.6-sol", + }); + }); + + it("waits for hydration before persisting the latest composer state", async () => { + vi.useFakeTimers(); + composerDraftFileMocks.setDocument({ + schemaVersion: 1, + drafts: { + "environment-1:thread-1": DRAFT, + }, + stickyModelSelection: { + instanceId: "codex", + model: "gpt-5.6-sol", + }, + }); + composerDraftFileMocks.blockRead(); + composerDraftFileMocks.resetWrites(); + + ensureComposerDraftsLoaded(); + await Promise.resolve(); + // The read is blocked, hydration is pending. + setComposerDraftText("new-task:environment-1:project-1", "New prompt"); + await vi.advanceTimersByTimeAsync(200); + + // Write should still be deferred — hydration has not resolved. + expect(composerDraftFileMocks.getWrites()).toHaveLength(0); + + composerDraftFileMocks.releaseRead(); + // Let the loadPromise settle and chain into the deferred persist. + await vi.runAllTimersAsync(); + + expect(JSON.parse(composerDraftFileMocks.getWrites()[0]!)).toEqual({ + schemaVersion: 1, + drafts: { + "environment-1:thread-1": DRAFT, + "new-task:environment-1:project-1": { + text: "New prompt", + attachments: [], + }, + }, + stickyModelSelection: { + instanceId: "codex", + model: "gpt-5.6-sol", + }, + }); + }); + + it("flush waits for pending hydration instead of clobbering disk", async () => { + vi.useFakeTimers(); + composerDraftFileMocks.setDocument({ + schemaVersion: 1, + drafts: { + "environment-1:thread-1": DRAFT, + }, + stickyModelSelection: { + instanceId: "codex", + model: "gpt-5.6-sol", + }, + }); + composerDraftFileMocks.blockRead(); + composerDraftFileMocks.resetWrites(); + + ensureComposerDraftsLoaded(); + await Promise.resolve(); + // An edit lands before hydration finishes; its debounced write is gated + // behind the blocked read. + setComposerDraftText("new-task:environment-1:project-1", "New prompt"); + + const flush = flushComposerDrafts(); + await vi.advanceTimersByTimeAsync(200); + // The flush must not have written the pre-hydration snapshot over disk. + expect(composerDraftFileMocks.getWrites()).toHaveLength(0); + + composerDraftFileMocks.releaseRead(); + await flush; + + const written = JSON.parse(composerDraftFileMocks.getDocument()); + expect(written.drafts["environment-1:thread-1"]).toEqual(DRAFT); + expect(written.drafts["new-task:environment-1:project-1"]).toEqual({ + text: "New prompt", + attachments: [], + }); + expect(written.stickyModelSelection).toEqual({ + instanceId: "codex", + model: "gpt-5.6-sol", + }); + }); + + it("serializes environment cleanup after an older queued write", async () => { + vi.useFakeTimers(); + composerDraftFileMocks.setDocument(JSON.stringify({ schemaVersion: 1, drafts: {} })); + composerDraftFileMocks.resetWrites(); + let releaseFirstWrite!: () => void; + const firstWriteBarrier = new Promise((resolve) => { + releaseFirstWrite = resolve; + }); + composerDraftFileMocks.setNextWriteBarrier(firstWriteBarrier); + let writeCount = 0; + const bothWritesCommitted = new Promise((resolve) => { + composerDraftFileMocks.setOnWrite(() => { + writeCount += 1; + if (writeCount === 2) { + resolve(); + } + }); + }); + + appAtomRegistry.set(composerDraftsAtom, { + "environment-1:thread-1": DRAFT, + "environment-2:thread-2": { text: "keep", attachments: [] }, + }); + setStickyComposerModelSelection({ + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.6-sol", + }); + await vi.advanceTimersByTimeAsync(200); + + const clear = clearComposerDraftsEnvironment(EnvironmentId.make("environment-1")); + await Promise.resolve(); + // Cleanup write is queued behind the still-blocked debounced write. + expect(composerDraftFileMocks.getWrites()).toHaveLength(0); + + releaseFirstWrite(); + await clear; + await bothWritesCommitted; + + expect(JSON.parse(composerDraftFileMocks.getDocument())).toEqual({ + schemaVersion: 1, + drafts: { + "environment-2:thread-2": { text: "keep", attachments: [] }, + }, + stickyModelSelection: { + instanceId: "codex", + model: "gpt-5.6-sol", + }, + }); + }); + it("clears sent content without clearing the selected model or workspace", () => { const draftKey = "environment-1:thread-1"; const draft: ComposerDraft = { @@ -182,7 +411,7 @@ describe("mobile composer drafts", () => { }); }); - it("drops the workspace selection when clearing a sent new-task draft", () => { + it("drops draft-local model and workspace selections after sending a new task", () => { const draftKey = "new-task:environment-1:project-1"; const draft: ComposerDraft = { text: "send this", @@ -201,15 +430,10 @@ describe("mobile composer drafts", () => { expect( clearComposerDraftContentState({ [draftKey]: draft }, draftKey, { + clearModelSelection: true, clearWorkspaceSelection: true, }), - ).toEqual({ - [draftKey]: { - modelSelection: draft.modelSelection, - text: "", - attachments: [], - }, - }); + ).toEqual({}); }); it("reads the latest selector state synchronously for send", () => { diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index 7dbea23596c7..1cc3ef4e93e6 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -86,6 +86,7 @@ const ComposerDraftSchema = Schema.Struct({ const PersistedComposerDraftsSchema = Schema.Struct({ schemaVersion: Schema.Literal(COMPOSER_DRAFTS_SCHEMA_VERSION), drafts: Schema.Record(Schema.String, ComposerDraftSchema), + stickyModelSelection: Schema.optional(ModelSelectionSchema), }); const decodePersistedComposerDraftsDocument = Schema.decodeUnknownSync( @@ -102,10 +103,20 @@ export const composerDraftsAtom = Atom.make>({}).p Atom.withLabel("mobile:composer-drafts"), ); +export const stickyComposerModelSelectionAtom = Atom.make(null).pipe( + Atom.keepAlive, + Atom.withLabel("mobile:sticky-composer-model-selection"), +); + let loadPromise: Promise | null = null; let persistTimer: ReturnType | null = null; const persistenceQueue = new SerializedAsyncQueue(); +/** Resets module-level state between test runs. */ +export function resetComposerDraftsLoadState(): void { + loadPromise = null; +} + function normalizeDraft(draft: ComposerDraft | undefined): ComposerDraft { if (!draft) { return EMPTY_DRAFT; @@ -136,11 +147,46 @@ function isEmptyDraft(draft: ComposerDraft): boolean { ); } -export function decodePersistedComposerDrafts(value: unknown): Record { +export function decodePersistedComposerState(value: unknown): { + readonly drafts: Record; + readonly stickyModelSelection: ModelSelection | null; +} { const parsed = decodePersistedComposerDraftsDocument(value); - return Object.fromEntries( - Object.entries(parsed.drafts).filter(([, draft]) => !isEmptyDraft(draft)), - ); + return { + drafts: Object.fromEntries( + Object.entries(parsed.drafts) + .map( + ([key, draft]) => + [ + key, + // Stale new-task drafts left on disk by builds before the + // model-precedence fix carry a bare modelSelection with no + // other selector settings. Strip it so the next compose pass + // re-resolves project → sticky → provider defaults. Drafts + // with runtime/interaction/workspace settings or actual text / + // attachments were deliberately configured and are left alone. + key.startsWith("new-task:") && + draft.modelSelection && + draft.text.length === 0 && + draft.attachments.length === 0 && + draft.runtimeMode === undefined && + draft.interactionMode === undefined && + draft.workspaceSelection === undefined + ? { ...draft, modelSelection: undefined } + : draft, + ] as const, + ) + // importedShareIds are share-import receipts: a contentless draft + // carrying one is not empty, or the same native share would be + // re-imported after restart. + .filter(([, draft]) => !isEmptyDraft(draft) || (draft.importedShareIds?.length ?? 0) > 0), + ), + stickyModelSelection: parsed.stickyModelSelection ?? null, + }; +} + +export function decodePersistedComposerDrafts(value: unknown): Record { + return decodePersistedComposerState(value).drafts; } async function getComposerDraftsFile() { @@ -150,17 +196,20 @@ async function getComposerDraftsFile() { return new File(directory, COMPOSER_DRAFTS_FILE); } -async function loadPersistedComposerDrafts(): Promise> { +async function loadPersistedComposerState(): Promise<{ + readonly drafts: Record; + readonly stickyModelSelection: ModelSelection | null; +}> { let operation: ComposerDraftPersistenceError["operation"] = "open"; try { const file = await getComposerDraftsFile(); if (!file.exists) { - return {}; + return { drafts: {}, stickyModelSelection: null }; } operation = "read"; const raw = await file.text(); operation = "decode"; - return decodePersistedComposerDrafts(JSON.parse(raw) as unknown); + return decodePersistedComposerState(JSON.parse(raw) as unknown); } catch (cause) { console.warn( "[composer-drafts] ignored persisted draft failure", @@ -171,11 +220,14 @@ async function loadPersistedComposerDrafts(): Promise): Promise { +async function writePersistedComposerState( + drafts: Record, + stickyModelSelection: ModelSelection | null, +): Promise { let operation: ComposerDraftPersistenceError["operation"] = "open"; try { const file = await getComposerDraftsFile(); @@ -186,6 +238,7 @@ async function writePersistedComposerDrafts(drafts: Record): Promise { - try { - await persistenceQueue.run(() => writePersistedComposerDrafts(drafts)); - } catch (error) { - console.warn("[composer-drafts] failed to persist drafts", error); - // Draft persistence is best-effort; in-memory drafts still keep working. - } -} - /** * Lands any debounced or in-flight draft write before the JS runtime is torn * down (app update restart), so the freshest draft state survives it. A write * failure propagates so the caller can decide whether the restart may proceed. */ export async function flushComposerDrafts(): Promise { + // Never land a pre-hydration snapshot: persisted state must merge into the + // atoms first, or this write would clobber disk with partial data. + ensureComposerDraftsLoaded(); + if (loadPromise !== null) { + await loadPromise; + } // An edit during an awaited write schedules another debounced write, so // keep landing snapshots until no debounce is pending after a queue drain. do { @@ -222,20 +272,41 @@ export async function flushComposerDrafts(): Promise { clearTimeout(persistTimer); persistTimer = null; await persistenceQueue.run(() => - writePersistedComposerDrafts(appAtomRegistry.get(composerDraftsAtom)), + writePersistedComposerState( + appAtomRegistry.get(composerDraftsAtom), + appAtomRegistry.get(stickyComposerModelSelectionAtom), + ), ); } + // Draining also waits for an already-fired debounce whose write is still + // gated behind its own hydration await inside the queue. await persistenceQueue.run(() => Promise.resolve()); } while (persistTimer !== null); } -function schedulePersistComposerDrafts(drafts: Record): void { +function schedulePersistComposerState(): void { if (persistTimer !== null) { clearTimeout(persistTimer); } persistTimer = setTimeout(() => { persistTimer = null; - void savePersistedComposerDrafts(drafts); + ensureComposerDraftsLoaded(); + // The write enters the serialization queue before waiting on hydration, + // so flushComposerDrafts' queue drain cannot resolve ahead of it. + void persistenceQueue.run(async () => { + if (loadPromise !== null) { + await loadPromise; + } + try { + await writePersistedComposerState( + appAtomRegistry.get(composerDraftsAtom), + appAtomRegistry.get(stickyComposerModelSelectionAtom), + ); + } catch (error) { + console.warn("[composer-drafts] failed to persist drafts", error); + // Draft persistence is best-effort; in-memory drafts still keep working. + } + }); }, PERSIST_DEBOUNCE_MS); } @@ -243,16 +314,21 @@ export function ensureComposerDraftsLoaded(): void { if (loadPromise !== null) { return; } - loadPromise = loadPersistedComposerDrafts() - .then((persistedDrafts) => { - if (Object.keys(persistedDrafts).length === 0) { - return; + loadPromise = loadPersistedComposerState() + .then((persisted) => { + if (Object.keys(persisted.drafts).length > 0) { + const current = appAtomRegistry.get(composerDraftsAtom); + appAtomRegistry.set(composerDraftsAtom, { + ...persisted.drafts, + ...current, + }); + } + if ( + persisted.stickyModelSelection !== null && + appAtomRegistry.get(stickyComposerModelSelectionAtom) === null + ) { + appAtomRegistry.set(stickyComposerModelSelectionAtom, persisted.stickyModelSelection); } - const current = appAtomRegistry.get(composerDraftsAtom); - appAtomRegistry.set(composerDraftsAtom, { - ...persistedDrafts, - ...current, - }); }) .catch((cause) => { console.warn( @@ -277,7 +353,12 @@ function updateComposerDrafts( return; } appAtomRegistry.set(composerDraftsAtom, next); - schedulePersistComposerDrafts(next); + schedulePersistComposerState(); +} + +export function setStickyComposerModelSelection(modelSelection: ModelSelection): void { + appAtomRegistry.set(stickyComposerModelSelectionAtom, modelSelection); + schedulePersistComposerState(); } export function setComposerDraftText(draftKey: string, value: string): void { @@ -394,15 +475,24 @@ export function updateComposerDraftSettings( export function clearComposerDraftContentState( current: Record, draftKey: string, - options?: { readonly clearWorkspaceSelection?: boolean }, + options?: { + readonly clearModelSelection?: boolean; + readonly clearWorkspaceSelection?: boolean; + }, ): Record { const existing = current[draftKey]; if (!existing) { return current; } - const { importedShareIds: _importedShareIds, workspaceSelection, ...retained } = existing; + const { + importedShareIds: _importedShareIds, + modelSelection, + workspaceSelection, + ...retained + } = existing; const draft = { ...retained, + ...(options?.clearModelSelection || modelSelection === undefined ? {} : { modelSelection }), ...(options?.clearWorkspaceSelection || workspaceSelection === undefined ? {} : { workspaceSelection }), @@ -571,7 +661,9 @@ export async function mergeComposerDraftContent( if (next !== current) { appAtomRegistry.set(composerDraftsAtom, next); } - await persistenceQueue.run(() => writePersistedComposerDrafts(next)); + await persistenceQueue.run(() => + writePersistedComposerState(next, appAtomRegistry.get(stickyComposerModelSelectionAtom)), + ); return { skippedAttachmentCount }; } @@ -594,12 +686,17 @@ export async function restoreComposerDraftSnapshot( snapshot, ); appAtomRegistry.set(composerDraftsAtom, next); - await persistenceQueue.run(() => writePersistedComposerDrafts(next)); + await persistenceQueue.run(() => + writePersistedComposerState(next, appAtomRegistry.get(stickyComposerModelSelectionAtom)), + ); } export function clearComposerDraftContent( draftKey: string, - options?: { readonly clearWorkspaceSelection?: boolean }, + options?: { + readonly clearModelSelection?: boolean; + readonly clearWorkspaceSelection?: boolean; + }, ): void { updateComposerDrafts((current) => clearComposerDraftContentState(current, draftKey, options)); } @@ -645,7 +742,9 @@ export async function clearComposerDraftsEnvironment(environmentId: EnvironmentI persistTimer = null; } appAtomRegistry.set(composerDraftsAtom, next); - await persistenceQueue.run(() => writePersistedComposerDrafts(next)); + await persistenceQueue.run(() => + writePersistedComposerState(next, appAtomRegistry.get(stickyComposerModelSelectionAtom)), + ); } export function useComposerDraft(draftKey: string | null): ComposerDraft { @@ -655,3 +754,11 @@ export function useComposerDraft(draftKey: string | null): ComposerDraft { }, []); return draftKey ? normalizeDraft(drafts[draftKey]) : EMPTY_DRAFT; } + +export function useStickyComposerModelSelection(): ModelSelection | null { + const selection = useAtomValue(stickyComposerModelSelectionAtom); + useEffect(() => { + ensureComposerDraftsLoaded(); + }, []); + return selection; +} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 8dcb6b8ed932..bc621b5a1b0b 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -6169,6 +6169,7 @@ function ChatViewContent(props: ChatViewProps) { setComposerDraftModelSelection( scopeThreadRef(activeThread.environmentId, activeThread.id), nextModelSelection, + { explicit: true }, ); setStickyComposerModelSelection(nextModelSelection); scheduleComposerFocus(); diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 20c6603f773b..cac736108d62 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -1314,6 +1314,47 @@ describe("composerDraftStore modelSelection", () => { ).toEqual(modelSelection(CODEX_DRIVER, "gpt-5.4")); }); + it("marks picker writes explicit and seeding writes non-explicit", () => { + const store = useComposerDraftStore.getState(); + store.setModelSelection(threadRef, modelSelection(CODEX_DRIVER, "gpt-5.4")); + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionExplicit).toBeUndefined(); + + store.setModelSelection(threadRef, modelSelection(CODEX_DRIVER, "gpt-5.4"), { + explicit: true, + }); + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionExplicit).toBe(true); + + // Last writer defines intent: a later seed clears the marker. + store.setModelSelection(threadRef, modelSelection(CODEX_DRIVER, "gpt-5.4"), { + replaceOptions: true, + }); + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionExplicit).toBeUndefined(); + }); + + it("persists the explicit marker through storage round-trips", async () => { + vi.useFakeTimers(); + try { + useComposerDraftStore + .getState() + .setModelSelection(threadRef, modelSelection(CODEX_DRIVER, "gpt-5.4"), { + explicit: true, + }); + // Land the debounced persist write. + await vi.advanceTimersByTimeAsync(300); + + // Hydrate from the same storage the store persists into and verify the + // marker survives the partialize → decode → merge path. + resetComposerDraftStore(); + await useComposerDraftStore.persist.rehydrate(); + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionExplicit).toBe(true); + expect( + draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider[CODEX_INSTANCE], + ).toEqual(modelSelection(CODEX_DRIVER, "gpt-5.4")); + } finally { + vi.useRealTimers(); + } + }); + it("replaces only the targeted provider options on the current model selection", () => { const store = useComposerDraftStore.getState(); @@ -1356,6 +1397,23 @@ describe("composerDraftStore modelSelection", () => { ); }); + it("marks trait edits as explicit model intent", () => { + const store = useComposerDraftStore.getState(); + store.setModelSelection(threadRef, modelSelection(CODEX_DRIVER, "gpt-5.4")); + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionExplicit).toBeUndefined(); + + store.setProviderModelOptions( + threadRef, + CODEX_DRIVER, + toSelections({ reasoningEffort: "xhigh" }), + ); + + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionExplicit).toBe(true); + expect( + draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider[CODEX_INSTANCE], + ).toEqual(modelSelection(CODEX_DRIVER, "gpt-5.4", { reasoningEffort: "xhigh" })); + }); + it("keeps explicit default-state overrides on the selection", () => { const store = useComposerDraftStore.getState(); @@ -1706,6 +1764,201 @@ describe("composerDraftStore sticky composer settings", () => { activeProvider: "claudeAgent", }); }); + + it("replaces a non-explicit stale model and its options with sticky state", () => { + const store = useComposerDraftStore.getState(); + const draftId = DraftId.make("draft-stale-sticky-seed"); + + store.setModelSelection( + draftId, + modelSelection(CODEX_DRIVER, "stale-model", { reasoningEffort: "low" }), + ); + store.setStickyModelSelection( + modelSelection(CODEX_DRIVER, "sticky-model", { reasoningEffort: "xhigh" }), + ); + store.applyStickyState(draftId); + + expect(draftByKey(draftId)).toMatchObject({ + activeProvider: CODEX_INSTANCE, + modelSelectionByProvider: { + [CODEX_INSTANCE]: modelSelection(CODEX_DRIVER, "sticky-model", { + reasoningEffort: "xhigh", + }), + }, + }); + }); + + it("clears a non-explicit stale model when there is no sticky state", () => { + const store = useComposerDraftStore.getState(); + const draftId = DraftId.make("draft-stale-without-sticky"); + + store.setModelSelection(draftId, modelSelection(CODEX_DRIVER, "stale-model")); + store.applyStickyState(draftId); + + expect(draftByKey(draftId)).toBeUndefined(); + }); +}); + +describe("composerDraftStore model seed migration", () => { + const staleDraftId = DraftId.make("draft-legacy-stale-model"); + const explicitDraftId = DraftId.make("draft-legacy-explicit-model"); + const typedDraftId = DraftId.make("draft-legacy-typed-model"); + const staleThreadId = ThreadId.make("thread-legacy-stale-model"); + const explicitThreadId = ThreadId.make("thread-legacy-explicit-model"); + const typedThreadId = ThreadId.make("thread-legacy-typed-model"); + const serverThreadId = ThreadId.make("thread-server-model"); + const serverThreadKey = scopedThreadKey(scopeThreadRef(TEST_ENVIRONMENT_ID, serverThreadId)); + const projectId = ProjectId.make("project-model-migration"); + const logicalProjectKey = `${TEST_ENVIRONMENT_ID}:/tmp/project-model-migration`; + + const draftThread = (threadId: ThreadId) => ({ + threadId, + environmentId: TEST_ENVIRONMENT_ID, + projectId, + logicalProjectKey, + createdAt: "2026-08-01T00:00:00.000Z", + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + envMode: "local", + startFromOrigin: false, + promotedTo: null, + }); + + beforeEach(async () => { + resetComposerDraftStore(); + await useComposerDraftStore.persist.clearStorage(); + }); + + afterEach(async () => { + await useComposerDraftStore.persist.clearStorage(); + }); + + it.each([1, 2])( + "keeps the legacy sticky Codex selection when v%s storage omitted the provider", + async (version) => { + vi.useFakeTimers(); + try { + const stickySelection = modelSelection(CODEX_DRIVER, "gpt-5.6-terra", { + reasoningEffort: "xhigh", + }); + const storage = useComposerDraftStore.persist.getOptions().storage; + expect(storage).toBeDefined(); + storage?.setItem(COMPOSER_DRAFT_STORAGE_KEY, { + version, + state: { + draftsByThreadId: {}, + draftThreadsByThreadId: {}, + projectDraftThreadIdByProjectId: {}, + stickyModel: stickySelection.model, + stickyModelOptions: providerModelOptions({ + [CODEX_DRIVER]: { reasoningEffort: "xhigh" }, + }), + }, + } as never); + await vi.advanceTimersByTimeAsync(300); + + await useComposerDraftStore.persist.rehydrate(); + + expect(useComposerDraftStore.getState()).toMatchObject({ + stickyModelSelectionByProvider: { [CODEX_INSTANCE]: stickySelection }, + stickyActiveProvider: null, + }); + } finally { + vi.useRealTimers(); + } + }, + ); + + it("strips seeded models only from empty draft sessions when upgrading storage", async () => { + vi.useFakeTimers(); + try { + const staleSelection = modelSelection(CODEX_DRIVER, "gpt-5.4"); + const stickySelection = modelSelection(CODEX_DRIVER, "gpt-5.6-terra", { + reasoningEffort: "xhigh", + }); + const storage = useComposerDraftStore.persist.getOptions().storage; + expect(storage).toBeDefined(); + storage?.setItem(COMPOSER_DRAFT_STORAGE_KEY, { + version: 8, + state: { + draftsByThreadKey: { + [staleDraftId]: { + prompt: "", + attachments: [], + modelSelectionByProvider: { [CODEX_INSTANCE]: staleSelection }, + activeProvider: CODEX_INSTANCE, + runtimeMode: "approval-required", + }, + [typedDraftId]: { + prompt: "keep this prompt", + attachments: [], + modelSelectionByProvider: { [CODEX_INSTANCE]: staleSelection }, + activeProvider: CODEX_INSTANCE, + }, + [explicitDraftId]: { + prompt: "", + attachments: [], + modelSelectionByProvider: { [CODEX_INSTANCE]: staleSelection }, + activeProvider: CODEX_INSTANCE, + modelSelectionExplicit: true, + }, + [serverThreadKey]: { + prompt: "", + attachments: [], + modelSelectionByProvider: { [CODEX_INSTANCE]: staleSelection }, + activeProvider: CODEX_INSTANCE, + }, + }, + draftThreadsByThreadKey: { + [staleDraftId]: draftThread(staleThreadId), + [explicitDraftId]: draftThread(explicitThreadId), + [typedDraftId]: draftThread(typedThreadId), + }, + logicalProjectDraftThreadKeyByLogicalProjectKey: { + [logicalProjectKey]: staleDraftId, + }, + stickyModelSelectionByProvider: { [CODEX_INSTANCE]: stickySelection }, + stickyActiveProvider: CODEX_INSTANCE, + }, + } as never); + await vi.advanceTimersByTimeAsync(300); + + await useComposerDraftStore.persist.rehydrate(); + + expect(draftByKey(staleDraftId)).toMatchObject({ + modelSelectionByProvider: {}, + activeProvider: null, + runtimeMode: "approval-required", + }); + expect(draftByKey(typedDraftId)).toMatchObject({ + prompt: "keep this prompt", + modelSelectionByProvider: { [CODEX_INSTANCE]: staleSelection }, + activeProvider: CODEX_INSTANCE, + }); + expect(draftByKey(explicitDraftId)).toMatchObject({ + modelSelectionByProvider: { [CODEX_INSTANCE]: staleSelection }, + activeProvider: CODEX_INSTANCE, + modelSelectionExplicit: true, + }); + expect(draftByKey(serverThreadKey)).toMatchObject({ + modelSelectionByProvider: { [CODEX_INSTANCE]: staleSelection }, + activeProvider: CODEX_INSTANCE, + }); + expect(useComposerDraftStore.getState().draftThreadsByThreadKey[staleDraftId]).toMatchObject({ + environmentId: TEST_ENVIRONMENT_ID, + projectId, + logicalProjectKey, + }); + expect(useComposerDraftStore.getState()).toMatchObject({ + stickyModelSelectionByProvider: { [CODEX_INSTANCE]: stickySelection }, + stickyActiveProvider: CODEX_INSTANCE, + }); + } finally { + vi.useRealTimers(); + } + }); }); describe("composerDraftStore provider-scoped option updates", () => { diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index f20385ee04f4..bafb1968c471 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -58,7 +58,7 @@ const isProviderDriverKind = Schema.is(ProviderDriverKind); const isReviewCommentContext = Schema.is(ReviewCommentContextSchema); export const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1"; -const COMPOSER_DRAFT_STORAGE_VERSION = 8; +const COMPOSER_DRAFT_STORAGE_VERSION = 9; const DraftThreadEnvModeSchema = Schema.Literals(["local", "worktree"]); export type DraftThreadEnvMode = typeof DraftThreadEnvModeSchema.Type; @@ -146,6 +146,10 @@ const PersistedComposerThreadDraftState = Schema.Struct({ // an entry already encodes "no selection for this instance". modelSelectionByProvider: Schema.optionalKey(Schema.Record(ProviderInstanceId, ModelSelection)), activeProvider: Schema.optionalKey(Schema.NullOr(ProviderInstanceId)), + // True only when a human picked this selection in the composer. Seeded + // selections (project default / sticky) leave it unset so later seeds can + // replace them; legacy entries predate the flag and read as seeded too. + modelSelectionExplicit: Schema.optionalKey(Schema.Boolean), runtimeMode: Schema.optionalKey(RuntimeMode), interactionMode: Schema.optionalKey(ProviderInteractionMode), }); @@ -274,6 +278,12 @@ export interface ComposerThreadDraftState { modelSelectionByProvider: Partial>; /** Routing key of the last picked instance (see `modelSelectionByProvider`). */ activeProvider: ProviderInstanceId | null; + /** + * True only when a human picked the active selection in the composer. + * Absent/false means seeded (project default / sticky), so later seeds + * may replace it. Legacy entries predate the flag and read as seeded. + */ + modelSelectionExplicit?: boolean; runtimeMode: RuntimeMode | null; interactionMode: ProviderInteractionMode | null; } @@ -434,6 +444,7 @@ interface ComposerDraftStoreState { threadRef: ComposerThreadTarget, modelSelection: ModelSelection | null | undefined, opts?: { + explicit?: boolean; /** * Replace the stored entry outright instead of preserving its * existing options when the incoming selection has none. Used when @@ -1609,12 +1620,18 @@ function normalizePersistedDraftThreads( const parsedThreadRef = parseScopedThreadKey(threadKeyOrId); const threadKey = normalizeLegacyComposerStorageKey(threadKeyOrId); logicalProjectDraftThreadKeyByLogicalProjectKey[logicalProjectKey] = threadKey; + const existingDraftThread = draftThreadsByThreadKey[threadKey]; if (parsedThreadRef) { environmentIdByThreadId.set(parsedThreadRef.threadId, parsedThreadRef.environmentId); } + // Logical project keys may contain a workspace path after the + // environment prefix. When the persisted draft already names that + // logical key, its concrete project id remains authoritative. + if (existingDraftThread?.logicalProjectKey === logicalProjectKey) { + continue; + } if (!projectRef) { - const existingDraftThread = draftThreadsByThreadKey[threadKey]; - if (existingDraftThread && !existingDraftThread.logicalProjectKey) { + if (existingDraftThread) { draftThreadsByThreadKey[threadKey] = { ...existingDraftThread, logicalProjectKey, @@ -1622,7 +1639,7 @@ function normalizePersistedDraftThreads( } continue; } - if (!draftThreadsByThreadKey[threadKey]) { + if (!existingDraftThread) { draftThreadsByThreadKey[threadKey] = { threadId: parsedThreadRef?.threadId ?? (threadKey as ThreadId), environmentId: projectRef.environmentId, @@ -1638,12 +1655,12 @@ function normalizePersistedDraftThreads( promotedTo: null, }; } else if ( - draftThreadsByThreadKey[threadKey]?.projectId !== projectRef.projectId || - draftThreadsByThreadKey[threadKey]?.environmentId !== projectRef.environmentId + existingDraftThread.projectId !== projectRef.projectId || + existingDraftThread.environmentId !== projectRef.environmentId ) { draftThreadsByThreadKey[threadKey] = { - ...draftThreadsByThreadKey[threadKey]!, - threadId: draftThreadsByThreadKey[threadKey]!.threadId, + ...existingDraftThread, + threadId: existingDraftThread.threadId, environmentId: projectRef.environmentId, projectId: projectRef.projectId, logicalProjectKey, @@ -1724,6 +1741,7 @@ function normalizePersistedDraftsByThreadId( const legacyDraftCandidate = draftValue as LegacyPersistedComposerThreadDraftState; let modelSelectionByProvider: Partial> = {}; let activeProvider: ProviderInstanceId | null = null; + let modelSelectionExplicit: true | undefined = undefined; if ( draftCandidate.modelSelectionByProvider && @@ -1734,6 +1752,7 @@ function normalizePersistedDraftsByThreadId( Record >; activeProvider = normalizeProviderInstanceId(draftCandidate.activeProvider); + modelSelectionExplicit = draftCandidate.modelSelectionExplicit === true ? true : undefined; } else { // v2 or legacy format: migrate const normalizedModelOptions = @@ -1802,6 +1821,7 @@ function normalizePersistedDraftsByThreadId( ? { modelSelectionByProvider: compactModelSelectionByProvider(modelSelectionByProvider), activeProvider, + ...(modelSelectionExplicit ? { modelSelectionExplicit: true } : {}), } : {}), ...(runtimeMode ? { runtimeMode } : {}), @@ -1812,55 +1832,52 @@ function normalizePersistedDraftsByThreadId( return nextDraftsByThreadKey; } -function migratePersistedComposerDraftStoreState( - persistedState: unknown, -): PersistedComposerDraftStoreState { - if (!persistedState || typeof persistedState !== "object") { - return EMPTY_PERSISTED_DRAFT_STORE_STATE; - } - const candidate = persistedState as LegacyPersistedComposerDraftStoreState; - const rawDraftMap = candidate.draftsByThreadKey ?? candidate.draftsByThreadId; - const rawDraftThreadsByThreadId = - candidate.draftThreadsByThreadKey ?? candidate.draftThreadsByThreadId; - const rawProjectDraftThreadIdByProjectKey = - candidate.logicalProjectDraftThreadKeyByLogicalProjectKey ?? - candidate.projectDraftThreadKeyByProjectKey ?? - candidate.projectDraftThreadIdByProjectKey ?? - candidate.projectDraftThreadIdByProjectId; - - // Migrate sticky state from v2 (dual) to v3 (consolidated) - const stickyModelOptions = normalizeProviderModelOptions(candidate.stickyModelOptions) ?? {}; - const normalizedStickyModelSelection = normalizeModelSelection(candidate.stickyModelSelection, { - provider: candidate.stickyProvider ?? "codex", - model: candidate.stickyModel, - modelOptions: stickyModelOptions, - }); - const nextStickyModelOptions = legacyMergeModelSelectionIntoProviderModelOptions( - normalizedStickyModelSelection, - stickyModelOptions, - ); - const stickyModelSelection = legacySyncModelSelectionOptions( - normalizedStickyModelSelection, - nextStickyModelOptions, - ); - const stickyModelSelectionByProvider = legacyToModelSelectionByProvider( - stickyModelSelection, - nextStickyModelOptions, +function persistedComposerDraftHasUserContent(draft: PersistedComposerThreadDraftState): boolean { + return ( + draft.prompt.trim().length > 0 || + draft.attachments.length > 0 || + (draft.terminalContexts?.length ?? 0) > 0 || + (draft.elementContexts?.length ?? 0) > 0 || + (draft.previewAnnotations?.length ?? 0) > 0 || + (draft.reviewComments?.length ?? 0) > 0 ); - const stickyActiveProvider = normalizeProviderInstanceId(candidate.stickyProvider) ?? null; +} - const { draftThreadsByThreadKey, logicalProjectDraftThreadKeyByLogicalProjectKey } = - normalizePersistedDraftThreads(rawDraftThreadsByThreadId, rawProjectDraftThreadIdByProjectKey); - const draftsByThreadKey = normalizePersistedDraftsByThreadId( - rawDraftMap, - draftThreadsByThreadKey, +function stripLegacyModelSeedsFromEmptyDraftSessions( + draftsByThreadKey: PersistedComposerDraftStoreState["draftsByThreadKey"], + draftThreadsByThreadKey: PersistedComposerDraftStoreState["draftThreadsByThreadKey"], +): PersistedComposerDraftStoreState["draftsByThreadKey"] { + return Object.fromEntries( + Object.entries(draftsByThreadKey).flatMap(([threadKey, draft]) => { + if ( + draftThreadsByThreadKey[threadKey] === undefined || + draft.modelSelectionExplicit === true || + persistedComposerDraftHasUserContent(draft) + ) { + return [[threadKey, draft]]; + } + + const { + activeProvider: _activeProvider, + modelSelectionByProvider: _modelSelectionByProvider, + modelSelectionExplicit: _modelSelectionExplicit, + ...retained + } = draft; + return retained.runtimeMode || retained.interactionMode ? [[threadKey, retained]] : []; + }), ); +} + +function migratePersistedComposerDraftStoreState( + persistedState: unknown, +): PersistedComposerDraftStoreState { + const normalized = normalizeCurrentPersistedComposerDraftStoreState(persistedState); return { - draftsByThreadKey, - draftThreadsByThreadKey, - logicalProjectDraftThreadKeyByLogicalProjectKey, - stickyModelSelectionByProvider: compactModelSelectionByProvider(stickyModelSelectionByProvider), - stickyActiveProvider, + ...normalized, + draftsByThreadKey: stripLegacyModelSeedsFromEmptyDraftSessions( + normalized.draftsByThreadKey, + normalized.draftThreadsByThreadKey, + ), }; } @@ -1963,6 +1980,7 @@ function partializeComposerDraftStoreState( draft.modelSelectionByProvider, ), activeProvider: draft.activeProvider, + ...(draft.modelSelectionExplicit ? { modelSelectionExplicit: true } : {}), } : {}), ...(draft.runtimeMode ? { runtimeMode: draft.runtimeMode } : {}), @@ -2029,7 +2047,7 @@ function normalizeCurrentPersistedComposerDraftStoreState( const normalizedStickyModelSelection = normalizeModelSelection( normalizedPersistedState.stickyModelSelection, { - provider: normalizedPersistedState.stickyProvider, + provider: normalizedPersistedState.stickyProvider ?? "codex", model: normalizedPersistedState.stickyModel, modelOptions: stickyModelOptions, }, @@ -2211,6 +2229,7 @@ function toHydratedThreadDraft( reviewComments: persistedDraft.reviewComments?.map((comment) => ({ ...comment })) ?? [], modelSelectionByProvider, activeProvider, + ...(persistedDraft.modelSelectionExplicit ? { modelSelectionExplicit: true } : {}), runtimeMode: persistedDraft.runtimeMode ?? null, interactionMode: persistedDraft.interactionMode ?? null, }; @@ -2628,33 +2647,19 @@ const composerDraftStore = create()( set((state) => { const stickyMap = state.stickyModelSelectionByProvider; const stickyActiveProvider = state.stickyActiveProvider; - if (Object.keys(stickyMap).length === 0 && stickyActiveProvider === null) { - return state; - } const existing = state.draftsByThreadKey[threadKey]; const base = existing ?? createEmptyThreadDraft(); - const nextMap = { ...base.modelSelectionByProvider }; - for (const [provider, selection] of Object.entries(stickyMap)) { - if (selection) { - // Iteration key comes from the instance-keyed sticky map, - // so coerce the string back to `ProviderInstanceId` for - // the typed lookup. - const instanceKey = provider as ProviderInstanceId; - const current = nextMap[instanceKey]; - nextMap[instanceKey] = { - ...selection, - model: current?.model ?? selection.model, - }; - } - } + const nextMap = compactModelSelectionByProvider(stickyMap); if ( Equal.equals(base.modelSelectionByProvider, nextMap) && - base.activeProvider === stickyActiveProvider + base.activeProvider === stickyActiveProvider && + base.modelSelectionExplicit === undefined ) { return state; } + const { modelSelectionExplicit: _modelSelectionExplicit, ...retained } = base; const nextDraft: ComposerThreadDraftState = { - ...base, + ...retained, modelSelectionByProvider: nextMap, activeProvider: stickyActiveProvider, }; @@ -2745,14 +2750,20 @@ const composerDraftStore = create()( const nextActiveProvider = normalized?.instanceId ?? base.activeProvider; if ( Equal.equals(base.modelSelectionByProvider, nextMap) && - base.activeProvider === nextActiveProvider + base.activeProvider === nextActiveProvider && + (base.modelSelectionExplicit ?? false) === (opts?.explicit === true) ) { return state; } + // Last writer defines intent: picker writes mark the selection + // explicit; seeding writes leave it unset so future seeds can + // replace it. + const { modelSelectionExplicit: _previousExplicit, ...restBase } = base; const nextDraft: ComposerThreadDraftState = { - ...base, + ...restBase, modelSelectionByProvider: nextMap, activeProvider: nextActiveProvider, + ...(opts?.explicit === true ? { modelSelectionExplicit: true as const } : {}), }; const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; if (shouldRemoveDraft(nextDraft)) { @@ -2875,10 +2886,14 @@ const composerDraftStore = create()( return state; } + // Trait edits are user-driven intent: mark the selection explicit + // so later seeds cannot silently replace the chosen options. + const { modelSelectionExplicit: _previousExplicit, ...restBase } = base; const nextDraft: ComposerThreadDraftState = { - ...base, + ...restBase, ...(options?.instanceId ? { activeProvider: instanceKey } : {}), modelSelectionByProvider: nextMap, + modelSelectionExplicit: true, }; const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; if (shouldRemoveDraft(nextDraft)) { diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index ed88a2033296..ca8967fdeb14 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -24,7 +24,10 @@ import { } from "../logicalProject"; import { resolveDefaultThreadEnvMode } from "@t3tools/shared/threadEnvMode"; import { readThreadShell, useProjects, useThread } from "../state/entities"; -import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions"; +import { + resolveNewDraftStartFromOrigin, + resolveNewThreadModelSelectionOverride, +} from "../lib/chatThreadActions"; import { readT3ProjectFileDefaultThreadEnvMode } from "../lib/t3ProjectFileDefaults"; import { primaryServerSettingsAtom } from "../state/server"; import { resolveThreadRouteTarget } from "../threadRoutes"; @@ -99,11 +102,10 @@ export function useNewThreadHandler() { setModelSelection, } = useComposerDraftStore.getState(); const currentRouteTarget = getCurrentRouteTarget(); - // A new thread carries the user's *working mode* from the thread being - // viewed: model (including options like reasoning effort and context - // window), permission mode, and interaction mode. Branch, worktree, and - // env mode never carry implicitly — those come from the configured - // defaults unless the caller passes them explicitly. + // A new thread carries the user's working mode from the thread being + // viewed. The target project's configured model still wins; runtime and + // interaction modes carry independently. Branch, worktree, and env mode + // come from configured defaults unless the caller passes them explicitly. const carrySourceShell = currentRouteTarget?.kind === "server" ? readThreadShell(currentRouteTarget.threadRef) @@ -161,6 +163,14 @@ export function useNewThreadHandler() { candidate.id === projectRef.projectId && candidate.environmentId === projectRef.environmentId, ); + const resolveModelSelectionOverride = (destinationDraftId: DraftId) => + resolveNewThreadModelSelectionOverride({ + projectDefaultSelection: project?.defaultModelSelection ?? null, + carrySelection: carryModelSelection, + carrySourceDraftId: + currentRouteTarget?.kind === "draft" ? currentRouteTarget.draftId : null, + destinationDraftId, + }); // The shared resolver owns the priority order. The t3.json read is // skipped entirely when a higher-priority source decides, and its // query atom caches per project after the first call. @@ -229,8 +239,10 @@ export function useNewThreadHandler() { // env context resets to the configured defaults so drafts seeded // before a defaults change (or by the old carry-over behavior) stop // landing on "current checkout" branches forever. When the draft is - // already open and no options were passed, leave it alone entirely — - // the user may have just picked a branch in the composer. + // already open and no options were passed, leave its workspace + // context alone entirely — the user may have just picked a branch + // in the composer. Model selection has its own explicit-pick rule + // below and does not follow this guard. let workspaceContext: NewThreadWorkspaceOptions | null = null; if (hasExplicitWorkspaceOption) { workspaceContext = pickExplicitWorkspaceOptions(options); @@ -276,11 +288,29 @@ export function useNewThreadHandler() { ...(carryRuntimeMode ? { runtimeMode: carryRuntimeMode } : {}), ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), }); - if (carryModelSelection) { - // The carried selection is a complete snapshot of the viewed - // thread's model state: absent options mean "no options", not - // "keep the stale draft's options". - setModelSelection(emptyStoredDraftThread.draftId, carryModelSelection, { + } + // Model intent: an explicit human pick always stands. Seeds and + // legacy entries alike re-resolve here — sticky first, mirroring + // the mint-fresh path, then the project default or carried + // selection on top. This runs even when the draft is already open: + // without it, a changed pin could never reach the draft the user + // is looking at, because explicit picks are the only thing the + // flag protects. + const storedDraft = getComposerDraft(emptyStoredDraftThread.draftId); + const storedActiveSelection = storedDraft?.activeProvider + ? storedDraft.modelSelectionByProvider[storedDraft.activeProvider] + : undefined; + const storedDraftHasExplicitModelPick = + Boolean(storedActiveSelection) && storedDraft?.modelSelectionExplicit === true; + if (!storedDraftHasExplicitModelPick) { + applyStickyState(emptyStoredDraftThread.draftId); + const modelSelectionOverride = resolveModelSelectionOverride( + emptyStoredDraftThread.draftId, + ); + if (modelSelectionOverride) { + // This is a complete snapshot: absent options mean "no options", + // not "keep the stale draft's options". + setModelSelection(emptyStoredDraftThread.draftId, modelSelectionOverride, { replaceOptions: true, }); } @@ -412,13 +442,11 @@ export function useNewThreadHandler() { ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), }); applyStickyState(draftId); - if (carryModelSelection) { - // After sticky state so the viewed thread's exact selection - // (model + options like effort and context window) wins over the - // globally sticky one. replaceOptions: the carried selection is a - // complete snapshot — absent options mean "no options", not "keep - // whatever sticky state just wrote". - setModelSelection(draftId, carryModelSelection, { replaceOptions: true }); + const modelSelectionOverride = resolveModelSelectionOverride(draftId); + if (modelSelectionOverride) { + // Project defaults and carried selections both outrank global sticky + // state. The project default wins when both are present. + setModelSelection(draftId, modelSelectionOverride, { replaceOptions: true }); } carryComposerContentTo(draftId); diff --git a/apps/web/src/lib/chatThreadActions.test.ts b/apps/web/src/lib/chatThreadActions.test.ts index 0902d8de7950..ee555231e43c 100644 --- a/apps/web/src/lib/chatThreadActions.test.ts +++ b/apps/web/src/lib/chatThreadActions.test.ts @@ -1,9 +1,15 @@ import { scopeProjectRef } from "@t3tools/client-runtime/environment"; -import { EnvironmentId, ProjectId } from "@t3tools/contracts"; +import { + EnvironmentId, + ProjectId, + ProviderInstanceId, + type ModelSelection, +} from "@t3tools/contracts"; import { describe, expect, it, vi } from "vite-plus/test"; import { resolveThreadActionProjectRef, resolveNewDraftStartFromOrigin, + resolveNewThreadModelSelectionOverride, startNewThreadFromContext, type ChatThreadActionContext, } from "./chatThreadActions"; @@ -11,6 +17,14 @@ import { const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); const PROJECT_ID = ProjectId.make("project-1"); const FALLBACK_PROJECT_ID = ProjectId.make("project-2"); +const PROJECT_DEFAULT_SELECTION: ModelSelection = { + instanceId: ProviderInstanceId.make("codex"), + model: "project-default", +}; +const CARRIED_SELECTION: ModelSelection = { + instanceId: ProviderInstanceId.make("codex"), + model: "carried-model", +}; function createContext(overrides: Partial = {}): ChatThreadActionContext { return { @@ -23,6 +37,39 @@ function createContext(overrides: Partial = {}): ChatTh } describe("chatThreadActions", () => { + it("does not carry a non-explicit model from the destination draft back into itself", () => { + expect( + resolveNewThreadModelSelectionOverride({ + projectDefaultSelection: null, + carrySelection: CARRIED_SELECTION, + carrySourceDraftId: "draft-a", + destinationDraftId: "draft-a", + }), + ).toBeNull(); + }); + + it("still carries models between different threads when the project has no default", () => { + expect( + resolveNewThreadModelSelectionOverride({ + projectDefaultSelection: null, + carrySelection: CARRIED_SELECTION, + carrySourceDraftId: "draft-a", + destinationDraftId: "draft-b", + }), + ).toEqual(CARRIED_SELECTION); + }); + + it("keeps the project default above any carried selection", () => { + expect( + resolveNewThreadModelSelectionOverride({ + projectDefaultSelection: PROJECT_DEFAULT_SELECTION, + carrySelection: CARRIED_SELECTION, + carrySourceDraftId: "draft-a", + destinationDraftId: "draft-b", + }), + ).toEqual(PROJECT_DEFAULT_SELECTION); + }); + it("only applies the start-from-origin default to new worktree drafts", () => { expect( resolveNewDraftStartFromOrigin({ diff --git a/apps/web/src/lib/chatThreadActions.ts b/apps/web/src/lib/chatThreadActions.ts index 3aa7db2c2627..4336fa5a825c 100644 --- a/apps/web/src/lib/chatThreadActions.ts +++ b/apps/web/src/lib/chatThreadActions.ts @@ -1,5 +1,10 @@ import { scopeProjectRef } from "@t3tools/client-runtime/environment"; -import type { EnvironmentId, ProjectId, ScopedProjectRef } from "@t3tools/contracts"; +import type { + EnvironmentId, + ModelSelection, + ProjectId, + ScopedProjectRef, +} from "@t3tools/contracts"; import type { DraftThreadEnvMode } from "../composerDraftStore"; interface ThreadContextLike { @@ -34,6 +39,18 @@ export function resolveNewDraftStartFromOrigin(input: { return input.envMode === "worktree" && input.newWorktreesStartFromOrigin; } +export function resolveNewThreadModelSelectionOverride(input: { + readonly projectDefaultSelection: ModelSelection | null; + readonly carrySelection: ModelSelection | null; + readonly carrySourceDraftId: string | null; + readonly destinationDraftId: string; +}): ModelSelection | null { + return ( + input.projectDefaultSelection ?? + (input.carrySourceDraftId === input.destinationDraftId ? null : input.carrySelection) + ); +} + export function resolveThreadActionProjectRef( context: ChatThreadActionContext, ): ScopedProjectRef | null {