| 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 | 712 | if (editingPendingTask) { |
| 713 | 713 | flow.finishEditingPendingTask(); |
| 714 | 714 | } else { |
| 715 | // Drop the workspace selection with the content: the next task should | |
| 716 | // re-resolve mode/branch/origin from the server's configured defaults | |
| 717 | // instead of resurrecting this task's picks. | |
| 718 | clearComposerDraftContent(draftKey, { clearWorkspaceSelection: true }); | |
| 715 | // Drop draft-local model/workspace selections with the content. The | |
| 716 | // next task re-resolves project defaults before sticky app defaults. | |
| 717 | clearComposerDraftContent(draftKey, { | |
| 718 | clearModelSelection: true, | |
| 719 | clearWorkspaceSelection: true, | |
| 720 | }); | |
| 719 | 721 | } |
| 720 | 722 | navigation.getParent()?.goBack(); |
| 721 | 723 | return; |
| ... | ... | @@ -779,7 +781,10 @@ export function NewTaskDraftScreen(props: { |
| 779 | 781 | } |
| 780 | 782 | flow.finishEditingPendingTask(); |
| 781 | 783 | } else { |
| 782 | clearComposerDraftContent(draftKey, { clearWorkspaceSelection: true }); | |
| 784 | clearComposerDraftContent(draftKey, { | |
| 785 | clearModelSelection: true, | |
| 786 | clearWorkspaceSelection: true, | |
| 787 | }); | |
| 783 | 788 | } |
| 784 | 789 | navigation.dispatch( |
| 785 | 790 | StackActions.replace("Thread", { |
apps/mobile/src/features/threads/new-task-flow-provider.tsx+27-11| ... | ... | @@ -33,6 +33,7 @@ import { |
| 33 | 33 | buildModelOptions, |
| 34 | 34 | groupByProvider, |
| 35 | 35 | resolveDefaultableModelSelection, |
| 36 | resolveNewTaskModelSelection, | |
| 36 | 37 | resolveSelectableModelSelection, |
| 37 | 38 | } from "../../lib/modelOptions"; |
| 38 | 39 | import { scopedProjectKey } from "../../lib/scopedEntities"; |
| ... | ... | @@ -48,8 +49,10 @@ import { |
| 48 | 49 | removeComposerDraftAttachment, |
| 49 | 50 | replaceComposerDraftAttachments, |
| 50 | 51 | setComposerDraftText, |
| 52 | setStickyComposerModelSelection, | |
| 51 | 53 | updateComposerDraftSettings, |
| 52 | 54 | useComposerDraft, |
| 55 | useStickyComposerModelSelection, | |
| 53 | 56 | } from "../../state/use-composer-drafts"; |
| 54 | 57 | import { useDebouncedValue, usePaginatedBranches } from "../../state/queries"; |
| 55 | 58 | import { vcsEnvironment } from "../../state/vcs"; |
| ... | ... | @@ -418,21 +421,33 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 418 | 421 | selectedEnvironmentServerConfig, |
| 419 | 422 | selectedProject?.defaultModelSelection ?? null, |
| 420 | 423 | ); |
| 424 | const storedStickyModelSelection = useStickyComposerModelSelection(); | |
| 425 | const stickyModelSelection = resolveDefaultableModelSelection( | |
| 426 | selectedEnvironmentServerConfig, | |
| 427 | storedStickyModelSelection, | |
| 428 | ); | |
| 421 | 429 | const modelOptions = useMemo( |
| 422 | 430 | () => |
| 423 | 431 | buildModelOptions( |
| 424 | 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 = | |
| 431 | draftModelSelection ?? | |
| 432 | projectDefaultModelSelection ?? | |
| 433 | modelOptions.find((option) => option.isDefault)?.selection ?? | |
| 434 | modelOptions[0]?.selection ?? | |
| 435 | null; | |
| 443 | // An unsent draft keeps its explicit pick. Fresh drafts resolve the project | |
| 444 | // default before the last manual app-wide selection and provider default. | |
| 445 | const selectedModel = resolveNewTaskModelSelection({ | |
| 446 | draftSelection: draftModelSelection, | |
| 447 | projectDefaultSelection: projectDefaultModelSelection, | |
| 448 | stickySelection: stickyModelSelection, | |
| 449 | modelOptions, | |
| 450 | }); | |
| 436 | 451 | const selectedModelKey = selectedModel |
| 437 | 452 | ? `${selectedModel.instanceId}:${selectedModel.model}` |
| 438 | 453 | : null; |
| ... | ... | @@ -462,9 +477,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 462 | 477 | if (!option) { |
| 463 | 478 | return; |
| 464 | 479 | } |
| 465 | updateComposerDraftSettings(selectedProjectDraftKey, { | |
| 466 | modelSelection: options ? { ...option.selection, options } : option.selection, | |
| 467 | }); | |
| 480 | const selection = options ? { ...option.selection, options } : option.selection; | |
| 481 | updateComposerDraftSettings(selectedProjectDraftKey, { modelSelection: selection }); | |
| 482 | setStickyComposerModelSelection(selection); | |
| 468 | 483 | }, |
| 469 | 484 | [modelOptions, selectedProjectDraftKey], |
| 470 | 485 | ); |
| ... | ... | @@ -482,6 +497,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 482 | 497 | updateComposerDraftSettings(selectedProjectDraftKey, { |
| 483 | 498 | modelSelection: nextSelection, |
| 484 | 499 | }); |
| 500 | setStickyComposerModelSelection(nextSelection); | |
| 485 | 501 | }, |
| 486 | 502 | [selectedModel, selectedProjectDraftKey], |
| 487 | 503 | ); |
apps/mobile/src/lib/modelOptions.test.ts+29-1| ... | ... | @@ -1,12 +1,14 @@ |
| 1 | 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 | 5 | import { |
| 6 | 6 | buildModelOptions, |
| 7 | 7 | groupByProvider, |
| 8 | 8 | resolveDefaultableModelSelection, |
| 9 | resolveNewTaskModelSelection, | |
| 9 | 10 | resolveSelectableModelSelection, |
| 11 | type ModelOption, | |
| 10 | 12 | } from "./modelOptions"; |
| 11 | 13 | |
| 12 | 14 | describe("mobile model options", () => { |
| ... | ... | @@ -220,4 +222,30 @@ describe("mobile model options", () => { |
| 220 | 222 | // Offline: nothing to validate against, selection passes through. |
| 221 | 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 | 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 | 123 | export function buildModelOptions( |
| 108 | 124 | config: T3ServerConfig | null | undefined, |
| 109 | 125 | fallbackModelSelection: ModelSelection | null, |
apps/mobile/src/state/use-composer-drafts.test.ts+232-8| ... | ... | @@ -7,6 +7,9 @@ const composerDraftFileMocks = vi.hoisted(() => { |
| 7 | 7 | let writeError: Error | null = null; |
| 8 | 8 | let releaseRead: (() => void) | null = null; |
| 9 | 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 | 14 | return { |
| 12 | 15 | blockRead() { |
| ... | ... | @@ -27,6 +30,18 @@ const composerDraftFileMocks = vi.hoisted(() => { |
| 27 | 30 | setWriteError(error: Error | null) { |
| 28 | 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 | 45 | Directory: class { |
| 31 | 46 | create() {} |
| 32 | 47 | }, |
| ... | ... | @@ -47,7 +62,18 @@ const composerDraftFileMocks = vi.hoisted(() => { |
| 47 | 62 | if (writeError) { |
| 48 | 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 | 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 | 88 | import { appAtomRegistry } from "./atom-registry"; |
| 63 | 89 | import { |
| 64 | 90 | clearComposerDraftContentState, |
| 91 | clearComposerDraftsEnvironment, | |
| 65 | 92 | ComposerDraftPersistenceError, |
| 66 | 93 | composerDraftsAtom, |
| 67 | 94 | copyComposerDraftContentIfEmpty, |
| 68 | 95 | copyComposerDraftContentState, |
| 96 | decodePersistedComposerState, | |
| 69 | 97 | decodePersistedComposerDrafts, |
| 98 | ensureComposerDraftsLoaded, | |
| 70 | 99 | type ComposerDraft, |
| 71 | 100 | flushComposerDrafts, |
| 72 | 101 | getComposerDraftSnapshot, |
| 73 | 102 | mergeComposerDraftContentState, |
| 74 | 103 | removeComposerDraftsForEnvironment, |
| 104 | resetComposerDraftsLoadState, | |
| 75 | 105 | restoreComposerDraftSnapshotState, |
| 76 | 106 | setComposerDraftText, |
| 107 | setStickyComposerModelSelection, | |
| 108 | stickyComposerModelSelectionAtom, | |
| 77 | 109 | } from "./use-composer-drafts"; |
| 78 | 110 | |
| 79 | 111 | const DRAFT: ComposerDraft = { |
| ... | ... | @@ -82,7 +114,15 @@ const DRAFT: ComposerDraft = { |
| 82 | 114 | }; |
| 83 | 115 | |
| 84 | 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 | 124 | appAtomRegistry.set(composerDraftsAtom, {}); |
| 125 | appAtomRegistry.set(stickyComposerModelSelectionAtom, null); | |
| 86 | 126 | }); |
| 87 | 127 | |
| 88 | 128 | describe("mobile composer drafts", () => { |
| ... | ... | @@ -154,6 +194,195 @@ describe("mobile composer drafts", () => { |
| 154 | 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 | 386 | it("clears sent content without clearing the selected model or workspace", () => { |
| 158 | 387 | const draftKey = "environment-1:thread-1"; |
| 159 | 388 | const draft: ComposerDraft = { |
| ... | ... | @@ -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 | 415 | const draftKey = "new-task:environment-1:project-1"; |
| 187 | 416 | const draft: ComposerDraft = { |
| 188 | 417 | text: "send this", |
| ... | ... | @@ -201,15 +430,10 @@ describe("mobile composer drafts", () => { |
| 201 | 430 | |
| 202 | 431 | expect( |
| 203 | 432 | clearComposerDraftContentState({ [draftKey]: draft }, draftKey, { |
| 433 | clearModelSelection: true, | |
| 204 | 434 | clearWorkspaceSelection: true, |
| 205 | 435 | }), |
| 206 | ).toEqual({ | |
| 207 | [draftKey]: { | |
| 208 | modelSelection: draft.modelSelection, | |
| 209 | text: "", | |
| 210 | attachments: [], | |
| 211 | }, | |
| 212 | }); | |
| 436 | ).toEqual({}); | |
| 213 | 437 | }); |
| 214 | 438 | |
| 215 | 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 | 86 | const PersistedComposerDraftsSchema = Schema.Struct({ |
| 87 | 87 | schemaVersion: Schema.Literal(COMPOSER_DRAFTS_SCHEMA_VERSION), |
| 88 | 88 | drafts: Schema.Record(Schema.String, ComposerDraftSchema), |
| 89 | stickyModelSelection: Schema.optional(ModelSelectionSchema), | |
| 89 | 90 | }); |
| 90 | 91 | |
| 91 | 92 | const decodePersistedComposerDraftsDocument = Schema.decodeUnknownSync( |
| ... | ... | @@ -102,10 +103,20 @@ export const composerDraftsAtom = Atom.make<Record<string, ComposerDraft>>({}).p |
| 102 | 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 | 111 | let loadPromise: Promise<void> | null = null; |
| 106 | 112 | let persistTimer: ReturnType<typeof setTimeout> | null = null; |
| 107 | 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 | 120 | function normalizeDraft(draft: ComposerDraft | undefined): ComposerDraft { |
| 110 | 121 | if (!draft) { |
| 111 | 122 | return EMPTY_DRAFT; |
| ... | ... | @@ -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 | 154 | const parsed = decodePersistedComposerDraftsDocument(value); |
| 141 | return Object.fromEntries( | |
| 142 | Object.entries(parsed.drafts).filter(([, draft]) => !isEmptyDraft(draft)), | |
| 143 | ); | |
| 155 | return { | |
| 156 | drafts: Object.fromEntries( | |
| 157 | Object.entries(parsed.drafts) | |
| 158 | .map( | |
| 159 | ([key, draft]) => | |
| 160 | [ | |
| 161 | key, | |
| 162 | // Stale new-task drafts left on disk by builds before the | |
| 163 | // model-precedence fix carry a bare modelSelection with no | |
| 164 | // other selector settings. Strip it so the next compose pass | |
| 165 | // re-resolves project → sticky → provider defaults. Drafts | |
| 166 | // with runtime/interaction/workspace settings or actual text / | |
| 167 | // attachments were deliberately configured and are left alone. | |
| 168 | key.startsWith("new-task:") && | |
| 169 | draft.modelSelection && | |
| 170 | draft.text.length === 0 && | |
| 171 | draft.attachments.length === 0 && | |
| 172 | draft.runtimeMode === undefined && | |
| 173 | draft.interactionMode === undefined && | |
| 174 | draft.workspaceSelection === undefined | |
| 175 | ? { ...draft, modelSelection: undefined } | |
| 176 | : draft, | |
| 177 | ] as const, | |
| 178 | ) | |
| 179 | // importedShareIds are share-import receipts: a contentless draft | |
| 180 | // carrying one is not empty, or the same native share would be | |
| 181 | // re-imported after restart. | |
| 182 | .filter(([, draft]) => !isEmptyDraft(draft) || (draft.importedShareIds?.length ?? 0) > 0), | |
| 183 | ), | |
| 184 | stickyModelSelection: parsed.stickyModelSelection ?? null, | |
| 185 | }; | |
| 186 | } | |
| 187 | ||
| 188 | export function decodePersistedComposerDrafts(value: unknown): Record<string, ComposerDraft> { | |
| 189 | return decodePersistedComposerState(value).drafts; | |
| 144 | 190 | } |
| 145 | 191 | |
| 146 | 192 | async function getComposerDraftsFile() { |
| ... | ... | @@ -150,17 +196,20 @@ async function getComposerDraftsFile() { |
| 150 | 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 | 203 | let operation: ComposerDraftPersistenceError["operation"] = "open"; |
| 155 | 204 | try { |
| 156 | 205 | const file = await getComposerDraftsFile(); |
| 157 | 206 | if (!file.exists) { |
| 158 | return {}; | |
| 207 | return { drafts: {}, stickyModelSelection: null }; | |
| 159 | 208 | } |
| 160 | 209 | operation = "read"; |
| 161 | 210 | const raw = await file.text(); |
| 162 | 211 | operation = "decode"; |
| 163 | return decodePersistedComposerDrafts(JSON.parse(raw) as unknown); | |
| 212 | return decodePersistedComposerState(JSON.parse(raw) as unknown); | |
| 164 | 213 | } catch (cause) { |
| 165 | 214 | console.warn( |
| 166 | 215 | "[composer-drafts] ignored persisted draft failure", |
| ... | ... | @@ -171,11 +220,14 @@ async function loadPersistedComposerDrafts(): Promise<Record<string, ComposerDra |
| 171 | 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 | 231 | let operation: ComposerDraftPersistenceError["operation"] = "open"; |
| 180 | 232 | try { |
| 181 | 233 | const file = await getComposerDraftsFile(); |
| ... | ... | @@ -186,6 +238,7 @@ async function writePersistedComposerDrafts(drafts: Record<string, ComposerDraft |
| 186 | 238 | const document = { |
| 187 | 239 | schemaVersion: COMPOSER_DRAFTS_SCHEMA_VERSION, |
| 188 | 240 | drafts: nonEmptyDrafts, |
| 241 | ...(stickyModelSelection ? { stickyModelSelection } : {}), | |
| 189 | 242 | } as const; |
| 190 | 243 | const encoded = JSON.stringify(document); |
| 191 | 244 | operation = "write"; |
| ... | ... | @@ -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 | 257 | * Lands any debounced or in-flight draft write before the JS runtime is torn |
| 214 | 258 | * down (app update restart), so the freshest draft state survives it. A write |
| 215 | 259 | * failure propagates so the caller can decide whether the restart may proceed. |
| 216 | 260 | */ |
| 217 | 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 | 268 | // An edit during an awaited write schedules another debounced write, so |
| 219 | 269 | // keep landing snapshots until no debounce is pending after a queue drain. |
| 220 | 270 | do { |
| ... | ... | @@ -222,20 +272,41 @@ export async function flushComposerDrafts(): Promise<void> { |
| 222 | 272 | clearTimeout(persistTimer); |
| 223 | 273 | persistTimer = null; |
| 224 | 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 | 283 | await persistenceQueue.run(() => Promise.resolve()); |
| 229 | 284 | } while (persistTimer !== null); |
| 230 | 285 | } |
| 231 | 286 | |
| 232 | function schedulePersistComposerDrafts(drafts: Record<string, ComposerDraft>): void { | |
| 287 | function schedulePersistComposerState(): void { | |
| 233 | 288 | if (persistTimer !== null) { |
| 234 | 289 | clearTimeout(persistTimer); |
| 235 | 290 | } |
| 236 | 291 | persistTimer = setTimeout(() => { |
| 237 | 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 | 310 | }, PERSIST_DEBOUNCE_MS); |
| 240 | 311 | } |
| 241 | 312 | |
| ... | ... | @@ -243,16 +314,21 @@ export function ensureComposerDraftsLoaded(): void { |
| 243 | 314 | if (loadPromise !== null) { |
| 244 | 315 | return; |
| 245 | 316 | } |
| 246 | loadPromise = loadPersistedComposerDrafts() | |
| 247 | .then((persistedDrafts) => { | |
| 248 | if (Object.keys(persistedDrafts).length === 0) { | |
| 249 | return; | |
| 317 | loadPromise = loadPersistedComposerState() | |
| 318 | .then((persisted) => { | |
| 319 | if (Object.keys(persisted.drafts).length > 0) { | |
| 320 | const current = appAtomRegistry.get(composerDraftsAtom); | |
| 321 | appAtomRegistry.set(composerDraftsAtom, { | |
| 322 | ...persisted.drafts, | |
| 323 | ...current, | |
| 324 | }); | |
| 325 | } | |
| 326 | if ( | |
| 327 | persisted.stickyModelSelection !== null && | |
| 328 | appAtomRegistry.get(stickyComposerModelSelectionAtom) === null | |
| 329 | ) { | |
| 330 | appAtomRegistry.set(stickyComposerModelSelectionAtom, persisted.stickyModelSelection); | |
| 250 | 331 | } |
| 251 | const current = appAtomRegistry.get(composerDraftsAtom); | |
| 252 | appAtomRegistry.set(composerDraftsAtom, { | |
| 253 | ...persistedDrafts, | |
| 254 | ...current, | |
| 255 | }); | |
| 256 | 332 | }) |
| 257 | 333 | .catch((cause) => { |
| 258 | 334 | console.warn( |
| ... | ... | @@ -277,7 +353,12 @@ function updateComposerDrafts( |
| 277 | 353 | return; |
| 278 | 354 | } |
| 279 | 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 | 364 | export function setComposerDraftText(draftKey: string, value: string): void { |
| ... | ... | @@ -394,15 +475,24 @@ export function updateComposerDraftSettings( |
| 394 | 475 | export function clearComposerDraftContentState( |
| 395 | 476 | current: Record<string, ComposerDraft>, |
| 396 | 477 | draftKey: string, |
| 397 | options?: { readonly clearWorkspaceSelection?: boolean }, | |
| 478 | options?: { | |
| 479 | readonly clearModelSelection?: boolean; | |
| 480 | readonly clearWorkspaceSelection?: boolean; | |
| 481 | }, | |
| 398 | 482 | ): Record<string, ComposerDraft> { |
| 399 | 483 | const existing = current[draftKey]; |
| 400 | 484 | if (!existing) { |
| 401 | 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 | 493 | const draft = { |
| 405 | 494 | ...retained, |
| 495 | ...(options?.clearModelSelection || modelSelection === undefined ? {} : { modelSelection }), | |
| 406 | 496 | ...(options?.clearWorkspaceSelection || workspaceSelection === undefined |
| 407 | 497 | ? {} |
| 408 | 498 | : { workspaceSelection }), |
| ... | ... | @@ -571,7 +661,9 @@ export async function mergeComposerDraftContent( |
| 571 | 661 | if (next !== current) { |
| 572 | 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 | 667 | return { skippedAttachmentCount }; |
| 576 | 668 | } |
| 577 | 669 | |
| ... | ... | @@ -594,12 +686,17 @@ export async function restoreComposerDraftSnapshot( |
| 594 | 686 | snapshot, |
| 595 | 687 | ); |
| 596 | 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 | 694 | export function clearComposerDraftContent( |
| 601 | 695 | draftKey: string, |
| 602 | options?: { readonly clearWorkspaceSelection?: boolean }, | |
| 696 | options?: { | |
| 697 | readonly clearModelSelection?: boolean; | |
| 698 | readonly clearWorkspaceSelection?: boolean; | |
| 699 | }, | |
| 603 | 700 | ): void { |
| 604 | 701 | updateComposerDrafts((current) => clearComposerDraftContentState(current, draftKey, options)); |
| 605 | 702 | } |
| ... | ... | @@ -645,7 +742,9 @@ export async function clearComposerDraftsEnvironment(environmentId: EnvironmentI |
| 645 | 742 | persistTimer = null; |
| 646 | 743 | } |
| 647 | 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 | 750 | export function useComposerDraft(draftKey: string | null): ComposerDraft { |
| ... | ... | @@ -655,3 +754,11 @@ export function useComposerDraft(draftKey: string | null): ComposerDraft { |
| 655 | 754 | }, []); |
| 656 | 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 | 6598 | setComposerDraftModelSelection( |
| 6599 | 6599 | scopeThreadRef(activeThread.environmentId, activeThread.id), |
| 6600 | 6600 | nextModelSelection, |
| 6601 | { explicit: true }, | |
| 6601 | 6602 | ); |
| 6602 | 6603 | setStickyComposerModelSelection(nextModelSelection); |
| 6603 | 6604 | scheduleComposerFocus(); |
apps/web/src/composerDraftStore.test.ts+253| ... | ... | @@ -1314,6 +1314,47 @@ describe("composerDraftStore modelSelection", () => { |
| 1314 | 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 | 1358 | it("replaces only the targeted provider options on the current model selection", () => { |
| 1318 | 1359 | const store = useComposerDraftStore.getState(); |
| 1319 | 1360 | |
| ... | ... | @@ -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 | 1417 | it("keeps explicit default-state overrides on the selection", () => { |
| 1360 | 1418 | const store = useComposerDraftStore.getState(); |
| 1361 | 1419 | |
| ... | ... | @@ -1706,6 +1764,201 @@ describe("composerDraftStore sticky composer settings", () => { |
| 1706 | 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 | 1964 | describe("composerDraftStore provider-scoped option updates", () => { |
apps/web/src/composerDraftStore.ts+91-76| ... | ... | @@ -58,7 +58,7 @@ const isProviderDriverKind = Schema.is(ProviderDriverKind); |
| 58 | 58 | const isReviewCommentContext = Schema.is(ReviewCommentContextSchema); |
| 59 | 59 | |
| 60 | 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 | 62 | const DraftThreadEnvModeSchema = Schema.Literals(["local", "worktree"]); |
| 63 | 63 | export type DraftThreadEnvMode = typeof DraftThreadEnvModeSchema.Type; |
| 64 | 64 | |
| ... | ... | @@ -146,6 +146,10 @@ const PersistedComposerThreadDraftState = Schema.Struct({ |
| 146 | 146 | // an entry already encodes "no selection for this instance". |
| 147 | 147 | modelSelectionByProvider: Schema.optionalKey(Schema.Record(ProviderInstanceId, ModelSelection)), |
| 148 | 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 | 153 | runtimeMode: Schema.optionalKey(RuntimeMode), |
| 150 | 154 | interactionMode: Schema.optionalKey(ProviderInteractionMode), |
| 151 | 155 | }); |
| ... | ... | @@ -274,6 +278,12 @@ export interface ComposerThreadDraftState { |
| 274 | 278 | modelSelectionByProvider: Partial<Record<ProviderInstanceId, ModelSelection>>; |
| 275 | 279 | /** Routing key of the last picked instance (see `modelSelectionByProvider`). */ |
| 276 | 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 | 287 | runtimeMode: RuntimeMode | null; |
| 278 | 288 | interactionMode: ProviderInteractionMode | null; |
| 279 | 289 | } |
| ... | ... | @@ -434,6 +444,7 @@ interface ComposerDraftStoreState { |
| 434 | 444 | threadRef: ComposerThreadTarget, |
| 435 | 445 | modelSelection: ModelSelection | null | undefined, |
| 436 | 446 | opts?: { |
| 447 | explicit?: boolean; | |
| 437 | 448 | /** |
| 438 | 449 | * Replace the stored entry outright instead of preserving its |
| 439 | 450 | * existing options when the incoming selection has none. Used when |
| ... | ... | @@ -1609,12 +1620,18 @@ function normalizePersistedDraftThreads( |
| 1609 | 1620 | const parsedThreadRef = parseScopedThreadKey(threadKeyOrId); |
| 1610 | 1621 | const threadKey = normalizeLegacyComposerStorageKey(threadKeyOrId); |
| 1611 | 1622 | logicalProjectDraftThreadKeyByLogicalProjectKey[logicalProjectKey] = threadKey; |
| 1623 | const existingDraftThread = draftThreadsByThreadKey[threadKey]; | |
| 1612 | 1624 | if (parsedThreadRef) { |
| 1613 | 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 | 1633 | if (!projectRef) { |
| 1616 | const existingDraftThread = draftThreadsByThreadKey[threadKey]; | |
| 1617 | if (existingDraftThread && !existingDraftThread.logicalProjectKey) { | |
| 1634 | if (existingDraftThread) { | |
| 1618 | 1635 | draftThreadsByThreadKey[threadKey] = { |
| 1619 | 1636 | ...existingDraftThread, |
| 1620 | 1637 | logicalProjectKey, |
| ... | ... | @@ -1622,7 +1639,7 @@ function normalizePersistedDraftThreads( |
| 1622 | 1639 | } |
| 1623 | 1640 | continue; |
| 1624 | 1641 | } |
| 1625 | if (!draftThreadsByThreadKey[threadKey]) { | |
| 1642 | if (!existingDraftThread) { | |
| 1626 | 1643 | draftThreadsByThreadKey[threadKey] = { |
| 1627 | 1644 | threadId: parsedThreadRef?.threadId ?? (threadKey as ThreadId), |
| 1628 | 1645 | environmentId: projectRef.environmentId, |
| ... | ... | @@ -1638,12 +1655,12 @@ function normalizePersistedDraftThreads( |
| 1638 | 1655 | promotedTo: null, |
| 1639 | 1656 | }; |
| 1640 | 1657 | } else if ( |
| 1641 | draftThreadsByThreadKey[threadKey]?.projectId !== projectRef.projectId || | |
| 1642 | draftThreadsByThreadKey[threadKey]?.environmentId !== projectRef.environmentId | |
| 1658 | existingDraftThread.projectId !== projectRef.projectId || | |
| 1659 | existingDraftThread.environmentId !== projectRef.environmentId | |
| 1643 | 1660 | ) { |
| 1644 | 1661 | draftThreadsByThreadKey[threadKey] = { |
| 1645 | ...draftThreadsByThreadKey[threadKey]!, | |
| 1646 | threadId: draftThreadsByThreadKey[threadKey]!.threadId, | |
| 1662 | ...existingDraftThread, | |
| 1663 | threadId: existingDraftThread.threadId, | |
| 1647 | 1664 | environmentId: projectRef.environmentId, |
| 1648 | 1665 | projectId: projectRef.projectId, |
| 1649 | 1666 | logicalProjectKey, |
| ... | ... | @@ -1724,6 +1741,7 @@ function normalizePersistedDraftsByThreadId( |
| 1724 | 1741 | const legacyDraftCandidate = draftValue as LegacyPersistedComposerThreadDraftState; |
| 1725 | 1742 | let modelSelectionByProvider: Partial<Record<ProviderInstanceId, ModelSelection>> = {}; |
| 1726 | 1743 | let activeProvider: ProviderInstanceId | null = null; |
| 1744 | let modelSelectionExplicit: true | undefined = undefined; | |
| 1727 | 1745 | |
| 1728 | 1746 | if ( |
| 1729 | 1747 | draftCandidate.modelSelectionByProvider && |
| ... | ... | @@ -1734,6 +1752,7 @@ function normalizePersistedDraftsByThreadId( |
| 1734 | 1752 | Record<ProviderInstanceId, ModelSelection> |
| 1735 | 1753 | >; |
| 1736 | 1754 | activeProvider = normalizeProviderInstanceId(draftCandidate.activeProvider); |
| 1755 | modelSelectionExplicit = draftCandidate.modelSelectionExplicit === true ? true : undefined; | |
| 1737 | 1756 | } else { |
| 1738 | 1757 | // v2 or legacy format: migrate |
| 1739 | 1758 | const normalizedModelOptions = |
| ... | ... | @@ -1802,6 +1821,7 @@ function normalizePersistedDraftsByThreadId( |
| 1802 | 1821 | ? { |
| 1803 | 1822 | modelSelectionByProvider: compactModelSelectionByProvider(modelSelectionByProvider), |
| 1804 | 1823 | activeProvider, |
| 1824 | ...(modelSelectionExplicit ? { modelSelectionExplicit: true } : {}), | |
| 1805 | 1825 | } |
| 1806 | 1826 | : {}), |
| 1807 | 1827 | ...(runtimeMode ? { runtimeMode } : {}), |
| ... | ... | @@ -1812,55 +1832,52 @@ function normalizePersistedDraftsByThreadId( |
| 1812 | 1832 | return nextDraftsByThreadKey; |
| 1813 | 1833 | } |
| 1814 | 1834 | |
| 1815 | function migratePersistedComposerDraftStoreState( | |
| 1816 | persistedState: unknown, | |
| 1817 | ): PersistedComposerDraftStoreState { | |
| 1818 | if (!persistedState || typeof persistedState !== "object") { | |
| 1819 | return EMPTY_PERSISTED_DRAFT_STORE_STATE; | |
| 1820 | } | |
| 1821 | const candidate = persistedState as LegacyPersistedComposerDraftStoreState; | |
| 1822 | const rawDraftMap = candidate.draftsByThreadKey ?? candidate.draftsByThreadId; | |
| 1823 | const rawDraftThreadsByThreadId = | |
| 1824 | candidate.draftThreadsByThreadKey ?? candidate.draftThreadsByThreadId; | |
| 1825 | const rawProjectDraftThreadIdByProjectKey = | |
| 1826 | candidate.logicalProjectDraftThreadKeyByLogicalProjectKey ?? | |
| 1827 | candidate.projectDraftThreadKeyByProjectKey ?? | |
| 1828 | candidate.projectDraftThreadIdByProjectKey ?? | |
| 1829 | candidate.projectDraftThreadIdByProjectId; | |
| 1830 | ||
| 1831 | // Migrate sticky state from v2 (dual) to v3 (consolidated) | |
| 1832 | const stickyModelOptions = normalizeProviderModelOptions(candidate.stickyModelOptions) ?? {}; | |
| 1833 | const normalizedStickyModelSelection = normalizeModelSelection(candidate.stickyModelSelection, { | |
| 1834 | provider: candidate.stickyProvider ?? "codex", | |
| 1835 | model: candidate.stickyModel, | |
| 1836 | modelOptions: stickyModelOptions, | |
| 1837 | }); | |
| 1838 | const nextStickyModelOptions = legacyMergeModelSelectionIntoProviderModelOptions( | |
| 1839 | normalizedStickyModelSelection, | |
| 1840 | stickyModelOptions, | |
| 1841 | ); | |
| 1842 | const stickyModelSelection = legacySyncModelSelectionOptions( | |
| 1843 | normalizedStickyModelSelection, | |
| 1844 | nextStickyModelOptions, | |
| 1845 | ); | |
| 1846 | const stickyModelSelectionByProvider = legacyToModelSelectionByProvider( | |
| 1847 | stickyModelSelection, | |
| 1848 | nextStickyModelOptions, | |
| 1835 | function persistedComposerDraftHasUserContent(draft: PersistedComposerThreadDraftState): boolean { | |
| 1836 | return ( | |
| 1837 | draft.prompt.trim().length > 0 || | |
| 1838 | draft.attachments.length > 0 || | |
| 1839 | (draft.terminalContexts?.length ?? 0) > 0 || | |
| 1840 | (draft.elementContexts?.length ?? 0) > 0 || | |
| 1841 | (draft.previewAnnotations?.length ?? 0) > 0 || | |
| 1842 | (draft.reviewComments?.length ?? 0) > 0 | |
| 1849 | 1843 | ); |
| 1850 | const stickyActiveProvider = normalizeProviderInstanceId(candidate.stickyProvider) ?? null; | |
| 1844 | } | |
| 1851 | 1845 | |
| 1852 | const { draftThreadsByThreadKey, logicalProjectDraftThreadKeyByLogicalProjectKey } = | |
| 1853 | normalizePersistedDraftThreads(rawDraftThreadsByThreadId, rawProjectDraftThreadIdByProjectKey); | |
| 1854 | const draftsByThreadKey = normalizePersistedDraftsByThreadId( | |
| 1855 | rawDraftMap, | |
| 1856 | draftThreadsByThreadKey, | |
| 1846 | function stripLegacyModelSeedsFromEmptyDraftSessions( | |
| 1847 | draftsByThreadKey: PersistedComposerDraftStoreState["draftsByThreadKey"], | |
| 1848 | draftThreadsByThreadKey: PersistedComposerDraftStoreState["draftThreadsByThreadKey"], | |
| 1849 | ): PersistedComposerDraftStoreState["draftsByThreadKey"] { | |
| 1850 | return Object.fromEntries( | |
| 1851 | Object.entries(draftsByThreadKey).flatMap(([threadKey, draft]) => { | |
| 1852 | if ( | |
| 1853 | draftThreadsByThreadKey[threadKey] === undefined || | |
| 1854 | draft.modelSelectionExplicit === true || | |
| 1855 | persistedComposerDraftHasUserContent(draft) | |
| 1856 | ) { | |
| 1857 | return [[threadKey, draft]]; | |
| 1858 | } | |
| 1859 | ||
| 1860 | const { | |
| 1861 | activeProvider: _activeProvider, | |
| 1862 | modelSelectionByProvider: _modelSelectionByProvider, | |
| 1863 | modelSelectionExplicit: _modelSelectionExplicit, | |
| 1864 | ...retained | |
| 1865 | } = draft; | |
| 1866 | return retained.runtimeMode || retained.interactionMode ? [[threadKey, retained]] : []; | |
| 1867 | }), | |
| 1857 | 1868 | ); |
| 1869 | } | |
| 1870 | ||
| 1871 | function migratePersistedComposerDraftStoreState( | |
| 1872 | persistedState: unknown, | |
| 1873 | ): PersistedComposerDraftStoreState { | |
| 1874 | const normalized = normalizeCurrentPersistedComposerDraftStoreState(persistedState); | |
| 1858 | 1875 | return { |
| 1859 | draftsByThreadKey, | |
| 1860 | draftThreadsByThreadKey, | |
| 1861 | logicalProjectDraftThreadKeyByLogicalProjectKey, | |
| 1862 | stickyModelSelectionByProvider: compactModelSelectionByProvider(stickyModelSelectionByProvider), | |
| 1863 | stickyActiveProvider, | |
| 1876 | ...normalized, | |
| 1877 | draftsByThreadKey: stripLegacyModelSeedsFromEmptyDraftSessions( | |
| 1878 | normalized.draftsByThreadKey, | |
| 1879 | normalized.draftThreadsByThreadKey, | |
| 1880 | ), | |
| 1864 | 1881 | }; |
| 1865 | 1882 | } |
| 1866 | 1883 | |
| ... | ... | @@ -1963,6 +1980,7 @@ function partializeComposerDraftStoreState( |
| 1963 | 1980 | draft.modelSelectionByProvider, |
| 1964 | 1981 | ), |
| 1965 | 1982 | activeProvider: draft.activeProvider, |
| 1983 | ...(draft.modelSelectionExplicit ? { modelSelectionExplicit: true } : {}), | |
| 1966 | 1984 | } |
| 1967 | 1985 | : {}), |
| 1968 | 1986 | ...(draft.runtimeMode ? { runtimeMode: draft.runtimeMode } : {}), |
| ... | ... | @@ -2029,7 +2047,7 @@ function normalizeCurrentPersistedComposerDraftStoreState( |
| 2029 | 2047 | const normalizedStickyModelSelection = normalizeModelSelection( |
| 2030 | 2048 | normalizedPersistedState.stickyModelSelection, |
| 2031 | 2049 | { |
| 2032 | provider: normalizedPersistedState.stickyProvider, | |
| 2050 | provider: normalizedPersistedState.stickyProvider ?? "codex", | |
| 2033 | 2051 | model: normalizedPersistedState.stickyModel, |
| 2034 | 2052 | modelOptions: stickyModelOptions, |
| 2035 | 2053 | }, |
| ... | ... | @@ -2211,6 +2229,7 @@ function toHydratedThreadDraft( |
| 2211 | 2229 | reviewComments: persistedDraft.reviewComments?.map((comment) => ({ ...comment })) ?? [], |
| 2212 | 2230 | modelSelectionByProvider, |
| 2213 | 2231 | activeProvider, |
| 2232 | ...(persistedDraft.modelSelectionExplicit ? { modelSelectionExplicit: true } : {}), | |
| 2214 | 2233 | runtimeMode: persistedDraft.runtimeMode ?? null, |
| 2215 | 2234 | interactionMode: persistedDraft.interactionMode ?? null, |
| 2216 | 2235 | }; |
| ... | ... | @@ -2628,33 +2647,19 @@ const composerDraftStore = create<ComposerDraftStoreState>()( |
| 2628 | 2647 | set((state) => { |
| 2629 | 2648 | const stickyMap = state.stickyModelSelectionByProvider; |
| 2630 | 2649 | const stickyActiveProvider = state.stickyActiveProvider; |
| 2631 | if (Object.keys(stickyMap).length === 0 && stickyActiveProvider === null) { | |
| 2632 | return state; | |
| 2633 | } | |
| 2634 | 2650 | const existing = state.draftsByThreadKey[threadKey]; |
| 2635 | 2651 | const base = existing ?? createEmptyThreadDraft(); |
| 2636 | const nextMap = { ...base.modelSelectionByProvider }; | |
| 2637 | for (const [provider, selection] of Object.entries(stickyMap)) { | |
| 2638 | if (selection) { | |
| 2639 | // Iteration key comes from the instance-keyed sticky map, | |
| 2640 | // so coerce the string back to `ProviderInstanceId` for | |
| 2641 | // the typed lookup. | |
| 2642 | const instanceKey = provider as ProviderInstanceId; | |
| 2643 | const current = nextMap[instanceKey]; | |
| 2644 | nextMap[instanceKey] = { | |
| 2645 | ...selection, | |
| 2646 | model: current?.model ?? selection.model, | |
| 2647 | }; | |
| 2648 | } | |
| 2649 | } | |
| 2652 | const nextMap = compactModelSelectionByProvider(stickyMap); | |
| 2650 | 2653 | if ( |
| 2651 | 2654 | Equal.equals(base.modelSelectionByProvider, nextMap) && |
| 2652 | base.activeProvider === stickyActiveProvider | |
| 2655 | base.activeProvider === stickyActiveProvider && | |
| 2656 | base.modelSelectionExplicit === undefined | |
| 2653 | 2657 | ) { |
| 2654 | 2658 | return state; |
| 2655 | 2659 | } |
| 2660 | const { modelSelectionExplicit: _modelSelectionExplicit, ...retained } = base; | |
| 2656 | 2661 | const nextDraft: ComposerThreadDraftState = { |
| 2657 | ...base, | |
| 2662 | ...retained, | |
| 2658 | 2663 | modelSelectionByProvider: nextMap, |
| 2659 | 2664 | activeProvider: stickyActiveProvider, |
| 2660 | 2665 | }; |
| ... | ... | @@ -2745,14 +2750,20 @@ const composerDraftStore = create<ComposerDraftStoreState>()( |
| 2745 | 2750 | const nextActiveProvider = normalized?.instanceId ?? base.activeProvider; |
| 2746 | 2751 | if ( |
| 2747 | 2752 | Equal.equals(base.modelSelectionByProvider, nextMap) && |
| 2748 | base.activeProvider === nextActiveProvider | |
| 2753 | base.activeProvider === nextActiveProvider && | |
| 2754 | (base.modelSelectionExplicit ?? false) === (opts?.explicit === true) | |
| 2749 | 2755 | ) { |
| 2750 | 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 | 2762 | const nextDraft: ComposerThreadDraftState = { |
| 2753 | ...base, | |
| 2763 | ...restBase, | |
| 2754 | 2764 | modelSelectionByProvider: nextMap, |
| 2755 | 2765 | activeProvider: nextActiveProvider, |
| 2766 | ...(opts?.explicit === true ? { modelSelectionExplicit: true as const } : {}), | |
| 2756 | 2767 | }; |
| 2757 | 2768 | const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; |
| 2758 | 2769 | if (shouldRemoveDraft(nextDraft)) { |
| ... | ... | @@ -2875,10 +2886,14 @@ const composerDraftStore = create<ComposerDraftStoreState>()( |
| 2875 | 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 | 2892 | const nextDraft: ComposerThreadDraftState = { |
| 2879 | ...base, | |
| 2893 | ...restBase, | |
| 2880 | 2894 | ...(options?.instanceId ? { activeProvider: instanceKey } : {}), |
| 2881 | 2895 | modelSelectionByProvider: nextMap, |
| 2896 | modelSelectionExplicit: true, | |
| 2882 | 2897 | }; |
| 2883 | 2898 | const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; |
| 2884 | 2899 | if (shouldRemoveDraft(nextDraft)) { |
apps/web/src/hooks/useHandleNewThread.ts+48-20| ... | ... | @@ -24,7 +24,10 @@ import { |
| 24 | 24 | } from "../logicalProject"; |
| 25 | 25 | import { resolveDefaultThreadEnvMode } from "@t3tools/shared/threadEnvMode"; |
| 26 | 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 | 31 | import { readT3ProjectFileDefaultThreadEnvMode } from "../lib/t3ProjectFileDefaults"; |
| 29 | 32 | import { primaryServerSettingsAtom } from "../state/server"; |
| 30 | 33 | import { resolveThreadRouteTarget } from "../threadRoutes"; |
| ... | ... | @@ -99,11 +102,10 @@ export function useNewThreadHandler() { |
| 99 | 102 | setModelSelection, |
| 100 | 103 | } = useComposerDraftStore.getState(); |
| 101 | 104 | const currentRouteTarget = getCurrentRouteTarget(); |
| 102 | // A new thread carries the user's *working mode* from the thread being | |
| 103 | // viewed: model (including options like reasoning effort and context | |
| 104 | // window), permission mode, and interaction mode. Branch, worktree, and | |
| 105 | // env mode never carry implicitly — those come from the configured | |
| 106 | // defaults unless the caller passes them explicitly. | |
| 105 | // A new thread carries the user's working mode from the thread being | |
| 106 | // viewed. The target project's configured model still wins; runtime and | |
| 107 | // interaction modes carry independently. Branch, worktree, and env mode | |
| 108 | // come from configured defaults unless the caller passes them explicitly. | |
| 107 | 109 | const carrySourceShell = |
| 108 | 110 | currentRouteTarget?.kind === "server" |
| 109 | 111 | ? readThreadShell(currentRouteTarget.threadRef) |
| ... | ... | @@ -161,6 +163,14 @@ export function useNewThreadHandler() { |
| 161 | 163 | candidate.id === projectRef.projectId && |
| 162 | 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 | 174 | // The shared resolver owns the priority order. The t3.json read is |
| 165 | 175 | // skipped entirely when a higher-priority source decides, and its |
| 166 | 176 | // query atom caches per project after the first call. |
| ... | ... | @@ -229,8 +239,10 @@ export function useNewThreadHandler() { |
| 229 | 239 | // env context resets to the configured defaults so drafts seeded |
| 230 | 240 | // before a defaults change (or by the old carry-over behavior) stop |
| 231 | 241 | // landing on "current checkout" branches forever. When the draft is |
| 232 | // already open and no options were passed, leave it alone entirely — | |
| 233 | // the user may have just picked a branch in the composer. | |
| 242 | // already open and no options were passed, leave its workspace | |
| 243 | // context alone entirely — the user may have just picked a branch | |
| 244 | // in the composer. Model selection has its own explicit-pick rule | |
| 245 | // below and does not follow this guard. | |
| 234 | 246 | let workspaceContext: NewThreadWorkspaceOptions | null = null; |
| 235 | 247 | if (hasExplicitWorkspaceOption) { |
| 236 | 248 | workspaceContext = pickExplicitWorkspaceOptions(options); |
| ... | ... | @@ -276,11 +288,29 @@ export function useNewThreadHandler() { |
| 276 | 288 | ...(carryRuntimeMode ? { runtimeMode: carryRuntimeMode } : {}), |
| 277 | 289 | ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), |
| 278 | 290 | }); |
| 279 | if (carryModelSelection) { | |
| 280 | // The carried selection is a complete snapshot of the viewed | |
| 281 | // thread's model state: absent options mean "no options", not | |
| 282 | // "keep the stale draft's options". | |
| 283 | setModelSelection(emptyStoredDraftThread.draftId, carryModelSelection, { | |
| 291 | } | |
| 292 | // Model intent: an explicit human pick always stands. Seeds and | |
| 293 | // legacy entries alike re-resolve here — sticky first, mirroring | |
| 294 | // the mint-fresh path, then the project default or carried | |
| 295 | // selection on top. This runs even when the draft is already open: | |
| 296 | // without it, a changed pin could never reach the draft the user | |
| 297 | // is looking at, because explicit picks are the only thing the | |
| 298 | // flag protects. | |
| 299 | const storedDraft = getComposerDraft(emptyStoredDraftThread.draftId); | |
| 300 | const storedActiveSelection = storedDraft?.activeProvider | |
| 301 | ? storedDraft.modelSelectionByProvider[storedDraft.activeProvider] | |
| 302 | : undefined; | |
| 303 | const storedDraftHasExplicitModelPick = | |
| 304 | Boolean(storedActiveSelection) && storedDraft?.modelSelectionExplicit === true; | |
| 305 | if (!storedDraftHasExplicitModelPick) { | |
| 306 | applyStickyState(emptyStoredDraftThread.draftId); | |
| 307 | const modelSelectionOverride = resolveModelSelectionOverride( | |
| 308 | emptyStoredDraftThread.draftId, | |
| 309 | ); | |
| 310 | if (modelSelectionOverride) { | |
| 311 | // This is a complete snapshot: absent options mean "no options", | |
| 312 | // not "keep the stale draft's options". | |
| 313 | setModelSelection(emptyStoredDraftThread.draftId, modelSelectionOverride, { | |
| 284 | 314 | replaceOptions: true, |
| 285 | 315 | }); |
| 286 | 316 | } |
| ... | ... | @@ -412,13 +442,11 @@ export function useNewThreadHandler() { |
| 412 | 442 | ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), |
| 413 | 443 | }); |
| 414 | 444 | applyStickyState(draftId); |
| 415 | if (carryModelSelection) { | |
| 416 | // After sticky state so the viewed thread's exact selection | |
| 417 | // (model + options like effort and context window) wins over the | |
| 418 | // globally sticky one. replaceOptions: the carried selection is a | |
| 419 | // complete snapshot — absent options mean "no options", not "keep | |
| 420 | // whatever sticky state just wrote". | |
| 421 | setModelSelection(draftId, carryModelSelection, { replaceOptions: true }); | |
| 445 | const modelSelectionOverride = resolveModelSelectionOverride(draftId); | |
| 446 | if (modelSelectionOverride) { | |
| 447 | // Project defaults and carried selections both outrank global sticky | |
| 448 | // state. The project default wins when both are present. | |
| 449 | setModelSelection(draftId, modelSelectionOverride, { replaceOptions: true }); | |
| 422 | 450 | } |
| 423 | 451 | carryComposerContentTo(draftId); |
| 424 | 452 |
apps/web/src/lib/chatThreadActions.test.ts+48-1| ... | ... | @@ -1,9 +1,15 @@ |
| 1 | 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 | 8 | import { describe, expect, it, vi } from "vite-plus/test"; |
| 4 | 9 | import { |
| 5 | 10 | resolveThreadActionProjectRef, |
| 6 | 11 | resolveNewDraftStartFromOrigin, |
| 12 | resolveNewThreadModelSelectionOverride, | |
| 7 | 13 | startNewThreadFromContext, |
| 8 | 14 | type ChatThreadActionContext, |
| 9 | 15 | } from "./chatThreadActions"; |
| ... | ... | @@ -11,6 +17,14 @@ import { |
| 11 | 17 | const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); |
| 12 | 18 | const PROJECT_ID = ProjectId.make("project-1"); |
| 13 | 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 | 29 | function createContext(overrides: Partial<ChatThreadActionContext> = {}): ChatThreadActionContext { |
| 16 | 30 | return { |
| ... | ... | @@ -23,6 +37,39 @@ function createContext(overrides: Partial<ChatThreadActionContext> = {}): ChatTh |
| 23 | 37 | } |
| 24 | 38 | |
| 25 | 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 | 73 | it("only applies the start-from-origin default to new worktree drafts", () => { |
| 27 | 74 | expect( |
| 28 | 75 | resolveNewDraftStartFromOrigin({ |
apps/web/src/lib/chatThreadActions.ts+18-1| ... | ... | @@ -1,5 +1,10 @@ |
| 1 | 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 | 8 | import type { DraftThreadEnvMode } from "../composerDraftStore"; |
| 4 | 9 | |
| 5 | 10 | interface ThreadContextLike { |
| ... | ... | @@ -34,6 +39,18 @@ export function resolveNewDraftStartFromOrigin(input: { |
| 34 | 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 | 54 | export function resolveThreadActionProjectRef( |
| 38 | 55 | context: ChatThreadActionContext, |
| 39 | 56 | ): ScopedProjectRef | null { |