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