1import { stripInlineContextReferences } from "./lib/composerContextReferences";
2import { elementContextToPreviewAnnotation } from "./lib/elementContext";
3import {
4 ElementContextDetails,
5 DEFAULT_MODEL,
6 DEFAULT_MODEL_BY_PROVIDER,
7 defaultInstanceIdForDriver,
8 EnvironmentId,
9 ModelSelection,
10 ProjectId,
11 ProviderInstanceId,
12 ProviderInteractionMode,
13 ProviderDriverKind,
14 ProviderOptionSelection,
15 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
16 ProviderOptionSelections,
17 PreviewAnnotationPayloadSchema,
18 PastedTextAttachmentSource,
19 type PreviewAnnotationPayload,
20 RuntimeMode,
21 ThreadContextRecord,
22 type ServerProvider,
23 type ScopedProjectRef,
24 type ScopedThreadRef,
25 ThreadId,
26 SnapShotSource,
27} from "@t3tools/contracts";
28import {
29 parseScopedProjectKey,
30 parseScopedThreadKey,
31 scopedProjectKey,
32 scopeProjectRef,
33 scopedThreadKey,
34 scopeThreadRef,
35} from "@t3tools/client-runtime/environment";
36import * as Schema from "effect/Schema";
37import * as Equal from "effect/Equal";
38import * as Effect from "effect/Effect";
39import { DeepMutable } from "effect/Types";
40import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model";
41import { useMemo } from "react";
42import { getLocalStorageItem } from "./hooks/useLocalStorage";
43import { readThreadPreviewState } from "./previewStateStore";
44import { resolveAppModelSelection, resolveAppModelSelectionForInstance } from "./modelSelection";
45import {
46 DEFAULT_INTERACTION_MODE,
47 DEFAULT_RUNTIME_MODE,
48 type ChatFileAttachment,
49 type ChatImageAttachment,
50 videoMimeType,
51} from "./types";
52import {
53 type TerminalContextDraft,
54 migrateLegacyTerminalContextPlaceholders,
55 normalizeTerminalContextText,
56} from "./lib/terminalContext";
57import {
58 appendInlineContextReference,
59 type ComposerContextReference,
60 ensureInlineContextReferences,
61 formatInlineContextReference,
62 removeInlineContextReference,
63 toComposerContextId,
64 toKindScopedComposerContextId,
65} from "./lib/composerContextReferences";
66import {
67 fileContextReference,
68 previewAnnotationContextId,
69 previewAnnotationContextReference,
70 reviewCommentContextId,
71 reviewCommentContextReference,
72 terminalContextReference,
73 threadContextReference,
74} from "./lib/composerContextRecords";
75import { create } from "zustand";
76import { persist, type PersistStorage, type StorageValue } from "zustand/middleware";
77import { useShallow } from "zustand/react/shallow";
78import { createDeferredStorage, createMemoryStorage } from "./lib/storage";
79import { getDefaultServerModel } from "./providerModels";
80import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences";
81import { UnifiedSettings } from "@t3tools/contracts/settings";
82import { ReviewCommentContextSchema, type ReviewCommentContext } from "./reviewCommentContext";
83const isRuntimeMode = Schema.is(RuntimeMode);
84const isProviderDriverKind = Schema.is(ProviderDriverKind);
85const isReviewCommentContext = Schema.is(ReviewCommentContextSchema);
86const isThreadContextRecord = Schema.is(ThreadContextRecord);
87const isSnapShotSource = Schema.is(SnapShotSource);
88const isPreviewAnnotationPayload = Schema.is(PreviewAnnotationPayloadSchema);
89
90export const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1";
91const COMPOSER_DRAFT_STORAGE_VERSION = 9;
92const DraftThreadEnvModeSchema = Schema.Literals(["local", "worktree"]);
93export type DraftThreadEnvMode = typeof DraftThreadEnvModeSchema.Type;
94
95export const DraftId = Schema.String.pipe(Schema.brand("DraftId"));
96export type DraftId = typeof DraftId.Type;
97
98const COMPOSER_PERSIST_DEBOUNCE_MS = 300;
99
100// Keep the immutable state until flush. Migration writebacks already have the persisted shape.
101type ComposerPersistState =
102 | { capturedState: ComposerDraftStoreState }
103 | PersistedComposerDraftStoreState;
104
105const composerDebouncedStorage = createDeferredStorage<StorageValue<ComposerPersistState>>(
106 typeof localStorage !== "undefined" ? localStorage : createMemoryStorage(),
107 (value) =>
108 JSON.stringify({
109 state:
110 "capturedState" in value.state
111 ? partializeComposerDraftStoreState(value.state.capturedState)
112 : value.state,
113 version: value.version,
114 }),
115 COMPOSER_PERSIST_DEBOUNCE_MS,
116);
117
118const composerPersistStorage: PersistStorage<ComposerPersistState> = {
119 getItem: (name) => {
120 // The base storage is localStorage (or in-memory), which is synchronous.
121 const raw = composerDebouncedStorage.getItem(name);
122 if (typeof raw !== "string") {
123 return null;
124 }
125 // Parsed persisted JSON. `migrate` and `merge` normalize it from unknown,
126 // so the cast mirrors the one zustand's createJSONStorage performs.
127 return JSON.parse(raw) as StorageValue<ComposerPersistState>;
128 },
129 setItem: (name, value) => composerDebouncedStorage.setItem(name, value),
130 removeItem: (name) => composerDebouncedStorage.removeItem(name),
131};
132
133/** A reload is safe only after every draft byte is durably represented in storage. */
134export function prepareComposerDraftsForReload(): boolean {
135 const state = useComposerDraftStore.getState();
136 if (
137 state.rewindingThreadKeys.size > 0 ||
138 Object.keys(state.backgroundSubmissionThreadKeys).length > 0 ||
139 Object.values(state.draftThreadsByThreadKey).some(isDraftThreadPromoting) ||
140 Object.values(state.draftsByThreadKey).some(
141 (draft) =>
142 draft.nonPersistedImageIds.length > 0 ||
143 draft.images.some((image) => !draft.persistedAttachments.some(({ id }) => id === image.id)) ||
144 draft.files.some((file) => !file.uploadedAttachmentId || !file.uploadEnvironmentId),
145 )
146 )
147 return false;
148
149 try {
150 composerPersistStorage.setItem(COMPOSER_DRAFT_STORAGE_KEY, {
151 state: { capturedState: state },
152 version: COMPOSER_DRAFT_STORAGE_VERSION,
153 });
154 composerDebouncedStorage.flush();
155 const expected = JSON.stringify({
156 state: partializeComposerDraftStoreState(state),
157 version: COMPOSER_DRAFT_STORAGE_VERSION,
158 });
159 return window.localStorage.getItem(COMPOSER_DRAFT_STORAGE_KEY) === expected;
160 } catch {
161 return false;
162 }
163}
164
165// Flush pending composer draft writes before page unload to prevent data loss.
166if (typeof window !== "undefined" && typeof window.addEventListener === "function") {
167 window.addEventListener("beforeunload", () => {
168 composerDebouncedStorage.flush();
169 });
170}
171
172export const PersistedComposerImageAttachment = Schema.Struct({
173 id: Schema.String,
174 name: Schema.String,
175 mimeType: Schema.String,
176 sizeBytes: Schema.Number,
177 source: Schema.optional(SnapShotSource),
178 dataUrl: Schema.String,
179});
180export type PersistedComposerImageAttachment = typeof PersistedComposerImageAttachment.Type;
181
182export interface ComposerImageAttachment extends Omit<ChatImageAttachment, "previewUrl"> {
183 previewUrl: string;
184 file: File;
185}
186
187export interface ComposerFileAttachment extends Omit<ChatFileAttachment, "previewUrl"> {
188 file: File | null;
189 uploadedAttachmentId?: string;
190 uploadEnvironmentId?: EnvironmentId;
191}
192
193/**
194 * A hydrated draft file whose upload never finished before the reload has
195 * neither bytes (`file` is only ever null after hydration) nor a server-side
196 * upload. The composer renders it as a needs-reattach row: the user must
197 * attach the file again or remove it before sending.
198 */
199export function composerFileNeedsReattach(file: ComposerFileAttachment): boolean {
200 return file.file === null && file.uploadedAttachmentId === undefined;
201}
202
203function clearStaleFileUploadMetadata(
204 draft: ComposerThreadDraftState,
205 environmentId: EnvironmentId,
206): ComposerThreadDraftState {
207 let changed = false;
208 const files = draft.files.map((file) => {
209 if (
210 (file.uploadedAttachmentId === undefined && file.uploadEnvironmentId === undefined) ||
211 file.uploadEnvironmentId === environmentId
212 ) {
213 return file;
214 }
215
216 changed = true;
217 const nextFile = { ...file };
218 delete nextFile.uploadedAttachmentId;
219 delete nextFile.uploadEnvironmentId;
220 return nextFile;
221 });
222
223 return changed ? { ...draft, files } : draft;
224}
225
226export const PersistedComposerFileAttachment = Schema.Struct({
227 id: Schema.String,
228 name: Schema.String,
229 mimeType: Schema.String,
230 sizeBytes: Schema.Number,
231 attachmentId: Schema.String,
232 environmentId: EnvironmentId,
233 source: Schema.optional(PastedTextAttachmentSource),
234});
235export type PersistedComposerFileAttachment = typeof PersistedComposerFileAttachment.Type;
236
237/**
238 * Draft-persisted file. Unlike a stash entry (which requires a finished
239 * upload), a draft may hold a file whose upload never completed. Its `File`
240 * handle cannot serialize, so it persists as a metadata-only marker (no
241 * `attachmentId`) and hydrates as a needs-reattach row instead of vanishing.
242 */
243export const PersistedComposerDraftFileAttachment = Schema.Struct({
244 id: Schema.String,
245 name: Schema.String,
246 mimeType: Schema.String,
247 sizeBytes: Schema.Number,
248 attachmentId: Schema.optionalKey(Schema.String),
249 environmentId: Schema.optionalKey(EnvironmentId),
250 source: Schema.optional(PastedTextAttachmentSource),
251});
252export type PersistedComposerDraftFileAttachment = typeof PersistedComposerDraftFileAttachment.Type;
253const isPersistedComposerDraftFileAttachment = Schema.is(PersistedComposerDraftFileAttachment);
254
255const PersistedTerminalContextDraft = Schema.Struct({
256 id: Schema.String,
257 threadId: ThreadId,
258 createdAt: Schema.String,
259 terminalId: Schema.String,
260 terminalLabel: Schema.String,
261 lineStart: Schema.Number,
262 lineEnd: Schema.Number,
263 text: Schema.optionalKey(Schema.String),
264});
265type PersistedTerminalContextDraft = typeof PersistedTerminalContextDraft.Type;
266
267const PersistedComposerThreadDraftState = Schema.Struct({
268 prompt: Schema.String,
269 attachments: Schema.Array(PersistedComposerImageAttachment),
270 files: Schema.optionalKey(Schema.Array(PersistedComposerDraftFileAttachment)),
271 terminalContexts: Schema.optionalKey(Schema.Array(PersistedTerminalContextDraft)),
272 previewAnnotations: Schema.optionalKey(Schema.Array(PreviewAnnotationPayloadSchema)),
273 reviewComments: Schema.optionalKey(Schema.Array(ReviewCommentContextSchema)),
274 threadContexts: Schema.optionalKey(Schema.Array(ThreadContextRecord)),
275 // Keyed by `ProviderInstanceId` (open branded slug) so custom provider
276 // instances (e.g. `codex_personal`) round-trip alongside the built-in
277 // `codex` / `claudeAgent` / ... entries. Every prior `ProviderDriverKind`
278 // literal satisfies the `ProviderInstanceId` slug pattern, so existing
279 // persisted drafts decode unchanged.
280 //
281 // The record's value schema is NOT wrapped in `Schema.optionalKey`:
282 // that helper is only meaningful on property signatures with a known
283 // key set, and `Schema.Record(<branded string>, …)` produces an index
284 // signature at runtime (Schema rejects the combination). Absence of
285 // an entry already encodes "no selection for this instance".
286 modelSelectionByProvider: Schema.optionalKey(Schema.Record(ProviderInstanceId, ModelSelection)),
287 activeProvider: Schema.optionalKey(Schema.NullOr(ProviderInstanceId)),
288 // True only when a human picked this selection in the composer. Seeded
289 // selections (project default / sticky) leave it unset so later seeds can
290 // replace them; legacy entries predate the flag and read as seeded too.
291 modelSelectionExplicit: Schema.optionalKey(Schema.Boolean),
292 runtimeMode: Schema.optionalKey(RuntimeMode),
293 interactionMode: Schema.optionalKey(ProviderInteractionMode),
294});
295type PersistedComposerThreadDraftState = typeof PersistedComposerThreadDraftState.Type;
296
297/**
298 * Per-provider record of generic option selections. Used as a transient
299 * representation when migrating legacy v2 storage payloads and when
300 * deriving per-provider option bundles for downstream consumers.
301 */
302type ProviderOptionSelectionsByProvider = Partial<
303 Record<string, ReadonlyArray<ProviderOptionSelection>>
304>;
305
306type LegacyCodexFields = {
307 effort?: unknown;
308 codexFastMode?: unknown;
309 serviceTier?: unknown;
310};
311
312type LegacyThreadModelFields = {
313 provider?: unknown;
314 model?: unknown;
315 modelOptions?: unknown;
316};
317
318type LegacyV2ThreadDraftFields = {
319 modelSelection?: ModelSelection | null;
320 modelOptions?: unknown;
321};
322
323type LegacyPersistedComposerThreadDraftState = PersistedComposerThreadDraftState &
324 LegacyCodexFields &
325 LegacyThreadModelFields &
326 LegacyV2ThreadDraftFields;
327
328type LegacyStickyModelFields = {
329 stickyProvider?: unknown;
330 stickyModel?: unknown;
331 stickyModelOptions?: unknown;
332};
333
334type LegacyV2StoreFields = {
335 stickyModelSelection?: ModelSelection | null;
336 stickyModelOptions?: unknown;
337 projectDraftThreadIdByProjectId?: Record<string, string> | null;
338 draftsByThreadId?: Record<string, PersistedComposerThreadDraftState> | null;
339 draftThreadsByThreadId?: Record<string, PersistedDraftThreadState> | null;
340 projectDraftThreadIdByProjectKey?: Record<string, string> | null;
341 draftsByThreadKey?: Record<string, PersistedComposerThreadDraftState> | null;
342 draftThreadsByThreadKey?: Record<string, PersistedDraftThreadState> | null;
343 projectDraftThreadKeyByProjectKey?: Record<string, string> | null;
344 logicalProjectDraftThreadKeyByLogicalProjectKey?: Record<string, string> | null;
345};
346
347type LegacyPersistedComposerDraftStoreState = PersistedComposerDraftStoreState &
348 LegacyStickyModelFields &
349 LegacyV2StoreFields;
350
351const PersistedDraftThreadState = Schema.Struct({
352 threadId: ThreadId,
353 environmentId: Schema.String,
354 projectId: ProjectId,
355 logicalProjectKey: Schema.optionalKey(Schema.String),
356 environmentSelection: Schema.optionalKey(Schema.Literals(["auto", "manual"])),
357 loadBalancedEnvironmentId: Schema.optionalKey(Schema.NullOr(Schema.String)),
358 createdAt: Schema.String,
359 runtimeMode: RuntimeMode,
360 interactionMode: ProviderInteractionMode,
361 branch: Schema.NullOr(Schema.String),
362 worktreePath: Schema.NullOr(Schema.String),
363 envMode: DraftThreadEnvModeSchema,
364 startFromOrigin: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
365 promotedTo: Schema.optionalKey(
366 Schema.NullOr(
367 Schema.Struct({
368 environmentId: Schema.String,
369 threadId: Schema.String,
370 }),
371 ),
372 ),
373});
374type PersistedDraftThreadState = typeof PersistedDraftThreadState.Type;
375
376const PersistedComposerDraftStoreState = Schema.Struct({
377 draftsByThreadKey: Schema.Record(Schema.String, PersistedComposerThreadDraftState),
378 draftThreadsByThreadKey: Schema.Record(Schema.String, PersistedDraftThreadState),
379 logicalProjectDraftThreadKeyByLogicalProjectKey: Schema.Record(Schema.String, Schema.String),
380 stickyModelSelectionByProvider: Schema.optionalKey(
381 Schema.Record(ProviderInstanceId, ModelSelection),
382 ),
383 stickyOptionsByModelByProvider: Schema.optionalKey(
384 Schema.Record(ProviderInstanceId, Schema.Record(Schema.String, ProviderOptionSelections)),
385 ),
386 stickyActiveProvider: Schema.optionalKey(Schema.NullOr(ProviderInstanceId)),
387});
388type PersistedComposerDraftStoreState = typeof PersistedComposerDraftStoreState.Type;
389
390const PersistedComposerDraftStoreStorage = Schema.Struct({
391 version: Schema.Number,
392 state: PersistedComposerDraftStoreState,
393});
394
395/**
396 * Composer content keyed by either a draft session (`DraftId`) or a real server
397 * thread (`ScopedThreadRef`). This is the editable payload shown in the composer.
398 */
399/** Options for adding a context record and, independently, a reference to it. */
400export interface ComposerContextAddOptions {
401 /** `false` when the caller already placed the chip (paste, caret insertion). */
402 appendReference?: boolean;
403 /** `true` when this action intentionally adds another reference to an existing record. */
404 allowDuplicateReference?: boolean;
405 /** `false` to append synchronously instead of asking a mounted editor for its current caret. */
406 insertAtCaret?: boolean;
407}
408
409/**
410 * A mounted composer registers itself here so context produced by other panels (diff
411 * comments, preview picks) lands at its caret. Without a handler the store appends the
412 * reference to the prompt, which is where a draft with no composer open would show it.
413 * Runtime-only: never persisted.
414 */
415export type ComposerContextInsertionHandler = (
416 references: ReadonlyArray<ComposerContextReference>,
417) => boolean;
418const contextInsertionHandlers = new Map<string, ComposerContextInsertionHandler>();
419
420export interface ComposerThreadDraftState {
421 prompt: string;
422 images: ComposerImageAttachment[];
423 files: ComposerFileAttachment[];
424 nonPersistedImageIds: string[];
425 persistedAttachments: PersistedComposerImageAttachment[];
426 terminalContexts: TerminalContextDraft[];
427 previewAnnotations: PreviewAnnotationPayload[];
428 reviewComments: ReviewCommentContext[];
429 threadContexts: ThreadContextRecord[];
430 /**
431 * Per-instance model selection. Keyed by `ProviderInstanceId` (open
432 * branded slug) so a default `codex` instance and a user-authored
433 * `codex_personal` instance each persist their own selected model. Every
434 * historical `ProviderDriverKind` literal (`codex` / `claudeAgent` / `cursor` /
435 * `opencode`) also satisfies the `ProviderInstanceId` slug pattern, so
436 * legacy kind-keyed drafts round-trip unchanged.
437 */
438 modelSelectionByProvider: Partial<Record<ProviderInstanceId, ModelSelection>>;
439 /** Routing key of the last picked instance (see `modelSelectionByProvider`). */
440 activeProvider: ProviderInstanceId | null;
441 /**
442 * True only when a human picked the active selection in the composer.
443 * Absent/false means seeded (project default / sticky), so later seeds
444 * may replace it. Legacy entries predate the flag and read as seeded.
445 */
446 modelSelectionExplicit?: boolean;
447 runtimeMode: RuntimeMode | null;
448 interactionMode: ProviderInteractionMode | null;
449}
450
451/**
452 * True when the user has invested real content in the draft: typed text or
453 * any attachment/context. Model selection and mode choices alone do not
454 * count — those are ambient defaults, not work in progress. Used by the
455 * sidebar draft rows (which draft sessions deserve a row) and by new-thread
456 * resurrection (a draft with content keeps its settings instead of being
457 * reset to defaults).
458 */
459export function composerDraftHasUserContent(
460 draft: ComposerThreadDraftState | null | undefined,
461): boolean {
462 if (!draft) {
463 return false;
464 }
465 return (
466 draft.prompt.trim().length > 0 ||
467 draft.images.length > 0 ||
468 draft.files.length > 0 ||
469 draft.persistedAttachments.length > 0 ||
470 draft.terminalContexts.length > 0 ||
471 draft.previewAnnotations.length > 0 ||
472 draft.reviewComments.length > 0 ||
473 draft.threadContexts.length > 0
474 );
475}
476
477/**
478 * Mutable routing and execution context for a pre-thread draft session.
479 *
480 * Unlike a real server thread, a draft session can still change target
481 * environment/worktree configuration before the first send.
482 */
483export interface DraftSessionState {
484 threadId: ThreadId;
485 environmentId: EnvironmentId;
486 projectId: ProjectId;
487 logicalProjectKey: string;
488 environmentSelection?: "auto" | "manual";
489 loadBalancedEnvironmentId?: EnvironmentId | null;
490 createdAt: string;
491 runtimeMode: RuntimeMode;
492 interactionMode: ProviderInteractionMode;
493 branch: string | null;
494 worktreePath: string | null;
495 envMode: DraftThreadEnvMode;
496 startFromOrigin: boolean;
497 promotedTo?: ScopedThreadRef | null;
498}
499
500export type DraftThreadState = DraftSessionState;
501
502/**
503 * Draft session metadata paired with its stable draft-session identity.
504 */
505interface ProjectDraftSession extends DraftSessionState {
506 draftId: DraftId;
507}
508
509/**
510 * App-facing composer identity:
511 * - `DraftId` for pre-thread draft sessions
512 * - `ScopedThreadRef` for server-backed threads
513 *
514 * Raw `ThreadId` is intentionally excluded so callers cannot drop environment
515 * identity for real threads.
516 */
517export type ComposerThreadTarget = ScopedThreadRef | DraftId;
518
519/**
520 * Persisted store for composer content plus draft-session metadata.
521 *
522 * The store intentionally models two domains:
523 * - draft sessions keyed by `DraftId`
524 * - server thread composer state keyed by `ScopedThreadRef`
525 */
526interface ComposerDraftStoreState {
527 draftsByThreadKey: Record<string, ComposerThreadDraftState>;
528 draftThreadsByThreadKey: Record<string, DraftThreadState>;
529 logicalProjectDraftThreadKeyByLogicalProjectKey: Record<string, string>;
530 backgroundSubmissionThreadKeys: Record<string, true>;
531 rewindingThreadKeys: ReadonlySet<string>;
532 stickyModelSelectionByProvider: Partial<Record<ProviderInstanceId, ModelSelection>>;
533 /**
534 * Option selections remembered per provider instance and model slug.
535 * Switching models restores the target model's own last choices instead of
536 * carrying the previous model's options over.
537 */
538 stickyOptionsByModelByProvider: Partial<
539 Record<ProviderInstanceId, Partial<Record<string, ReadonlyArray<ProviderOptionSelection>>>>
540 >;
541 stickyActiveProvider: ProviderInstanceId | null;
542 /** Returns the editable composer content for a draft session or server thread. */
543 getComposerDraft: (target: ComposerThreadTarget) => ComposerThreadDraftState | null;
544 /** Looks up the active draft session for a logical project identity. */
545 getDraftThreadByLogicalProjectKey: (logicalProjectKey: string) => ProjectDraftSession | null;
546 getDraftSessionByLogicalProjectKey: (logicalProjectKey: string) => ProjectDraftSession | null;
547 getDraftThreadByProjectRef: (projectRef: ScopedProjectRef) => ProjectDraftSession | null;
548 getDraftSessionByProjectRef: (projectRef: ScopedProjectRef) => ProjectDraftSession | null;
549 /** Reads mutable draft-session metadata by `DraftId`. */
550 getDraftSession: (draftId: DraftId) => DraftSessionState | null;
551 /** Resolves a server-thread ref back to a matching draft session when one exists. */
552 getDraftSessionByRef: (threadRef: ScopedThreadRef) => DraftSessionState | null;
553 /** The draft id that reserved a server-thread ref, while its draft record still exists. */
554 getDraftIdByRef: (threadRef: ScopedThreadRef) => DraftId | null;
555 getDraftThreadByRef: (threadRef: ScopedThreadRef) => DraftThreadState | null;
556 getDraftThread: (threadRef: ComposerThreadTarget) => DraftThreadState | null;
557 listDraftThreadKeys: () => string[];
558 hasDraftThreadsInEnvironment: (environmentId: EnvironmentId) => boolean;
559 /**
560 * Creates or updates the draft session tracked for a logical project.
561 * Reassigning an existing draft removes its previous logical-project
562 * mapping so one session cannot resolve from two projects.
563 */
564 setLogicalProjectDraftThreadId: (
565 logicalProjectKey: string,
566 projectRef: ScopedProjectRef,
567 draftId: DraftId,
568 options?: {
569 threadId?: ThreadId;
570 branch?: string | null;
571 worktreePath?: string | null;
572 createdAt?: string;
573 envMode?: DraftThreadEnvMode;
574 startFromOrigin?: boolean;
575 runtimeMode?: RuntimeMode;
576 interactionMode?: ProviderInteractionMode;
577 environmentSelection?: "auto" | "manual";
578 loadBalancedEnvironmentId?: EnvironmentId | null;
579 },
580 ) => void;
581 /** Creates or updates the draft session tracked for a concrete project ref. */
582 setProjectDraftThreadId: (
583 projectRef: ScopedProjectRef,
584 draftId: DraftId,
585 options?: {
586 threadId?: ThreadId;
587 branch?: string | null;
588 worktreePath?: string | null;
589 createdAt?: string;
590 envMode?: DraftThreadEnvMode;
591 startFromOrigin?: boolean;
592 runtimeMode?: RuntimeMode;
593 interactionMode?: ProviderInteractionMode;
594 environmentSelection?: "auto" | "manual";
595 loadBalancedEnvironmentId?: EnvironmentId | null;
596 },
597 ) => void;
598 /** Updates mutable draft-session metadata without touching composer content. */
599 setDraftThreadContext: (
600 threadRef: ComposerThreadTarget,
601 options: {
602 branch?: string | null;
603 worktreePath?: string | null;
604 projectRef?: ScopedProjectRef;
605 createdAt?: string;
606 envMode?: DraftThreadEnvMode;
607 startFromOrigin?: boolean;
608 runtimeMode?: RuntimeMode;
609 interactionMode?: ProviderInteractionMode;
610 environmentSelection?: "auto" | "manual";
611 loadBalancedEnvironmentId?: EnvironmentId | null;
612 },
613 ) => void;
614 clearProjectDraftThreadId: (projectRef: ScopedProjectRef) => void;
615 clearProjectDraftThreadById: (
616 projectRef: ScopedProjectRef,
617 threadRef: ComposerThreadTarget,
618 ) => void;
619 /** Marks a draft session as being promoted to a real server thread. */
620 markDraftThreadPromoting: (threadRef: ComposerThreadTarget, promotedTo?: ScopedThreadRef) => void;
621 /** Removes draft-session metadata after promotion is complete. */
622 finalizePromotedDraftThread: (threadRef: ComposerThreadTarget) => void;
623 clearDraftThread: (threadRef: ComposerThreadTarget) => void;
624 setStickyModelSelection: (modelSelection: ModelSelection | null | undefined) => void;
625 setPrompt: (threadRef: ComposerThreadTarget, prompt: string) => void;
626 setTerminalContexts: (threadRef: ComposerThreadTarget, contexts: TerminalContextDraft[]) => void;
627 setModelSelection: (
628 threadRef: ComposerThreadTarget,
629 modelSelection: ModelSelection | null | undefined,
630 opts?: {
631 explicit?: boolean;
632 /**
633 * Replace the stored entry outright instead of preserving its
634 * existing options when the incoming selection has none. Used when
635 * the selection is a complete snapshot (e.g. carried from another
636 * thread) rather than a model-only change.
637 */
638 replaceOptions?: boolean;
639 },
640 ) => void;
641 /** Replace the model options for one or more providers in the draft. */
642 setModelOptions: (
643 threadRef: ComposerThreadTarget,
644 modelOptions:
645 | Partial<Record<string, ReadonlyArray<ProviderOptionSelection>>>
646 | null
647 | undefined,
648 ) => void;
649 applyStickyState: (threadRef: ComposerThreadTarget) => void;
650 setProviderModelOptions: (
651 threadRef: ComposerThreadTarget,
652 provider: ProviderDriverKind,
653 nextProviderOptions: ReadonlyArray<ProviderOptionSelection> | null | undefined,
654 options?: {
655 instanceId?: ProviderInstanceId | null | undefined;
656 model?: string | null | undefined;
657 persistSticky?: boolean;
658 },
659 ) => void;
660 setRuntimeMode: (
661 threadRef: ComposerThreadTarget,
662 runtimeMode: RuntimeMode | null | undefined,
663 ) => void;
664 setInteractionMode: (
665 threadRef: ComposerThreadTarget,
666 interactionMode: ProviderInteractionMode | null | undefined,
667 ) => void;
668 addImage: (threadRef: ComposerThreadTarget, image: ComposerImageAttachment) => boolean;
669 /** Returns the ids the draft accepted; duplicates and over-cap attachments are left out. */
670 addImages: (
671 threadRef: ComposerThreadTarget,
672 images: ComposerImageAttachment[],
673 options?: { allowDuplicates?: boolean },
674 ) => string[];
675 removeImage: (threadRef: ComposerThreadTarget, imageId: string) => void;
676 /** Returns the ids of files appended; a re-pick that replaces a marker is not listed. */
677 addFiles: (
678 threadRef: ComposerThreadTarget,
679 files: ComposerFileAttachment[],
680 options?: { allowDuplicates?: boolean; appendReference?: boolean },
681 ) => string[];
682 removeFile: (threadRef: ComposerThreadTarget, fileId: string) => void;
683 setFileUpload: (
684 threadRef: ComposerThreadTarget,
685 fileId: string,
686 environmentId: EnvironmentId,
687 attachmentId: string,
688 ) => void;
689 markFileUploadMissing: (
690 threadRef: ComposerThreadTarget,
691 fileId: string,
692 environmentId: EnvironmentId,
693 attachmentId: string,
694 ) => boolean;
695 insertTerminalContext: (
696 threadRef: ComposerThreadTarget,
697 prompt: string,
698 context: TerminalContextDraft,
699 index: number,
700 ) => boolean;
701 addTerminalContext: (threadRef: ComposerThreadTarget, context: TerminalContextDraft) => void;
702 addTerminalContexts: (
703 threadRef: ComposerThreadTarget,
704 contexts: TerminalContextDraft[],
705 options?: ComposerContextAddOptions,
706 ) => void;
707 removeTerminalContext: (threadRef: ComposerThreadTarget, contextId: string) => void;
708 clearTerminalContexts: (threadRef: ComposerThreadTarget) => void;
709 addPreviewAnnotation: (
710 threadRef: ComposerThreadTarget,
711 annotation: PreviewAnnotationPayload,
712 options?: ComposerContextAddOptions,
713 ) => void;
714 setPreviewAnnotations: (
715 threadRef: ComposerThreadTarget,
716 annotations: ReadonlyArray<PreviewAnnotationPayload>,
717 ) => void;
718 removePreviewAnnotation: (threadRef: ComposerThreadTarget, annotationId: string) => void;
719 addReviewComment: (
720 threadRef: ComposerThreadTarget,
721 comment: ReviewCommentContext,
722 options?: ComposerContextAddOptions,
723 ) => void;
724 /** Registers (or clears, with null) the caret-insertion handler for a draft. */
725 setContextInsertionHandler: (
726 threadRef: ComposerThreadTarget,
727 handler: ComposerContextInsertionHandler | null,
728 ) => (() => void) | undefined;
729 setReviewComments: (
730 threadRef: ComposerThreadTarget,
731 comments: ReadonlyArray<ReviewCommentContext>,
732 ) => void;
733 removeReviewComment: (threadRef: ComposerThreadTarget, commentId: string) => void;
734 /** Attaches threads as context; already-attached threads are skipped. */
735 addThreadContexts: (
736 threadRef: ComposerThreadTarget,
737 records: ReadonlyArray<ThreadContextRecord>,
738 options?: ComposerContextAddOptions,
739 ) => void;
740 setThreadContexts: (
741 threadRef: ComposerThreadTarget,
742 records: ReadonlyArray<ThreadContextRecord>,
743 ) => void;
744 clearPersistedAttachments: (threadRef: ComposerThreadTarget) => void;
745 syncPersistedAttachments: (
746 threadRef: ComposerThreadTarget,
747 attachments: PersistedComposerImageAttachment[],
748 ) => Promise<void>;
749 clearComposerContent: (threadRef: ComposerThreadTarget) => void;
750 /**
751 * Clears the prompt text and attachments, preserving terminal /
752 * element contexts, preview annotations, and review comments. Used by the
753 * prompt stash. Session-bound context stays in the source draft.
754 */
755 clearComposerPromptAndImages: (threadRef: ComposerThreadTarget) => void;
756 /**
757 * Moves prompt text and transferable attachments into another composer.
758 * Attachments over the destination limit and uploaded files that belong to
759 * another environment stay in the source draft. Terminal and element
760 * context, preview annotations, and review comments also stay in the source.
761 */
762 moveComposerPromptAndImages: (from: ComposerThreadTarget, to: ComposerThreadTarget) => void;
763}
764
765export interface EffectiveComposerModelState {
766 selectedModel: string;
767 modelOptions: ProviderOptionSelectionsByProvider | null;
768}
769
770interface ComposerDraftModelState {
771 activeProvider: ProviderInstanceId | null;
772 modelSelectionByProvider: Partial<Record<ProviderInstanceId, ModelSelection>>;
773}
774
775function providerSelectionsFromModelSelection(
776 modelSelection: ModelSelection | null | undefined,
777): ProviderOptionSelectionsByProvider | null {
778 if (!modelSelection) {
779 return null;
780 }
781 const options = modelSelection.options;
782 if (!options || options.length === 0) {
783 return null;
784 }
785 return { [modelSelection.instanceId]: options };
786}
787
788function modelSelectionByProviderToOptions(
789 map: Partial<Record<string, ModelSelection>> | null | undefined,
790): ProviderOptionSelectionsByProvider | null {
791 if (!map) return null;
792 const result: ProviderOptionSelectionsByProvider = {};
793 for (const [provider, selection] of Object.entries(map)) {
794 if (selection?.options && selection.options.length > 0) {
795 result[provider] = selection.options;
796 }
797 }
798 return Object.keys(result).length > 0 ? result : null;
799}
800
801function cloneModelSelection(selection: ModelSelection): DeepMutable<ModelSelection> {
802 return {
803 ...selection,
804 ...(selection.options ? { options: selection.options.map((option) => ({ ...option })) } : {}),
805 } as DeepMutable<ModelSelection>;
806}
807
808function compactModelSelectionByProvider(
809 selections: Partial<Record<ProviderInstanceId, ModelSelection>>,
810): DeepMutable<Record<ProviderInstanceId, ModelSelection>> {
811 const entries: Array<[string, DeepMutable<ModelSelection>]> = [];
812 for (const [provider, selection] of Object.entries(selections)) {
813 if (selection !== undefined) {
814 entries.push([provider, cloneModelSelection(selection)]);
815 }
816 }
817 return Object.fromEntries(entries) as DeepMutable<Record<ProviderInstanceId, ModelSelection>>;
818}
819
820function compactStickyOptionsByModel(
821 optionsByModelByProvider: ComposerDraftStoreState["stickyOptionsByModelByProvider"],
822): NonNullable<PersistedComposerDraftStoreState["stickyOptionsByModelByProvider"]> {
823 const result: Record<string, Record<string, ReadonlyArray<ProviderOptionSelection>>> = {};
824 for (const [instanceId, optionsByModel] of Object.entries(optionsByModelByProvider)) {
825 for (const [modelSlug, options] of Object.entries(optionsByModel ?? {})) {
826 if (options === undefined || options.length === 0) continue;
827 result[instanceId] ??= {};
828 result[instanceId][modelSlug] = options;
829 }
830 }
831 return result as NonNullable<PersistedComposerDraftStoreState["stickyOptionsByModelByProvider"]>;
832}
833
834/**
835 * Storage written before per-model memory existed carries the last sticky
836 * selection only. Seed the map from it so an upgrade keeps that model's
837 * remembered options instead of clearing them on the first switch.
838 */
839function seedStickyOptionsByModel(
840 stickySelections: Partial<Record<ProviderInstanceId, ModelSelection>>,
841): NonNullable<PersistedComposerDraftStoreState["stickyOptionsByModelByProvider"]> {
842 const result: Record<string, Record<string, ReadonlyArray<ProviderOptionSelection>>> = {};
843 for (const [instanceId, selection] of Object.entries(stickySelections)) {
844 if (
845 selection === undefined ||
846 selection.options === undefined ||
847 selection.options.length === 0
848 ) {
849 continue;
850 }
851 result[instanceId] = { [selection.model]: selection.options };
852 }
853 return result as NonNullable<PersistedComposerDraftStoreState["stickyOptionsByModelByProvider"]>;
854}
855
856const EMPTY_PERSISTED_DRAFT_STORE_STATE = Object.freeze<PersistedComposerDraftStoreState>({
857 draftsByThreadKey: {},
858 draftThreadsByThreadKey: {},
859 logicalProjectDraftThreadKeyByLogicalProjectKey: {},
860 stickyModelSelectionByProvider: {},
861 stickyOptionsByModelByProvider: {},
862 stickyActiveProvider: null,
863});
864
865const EMPTY_IMAGES: ComposerImageAttachment[] = [];
866const EMPTY_FILES: ComposerFileAttachment[] = [];
867const EMPTY_IDS: string[] = [];
868const EMPTY_PERSISTED_ATTACHMENTS: PersistedComposerImageAttachment[] = [];
869const EMPTY_TERMINAL_CONTEXTS: TerminalContextDraft[] = [];
870const EMPTY_PREVIEW_ANNOTATIONS: PreviewAnnotationPayload[] = [];
871const EMPTY_REVIEW_COMMENTS: ReviewCommentContext[] = [];
872const EMPTY_THREAD_CONTEXTS: ThreadContextRecord[] = [];
873Object.freeze(EMPTY_IMAGES);
874Object.freeze(EMPTY_FILES);
875Object.freeze(EMPTY_IDS);
876Object.freeze(EMPTY_PERSISTED_ATTACHMENTS);
877Object.freeze(EMPTY_PREVIEW_ANNOTATIONS);
878Object.freeze(EMPTY_REVIEW_COMMENTS);
879Object.freeze(EMPTY_THREAD_CONTEXTS);
880const EMPTY_MODEL_SELECTION_BY_PROVIDER: Partial<Record<ProviderDriverKind, ModelSelection>> =
881 Object.freeze({});
882const EMPTY_COMPOSER_DRAFT_MODEL_STATE = Object.freeze<ComposerDraftModelState>({
883 activeProvider: null,
884 modelSelectionByProvider: EMPTY_MODEL_SELECTION_BY_PROVIDER,
885});
886
887const EMPTY_THREAD_DRAFT = Object.freeze<ComposerThreadDraftState>({
888 prompt: "",
889 images: EMPTY_IMAGES,
890 files: EMPTY_FILES,
891 nonPersistedImageIds: EMPTY_IDS,
892 persistedAttachments: EMPTY_PERSISTED_ATTACHMENTS,
893 terminalContexts: EMPTY_TERMINAL_CONTEXTS,
894 previewAnnotations: EMPTY_PREVIEW_ANNOTATIONS,
895 reviewComments: EMPTY_REVIEW_COMMENTS,
896 threadContexts: EMPTY_THREAD_CONTEXTS,
897 modelSelectionByProvider: EMPTY_MODEL_SELECTION_BY_PROVIDER,
898 activeProvider: null,
899 runtimeMode: null,
900 interactionMode: null,
901});
902
903/**
904 * Canonical factory for a blank `ComposerThreadDraftState`. Exported so tests
905 * (and any other call sites) can build a draft without re-declaring every
906 * slice — adding a new field to the interface (e.g. `reviewComments`) only
907 * has to be reflected here, not in every stub.
908 */
909function createEmptyThreadDraft(): ComposerThreadDraftState {
910 return {
911 prompt: "",
912 images: [],
913 files: [],
914 nonPersistedImageIds: [],
915 persistedAttachments: [],
916 terminalContexts: [],
917 previewAnnotations: [],
918 reviewComments: [],
919 threadContexts: [],
920 modelSelectionByProvider: {},
921 activeProvider: null,
922 runtimeMode: null,
923 interactionMode: null,
924 };
925}
926
927function composerImageDedupKey(image: ComposerImageAttachment): string {
928 // Keep this independent from File.lastModified so dedupe is stable for hydrated
929 // images reconstructed from localStorage (which get a fresh lastModified value).
930 return `${image.mimeType}\u0000${image.sizeBytes}\u0000${image.name}`;
931}
932
933export function composerFileDedupKey(
934 file: Pick<ComposerFileAttachment, "mimeType" | "sizeBytes" | "name">,
935): string {
936 return `${file.mimeType}\u0000${file.sizeBytes}\u0000${file.name}`;
937}
938
939export function composerFileMatchesReattachMarker(
940 marker: Pick<ComposerFileAttachment, "mimeType" | "sizeBytes" | "name">,
941 file: Pick<ComposerFileAttachment, "mimeType" | "sizeBytes" | "name">,
942): boolean {
943 if (marker.name !== file.name || marker.sizeBytes !== file.sizeBytes) return false;
944 if (marker.mimeType === file.mimeType) return true;
945 const markerMimeType = marker.mimeType.toLowerCase();
946 return (
947 (markerMimeType === "" || markerMimeType === "application/octet-stream") &&
948 videoMimeType(marker) !== null &&
949 videoMimeType(file) !== null
950 );
951}
952
953function terminalContextDedupKey(context: TerminalContextDraft): string {
954 return `${context.terminalId}\u0000${context.lineStart}\u0000${context.lineEnd}`;
955}
956
957function normalizeTerminalContextForThread(
958 threadId: ThreadId,
959 context: TerminalContextDraft,
960): TerminalContextDraft | null {
961 const terminalId = context.terminalId.trim();
962 const terminalLabel = context.terminalLabel.trim();
963 if (terminalId.length === 0 || terminalLabel.length === 0) {
964 return null;
965 }
966 const lineStart = Math.max(1, Math.floor(context.lineStart));
967 const lineEnd = Math.max(lineStart, Math.floor(context.lineEnd));
968 return {
969 ...context,
970 id: toComposerContextId(context.id),
971 threadId,
972 terminalId,
973 terminalLabel,
974 lineStart,
975 lineEnd,
976 text: normalizeTerminalContextText(context.text),
977 };
978}
979
980function normalizeTerminalContextsForThread(
981 threadId: ThreadId,
982 contexts: ReadonlyArray<TerminalContextDraft>,
983): TerminalContextDraft[] {
984 const existingIds = new Set<string>();
985 const existingDedupKeys = new Set<string>();
986 const normalizedContexts: TerminalContextDraft[] = [];
987
988 for (const context of contexts) {
989 const normalizedContext = normalizeTerminalContextForThread(threadId, context);
990 if (!normalizedContext) {
991 continue;
992 }
993 const dedupKey = terminalContextDedupKey(normalizedContext);
994 if (existingIds.has(normalizedContext.id) || existingDedupKeys.has(dedupKey)) {
995 continue;
996 }
997 normalizedContexts.push(normalizedContext);
998 existingIds.add(normalizedContext.id);
999 existingDedupKeys.add(dedupKey);
1000 }
1001
1002 return normalizedContexts;
1003}
1004
1005function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean {
1006 return (
1007 draft.prompt.length === 0 &&
1008 draft.images.length === 0 &&
1009 draft.files.length === 0 &&
1010 draft.persistedAttachments.length === 0 &&
1011 draft.terminalContexts.length === 0 &&
1012 draft.previewAnnotations.length === 0 &&
1013 draft.reviewComments.length === 0 &&
1014 draft.threadContexts.length === 0 &&
1015 Object.keys(draft.modelSelectionByProvider).length === 0 &&
1016 draft.activeProvider === null &&
1017 draft.runtimeMode === null &&
1018 draft.interactionMode === null
1019 );
1020}
1021
1022function normalizeProviderDriverKind(value: unknown): ProviderDriverKind | null {
1023 return isProviderDriverKind(value) ? value : null;
1024}
1025
1026/**
1027 * Match the `ProviderInstanceId` slug pattern (letter followed by
1028 * letters/digits/`-`/`_`, 1..64 chars). Permissive validator — the schema
1029 * layer owns authoritative validation; this is used inline to gate typed
1030 * writes to the draft's instance-keyed maps without pulling the full
1031 * Effect Schema runtime into the hot path.
1032 */
1033const PROVIDER_INSTANCE_ID_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/;
1034
1035/**
1036 * Coerce an arbitrary persisted value into a valid `ProviderInstanceId`. Used
1037 * wherever we need to accept both legacy driver-kind keys and custom instance
1038 * slugs (e.g. `codex_personal`) as routing keys.
1039 */
1040function normalizeProviderInstanceId(value: unknown): ProviderInstanceId | null {
1041 if (typeof value !== "string") return null;
1042 if (!PROVIDER_INSTANCE_ID_PATTERN.test(value)) return null;
1043 return value as ProviderInstanceId;
1044}
1045
1046/**
1047 * Coerce an unknown value into a `ReadonlyArray<ProviderOptionSelection>`.
1048 * Accepts either:
1049 * - the v3 representation: an array of `{ id, value }` entries
1050 * - the legacy v2 representation: a record of `{ id: string | boolean }`
1051 *
1052 * Validation is intentionally permissive: descriptors are the source of truth
1053 * for which option ids are meaningful for a given provider/model. Anything
1054 * outside the descriptor list is harmless trailing data and will simply be
1055 * ignored downstream.
1056 */
1057function coerceProviderOptionSelections(
1058 value: unknown,
1059): ReadonlyArray<ProviderOptionSelection> | undefined {
1060 if (Array.isArray(value)) {
1061 const out: ProviderOptionSelection[] = [];
1062 for (const entry of value) {
1063 if (!entry || typeof entry !== "object") continue;
1064 const record = entry as Record<string, unknown>;
1065 const id = record.id;
1066 const optionValue = record.value;
1067 if (typeof id !== "string" || id.length === 0) continue;
1068 if (typeof optionValue === "string" || typeof optionValue === "boolean") {
1069 out.push({ id, value: optionValue });
1070 }
1071 }
1072 return out.length > 0 ? out : undefined;
1073 }
1074 if (value && typeof value === "object") {
1075 const record = value as Record<string, unknown>;
1076 const out: ProviderOptionSelection[] = [];
1077 for (const [id, raw] of Object.entries(record)) {
1078 if (typeof raw === "string" || typeof raw === "boolean") {
1079 out.push({ id, value: raw });
1080 }
1081 }
1082 return out.length > 0 ? out : undefined;
1083 }
1084 return undefined;
1085}
1086
1087/**
1088 * Normalize a per-provider options bag from either the v3 or legacy v2 shape.
1089 *
1090 * `provider` and `legacy` parameters are migration-only inputs used to
1091 * recover legacy codex fields (effort/codexFastMode/serviceTier) that lived
1092 * directly on the draft instead of inside `modelOptions.codex`.
1093 */
1094function normalizeProviderModelOptions(
1095 value: unknown,
1096 provider?: ProviderDriverKind | null,
1097 legacy?: LegacyCodexFields,
1098): ProviderOptionSelectionsByProvider | null {
1099 const candidate = value && typeof value === "object" ? (value as Record<string, unknown>) : null;
1100 const result: ProviderOptionSelectionsByProvider = {};
1101 for (const providerKey of ["codex", "claudeAgent", "cursor", "opencode"] as const) {
1102 const selections = coerceProviderOptionSelections(candidate?.[providerKey]);
1103 if (selections) {
1104 result[providerKey] = selections;
1105 }
1106 }
1107
1108 // Recover legacy codex fields that lived outside modelOptions.
1109 if (provider === "codex" && legacy) {
1110 const codexExtras: ProviderOptionSelection[] = [];
1111 if (typeof legacy.effort === "string" && legacy.effort.length > 0) {
1112 codexExtras.push({ id: "reasoningEffort", value: legacy.effort });
1113 }
1114 const fastMode =
1115 legacy.codexFastMode === true ||
1116 (typeof legacy.serviceTier === "string" && legacy.serviceTier === "fast");
1117 if (fastMode) {
1118 codexExtras.push({ id: "fastMode", value: true });
1119 }
1120 if (codexExtras.length > 0) {
1121 const existing = result.codex ?? [];
1122 const existingIds = new Set(existing.map((entry) => entry.id));
1123 const merged = [...existing];
1124 for (const extra of codexExtras) {
1125 if (!existingIds.has(extra.id)) merged.push(extra);
1126 }
1127 result.codex = merged;
1128 }
1129 }
1130
1131 return Object.keys(result).length > 0 ? result : null;
1132}
1133
1134// Returns a model selection whose `instanceId` is a valid
1135// `ProviderInstanceId` slug. Legacy `provider` fields are promoted verbatim
1136// because default instance ids used the same slug as the driver kind.
1137//
1138// Selections whose instance id doesn't match the slug pattern collapse to
1139// `null` — caller is responsible for deciding whether that's a dropped
1140// write or a routed error.
1141function normalizeModelSelection(
1142 value: unknown,
1143 legacy?: {
1144 provider?: unknown;
1145 model?: unknown;
1146 modelOptions?: unknown;
1147 legacyCodex?: LegacyCodexFields;
1148 },
1149): NormalizedModelSelection | null {
1150 const candidate = value && typeof value === "object" ? (value as Record<string, unknown>) : null;
1151 // Post-migration ModelSelection carries `instanceId`; pre-migration (v2
1152 // storage, legacy wire shapes) carries `provider`. Accept either so both
1153 // normalized stores and legacy drafts round-trip through this helper.
1154 const instanceId = normalizeProviderInstanceId(
1155 candidate?.instanceId ?? candidate?.provider ?? legacy?.provider,
1156 );
1157 if (instanceId === null) {
1158 return null;
1159 }
1160 const rawModel = candidate?.model ?? legacy?.model;
1161 if (typeof rawModel !== "string") {
1162 return null;
1163 }
1164 // Slug normalization can use provider-kind-specific rules when a legacy
1165 // driver key is present. Instance-only selections are not reverse-inferred
1166 // into a driver kind here; they get generic default normalization.
1167 const driverKindHint =
1168 normalizeProviderDriverKind(candidate?.provider ?? legacy?.provider) ??
1169 ProviderDriverKind.make("codex");
1170 const model = normalizeModelSlug(rawModel, driverKindHint);
1171 if (!model) {
1172 return null;
1173 }
1174 if (Array.isArray(candidate?.options)) {
1175 const selections = coerceProviderOptionSelections(candidate.options);
1176 return createModelSelection(instanceId, model, selections) as NormalizedModelSelection;
1177 }
1178 // Per-kind options were a pre-migration concern; only recover them for a
1179 // built-in-kind instance. Custom instances don't have a legacy options
1180 // store to thread through here.
1181 const kindForLegacyOptions = normalizeProviderDriverKind(instanceId);
1182 const modelOptions = kindForLegacyOptions
1183 ? normalizeProviderModelOptions(
1184 candidate?.options ? { [kindForLegacyOptions]: candidate.options } : legacy?.modelOptions,
1185 kindForLegacyOptions,
1186 kindForLegacyOptions === "codex" ? legacy?.legacyCodex : undefined,
1187 )
1188 : null;
1189 const options = kindForLegacyOptions ? modelOptions?.[kindForLegacyOptions] : undefined;
1190 return createModelSelection(instanceId, model, options) as NormalizedModelSelection;
1191}
1192
1193type NormalizedModelSelection = Omit<ModelSelection, "instanceId"> & {
1194 readonly instanceId: ProviderInstanceId;
1195};
1196
1197// ── Legacy sync helpers (used only during migration from v2 storage) ──
1198//
1199// These operate against the legacy kind-keyed `modelOptions` map. The
1200// normalized selection now carries an open `ProviderInstanceId`; legacy
1201// migration only recovers options for keys that existed before custom
1202// provider instances.
1203
1204function legacySyncModelSelectionOptions(
1205 modelSelection: NormalizedModelSelection | null,
1206 modelOptions: ProviderOptionSelectionsByProvider | null | undefined,
1207): NormalizedModelSelection | null {
1208 if (modelSelection === null) {
1209 return null;
1210 }
1211 const kind = normalizeProviderDriverKind(modelSelection.instanceId);
1212 const options = kind ? modelOptions?.[kind] : undefined;
1213 return createModelSelection(
1214 modelSelection.instanceId,
1215 modelSelection.model,
1216 options,
1217 ) as NormalizedModelSelection;
1218}
1219
1220function legacyMergeModelSelectionIntoProviderModelOptions(
1221 modelSelection: NormalizedModelSelection | null,
1222 currentModelOptions: ProviderOptionSelectionsByProvider | null | undefined,
1223): ProviderOptionSelectionsByProvider | null {
1224 if (!modelSelection?.options || modelSelection.options.length === 0) {
1225 return normalizeProviderModelOptions(currentModelOptions);
1226 }
1227 const kind = normalizeProviderDriverKind(modelSelection.instanceId);
1228 if (!kind) {
1229 return normalizeProviderModelOptions(currentModelOptions);
1230 }
1231 return legacyReplaceProviderModelOptions(
1232 normalizeProviderModelOptions(currentModelOptions),
1233 kind,
1234 modelSelection.options,
1235 );
1236}
1237
1238function legacyReplaceProviderModelOptions(
1239 currentModelOptions: ProviderOptionSelectionsByProvider | null | undefined,
1240 provider: ProviderDriverKind,
1241 nextProviderOptions: ReadonlyArray<ProviderOptionSelection> | null | undefined,
1242): ProviderOptionSelectionsByProvider | null {
1243 const { [provider]: _discardedProviderModelOptions, ...otherProviderModelOptions } =
1244 currentModelOptions ?? {};
1245 const merged: ProviderOptionSelectionsByProvider = { ...otherProviderModelOptions };
1246 if (nextProviderOptions && nextProviderOptions.length > 0) {
1247 merged[provider] = nextProviderOptions;
1248 }
1249 return Object.keys(merged).length > 0 ? merged : null;
1250}
1251
1252// ── New helpers for the consolidated representation ────────────────────
1253
1254function legacyToModelSelectionByProvider(
1255 modelSelection: NormalizedModelSelection | null,
1256 modelOptions: ProviderOptionSelectionsByProvider | null | undefined,
1257): Partial<Record<ProviderInstanceId, ModelSelection>> {
1258 const result: Partial<Record<ProviderInstanceId, ModelSelection>> = {};
1259 if (modelOptions) {
1260 for (const provider of ["codex", "claudeAgent", "cursor", "opencode"] as const) {
1261 const options = modelOptions[provider];
1262 if (options && options.length > 0) {
1263 const driverKind = ProviderDriverKind.make(provider);
1264 const instanceKey = defaultInstanceIdForDriver(driverKind);
1265 result[instanceKey] = createModelSelection(
1266 instanceKey,
1267 modelSelection?.instanceId === instanceKey
1268 ? modelSelection.model
1269 : (DEFAULT_MODEL_BY_PROVIDER[driverKind] ?? DEFAULT_MODEL),
1270 options,
1271 );
1272 }
1273 }
1274 }
1275 if (modelSelection) {
1276 result[modelSelection.instanceId] = modelSelection as ModelSelection;
1277 }
1278 return result;
1279}
1280
1281export function deriveEffectiveComposerModelState(input: {
1282 draft:
1283 | Pick<ComposerThreadDraftState, "modelSelectionByProvider" | "activeProvider">
1284 | null
1285 | undefined;
1286 providers: ReadonlyArray<ServerProvider>;
1287 selectedProvider: ProviderDriverKind;
1288 /**
1289 * Optional routing key of the instance whose selection should override
1290 * the driver-level lookup. When present, the draft is queried by
1291 * `modelSelectionByProvider[selectedInstanceId]` so a custom Codex
1292 * instance (e.g. `codex_personal`) reads its own saved model instead of
1293 * collapsing to the default Codex bucket.
1294 */
1295 selectedInstanceId?: ProviderInstanceId | null | undefined;
1296 threadModelSelection: ModelSelection | null | undefined;
1297 projectModelSelection: ModelSelection | null | undefined;
1298 settings: UnifiedSettings;
1299}): EffectiveComposerModelState {
1300 const baseModelCandidate =
1301 input.threadModelSelection?.model ?? input.projectModelSelection?.model ?? null;
1302 const preserveThreadModel =
1303 input.selectedInstanceId !== null &&
1304 input.selectedInstanceId !== undefined &&
1305 input.threadModelSelection?.instanceId === input.selectedInstanceId;
1306 const baseModel =
1307 (input.selectedInstanceId
1308 ? resolveAppModelSelectionForInstance(
1309 input.selectedInstanceId,
1310 input.settings,
1311 input.providers,
1312 baseModelCandidate,
1313 { preserveUnavailableSelection: preserveThreadModel },
1314 )
1315 : null) ??
1316 // Antigravity has no static model or cross-account catalog fallback.
1317 (input.selectedProvider === "antigravity" && input.selectedInstanceId ? "" : null) ??
1318 resolveAppModelSelection(
1319 input.selectedProvider,
1320 input.settings,
1321 input.providers,
1322 baseModelCandidate,
1323 ) ??
1324 normalizeModelSlug(baseModelCandidate, input.selectedProvider) ??
1325 getDefaultServerModel(input.providers, input.selectedProvider);
1326 // Look up the instance's saved selection first; fall back to the
1327 // driver-kind bucket so legacy kind-keyed drafts still resolve. Every
1328 // `ProviderDriverKind` literal is a valid `ProviderInstanceId` slug, so the
1329 // cast to the branded type is safe.
1330 const instanceSelection = input.selectedInstanceId
1331 ? input.draft?.modelSelectionByProvider?.[input.selectedInstanceId]
1332 : undefined;
1333 const legacySelection =
1334 input.selectedProvider === "antigravity" &&
1335 input.selectedInstanceId &&
1336 input.selectedInstanceId !== defaultInstanceIdForDriver(input.selectedProvider)
1337 ? undefined
1338 : input.draft?.modelSelectionByProvider?.[ProviderInstanceId.make(input.selectedProvider)];
1339 const activeSelection = instanceSelection ?? legacySelection;
1340 const activeSelectionInstanceId = instanceSelection
1341 ? (input.selectedInstanceId ?? ProviderInstanceId.make(input.selectedProvider))
1342 : ProviderInstanceId.make(input.selectedProvider);
1343 const selectedModel = activeSelection?.model
1344 ? (resolveAppModelSelectionForInstance(
1345 activeSelectionInstanceId,
1346 input.settings,
1347 input.providers,
1348 activeSelection.model,
1349 { preserveUnavailableSelection: true },
1350 ) ??
1351 (input.selectedProvider === "antigravity" ? "" : null) ??
1352 resolveAppModelSelection(
1353 input.selectedProvider,
1354 input.settings,
1355 input.providers,
1356 activeSelection.model,
1357 ))
1358 : baseModel;
1359 const modelOptions =
1360 modelSelectionByProviderToOptions(input.draft?.modelSelectionByProvider) ??
1361 providerSelectionsFromModelSelection(input.threadModelSelection) ??
1362 providerSelectionsFromModelSelection(input.projectModelSelection) ??
1363 null;
1364
1365 return {
1366 selectedModel,
1367 modelOptions,
1368 };
1369}
1370
1371function revokeObjectPreviewUrl(previewUrl: string): void {
1372 if (typeof URL === "undefined") {
1373 return;
1374 }
1375 if (!previewUrl.startsWith("blob:")) {
1376 return;
1377 }
1378 URL.revokeObjectURL(previewUrl);
1379}
1380
1381function revokeDraftThreadPreviewUrls(draft: ComposerThreadDraftState | undefined): void {
1382 if (!draft) {
1383 return;
1384 }
1385 for (const image of draft.images) {
1386 revokeObjectPreviewUrl(image.previewUrl);
1387 }
1388}
1389
1390function normalizePersistedAttachment(value: unknown): PersistedComposerImageAttachment | null {
1391 if (!value || typeof value !== "object") {
1392 return null;
1393 }
1394 const candidate = value as Record<string, unknown>;
1395 const id = candidate.id;
1396 const name = candidate.name;
1397 const mimeType = candidate.mimeType;
1398 const sizeBytes = candidate.sizeBytes;
1399 const dataUrl = candidate.dataUrl;
1400 if (
1401 typeof id !== "string" ||
1402 typeof name !== "string" ||
1403 typeof mimeType !== "string" ||
1404 typeof sizeBytes !== "number" ||
1405 !Number.isFinite(sizeBytes) ||
1406 typeof dataUrl !== "string" ||
1407 id.length === 0 ||
1408 dataUrl.length === 0
1409 ) {
1410 return null;
1411 }
1412 return {
1413 id,
1414 name,
1415 mimeType,
1416 sizeBytes,
1417 dataUrl,
1418 ...(isSnapShotSource(candidate.source) ? { source: candidate.source } : {}),
1419 };
1420}
1421
1422function normalizePersistedTerminalContextDraft(
1423 value: unknown,
1424): PersistedTerminalContextDraft | null {
1425 if (!value || typeof value !== "object") {
1426 return null;
1427 }
1428 const candidate = value as Record<string, unknown>;
1429 const id = candidate.id;
1430 const threadId = candidate.threadId;
1431 const createdAt = candidate.createdAt;
1432 const lineStart = candidate.lineStart;
1433 const lineEnd = candidate.lineEnd;
1434 if (
1435 typeof id !== "string" ||
1436 id.length === 0 ||
1437 typeof threadId !== "string" ||
1438 threadId.length === 0 ||
1439 typeof createdAt !== "string" ||
1440 createdAt.length === 0 ||
1441 typeof lineStart !== "number" ||
1442 !Number.isFinite(lineStart) ||
1443 typeof lineEnd !== "number" ||
1444 !Number.isFinite(lineEnd)
1445 ) {
1446 return null;
1447 }
1448 const terminalId = typeof candidate.terminalId === "string" ? candidate.terminalId.trim() : "";
1449 const terminalLabel =
1450 typeof candidate.terminalLabel === "string" ? candidate.terminalLabel.trim() : "";
1451 if (terminalId.length === 0 || terminalLabel.length === 0) {
1452 return null;
1453 }
1454 const normalizedLineStart = Math.max(1, Math.floor(lineStart));
1455 const normalizedLineEnd = Math.max(normalizedLineStart, Math.floor(lineEnd));
1456 return {
1457 id: toComposerContextId(id),
1458 threadId: threadId as ThreadId,
1459 createdAt,
1460 terminalId,
1461 terminalLabel,
1462 lineStart: normalizedLineStart,
1463 lineEnd: normalizedLineEnd,
1464 ...(typeof candidate.text === "string" ? { text: candidate.text } : {}),
1465 };
1466}
1467
1468function normalizeDraftThreadEnvMode(
1469 value: unknown,
1470 fallbackWorktreePath: string | null,
1471): DraftThreadEnvMode {
1472 if (value === "local" || value === "worktree") {
1473 return value;
1474 }
1475 return fallbackWorktreePath ? "worktree" : "local";
1476}
1477
1478function projectDraftKey(projectRef: ScopedProjectRef): string {
1479 return scopedProjectKey(projectRef);
1480}
1481
1482function logicalProjectDraftKey(logicalProjectKey: string): string {
1483 return logicalProjectKey.trim();
1484}
1485
1486/**
1487 * Runtime composer storage key for app-facing identities only.
1488 *
1489 * Draft sessions are keyed by `DraftId`. Real threads are keyed by
1490 * `ScopedThreadRef` so environment identity is always preserved.
1491 */
1492export function composerTargetKey(target: ScopedThreadRef | DraftId): string {
1493 if (typeof target === "string") {
1494 return target.trim();
1495 }
1496 return scopedThreadKey(target);
1497}
1498
1499/**
1500 * Legacy persisted data may still be keyed by a raw `ThreadId`. This helper is
1501 * intentionally migration-only so live code cannot accidentally accept that
1502 * incomplete identity.
1503 */
1504function normalizeLegacyComposerStorageKey(
1505 threadKeyOrId: string,
1506 options?: {
1507 environmentId?: EnvironmentId;
1508 },
1509): string {
1510 const parsedThreadRef = parseScopedThreadKey(threadKeyOrId);
1511 if (parsedThreadRef) {
1512 return composerTargetKey(parsedThreadRef);
1513 }
1514 if (options?.environmentId) {
1515 return composerTargetKey(scopeThreadRef(options.environmentId, threadKeyOrId as ThreadId));
1516 }
1517 return threadKeyOrId;
1518}
1519
1520function composerThreadRefFromKey(threadKey: string): ScopedThreadRef | null {
1521 return parseScopedThreadKey(threadKey);
1522}
1523
1524type ComposerThreadLookupState = Pick<
1525 ComposerDraftStoreState,
1526 "draftsByThreadKey" | "draftThreadsByThreadKey"
1527>;
1528
1529function normalizeComposerTarget(
1530 state: ComposerThreadLookupState,
1531 target: ComposerThreadTarget,
1532): ComposerThreadTarget | null {
1533 if (typeof target === "string") {
1534 const draftId = target.trim();
1535 return draftId.length > 0 ? DraftId.make(draftId) : null;
1536 }
1537 return target;
1538}
1539
1540export function resolveComposerDraftKey(
1541 state: ComposerThreadLookupState,
1542 target: ComposerThreadTarget,
1543): string | null {
1544 const normalizedTarget = normalizeComposerTarget(state, target);
1545 if (!normalizedTarget) {
1546 return null;
1547 }
1548 if (typeof normalizedTarget !== "string") {
1549 const scopedKey = composerTargetKey(normalizedTarget);
1550 if (state.draftsByThreadKey[scopedKey]) {
1551 return scopedKey;
1552 }
1553 for (const [draftId, draftSession] of Object.entries(state.draftThreadsByThreadKey)) {
1554 if (
1555 draftSession.environmentId === normalizedTarget.environmentId &&
1556 draftSession.threadId === normalizedTarget.threadId
1557 ) {
1558 return draftId;
1559 }
1560 }
1561 return scopedKey;
1562 }
1563 const threadKey = composerTargetKey(normalizedTarget);
1564 return threadKey.length > 0 ? threadKey : null;
1565}
1566
1567function resolveComposerThreadId(
1568 state: ComposerThreadLookupState,
1569 target: ComposerThreadTarget,
1570): ThreadId | null {
1571 const normalizedTarget = normalizeComposerTarget(state, target);
1572 if (!normalizedTarget) {
1573 return null;
1574 }
1575 if (typeof normalizedTarget !== "string") {
1576 return normalizedTarget.threadId;
1577 }
1578 return state.draftThreadsByThreadKey[normalizedTarget]?.threadId ?? null;
1579}
1580
1581function getComposerDraftState(
1582 state: Pick<ComposerDraftStoreState, "draftsByThreadKey" | "draftThreadsByThreadKey">,
1583 target: ComposerThreadTarget,
1584): ComposerThreadDraftState | null {
1585 const threadKey = resolveComposerDraftKey(state, target);
1586 if (!threadKey) {
1587 return null;
1588 }
1589 return state.draftsByThreadKey[threadKey] ?? null;
1590}
1591
1592/**
1593 * A draft holding an open page, such as one started for a link another app
1594 * opened, is work in progress even with an empty composer.
1595 */
1596function draftThreadHasOpenPreview(draftThread: DraftThreadState | undefined): boolean {
1597 if (draftThread === undefined) return false;
1598 const ref = scopeThreadRef(draftThread.environmentId, draftThread.threadId);
1599 return Object.keys(readThreadPreviewState(ref).sessions).length > 0;
1600}
1601
1602function isComposerThreadKeyInUse(mappings: Record<string, string>, threadKey: string): boolean {
1603 return Object.values(mappings).includes(threadKey);
1604}
1605
1606function toProjectDraftSession(
1607 draftId: DraftId,
1608 draftSession: DraftSessionState,
1609): ProjectDraftSession {
1610 return {
1611 draftId,
1612 ...draftSession,
1613 };
1614}
1615
1616function createDraftThreadState(
1617 projectRef: ScopedProjectRef,
1618 threadId: ThreadId,
1619 logicalProjectKey: string,
1620 existingThread: DraftThreadState | undefined,
1621 options?: {
1622 threadId?: ThreadId;
1623 branch?: string | null;
1624 worktreePath?: string | null;
1625 createdAt?: string;
1626 envMode?: DraftThreadEnvMode;
1627 startFromOrigin?: boolean;
1628 runtimeMode?: RuntimeMode;
1629 interactionMode?: ProviderInteractionMode;
1630 environmentSelection?: "auto" | "manual";
1631 loadBalancedEnvironmentId?: EnvironmentId | null;
1632 },
1633): DraftThreadState {
1634 // A project change (including switching environments within a logical
1635 // project) invalidates machine-specific context: the branch may not exist
1636 // there and the worktree path certainly doesn't. The user's *intent* —
1637 // env mode and start-from-origin — is machine-independent and carries.
1638 const projectChanged =
1639 existingThread !== undefined &&
1640 (existingThread.environmentId !== projectRef.environmentId ||
1641 existingThread.projectId !== projectRef.projectId);
1642 const nextWorktreePath =
1643 options?.worktreePath === undefined
1644 ? projectChanged
1645 ? null
1646 : (existingThread?.worktreePath ?? null)
1647 : (options.worktreePath ?? null);
1648 const nextBranch =
1649 options?.branch === undefined
1650 ? projectChanged
1651 ? null
1652 : (existingThread?.branch ?? null)
1653 : (options.branch ?? null);
1654 const nextStartFromOrigin =
1655 options?.startFromOrigin === undefined
1656 ? (existingThread?.startFromOrigin ?? false)
1657 : options.startFromOrigin;
1658 const environmentSelection =
1659 options?.environmentSelection ?? existingThread?.environmentSelection;
1660 return {
1661 threadId,
1662 environmentId: projectRef.environmentId,
1663 projectId: projectRef.projectId,
1664 logicalProjectKey,
1665 ...(environmentSelection ? { environmentSelection } : {}),
1666 ...(options?.loadBalancedEnvironmentId !== undefined
1667 ? { loadBalancedEnvironmentId: options.loadBalancedEnvironmentId }
1668 : existingThread?.loadBalancedEnvironmentId !== undefined
1669 ? {
1670 loadBalancedEnvironmentId: projectChanged
1671 ? null
1672 : existingThread.loadBalancedEnvironmentId,
1673 }
1674 : {}),
1675 createdAt: options?.createdAt ?? existingThread?.createdAt ?? new Date().toISOString(),
1676 runtimeMode: options?.runtimeMode ?? existingThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE,
1677 interactionMode:
1678 options?.interactionMode ?? existingThread?.interactionMode ?? DEFAULT_INTERACTION_MODE,
1679 branch: nextBranch,
1680 worktreePath: nextWorktreePath,
1681 envMode:
1682 options?.envMode ?? (nextWorktreePath ? "worktree" : (existingThread?.envMode ?? "local")),
1683 startFromOrigin: nextStartFromOrigin,
1684 promotedTo: null,
1685 };
1686}
1687
1688function scopedThreadRefsEqual(
1689 left: ScopedThreadRef | null | undefined,
1690 right: ScopedThreadRef | null | undefined,
1691): boolean {
1692 if (!left || !right) {
1693 return left === right;
1694 }
1695 return left.environmentId === right.environmentId && left.threadId === right.threadId;
1696}
1697
1698function isDraftThreadPromoting(draftThread: DraftThreadState | null | undefined): boolean {
1699 return draftThread?.promotedTo !== null && draftThread?.promotedTo !== undefined;
1700}
1701
1702function draftThreadsEqual(left: DraftThreadState | undefined, right: DraftThreadState): boolean {
1703 return (
1704 !!left &&
1705 left.threadId === right.threadId &&
1706 left.environmentId === right.environmentId &&
1707 left.projectId === right.projectId &&
1708 left.logicalProjectKey === right.logicalProjectKey &&
1709 left.environmentSelection === right.environmentSelection &&
1710 left.loadBalancedEnvironmentId === right.loadBalancedEnvironmentId &&
1711 left.createdAt === right.createdAt &&
1712 left.runtimeMode === right.runtimeMode &&
1713 left.interactionMode === right.interactionMode &&
1714 left.branch === right.branch &&
1715 left.worktreePath === right.worktreePath &&
1716 left.envMode === right.envMode &&
1717 left.startFromOrigin === right.startFromOrigin &&
1718 scopedThreadRefsEqual(left.promotedTo, right.promotedTo)
1719 );
1720}
1721
1722function removeDraftThreadReferences(
1723 state: Pick<
1724 ComposerDraftStoreState,
1725 | "draftThreadsByThreadKey"
1726 | "draftsByThreadKey"
1727 | "logicalProjectDraftThreadKeyByLogicalProjectKey"
1728 >,
1729 threadKey: string,
1730 composerDestination?: ScopedThreadRef,
1731): Pick<
1732 ComposerDraftStoreState,
1733 | "draftThreadsByThreadKey"
1734 | "draftsByThreadKey"
1735 | "logicalProjectDraftThreadKeyByLogicalProjectKey"
1736> {
1737 const nextLogicalMappings = Object.fromEntries(
1738 Object.entries(state.logicalProjectDraftThreadKeyByLogicalProjectKey).filter(
1739 ([, draftThreadKey]) => draftThreadKey !== threadKey,
1740 ),
1741 ) as Record<string, string>;
1742 const { [threadKey]: _removedDraftThread, ...restDraftThreadsByThreadKey } =
1743 state.draftThreadsByThreadKey;
1744 const { [threadKey]: removedComposerDraft, ...restDraftsByThreadKey } = state.draftsByThreadKey;
1745 if (composerDestination && removedComposerDraft) {
1746 restDraftsByThreadKey[composerTargetKey(composerDestination)] = removedComposerDraft;
1747 } else {
1748 revokeDraftThreadPreviewUrls(removedComposerDraft);
1749 }
1750 return {
1751 draftsByThreadKey: restDraftsByThreadKey,
1752 draftThreadsByThreadKey: restDraftThreadsByThreadKey,
1753 logicalProjectDraftThreadKeyByLogicalProjectKey: nextLogicalMappings,
1754 };
1755}
1756
1757function normalizePersistedDraftThreads(
1758 rawDraftThreadsByThreadId: unknown,
1759 rawProjectDraftThreadIdByProjectKey: unknown,
1760): Pick<
1761 PersistedComposerDraftStoreState,
1762 "draftThreadsByThreadKey" | "logicalProjectDraftThreadKeyByLogicalProjectKey"
1763> {
1764 const draftThreadsByThreadKey: Record<string, PersistedDraftThreadState> = {};
1765 const environmentIdByThreadId = new Map<ThreadId, EnvironmentId>();
1766 if (
1767 rawProjectDraftThreadIdByProjectKey &&
1768 typeof rawProjectDraftThreadIdByProjectKey === "object"
1769 ) {
1770 for (const [projectKey, threadId] of Object.entries(
1771 rawProjectDraftThreadIdByProjectKey as Record<string, unknown>,
1772 )) {
1773 if (typeof threadId !== "string" || threadId.length === 0) {
1774 continue;
1775 }
1776 const projectRef = parseScopedProjectKey(projectKey);
1777 if (!projectRef) {
1778 continue;
1779 }
1780 const parsedThreadRef = parseScopedThreadKey(threadId);
1781 if (parsedThreadRef) {
1782 environmentIdByThreadId.set(parsedThreadRef.threadId, parsedThreadRef.environmentId);
1783 continue;
1784 }
1785 environmentIdByThreadId.set(threadId as ThreadId, projectRef.environmentId);
1786 }
1787 }
1788 if (rawDraftThreadsByThreadId && typeof rawDraftThreadsByThreadId === "object") {
1789 for (const [threadKeyOrId, rawDraftThread] of Object.entries(
1790 rawDraftThreadsByThreadId as Record<string, unknown>,
1791 )) {
1792 if (typeof threadKeyOrId !== "string" || threadKeyOrId.length === 0) {
1793 continue;
1794 }
1795 if (!rawDraftThread || typeof rawDraftThread !== "object") {
1796 continue;
1797 }
1798 const candidateDraftThread = rawDraftThread as Record<string, unknown>;
1799 const parsedThreadRef = parseScopedThreadKey(threadKeyOrId);
1800 const threadKey = normalizeLegacyComposerStorageKey(threadKeyOrId);
1801 const threadId =
1802 parsedThreadRef?.threadId ??
1803 (typeof candidateDraftThread.threadId === "string" &&
1804 candidateDraftThread.threadId.length > 0
1805 ? (candidateDraftThread.threadId as ThreadId)
1806 : (threadKeyOrId as ThreadId));
1807 const environmentId =
1808 parsedThreadRef?.environmentId ??
1809 (typeof candidateDraftThread.environmentId === "string" &&
1810 candidateDraftThread.environmentId.length > 0
1811 ? (candidateDraftThread.environmentId as EnvironmentId)
1812 : environmentIdByThreadId.get(threadKeyOrId as ThreadId));
1813 const projectId = candidateDraftThread.projectId;
1814 const createdAt = candidateDraftThread.createdAt;
1815 const branch = candidateDraftThread.branch;
1816 const worktreePath = candidateDraftThread.worktreePath;
1817 const startFromOrigin = candidateDraftThread.startFromOrigin === true;
1818 const normalizedWorktreePath = typeof worktreePath === "string" ? worktreePath : null;
1819 const promotedToCandidate = candidateDraftThread.promotedTo;
1820 const promotedToRecord =
1821 promotedToCandidate && typeof promotedToCandidate === "object"
1822 ? (promotedToCandidate as Record<string, unknown>)
1823 : null;
1824 const promotedTo =
1825 promotedToRecord &&
1826 typeof promotedToRecord.environmentId === "string" &&
1827 promotedToRecord.environmentId.length > 0 &&
1828 typeof promotedToRecord.threadId === "string" &&
1829 promotedToRecord.threadId.length > 0
1830 ? scopeThreadRef(
1831 promotedToRecord.environmentId as EnvironmentId,
1832 promotedToRecord.threadId as ThreadId,
1833 )
1834 : null;
1835 if (typeof projectId !== "string" || projectId.length === 0 || environmentId === undefined) {
1836 continue;
1837 }
1838 const normalizedEnvironmentId = environmentId as EnvironmentId;
1839 draftThreadsByThreadKey[threadKey] = {
1840 threadId,
1841 environmentId: normalizedEnvironmentId,
1842 projectId: projectId as ProjectId,
1843 logicalProjectKey:
1844 typeof candidateDraftThread.logicalProjectKey === "string" &&
1845 candidateDraftThread.logicalProjectKey.length > 0
1846 ? candidateDraftThread.logicalProjectKey
1847 : parsedThreadRef
1848 ? projectDraftKey(scopeProjectRef(normalizedEnvironmentId, projectId as ProjectId))
1849 : threadKeyOrId,
1850 createdAt:
1851 typeof createdAt === "string" && createdAt.length > 0
1852 ? createdAt
1853 : new Date().toISOString(),
1854 runtimeMode: isRuntimeMode(candidateDraftThread.runtimeMode)
1855 ? candidateDraftThread.runtimeMode
1856 : DEFAULT_RUNTIME_MODE,
1857 interactionMode:
1858 candidateDraftThread.interactionMode === "plan" ||
1859 candidateDraftThread.interactionMode === "default"
1860 ? candidateDraftThread.interactionMode
1861 : DEFAULT_INTERACTION_MODE,
1862 branch: typeof branch === "string" ? branch : null,
1863 worktreePath: normalizedWorktreePath,
1864 envMode: normalizeDraftThreadEnvMode(candidateDraftThread.envMode, normalizedWorktreePath),
1865 startFromOrigin,
1866 ...(candidateDraftThread.environmentSelection === "manual" ||
1867 candidateDraftThread.environmentSelection === "auto"
1868 ? { environmentSelection: candidateDraftThread.environmentSelection }
1869 : {}),
1870 ...(typeof candidateDraftThread.loadBalancedEnvironmentId === "string" &&
1871 candidateDraftThread.loadBalancedEnvironmentId.length > 0
1872 ? { loadBalancedEnvironmentId: candidateDraftThread.loadBalancedEnvironmentId }
1873 : candidateDraftThread.loadBalancedEnvironmentId === null
1874 ? { loadBalancedEnvironmentId: null }
1875 : {}),
1876 promotedTo,
1877 };
1878 }
1879 }
1880
1881 const logicalProjectDraftThreadKeyByLogicalProjectKey: Record<string, string> = {};
1882 if (
1883 rawProjectDraftThreadIdByProjectKey &&
1884 typeof rawProjectDraftThreadIdByProjectKey === "object"
1885 ) {
1886 for (const [logicalProjectKey, threadKeyOrId] of Object.entries(
1887 rawProjectDraftThreadIdByProjectKey as Record<string, unknown>,
1888 )) {
1889 if (typeof threadKeyOrId !== "string" || threadKeyOrId.length === 0) {
1890 continue;
1891 }
1892 const projectRef = parseScopedProjectKey(logicalProjectKey);
1893 const parsedThreadRef = parseScopedThreadKey(threadKeyOrId);
1894 const threadKey = normalizeLegacyComposerStorageKey(threadKeyOrId);
1895 logicalProjectDraftThreadKeyByLogicalProjectKey[logicalProjectKey] = threadKey;
1896 const existingDraftThread = draftThreadsByThreadKey[threadKey];
1897 if (parsedThreadRef) {
1898 environmentIdByThreadId.set(parsedThreadRef.threadId, parsedThreadRef.environmentId);
1899 }
1900 // Logical project keys may contain a workspace path after the
1901 // environment prefix. When the persisted draft already names that
1902 // logical key, its concrete project id remains authoritative.
1903 if (existingDraftThread?.logicalProjectKey === logicalProjectKey) {
1904 continue;
1905 }
1906 if (!projectRef) {
1907 if (existingDraftThread) {
1908 draftThreadsByThreadKey[threadKey] = {
1909 ...existingDraftThread,
1910 logicalProjectKey,
1911 };
1912 }
1913 continue;
1914 }
1915 if (!existingDraftThread) {
1916 draftThreadsByThreadKey[threadKey] = {
1917 threadId: parsedThreadRef?.threadId ?? (threadKey as ThreadId),
1918 environmentId: projectRef.environmentId,
1919 projectId: projectRef.projectId,
1920 logicalProjectKey,
1921 createdAt: new Date().toISOString(),
1922 runtimeMode: DEFAULT_RUNTIME_MODE,
1923 interactionMode: DEFAULT_INTERACTION_MODE,
1924 branch: null,
1925 worktreePath: null,
1926 envMode: "local",
1927 startFromOrigin: false,
1928 promotedTo: null,
1929 };
1930 } else if (
1931 existingDraftThread.projectId !== projectRef.projectId ||
1932 existingDraftThread.environmentId !== projectRef.environmentId
1933 ) {
1934 draftThreadsByThreadKey[threadKey] = {
1935 ...existingDraftThread,
1936 threadId: existingDraftThread.threadId,
1937 environmentId: projectRef.environmentId,
1938 projectId: projectRef.projectId,
1939 logicalProjectKey,
1940 };
1941 }
1942 }
1943 }
1944
1945 return { draftThreadsByThreadKey, logicalProjectDraftThreadKeyByLogicalProjectKey };
1946}
1947
1948function normalizePersistedDraftsByThreadId(
1949 rawDraftMap: unknown,
1950 draftThreadsByThreadKey: PersistedComposerDraftStoreState["draftThreadsByThreadKey"],
1951): PersistedComposerDraftStoreState["draftsByThreadKey"] {
1952 if (!rawDraftMap || typeof rawDraftMap !== "object") {
1953 return {};
1954 }
1955
1956 const environmentIdByThreadId = new Map<ThreadId, EnvironmentId>();
1957 for (const [threadKey, draftThread] of Object.entries(draftThreadsByThreadKey)) {
1958 const parsedThreadRef = composerThreadRefFromKey(threadKey);
1959 if (!parsedThreadRef) {
1960 continue;
1961 }
1962 environmentIdByThreadId.set(
1963 parsedThreadRef.threadId,
1964 draftThread.environmentId as EnvironmentId,
1965 );
1966 }
1967
1968 const nextDraftsByThreadKey: DeepMutable<PersistedComposerDraftStoreState["draftsByThreadKey"]> =
1969 {};
1970 for (const [threadKeyOrId, draftValue] of Object.entries(
1971 rawDraftMap as Record<string, unknown>,
1972 )) {
1973 if (typeof threadKeyOrId !== "string" || threadKeyOrId.length === 0) {
1974 continue;
1975 }
1976 if (!draftValue || typeof draftValue !== "object") {
1977 continue;
1978 }
1979 const draftCandidate = draftValue as PersistedComposerThreadDraftState;
1980 const promptCandidate = typeof draftCandidate.prompt === "string" ? draftCandidate.prompt : "";
1981 const attachments = Array.isArray(draftCandidate.attachments)
1982 ? draftCandidate.attachments.flatMap((entry) => {
1983 const normalized = normalizePersistedAttachment(entry);
1984 return normalized ? [normalized] : [];
1985 })
1986 : [];
1987 const files = Array.isArray(draftCandidate.files)
1988 ? draftCandidate.files.filter(isPersistedComposerDraftFileAttachment)
1989 : [];
1990 const terminalContexts = Array.isArray(draftCandidate.terminalContexts)
1991 ? draftCandidate.terminalContexts.flatMap((entry) => {
1992 const normalized = normalizePersistedTerminalContextDraft(entry);
1993 return normalized ? [normalized] : [];
1994 })
1995 : [];
1996 const reviewComments = Array.isArray(draftCandidate.reviewComments)
1997 ? draftCandidate.reviewComments.filter(isReviewCommentContext)
1998 : [];
1999 const threadContexts = Array.isArray(draftCandidate.threadContexts)
2000 ? draftCandidate.threadContexts.filter(isThreadContextRecord)
2001 : [];
2002 const previewAnnotations = Array.isArray(draftCandidate.previewAnnotations)
2003 ? draftCandidate.previewAnnotations.filter(isPreviewAnnotationPayload)
2004 : [];
2005 const legacyElements =
2006 "elementContexts" in draftValue && Array.isArray(draftValue.elementContexts)
2007 ? draftValue.elementContexts
2008 : [];
2009 for (const element of legacyElements) {
2010 if (
2011 !Schema.is(ElementContextDetails)(element) ||
2012 !("id" in element) ||
2013 typeof element.id !== "string" ||
2014 !("pickedAt" in element) ||
2015 typeof element.pickedAt !== "string"
2016 )
2017 continue;
2018 if (!previewAnnotations.some((annotation) => annotation.id === element.id)) {
2019 previewAnnotations.push(
2020 elementContextToPreviewAnnotation(element, element.id, element.pickedAt),
2021 );
2022 }
2023 }
2024 const runtimeMode = isRuntimeMode(draftCandidate.runtimeMode)
2025 ? draftCandidate.runtimeMode
2026 : null;
2027 const interactionMode =
2028 draftCandidate.interactionMode === "plan" || draftCandidate.interactionMode === "default"
2029 ? draftCandidate.interactionMode
2030 : null;
2031 const contextIds = new Map<string, string>();
2032 for (const [kind, entries] of [
2033 ["image", attachments],
2034 ["file", files],
2035 ["terminal", terminalContexts],
2036 ["review-comment", reviewComments],
2037 ["preview-annotation", previewAnnotations],
2038 ] as const) {
2039 for (const entry of entries) {
2040 const contextId = toKindScopedComposerContextId(kind, entry.id);
2041 contextIds.set(`${kind}/${entry.id}`, contextId);
2042 contextIds.set(`${kind}/${toComposerContextId(entry.id)}`, contextId);
2043 if (kind === "preview-annotation") {
2044 contextIds.set(`${kind}/${toComposerContextId(`annotation-${entry.id}`)}`, contextId);
2045 }
2046 }
2047 // A live canonical reference wins over another record's legacy producer-ID alias.
2048 for (const entry of entries) {
2049 const contextId = toKindScopedComposerContextId(kind, entry.id);
2050 contextIds.set(`${kind}/${contextId}`, contextId);
2051 }
2052 }
2053 // Older drafts used producer ids (including dots and colons) directly in links.
2054 // Rewrite only links backed by this draft, before appending any missing references.
2055 const migratedPrompt = promptCandidate.replace(
2056 /!?\[([^\]\r\n]*)\]\(t3-context:\/\/v1\/([a-z-]+)\/([^/()\r\n]+)\)/g,
2057 (source, label: string, kind: string, id: string) => {
2058 const contextId = contextIds.get(`${kind}/${id}`);
2059 return contextId ? formatInlineContextReference({ kind, contextId, label }) : source;
2060 },
2061 );
2062 const prompt = ensureInlineContextReferences(
2063 migrateLegacyTerminalContextPlaceholders(migratedPrompt, terminalContexts),
2064 terminalContexts.map((context) => terminalContextReference({ ...context, text: "" })),
2065 );
2066 // If the draft already has the v3 shape, use it directly
2067 const legacyDraftCandidate = draftValue as LegacyPersistedComposerThreadDraftState;
2068 let modelSelectionByProvider: Partial<Record<ProviderInstanceId, ModelSelection>> = {};
2069 let activeProvider: ProviderInstanceId | null = null;
2070 let modelSelectionExplicit: true | undefined = undefined;
2071
2072 if (
2073 draftCandidate.modelSelectionByProvider &&
2074 typeof draftCandidate.modelSelectionByProvider === "object"
2075 ) {
2076 // v3 format
2077 modelSelectionByProvider = draftCandidate.modelSelectionByProvider as Partial<
2078 Record<ProviderInstanceId, ModelSelection>
2079 >;
2080 activeProvider = normalizeProviderInstanceId(draftCandidate.activeProvider);
2081 modelSelectionExplicit = draftCandidate.modelSelectionExplicit === true ? true : undefined;
2082 } else {
2083 // v2 or legacy format: migrate
2084 const normalizedModelOptions =
2085 normalizeProviderModelOptions(
2086 legacyDraftCandidate.modelOptions,
2087 undefined,
2088 legacyDraftCandidate,
2089 ) ?? null;
2090 const normalizedModelSelection = normalizeModelSelection(
2091 legacyDraftCandidate.modelSelection,
2092 {
2093 provider: legacyDraftCandidate.provider,
2094 model: legacyDraftCandidate.model,
2095 modelOptions: normalizedModelOptions ?? (legacyDraftCandidate.modelOptions as unknown),
2096 legacyCodex: legacyDraftCandidate,
2097 },
2098 );
2099 const mergedModelOptions = legacyMergeModelSelectionIntoProviderModelOptions(
2100 normalizedModelSelection,
2101 normalizedModelOptions,
2102 );
2103 const modelSelection = legacySyncModelSelectionOptions(
2104 normalizedModelSelection,
2105 mergedModelOptions,
2106 );
2107 modelSelectionByProvider = legacyToModelSelectionByProvider(
2108 modelSelection,
2109 mergedModelOptions,
2110 );
2111 activeProvider = modelSelection?.instanceId ?? null;
2112 }
2113
2114 const hasModelData =
2115 Object.keys(modelSelectionByProvider).length > 0 || activeProvider !== null;
2116 if (
2117 promptCandidate.length === 0 &&
2118 attachments.length === 0 &&
2119 files.length === 0 &&
2120 terminalContexts.length === 0 &&
2121 previewAnnotations.length === 0 &&
2122 reviewComments.length === 0 &&
2123 threadContexts.length === 0 &&
2124 !hasModelData &&
2125 !runtimeMode &&
2126 !interactionMode
2127 ) {
2128 continue;
2129 }
2130 const parsedThreadRef = parseScopedThreadKey(threadKeyOrId);
2131 const normalizedThreadKey =
2132 parsedThreadRef !== null
2133 ? normalizeLegacyComposerStorageKey(threadKeyOrId)
2134 : draftThreadsByThreadKey[threadKeyOrId] !== undefined
2135 ? threadKeyOrId
2136 : (() => {
2137 const environmentId = environmentIdByThreadId.get(threadKeyOrId as ThreadId);
2138 return environmentId
2139 ? normalizeLegacyComposerStorageKey(threadKeyOrId, { environmentId })
2140 : threadKeyOrId;
2141 })();
2142 nextDraftsByThreadKey[normalizedThreadKey] = {
2143 prompt,
2144 attachments,
2145 ...(files.length > 0 ? { files } : {}),
2146 ...(terminalContexts.length > 0 ? { terminalContexts } : {}),
2147 ...(previewAnnotations.length > 0 ? { previewAnnotations } : {}),
2148 ...(reviewComments.length > 0 ? { reviewComments } : {}),
2149 ...(threadContexts.length > 0 ? { threadContexts } : {}),
2150 ...(hasModelData
2151 ? {
2152 modelSelectionByProvider: compactModelSelectionByProvider(modelSelectionByProvider),
2153 activeProvider,
2154 ...(modelSelectionExplicit ? { modelSelectionExplicit: true } : {}),
2155 }
2156 : {}),
2157 ...(runtimeMode ? { runtimeMode } : {}),
2158 ...(interactionMode ? { interactionMode } : {}),
2159 };
2160 }
2161
2162 return nextDraftsByThreadKey;
2163}
2164
2165function persistedComposerDraftHasUserContent(draft: PersistedComposerThreadDraftState): boolean {
2166 return (
2167 draft.prompt.trim().length > 0 ||
2168 draft.attachments.length > 0 ||
2169 (draft.files?.length ?? 0) > 0 ||
2170 (draft.terminalContexts?.length ?? 0) > 0 ||
2171 (draft.previewAnnotations?.length ?? 0) > 0 ||
2172 (draft.reviewComments?.length ?? 0) > 0 ||
2173 (draft.threadContexts?.length ?? 0) > 0
2174 );
2175}
2176
2177function stripLegacyModelSeedsFromEmptyDraftSessions(
2178 draftsByThreadKey: PersistedComposerDraftStoreState["draftsByThreadKey"],
2179 draftThreadsByThreadKey: PersistedComposerDraftStoreState["draftThreadsByThreadKey"],
2180): PersistedComposerDraftStoreState["draftsByThreadKey"] {
2181 return Object.fromEntries(
2182 Object.entries(draftsByThreadKey).flatMap(([threadKey, draft]) => {
2183 if (
2184 draftThreadsByThreadKey[threadKey] === undefined ||
2185 draft.modelSelectionExplicit === true ||
2186 persistedComposerDraftHasUserContent(draft)
2187 ) {
2188 return [[threadKey, draft]];
2189 }
2190
2191 const {
2192 activeProvider: _activeProvider,
2193 modelSelectionByProvider: _modelSelectionByProvider,
2194 modelSelectionExplicit: _modelSelectionExplicit,
2195 ...retained
2196 } = draft;
2197 return retained.runtimeMode || retained.interactionMode ? [[threadKey, retained]] : [];
2198 }),
2199 );
2200}
2201
2202function migratePersistedComposerDraftStoreState(
2203 persistedState: unknown,
2204): PersistedComposerDraftStoreState {
2205 const normalized = normalizeCurrentPersistedComposerDraftStoreState(persistedState);
2206 return {
2207 ...normalized,
2208 draftsByThreadKey: stripLegacyModelSeedsFromEmptyDraftSessions(
2209 normalized.draftsByThreadKey,
2210 normalized.draftThreadsByThreadKey,
2211 ),
2212 };
2213}
2214
2215/** Select the persisted draft fields when the storage write is ready to flush. */
2216export function partializeComposerDraftStoreState(
2217 state: ComposerDraftStoreState,
2218): PersistedComposerDraftStoreState {
2219 // Draft sessions worth persisting: mapped (a new-thread flow targets
2220 // them), promoting (mid-send), holding real user content (they back a
2221 // sidebar row), or holding an open page. Everything else is a zombie — and
2222 // its composer blob must be dropped WITH it, or model/mode-only entries
2223 // would persist forever keyed to a session that no longer exists.
2224 const mappedDraftKeys = new Set(
2225 Object.values(state.logicalProjectDraftThreadKeyByLogicalProjectKey),
2226 );
2227 const keptSessionKeys = new Set(
2228 Object.entries(state.draftThreadsByThreadKey)
2229 .filter(
2230 ([threadKey, draftThread]) =>
2231 mappedDraftKeys.has(threadKey) ||
2232 isDraftThreadPromoting(draftThread) ||
2233 composerDraftHasUserContent(state.draftsByThreadKey[threadKey]) ||
2234 draftThreadHasOpenPreview(draftThread),
2235 )
2236 .map(([threadKey]) => threadKey),
2237 );
2238 const persistedDraftsByThreadKey: DeepMutable<
2239 PersistedComposerDraftStoreState["draftsByThreadKey"]
2240 > = {};
2241 for (const [threadKey, draft] of Object.entries(state.draftsByThreadKey)) {
2242 if (typeof threadKey !== "string" || threadKey.length === 0) {
2243 continue;
2244 }
2245 // Composer content keyed to a dropped draft session goes with it.
2246 // Server-thread keys have no session entry and are unaffected.
2247 if (state.draftThreadsByThreadKey[threadKey] !== undefined && !keptSessionKeys.has(threadKey)) {
2248 continue;
2249 }
2250 const hasModelData =
2251 Object.keys(draft.modelSelectionByProvider).length > 0 || draft.activeProvider !== null;
2252 if (
2253 draft.prompt.length === 0 &&
2254 draft.persistedAttachments.length === 0 &&
2255 draft.files.length === 0 &&
2256 draft.terminalContexts.length === 0 &&
2257 draft.previewAnnotations.length === 0 &&
2258 draft.reviewComments.length === 0 &&
2259 draft.threadContexts.length === 0 &&
2260 !hasModelData &&
2261 draft.runtimeMode === null &&
2262 draft.interactionMode === null
2263 ) {
2264 continue;
2265 }
2266 const persistedDraft: DeepMutable<PersistedComposerThreadDraftState> = {
2267 prompt: draft.prompt,
2268 attachments: draft.persistedAttachments,
2269 ...(draft.files.length > 0
2270 ? {
2271 // A file whose upload has not finished has no serializable bytes.
2272 // It persists as a metadata-only marker so it can surface as a
2273 // needs-reattach row after reload instead of silently vanishing.
2274 files: draft.files.map((file) => ({
2275 id: file.id,
2276 name: file.name,
2277 mimeType: file.mimeType,
2278 sizeBytes: file.sizeBytes,
2279 ...(file.source ? { source: file.source } : {}),
2280 ...(file.uploadedAttachmentId && file.uploadEnvironmentId
2281 ? {
2282 attachmentId: file.uploadedAttachmentId,
2283 environmentId: file.uploadEnvironmentId,
2284 }
2285 : {}),
2286 })),
2287 }
2288 : {}),
2289 ...(draft.terminalContexts.length > 0
2290 ? {
2291 terminalContexts: draft.terminalContexts.map((context) => ({
2292 id: context.id,
2293 threadId: context.threadId,
2294 createdAt: context.createdAt,
2295 terminalId: context.terminalId,
2296 terminalLabel: context.terminalLabel,
2297 lineStart: context.lineStart,
2298 lineEnd: context.lineEnd,
2299 text: context.text,
2300 })),
2301 }
2302 : {}),
2303 ...(draft.previewAnnotations.length > 0
2304 ? {
2305 previewAnnotations: draft.previewAnnotations.map(
2306 (annotation) => ({ ...annotation }) as DeepMutable<PreviewAnnotationPayload>,
2307 ),
2308 }
2309 : {}),
2310 ...(draft.reviewComments.length > 0
2311 ? {
2312 reviewComments: draft.reviewComments.map((comment) => ({ ...comment })),
2313 }
2314 : {}),
2315 ...(draft.threadContexts.length > 0
2316 ? { threadContexts: draft.threadContexts.map((record) => ({ ...record })) }
2317 : {}),
2318 ...(hasModelData
2319 ? {
2320 modelSelectionByProvider: compactModelSelectionByProvider(
2321 draft.modelSelectionByProvider,
2322 ),
2323 activeProvider: draft.activeProvider,
2324 ...(draft.modelSelectionExplicit ? { modelSelectionExplicit: true } : {}),
2325 }
2326 : {}),
2327 ...(draft.runtimeMode ? { runtimeMode: draft.runtimeMode } : {}),
2328 ...(draft.interactionMode ? { interactionMode: draft.interactionMode } : {}),
2329 };
2330 persistedDraftsByThreadKey[threadKey] = persistedDraft;
2331 }
2332 const persistedDraftThreadsByThreadKey: DeepMutable<
2333 PersistedComposerDraftStoreState["draftThreadsByThreadKey"]
2334 > = {};
2335 for (const [threadKey, draftThread] of Object.entries(state.draftThreadsByThreadKey)) {
2336 if (!keptSessionKeys.has(threadKey)) {
2337 continue;
2338 }
2339 persistedDraftThreadsByThreadKey[threadKey] = draftThread;
2340 }
2341 return {
2342 draftsByThreadKey: persistedDraftsByThreadKey,
2343 draftThreadsByThreadKey: persistedDraftThreadsByThreadKey,
2344 logicalProjectDraftThreadKeyByLogicalProjectKey:
2345 state.logicalProjectDraftThreadKeyByLogicalProjectKey,
2346 stickyModelSelectionByProvider: compactModelSelectionByProvider(
2347 state.stickyModelSelectionByProvider,
2348 ),
2349 stickyOptionsByModelByProvider: compactStickyOptionsByModel(
2350 state.stickyOptionsByModelByProvider,
2351 ),
2352 stickyActiveProvider: state.stickyActiveProvider,
2353 };
2354}
2355
2356function normalizeCurrentPersistedComposerDraftStoreState(
2357 persistedState: unknown,
2358): PersistedComposerDraftStoreState {
2359 if (!persistedState || typeof persistedState !== "object") {
2360 return EMPTY_PERSISTED_DRAFT_STORE_STATE;
2361 }
2362 const normalizedPersistedState = persistedState as LegacyPersistedComposerDraftStoreState;
2363 const { draftThreadsByThreadKey, logicalProjectDraftThreadKeyByLogicalProjectKey } =
2364 normalizePersistedDraftThreads(
2365 normalizedPersistedState.draftThreadsByThreadKey ??
2366 normalizedPersistedState.draftThreadsByThreadId,
2367 normalizedPersistedState.logicalProjectDraftThreadKeyByLogicalProjectKey ??
2368 normalizedPersistedState.projectDraftThreadKeyByProjectKey ??
2369 normalizedPersistedState.projectDraftThreadIdByProjectKey ??
2370 normalizedPersistedState.projectDraftThreadIdByProjectId,
2371 );
2372
2373 // Handle both v3 (modelSelectionByProvider) and v2/legacy formats
2374 let stickyModelSelectionByProvider: Partial<Record<ProviderInstanceId, ModelSelection>> = {};
2375 let stickyActiveProvider: ProviderInstanceId | null = null;
2376 if (
2377 normalizedPersistedState.stickyModelSelectionByProvider &&
2378 typeof normalizedPersistedState.stickyModelSelectionByProvider === "object"
2379 ) {
2380 stickyModelSelectionByProvider =
2381 normalizedPersistedState.stickyModelSelectionByProvider as Partial<
2382 Record<ProviderInstanceId, ModelSelection>
2383 >;
2384 stickyActiveProvider = normalizeProviderInstanceId(
2385 normalizedPersistedState.stickyActiveProvider,
2386 );
2387 } else {
2388 // Legacy migration path
2389 const stickyModelOptions =
2390 normalizeProviderModelOptions(normalizedPersistedState.stickyModelOptions) ?? {};
2391 const normalizedStickyModelSelection = normalizeModelSelection(
2392 normalizedPersistedState.stickyModelSelection,
2393 {
2394 provider: normalizedPersistedState.stickyProvider ?? "codex",
2395 model: normalizedPersistedState.stickyModel,
2396 modelOptions: stickyModelOptions,
2397 },
2398 );
2399 const nextStickyModelOptions = legacyMergeModelSelectionIntoProviderModelOptions(
2400 normalizedStickyModelSelection,
2401 stickyModelOptions,
2402 );
2403 const stickyModelSelection = legacySyncModelSelectionOptions(
2404 normalizedStickyModelSelection,
2405 nextStickyModelOptions,
2406 );
2407 stickyModelSelectionByProvider = legacyToModelSelectionByProvider(
2408 stickyModelSelection,
2409 nextStickyModelOptions,
2410 );
2411 stickyActiveProvider = normalizeProviderInstanceId(normalizedPersistedState.stickyProvider);
2412 }
2413
2414 return {
2415 draftsByThreadKey: normalizePersistedDraftsByThreadId(
2416 normalizedPersistedState.draftsByThreadKey ?? normalizedPersistedState.draftsByThreadId,
2417 draftThreadsByThreadKey,
2418 ),
2419 draftThreadsByThreadKey,
2420 logicalProjectDraftThreadKeyByLogicalProjectKey,
2421 stickyModelSelectionByProvider: compactModelSelectionByProvider(stickyModelSelectionByProvider),
2422 stickyOptionsByModelByProvider:
2423 normalizedPersistedState.stickyOptionsByModelByProvider === undefined
2424 ? seedStickyOptionsByModel(stickyModelSelectionByProvider)
2425 : compactStickyOptionsByModel(normalizedPersistedState.stickyOptionsByModelByProvider),
2426 stickyActiveProvider,
2427 };
2428}
2429
2430function readPersistedAttachmentIdsFromStorage(threadKey: string): string[] {
2431 if (threadKey.length === 0) {
2432 return [];
2433 }
2434 try {
2435 const persisted = getLocalStorageItem(
2436 COMPOSER_DRAFT_STORAGE_KEY,
2437 PersistedComposerDraftStoreStorage,
2438 );
2439 if (!persisted || persisted.version !== COMPOSER_DRAFT_STORAGE_VERSION) {
2440 return [];
2441 }
2442 return (persisted.state.draftsByThreadKey[threadKey]?.attachments ?? []).map(
2443 (attachment) => attachment.id,
2444 );
2445 } catch {
2446 return [];
2447 }
2448}
2449
2450function verifyPersistedAttachments(
2451 threadKey: string,
2452 attachments: PersistedComposerImageAttachment[],
2453 set: (
2454 partial:
2455 | ComposerDraftStoreState
2456 | Partial<ComposerDraftStoreState>
2457 | ((
2458 state: ComposerDraftStoreState,
2459 ) => ComposerDraftStoreState | Partial<ComposerDraftStoreState>),
2460 replace?: false,
2461 ) => void,
2462): void {
2463 let persistedIdSet = new Set<string>();
2464 try {
2465 composerDebouncedStorage.flush();
2466 persistedIdSet = new Set(readPersistedAttachmentIdsFromStorage(threadKey));
2467 } catch {
2468 persistedIdSet = new Set();
2469 }
2470 set((state) => {
2471 const current = state.draftsByThreadKey[threadKey];
2472 if (!current) {
2473 return state;
2474 }
2475 const imageIdSet = new Set(current.images.map((image) => image.id));
2476 const persistedAttachments = attachments.filter(
2477 (attachment) => imageIdSet.has(attachment.id) && persistedIdSet.has(attachment.id),
2478 );
2479 const nonPersistedImageIds: string[] = [];
2480 for (const image of current.images) {
2481 if (!persistedIdSet.has(image.id)) {
2482 nonPersistedImageIds.push(image.id);
2483 }
2484 }
2485 const nextDraft: ComposerThreadDraftState = {
2486 ...current,
2487 persistedAttachments,
2488 nonPersistedImageIds,
2489 };
2490 const nextDraftsByThreadKey = { ...state.draftsByThreadKey };
2491 if (shouldRemoveDraft(nextDraft)) {
2492 delete nextDraftsByThreadKey[threadKey];
2493 } else {
2494 nextDraftsByThreadKey[threadKey] = nextDraft;
2495 }
2496 return { draftsByThreadKey: nextDraftsByThreadKey };
2497 });
2498}
2499
2500function hydratePersistedComposerImageAttachment(
2501 attachment: PersistedComposerImageAttachment,
2502): File | null {
2503 const commaIndex = attachment.dataUrl.indexOf(",");
2504 const header = commaIndex === -1 ? attachment.dataUrl : attachment.dataUrl.slice(0, commaIndex);
2505 const payload = commaIndex === -1 ? "" : attachment.dataUrl.slice(commaIndex + 1);
2506 if (payload.length === 0) {
2507 return null;
2508 }
2509 try {
2510 const isBase64 = header.includes(";base64");
2511 if (!isBase64) {
2512 const decodedText = decodeURIComponent(payload);
2513 const inferredMimeType =
2514 header.startsWith("data:") && header.includes(";")
2515 ? header.slice("data:".length, header.indexOf(";"))
2516 : attachment.mimeType;
2517 return new File([decodedText], attachment.name, {
2518 type: inferredMimeType || attachment.mimeType,
2519 });
2520 }
2521 const binary = atob(payload);
2522 const bytes = new Uint8Array(binary.length);
2523 for (let index = 0; index < binary.length; index += 1) {
2524 bytes[index] = binary.charCodeAt(index);
2525 }
2526 return new File([bytes], attachment.name, { type: attachment.mimeType });
2527 } catch {
2528 return null;
2529 }
2530}
2531
2532export function hydrateImagesFromPersisted(
2533 attachments: ReadonlyArray<PersistedComposerImageAttachment>,
2534): ComposerImageAttachment[] {
2535 return attachments.flatMap((attachment) => {
2536 const file = hydratePersistedComposerImageAttachment(attachment);
2537 if (!file) return [];
2538
2539 return [
2540 {
2541 type: "image" as const,
2542 id: attachment.id,
2543 name: attachment.name,
2544 mimeType: attachment.mimeType,
2545 sizeBytes: attachment.sizeBytes,
2546 previewUrl: attachment.dataUrl,
2547 file,
2548 ...(attachment.source ? { source: attachment.source } : {}),
2549 } satisfies ComposerImageAttachment,
2550 ];
2551 });
2552}
2553
2554function toHydratedThreadDraft(
2555 persistedDraft: PersistedComposerThreadDraftState,
2556): ComposerThreadDraftState {
2557 // The persisted draft is already in v3 shape (migration handles older formats)
2558 const modelSelectionByProvider: Partial<Record<ProviderInstanceId, ModelSelection>> =
2559 persistedDraft.modelSelectionByProvider ?? {};
2560 const activeProvider = normalizeProviderInstanceId(persistedDraft.activeProvider) ?? null;
2561 const files: ComposerFileAttachment[] =
2562 persistedDraft.files?.map((file) => ({
2563 type: "file" as const,
2564 id: file.id,
2565 name: file.name,
2566 mimeType: file.mimeType,
2567 sizeBytes: file.sizeBytes,
2568 file: null,
2569 ...(file.source ? { source: file.source } : {}),
2570 // A marker without an attachment id hydrates as needs-reattach: no
2571 // bytes, no server-side upload, only the metadata to tell the user
2572 // what to attach again.
2573 ...(file.attachmentId !== undefined && file.environmentId !== undefined
2574 ? { uploadedAttachmentId: file.attachmentId, uploadEnvironmentId: file.environmentId }
2575 : {}),
2576 })) ?? [];
2577
2578 return {
2579 // Files predating inline references get a chip appended; images stay shelf-only.
2580 prompt: ensureInlineContextReferences(persistedDraft.prompt, [
2581 ...(persistedDraft.reviewComments ?? []).map(reviewCommentContextReference),
2582 ...(persistedDraft.previewAnnotations ?? []).map(previewAnnotationContextReference),
2583 ...(persistedDraft.threadContexts ?? []).map(threadContextReference),
2584 ...files.map(fileContextReference),
2585 ]),
2586 images: hydrateImagesFromPersisted(persistedDraft.attachments),
2587 files,
2588 nonPersistedImageIds: [],
2589 persistedAttachments: [...persistedDraft.attachments],
2590 terminalContexts:
2591 persistedDraft.terminalContexts?.map((context) => ({
2592 ...context,
2593 text: context.text ?? "",
2594 })) ?? [],
2595 previewAnnotations:
2596 persistedDraft.previewAnnotations?.map((annotation) => ({ ...annotation })) ?? [],
2597 reviewComments: persistedDraft.reviewComments?.map((comment) => ({ ...comment })) ?? [],
2598 threadContexts: persistedDraft.threadContexts?.map((record) => ({ ...record })) ?? [],
2599 modelSelectionByProvider,
2600 activeProvider,
2601 ...(persistedDraft.modelSelectionExplicit ? { modelSelectionExplicit: true } : {}),
2602 runtimeMode: persistedDraft.runtimeMode ?? null,
2603 interactionMode: persistedDraft.interactionMode ?? null,
2604 };
2605}
2606
2607function toHydratedDraftThreadState(
2608 persistedDraftThread: PersistedDraftThreadState,
2609): DraftThreadState {
2610 return {
2611 threadId: persistedDraftThread.threadId,
2612 environmentId: persistedDraftThread.environmentId as EnvironmentId,
2613 projectId: persistedDraftThread.projectId,
2614 logicalProjectKey:
2615 persistedDraftThread.logicalProjectKey ??
2616 projectDraftKey(
2617 scopeProjectRef(
2618 persistedDraftThread.environmentId as EnvironmentId,
2619 persistedDraftThread.projectId,
2620 ),
2621 ),
2622 createdAt: persistedDraftThread.createdAt,
2623 runtimeMode: persistedDraftThread.runtimeMode,
2624 interactionMode: persistedDraftThread.interactionMode,
2625 branch: persistedDraftThread.branch,
2626 worktreePath: persistedDraftThread.worktreePath,
2627 envMode: persistedDraftThread.envMode,
2628 startFromOrigin: persistedDraftThread.startFromOrigin,
2629 ...(persistedDraftThread.environmentSelection
2630 ? { environmentSelection: persistedDraftThread.environmentSelection }
2631 : {}),
2632 ...(persistedDraftThread.loadBalancedEnvironmentId !== undefined
2633 ? {
2634 loadBalancedEnvironmentId:
2635 persistedDraftThread.loadBalancedEnvironmentId as EnvironmentId | null,
2636 }
2637 : {}),
2638 promotedTo: persistedDraftThread.promotedTo
2639 ? scopeThreadRef(
2640 persistedDraftThread.promotedTo.environmentId as EnvironmentId,
2641 persistedDraftThread.promotedTo.threadId as ThreadId,
2642 )
2643 : null,
2644 };
2645}
2646
2647const composerDraftStore = create<ComposerDraftStoreState>()(
2648 persist(
2649 (setBase, get) => {
2650 const set = setBase;
2651
2652 return {
2653 draftsByThreadKey: {},
2654 draftThreadsByThreadKey: {},
2655 logicalProjectDraftThreadKeyByLogicalProjectKey: {},
2656 backgroundSubmissionThreadKeys: {},
2657 rewindingThreadKeys: new Set<string>(),
2658 stickyModelSelectionByProvider: {},
2659 stickyOptionsByModelByProvider: {},
2660 stickyActiveProvider: null,
2661 getComposerDraft: (target) => getComposerDraftState(get(), target),
2662 getDraftThreadByLogicalProjectKey: (logicalProjectKey) => {
2663 return get().getDraftSessionByLogicalProjectKey(logicalProjectKey);
2664 },
2665 getDraftSessionByLogicalProjectKey: (logicalProjectKey) => {
2666 const normalizedLogicalProjectKey = logicalProjectDraftKey(logicalProjectKey);
2667 if (normalizedLogicalProjectKey.length === 0) {
2668 return null;
2669 }
2670 const draftId =
2671 get().logicalProjectDraftThreadKeyByLogicalProjectKey[normalizedLogicalProjectKey];
2672 if (!draftId) {
2673 return null;
2674 }
2675 const draftThread = get().draftThreadsByThreadKey[draftId];
2676 if (!draftThread || isDraftThreadPromoting(draftThread)) {
2677 return null;
2678 }
2679 return toProjectDraftSession(DraftId.make(draftId), draftThread);
2680 },
2681 getDraftThreadByProjectRef: (projectRef) => {
2682 return get().getDraftSessionByProjectRef(projectRef);
2683 },
2684 getDraftSessionByProjectRef: (projectRef) => {
2685 const state = get();
2686 // Mapped drafts win: a project can also own older unmapped drafts
2687 // (invested ones left behind by a remap), but "the" draft for a
2688 // project is the one new-thread flows currently target.
2689 for (const draftId of Object.values(
2690 state.logicalProjectDraftThreadKeyByLogicalProjectKey,
2691 )) {
2692 const draftThread = state.draftThreadsByThreadKey[draftId];
2693 if (!draftThread || isDraftThreadPromoting(draftThread)) {
2694 continue;
2695 }
2696 if (
2697 draftThread.projectId === projectRef.projectId &&
2698 draftThread.environmentId === projectRef.environmentId
2699 ) {
2700 return toProjectDraftSession(DraftId.make(draftId), draftThread);
2701 }
2702 }
2703 for (const [draftId, draftThread] of Object.entries(state.draftThreadsByThreadKey)) {
2704 if (isDraftThreadPromoting(draftThread)) {
2705 continue;
2706 }
2707 if (
2708 draftThread.projectId === projectRef.projectId &&
2709 draftThread.environmentId === projectRef.environmentId
2710 ) {
2711 return toProjectDraftSession(DraftId.make(draftId), draftThread);
2712 }
2713 }
2714 return null;
2715 },
2716 getDraftSession: (draftId) => get().draftThreadsByThreadKey[draftId] ?? null,
2717 getDraftSessionByRef: (threadRef) => {
2718 for (const draftSession of Object.values(get().draftThreadsByThreadKey)) {
2719 if (
2720 draftSession.environmentId === threadRef.environmentId &&
2721 draftSession.threadId === threadRef.threadId
2722 ) {
2723 return draftSession;
2724 }
2725 }
2726 return null;
2727 },
2728 getDraftIdByRef: (threadRef) => {
2729 for (const [draftId, draftSession] of Object.entries(get().draftThreadsByThreadKey)) {
2730 if (
2731 draftSession.environmentId === threadRef.environmentId &&
2732 draftSession.threadId === threadRef.threadId
2733 ) {
2734 return DraftId.make(draftId);
2735 }
2736 }
2737 return null;
2738 },
2739 getDraftThread: (threadRef) => {
2740 if (typeof threadRef === "string") {
2741 return get().getDraftSession(DraftId.make(threadRef));
2742 }
2743 return get().getDraftSessionByRef(threadRef);
2744 },
2745 getDraftThreadByRef: (threadRef) => {
2746 return get().getDraftSessionByRef(threadRef);
2747 },
2748 listDraftThreadKeys: () =>
2749 Object.values(get().draftThreadsByThreadKey).map((draftThread) =>
2750 scopedThreadKey(scopeThreadRef(draftThread.environmentId, draftThread.threadId)),
2751 ),
2752 hasDraftThreadsInEnvironment: (environmentId) =>
2753 Object.values(get().draftThreadsByThreadKey).some(
2754 (draftThread) => draftThread.environmentId === environmentId,
2755 ),
2756 setLogicalProjectDraftThreadId: (logicalProjectKey, projectRef, draftId, options) => {
2757 const normalizedLogicalProjectKey = logicalProjectDraftKey(logicalProjectKey);
2758 if (normalizedLogicalProjectKey.length === 0 || draftId.length === 0) {
2759 return;
2760 }
2761 set((state) => {
2762 const existingThread = state.draftThreadsByThreadKey[draftId];
2763 const previousThreadKeyForLogicalProject =
2764 state.logicalProjectDraftThreadKeyByLogicalProjectKey[normalizedLogicalProjectKey];
2765 const nextDraftThread = createDraftThreadState(
2766 projectRef,
2767 options?.threadId ?? existingThread?.threadId ?? ThreadId.make(draftId),
2768 normalizedLogicalProjectKey,
2769 existingThread,
2770 options,
2771 );
2772 const hasSameLogicalMapping = previousThreadKeyForLogicalProject === draftId;
2773 const hasNoStaleMappingsForDraft = Object.entries(
2774 state.logicalProjectDraftThreadKeyByLogicalProjectKey,
2775 ).every(
2776 ([logicalKey, mappedDraftId]) =>
2777 mappedDraftId !== draftId || logicalKey === normalizedLogicalProjectKey,
2778 );
2779 if (
2780 hasSameLogicalMapping &&
2781 hasNoStaleMappingsForDraft &&
2782 draftThreadsEqual(existingThread, nextDraftThread)
2783 ) {
2784 return state;
2785 }
2786 // A draft session belongs to one logical project at a time. When
2787 // an open draft is retargeted in place, remove any old mapping
2788 // for that same draft so the previous project cannot resolve it.
2789 const nextLogicalProjectDraftThreadKeyByLogicalProjectKey: Record<string, string> =
2790 Object.fromEntries(
2791 Object.entries(state.logicalProjectDraftThreadKeyByLogicalProjectKey).filter(
2792 ([logicalKey, mappedDraftId]) =>
2793 mappedDraftId !== draftId || logicalKey === normalizedLogicalProjectKey,
2794 ),
2795 );
2796 nextLogicalProjectDraftThreadKeyByLogicalProjectKey[normalizedLogicalProjectKey] =
2797 draftId;
2798 const nextDraftThreadsByThreadKey: Record<string, DraftThreadState> = {
2799 ...state.draftThreadsByThreadKey,
2800 [draftId]: nextDraftThread,
2801 };
2802 const existingDraft = state.draftsByThreadKey[draftId];
2803 let nextDraftsByThreadKey = state.draftsByThreadKey;
2804 if (
2805 existingThread &&
2806 existingThread.environmentId !== projectRef.environmentId &&
2807 existingDraft !== undefined
2808 ) {
2809 const nextDraft = clearStaleFileUploadMetadata(
2810 existingDraft,
2811 projectRef.environmentId,
2812 );
2813 if (nextDraft !== existingDraft) {
2814 nextDraftsByThreadKey = {
2815 ...state.draftsByThreadKey,
2816 [draftId]: nextDraft,
2817 };
2818 }
2819 }
2820 const previousDraftThread =
2821 previousThreadKeyForLogicalProject === undefined
2822 ? undefined
2823 : nextDraftThreadsByThreadKey[previousThreadKeyForLogicalProject];
2824 // A remap only garbage-collects the previous draft when the user
2825 // never invested content in it. A draft with typed text or
2826 // attachments stays alive unmapped — the sidebar draft rows list
2827 // every such session, so "new thread" can mint a fresh draft
2828 // without destroying the one the user walked away from.
2829 if (
2830 previousThreadKeyForLogicalProject &&
2831 previousThreadKeyForLogicalProject !== draftId &&
2832 !isComposerThreadKeyInUse(
2833 nextLogicalProjectDraftThreadKeyByLogicalProjectKey,
2834 previousThreadKeyForLogicalProject,
2835 ) &&
2836 !isDraftThreadPromoting(previousDraftThread) &&
2837 !composerDraftHasUserContent(
2838 state.draftsByThreadKey[previousThreadKeyForLogicalProject],
2839 ) &&
2840 !draftThreadHasOpenPreview(previousDraftThread)
2841 ) {
2842 delete nextDraftThreadsByThreadKey[previousThreadKeyForLogicalProject];
2843 if (state.draftsByThreadKey[previousThreadKeyForLogicalProject] !== undefined) {
2844 nextDraftsByThreadKey = { ...nextDraftsByThreadKey };
2845 delete nextDraftsByThreadKey[previousThreadKeyForLogicalProject];
2846 }
2847 }
2848 return {
2849 draftsByThreadKey: nextDraftsByThreadKey,
2850 draftThreadsByThreadKey: nextDraftThreadsByThreadKey,
2851 logicalProjectDraftThreadKeyByLogicalProjectKey:
2852 nextLogicalProjectDraftThreadKeyByLogicalProjectKey,
2853 };
2854 });
2855 },
2856 setProjectDraftThreadId: (projectRef, draftId, options) => {
2857 get().setLogicalProjectDraftThreadId(
2858 projectDraftKey(projectRef),
2859 projectRef,
2860 draftId,
2861 options,
2862 );
2863 },
2864 setDraftThreadContext: (threadRef, options) => {
2865 const threadKey = resolveComposerDraftKey(get(), threadRef) ?? "";
2866 if (threadKey.length === 0) {
2867 return;
2868 }
2869 set((state) => {
2870 const existing = state.draftThreadsByThreadKey[threadKey];
2871 if (!existing) {
2872 return state;
2873 }
2874 const nextProjectRef = options.projectRef ?? {
2875 environmentId: existing.environmentId,
2876 projectId: existing.projectId,
2877 };
2878 if (
2879 nextProjectRef.projectId.length === 0 ||
2880 nextProjectRef.environmentId.length === 0
2881 ) {
2882 return state;
2883 }
2884 // Mirrors createDraftThreadState: a project/environment change
2885 // drops machine-specific context (branch, worktree path) but
2886 // keeps the user's env mode and start-from-origin intent.
2887 const projectChanged =
2888 nextProjectRef.environmentId !== existing.environmentId ||
2889 nextProjectRef.projectId !== existing.projectId;
2890 const nextWorktreePath =
2891 options.worktreePath === undefined
2892 ? projectChanged
2893 ? null
2894 : existing.worktreePath
2895 : (options.worktreePath ?? null);
2896 const nextBranch =
2897 options.branch === undefined
2898 ? projectChanged
2899 ? null
2900 : existing.branch
2901 : (options.branch ?? null);
2902 const nextStartFromOrigin =
2903 options.startFromOrigin === undefined
2904 ? existing.startFromOrigin
2905 : options.startFromOrigin;
2906 const environmentSelection =
2907 options.environmentSelection ??
2908 (options.branch != null || options.worktreePath != null
2909 ? "manual"
2910 : existing.environmentSelection);
2911 const nextDraftThread: DraftThreadState = {
2912 threadId: existing.threadId,
2913 environmentId: nextProjectRef.environmentId,
2914 projectId: nextProjectRef.projectId,
2915 logicalProjectKey: existing.logicalProjectKey,
2916 ...(environmentSelection ? { environmentSelection } : {}),
2917 loadBalancedEnvironmentId:
2918 options.loadBalancedEnvironmentId === undefined
2919 ? projectChanged
2920 ? null
2921 : (existing.loadBalancedEnvironmentId ?? null)
2922 : options.loadBalancedEnvironmentId,
2923 createdAt:
2924 options.createdAt === undefined
2925 ? existing.createdAt
2926 : options.createdAt || existing.createdAt,
2927 runtimeMode: options.runtimeMode ?? existing.runtimeMode,
2928 interactionMode: options.interactionMode ?? existing.interactionMode,
2929 branch: nextBranch,
2930 worktreePath: nextWorktreePath,
2931 envMode:
2932 options.envMode ?? (nextWorktreePath ? "worktree" : (existing.envMode ?? "local")),
2933 startFromOrigin: nextStartFromOrigin,
2934 promotedTo: existing.promotedTo ?? null,
2935 };
2936 const isUnchanged =
2937 nextDraftThread.environmentId === existing.environmentId &&
2938 nextDraftThread.projectId === existing.projectId &&
2939 nextDraftThread.logicalProjectKey === existing.logicalProjectKey &&
2940 nextDraftThread.environmentSelection === existing.environmentSelection &&
2941 nextDraftThread.loadBalancedEnvironmentId === existing.loadBalancedEnvironmentId &&
2942 nextDraftThread.createdAt === existing.createdAt &&
2943 nextDraftThread.runtimeMode === existing.runtimeMode &&
2944 nextDraftThread.interactionMode === existing.interactionMode &&
2945 nextDraftThread.branch === existing.branch &&
2946 nextDraftThread.worktreePath === existing.worktreePath &&
2947 nextDraftThread.envMode === existing.envMode &&
2948 nextDraftThread.startFromOrigin === existing.startFromOrigin &&
2949 scopedThreadRefsEqual(nextDraftThread.promotedTo, existing.promotedTo);
2950 if (isUnchanged) {
2951 return state;
2952 }
2953 return {
2954 draftThreadsByThreadKey: {
2955 ...state.draftThreadsByThreadKey,
2956 [threadKey]: nextDraftThread,
2957 },
2958 };
2959 });
2960 },
2961 clearProjectDraftThreadId: (projectRef) => {
2962 set((state) => {
2963 // A project can own several sessions (invested drafts survive
2964 // remaps unmapped), so project removal must sweep them all — a
2965 // leftover would render a sidebar row for a project that no
2966 // longer exists.
2967 const matchingThreadKeys = Object.entries(state.draftThreadsByThreadKey)
2968 .filter(
2969 ([, draftThread]) =>
2970 draftThread.projectId === projectRef.projectId &&
2971 draftThread.environmentId === projectRef.environmentId,
2972 )
2973 .map(([threadKey]) => threadKey);
2974 if (matchingThreadKeys.length === 0) {
2975 return state;
2976 }
2977 let nextState = {
2978 draftsByThreadKey: state.draftsByThreadKey,
2979 draftThreadsByThreadKey: state.draftThreadsByThreadKey,
2980 logicalProjectDraftThreadKeyByLogicalProjectKey:
2981 state.logicalProjectDraftThreadKeyByLogicalProjectKey,
2982 };
2983 for (const threadKey of matchingThreadKeys) {
2984 nextState = removeDraftThreadReferences(nextState, threadKey);
2985 }
2986 return nextState;
2987 });
2988 },
2989 clearProjectDraftThreadById: (projectRef, threadRef) => {
2990 const threadKey = resolveComposerDraftKey(get(), threadRef) ?? "";
2991 if (threadKey.length === 0) {
2992 return;
2993 }
2994 set((state) => {
2995 const draftThread = state.draftThreadsByThreadKey[threadKey];
2996 if (
2997 !draftThread ||
2998 draftThread.projectId !== projectRef.projectId ||
2999 draftThread.environmentId !== projectRef.environmentId
3000 ) {
3001 return state;
3002 }
3003 return removeDraftThreadReferences(state, threadKey);
3004 });
3005 },
3006 markDraftThreadPromoting: (threadRef, promotedTo) => {
3007 const threadKey = resolveComposerDraftKey(get(), threadRef);
3008 if (!threadKey) {
3009 return;
3010 }
3011 set((state) => {
3012 const existing = state.draftThreadsByThreadKey[threadKey];
3013 if (!existing) {
3014 return state;
3015 }
3016 const nextPromotedTo =
3017 promotedTo ?? scopeThreadRef(existing.environmentId, existing.threadId);
3018 if (scopedThreadRefsEqual(existing.promotedTo, nextPromotedTo)) {
3019 return state;
3020 }
3021 return {
3022 draftThreadsByThreadKey: {
3023 ...state.draftThreadsByThreadKey,
3024 [threadKey]: {
3025 ...existing,
3026 promotedTo: nextPromotedTo,
3027 },
3028 },
3029 };
3030 });
3031 },
3032 finalizePromotedDraftThread: (threadRef) => {
3033 const threadKey = resolveComposerDraftKey(get(), threadRef) ?? "";
3034 if (threadKey.length === 0) {
3035 return;
3036 }
3037 set((state) => {
3038 const existing = state.draftThreadsByThreadKey[threadKey];
3039 if (!existing || !isDraftThreadPromoting(existing)) {
3040 return state;
3041 }
3042 return removeDraftThreadReferences(state, threadKey, existing.promotedTo ?? undefined);
3043 });
3044 },
3045 clearDraftThread: (threadRef) => {
3046 const threadKey = resolveComposerDraftKey(get(), threadRef) ?? "";
3047 if (threadKey.length === 0) {
3048 return;
3049 }
3050 set((state) => {
3051 const hasDraftThread = state.draftThreadsByThreadKey[threadKey] !== undefined;
3052 const hasLogicalProjectMapping = Object.values(
3053 state.logicalProjectDraftThreadKeyByLogicalProjectKey,
3054 ).includes(threadKey);
3055 const hasComposerDraft = state.draftsByThreadKey[threadKey] !== undefined;
3056 if (!hasDraftThread && !hasLogicalProjectMapping && !hasComposerDraft) {
3057 return state;
3058 }
3059 return removeDraftThreadReferences(state, threadKey);
3060 });
3061 },
3062 setStickyModelSelection: (modelSelection) => {
3063 const normalized = normalizeModelSelection(modelSelection);
3064 set((state) => {
3065 if (!normalized) {
3066 return state;
3067 }
3068 const current = state.stickyModelSelectionByProvider[normalized.instanceId];
3069 // Model-only picker updates omit options (same contract as
3070 // setModelSelection). Keep the last sticky traits so Fast/Normal
3071 // survives Composer 2 → 2.5 and new chats.
3072 const nextSelection =
3073 normalized.options !== undefined
3074 ? normalized
3075 : createModelSelection(normalized.instanceId, normalized.model, current?.options);
3076 const nextMap: Partial<Record<ProviderInstanceId, ModelSelection>> = {
3077 ...state.stickyModelSelectionByProvider,
3078 [normalized.instanceId]: nextSelection,
3079 };
3080 if (Equal.equals(state.stickyModelSelectionByProvider, nextMap)) {
3081 return state.stickyActiveProvider === normalized.instanceId
3082 ? state
3083 : { stickyActiveProvider: normalized.instanceId };
3084 }
3085 return {
3086 stickyModelSelectionByProvider: nextMap,
3087 stickyActiveProvider: normalized.instanceId,
3088 };
3089 });
3090 },
3091 applyStickyState: (threadRef) => {
3092 const threadKey = resolveComposerDraftKey(get(), threadRef) ?? "";
3093 if (threadKey.length === 0) {
3094 return;
3095 }
3096 set((state) => {
3097 const stickyMap = state.stickyModelSelectionByProvider;
3098 const stickyActiveProvider = state.stickyActiveProvider;
3099 const existing = state.draftsByThreadKey[threadKey];
3100 const base = existing ?? createEmptyThreadDraft();
3101 const nextMap = compactModelSelectionByProvider(stickyMap);
3102 if (
3103 Equal.equals(base.modelSelectionByProvider, nextMap) &&
3104 base.activeProvider === stickyActiveProvider &&
3105 base.modelSelectionExplicit === undefined
3106 ) {
3107 return state;
3108 }
3109 const { modelSelectionExplicit: _modelSelectionExplicit, ...retained } = base;
3110 const nextDraft: ComposerThreadDraftState = {
3111 ...retained,
3112 modelSelectionByProvider: nextMap,
3113 activeProvider: stickyActiveProvider,
3114 };
3115 const nextDraftsByThreadKey = { ...state.draftsByThreadKey };
3116 if (shouldRemoveDraft(nextDraft)) {
3117 delete nextDraftsByThreadKey[threadKey];
3118 } else {
3119 nextDraftsByThreadKey[threadKey] = nextDraft;
3120 }
3121 return { draftsByThreadKey: nextDraftsByThreadKey };
3122 });
3123 },
3124 setPrompt: (threadRef, prompt) => {
3125 const threadKey = resolveComposerDraftKey(get(), threadRef) ?? "";
3126 if (threadKey.length === 0) {
3127 return;
3128 }
3129 set((state) => {
3130 const existing = state.draftsByThreadKey[threadKey] ?? createEmptyThreadDraft();
3131 const nextDraft: ComposerThreadDraftState = {
3132 ...existing,
3133 prompt,
3134 };
3135 const nextDraftsByThreadKey = { ...state.draftsByThreadKey };
3136 if (shouldRemoveDraft(nextDraft)) {
3137 delete nextDraftsByThreadKey[threadKey];
3138 } else {
3139 nextDraftsByThreadKey[threadKey] = nextDraft;
3140 }
3141 return { draftsByThreadKey: nextDraftsByThreadKey };
3142 });
3143 },
3144 setTerminalContexts: (threadRef, contexts) => {
3145 const threadKey = resolveComposerDraftKey(get(), threadRef);
3146 const threadId = resolveComposerThreadId(get(), threadRef);
3147 if (!threadKey || !threadId) {
3148 return;
3149 }
3150 const normalizedContexts = normalizeTerminalContextsForThread(threadId, contexts);
3151 set((state) => {
3152 const existing = state.draftsByThreadKey[threadKey] ?? createEmptyThreadDraft();
3153 const nextDraft: ComposerThreadDraftState = {
3154 ...existing,
3155 prompt: ensureInlineContextReferences(
3156 existing.terminalContexts
3157 .filter((context) => !normalizedContexts.some((next) => next.id === context.id))
3158 .reduce(
3159 (prompt, context) =>
3160 removeInlineContextReference(
3161 prompt,
3162 terminalContextReference(context).contextId,
3163 ).prompt,
3164 existing.prompt,
3165 ),
3166 normalizedContexts.map(terminalContextReference),
3167 ),
3168 terminalContexts: normalizedContexts,
3169 };
3170 const nextDraftsByThreadKey = { ...state.draftsByThreadKey };
3171 if (shouldRemoveDraft(nextDraft)) {
3172 delete nextDraftsByThreadKey[threadKey];
3173 } else {
3174 nextDraftsByThreadKey[threadKey] = nextDraft;
3175 }
3176 return { draftsByThreadKey: nextDraftsByThreadKey };
3177 });
3178 },
3179 setModelSelection: (threadRef, modelSelection, opts) => {
3180 const threadKey = resolveComposerDraftKey(get(), threadRef) ?? "";
3181 if (threadKey.length === 0) {
3182 return;
3183 }
3184 const normalized = normalizeModelSelection(modelSelection);
3185 set((state) => {
3186 const existing = state.draftsByThreadKey[threadKey];
3187 if (!existing && normalized === null) {
3188 return state;
3189 }
3190 const base = existing ?? createEmptyThreadDraft();
3191 const nextMap = { ...base.modelSelectionByProvider };
3192 if (normalized) {
3193 const current = nextMap[normalized.instanceId];
3194 if (normalized.options !== undefined || opts?.replaceOptions) {
3195 // Explicit options provided (or the caller passed a complete
3196 // snapshot whose absent options mean "no options") → use the
3197 // selection as-is.
3198 nextMap[normalized.instanceId] = normalized as ModelSelection;
3199 } else {
3200 // No options in selection → preserve existing options, update provider+model
3201 nextMap[normalized.instanceId] = createModelSelection(
3202 normalized.instanceId,
3203 normalized.model,
3204 current?.options,
3205 );
3206 }
3207 }
3208 const nextActiveProvider = normalized?.instanceId ?? base.activeProvider;
3209 if (
3210 Equal.equals(base.modelSelectionByProvider, nextMap) &&
3211 base.activeProvider === nextActiveProvider &&
3212 (base.modelSelectionExplicit ?? false) === (opts?.explicit === true)
3213 ) {
3214 return state;
3215 }
3216 // Last writer defines intent: picker writes mark the selection
3217 // explicit; seeding writes leave it unset so future seeds can
3218 // replace it.
3219 const { modelSelectionExplicit: _previousExplicit, ...restBase } = base;
3220 const nextDraft: ComposerThreadDraftState = {
3221 ...restBase,
3222 modelSelectionByProvider: nextMap,
3223 activeProvider: nextActiveProvider,
3224 ...(opts?.explicit === true ? { modelSelectionExplicit: true as const } : {}),
3225 };
3226 const nextDraftsByThreadKey = { ...state.draftsByThreadKey };
3227 if (shouldRemoveDraft(nextDraft)) {
3228 delete nextDraftsByThreadKey[threadKey];
3229 } else {
3230 nextDraftsByThreadKey[threadKey] = nextDraft;
3231 }
3232 return { draftsByThreadKey: nextDraftsByThreadKey };
3233 });
3234 },
3235 setModelOptions: (threadRef, modelOptions) => {
3236 const threadKey = resolveComposerDraftKey(get(), threadRef) ?? "";
3237 if (threadKey.length === 0) {
3238 return;
3239 }
3240 set((state) => {
3241 const existing = state.draftsByThreadKey[threadKey];
3242 if (!existing && (!modelOptions || Object.keys(modelOptions).length === 0)) {
3243 return state;
3244 }
3245 const base = existing ?? createEmptyThreadDraft();
3246 const nextMap = { ...base.modelSelectionByProvider };
3247 for (const provider of ["codex", "claudeAgent", "cursor", "opencode"] as const) {
3248 if (!modelOptions || !(provider in modelOptions)) continue;
3249 const opts = modelOptions[provider];
3250 const driverKind = ProviderDriverKind.make(provider);
3251 const instanceKey = defaultInstanceIdForDriver(driverKind);
3252 const current = nextMap[instanceKey];
3253 if (opts && opts.length > 0) {
3254 nextMap[instanceKey] = createModelSelection(
3255 instanceKey,
3256 current?.model ?? DEFAULT_MODEL_BY_PROVIDER[driverKind] ?? DEFAULT_MODEL,
3257 opts,
3258 );
3259 } else if (current?.options) {
3260 const { options: _, ...rest } = current;
3261 nextMap[instanceKey] = rest as ModelSelection;
3262 }
3263 }
3264 if (Equal.equals(base.modelSelectionByProvider, nextMap)) {
3265 return state;
3266 }
3267 const nextDraft: ComposerThreadDraftState = {
3268 ...base,
3269 modelSelectionByProvider: nextMap,
3270 };
3271 const nextDraftsByThreadKey = { ...state.draftsByThreadKey };
3272 if (shouldRemoveDraft(nextDraft)) {
3273 delete nextDraftsByThreadKey[threadKey];
3274 } else {
3275 nextDraftsByThreadKey[threadKey] = nextDraft;
3276 }
3277 return { draftsByThreadKey: nextDraftsByThreadKey };
3278 });
3279 },
3280 setProviderModelOptions: (threadRef, provider, nextProviderOptions, options) => {
3281 const threadKey = resolveComposerDraftKey(get(), threadRef) ?? "";
3282 if (threadKey.length === 0) {
3283 return;
3284 }
3285 const normalizedProvider = normalizeProviderDriverKind(provider);
3286 if (normalizedProvider === null) {
3287 return;
3288 }
3289 const instanceKey = options?.instanceId ?? defaultInstanceIdForDriver(normalizedProvider);
3290 const fallbackModel =
3291 normalizeModelSlug(options?.model, normalizedProvider) ??
3292 DEFAULT_MODEL_BY_PROVIDER[normalizedProvider] ??
3293 DEFAULT_MODEL;
3294 const providerOpts =
3295 nextProviderOptions && nextProviderOptions.length > 0 ? nextProviderOptions : undefined;
3296
3297 set((state) => {
3298 const existing = state.draftsByThreadKey[threadKey];
3299 const base = existing ?? createEmptyThreadDraft();
3300
3301 // Update the map entry for this provider
3302 const nextMap = { ...base.modelSelectionByProvider };
3303 const currentForProvider = nextMap[instanceKey];
3304 if (providerOpts) {
3305 nextMap[instanceKey] = createModelSelection(
3306 instanceKey,
3307 currentForProvider?.model ?? fallbackModel,
3308 providerOpts,
3309 );
3310 } else if (currentForProvider && (currentForProvider.options?.length ?? 0) > 0) {
3311 const { options: _, ...rest } = currentForProvider;
3312 nextMap[instanceKey] = rest as ModelSelection;
3313 }
3314
3315 // Handle sticky persistence
3316 let nextStickyMap = state.stickyModelSelectionByProvider;
3317 let nextOptionsByModel = state.stickyOptionsByModelByProvider;
3318 let nextStickyActiveProvider = state.stickyActiveProvider;
3319 if (options?.persistSticky === true) {
3320 nextStickyMap = { ...state.stickyModelSelectionByProvider };
3321 const stickyBase =
3322 nextStickyMap[instanceKey] ??
3323 base.modelSelectionByProvider[instanceKey] ??
3324 createModelSelection(instanceKey, fallbackModel);
3325 // Memory keys follow the model the caller is configuring
3326 // (TraitsPicker passes it explicitly); the sticky selection
3327 // itself keeps preserving its current model on trait changes.
3328 const rememberedModel =
3329 normalizeModelSlug(options?.model, normalizedProvider) ?? stickyBase.model;
3330 if (providerOpts) {
3331 nextStickyMap[instanceKey] = createModelSelection(
3332 instanceKey,
3333 stickyBase.model,
3334 providerOpts,
3335 );
3336 // Remember the pick for this model so switching models and
3337 // coming back restores it instead of another model's effort.
3338 nextOptionsByModel = {
3339 ...state.stickyOptionsByModelByProvider,
3340 [instanceKey]: {
3341 ...state.stickyOptionsByModelByProvider[instanceKey],
3342 [rememberedModel]: providerOpts,
3343 },
3344 };
3345 } else if ((stickyBase.options?.length ?? 0) > 0) {
3346 const { options: _, ...rest } = stickyBase;
3347 nextStickyMap[instanceKey] = rest as ModelSelection;
3348 const rememberedByModel = {
3349 ...state.stickyOptionsByModelByProvider[instanceKey],
3350 };
3351 delete rememberedByModel[rememberedModel];
3352 nextOptionsByModel = {
3353 ...state.stickyOptionsByModelByProvider,
3354 [instanceKey]: rememberedByModel,
3355 };
3356 }
3357 nextStickyActiveProvider = options.instanceId
3358 ? instanceKey
3359 : (base.activeProvider ?? instanceKey);
3360 }
3361
3362 if (
3363 Equal.equals(base.modelSelectionByProvider, nextMap) &&
3364 Equal.equals(state.stickyModelSelectionByProvider, nextStickyMap) &&
3365 Equal.equals(state.stickyOptionsByModelByProvider, nextOptionsByModel) &&
3366 state.stickyActiveProvider === nextStickyActiveProvider
3367 ) {
3368 return state;
3369 }
3370
3371 // Trait edits are user-driven intent: mark the selection explicit
3372 // so later seeds cannot silently replace the chosen options.
3373 const { modelSelectionExplicit: _previousExplicit, ...restBase } = base;
3374 const nextDraft: ComposerThreadDraftState = {
3375 ...restBase,
3376 ...(options?.instanceId ? { activeProvider: instanceKey } : {}),
3377 modelSelectionByProvider: nextMap,
3378 modelSelectionExplicit: true,
3379 };
3380 const nextDraftsByThreadKey = { ...state.draftsByThreadKey };
3381 if (shouldRemoveDraft(nextDraft)) {
3382 delete nextDraftsByThreadKey[threadKey];
3383 } else {
3384 nextDraftsByThreadKey[threadKey] = nextDraft;
3385 }
3386
3387 return {
3388 draftsByThreadKey: nextDraftsByThreadKey,
3389 ...(options?.persistSticky === true
3390 ? {
3391 stickyModelSelectionByProvider: nextStickyMap,
3392 stickyOptionsByModelByProvider: nextOptionsByModel,
3393 stickyActiveProvider: nextStickyActiveProvider,
3394 }
3395 : {}),
3396 };
3397 });
3398 },
3399 setRuntimeMode: (threadRef, runtimeMode) => {
3400 const threadKey = resolveComposerDraftKey(get(), threadRef) ?? "";
3401 if (threadKey.length === 0) {
3402 return;
3403 }
3404 const nextRuntimeMode = isRuntimeMode(runtimeMode) ? runtimeMode : null;
3405 set((state) => {
3406 const existing = state.draftsByThreadKey[threadKey];
3407 if (!existing && nextRuntimeMode === null) {
3408 return state;
3409 }
3410 const base = existing ?? createEmptyThreadDraft();
3411 if (base.runtimeMode === nextRuntimeMode) {
3412 return state;
3413 }
3414 const nextDraft: ComposerThreadDraftState = {
3415 ...base,
3416 runtimeMode: nextRuntimeMode,
3417 };
3418 const nextDraftsByThreadKey = { ...state.draftsByThreadKey };
3419 if (shouldRemoveDraft(nextDraft)) {
3420 delete nextDraftsByThreadKey[threadKey];
3421 } else {
3422 nextDraftsByThreadKey[threadKey] = nextDraft;
3423 }
3424 return { draftsByThreadKey: nextDraftsByThreadKey };
3425 });
3426 },
3427 setInteractionMode: (threadRef, interactionMode) => {
3428 const threadKey = resolveComposerDraftKey(get(), threadRef) ?? "";
3429 if (threadKey.length === 0) {
3430 return;
3431 }
3432 const nextInteractionMode =
3433 interactionMode === "plan" || interactionMode === "default" ? interactionMode : null;
3434 set((state) => {
3435 const existing = state.draftsByThreadKey[threadKey];
3436 if (!existing && nextInteractionMode === null) {
3437 return state;
3438 }
3439 const base = existing ?? createEmptyThreadDraft();
3440 if (base.interactionMode === nextInteractionMode) {
3441 return state;
3442 }
3443 const nextDraft: ComposerThreadDraftState = {
3444 ...base,
3445 interactionMode: nextInteractionMode,
3446 };
3447 const nextDraftsByThreadKey = { ...state.draftsByThreadKey };
3448 if (shouldRemoveDraft(nextDraft)) {
3449 delete nextDraftsByThreadKey[threadKey];
3450 } else {
3451 nextDraftsByThreadKey[threadKey] = nextDraft;
3452 }
3453 return { draftsByThreadKey: nextDraftsByThreadKey };
3454 });
3455 },
3456 addImage: (threadRef, image) => {
3457 const threadKey = resolveComposerDraftKey(get(), threadRef);
3458 const threadId = resolveComposerThreadId(get(), threadRef);
3459 if (!threadKey || !threadId) {
3460 return false;
3461 }
3462 const acceptedIds = get().addImages(
3463 typeof threadRef === "string" ? DraftId.make(threadKey) : threadRef,
3464 [image],
3465 );
3466 return acceptedIds.includes(image.id);
3467 },
3468 addImages: (threadRef, images, options) => {
3469 const threadKey = resolveComposerDraftKey(get(), threadRef) ?? "";
3470 if (threadKey.length === 0 || images.length === 0) {
3471 return [];
3472 }
3473 let acceptedIds: string[] = [];
3474 set((state) => {
3475 const existing = state.draftsByThreadKey[threadKey] ?? createEmptyThreadDraft();
3476 const existingIds = new Set(existing.images.map((image) => image.id));
3477 const existingDedupKeys = new Set(
3478 existing.images.map((image) => composerImageDedupKey(image)),
3479 );
3480 const acceptedPreviewUrls = new Set(existing.images.map((image) => image.previewUrl));
3481 const dedupedIncoming: ComposerImageAttachment[] = [];
3482 for (const image of images) {
3483 const dedupKey = composerImageDedupKey(image);
3484 if (
3485 existingIds.has(image.id) ||
3486 (!options?.allowDuplicates && existingDedupKeys.has(dedupKey))
3487 ) {
3488 // Avoid revoking a blob URL that's still referenced by an accepted image.
3489 if (!acceptedPreviewUrls.has(image.previewUrl)) {
3490 revokeObjectPreviewUrl(image.previewUrl);
3491 }
3492 continue;
3493 }
3494 if (
3495 existing.images.length + existing.files.length + dedupedIncoming.length >=
3496 PROVIDER_SEND_TURN_MAX_ATTACHMENTS
3497 ) {
3498 if (!acceptedPreviewUrls.has(image.previewUrl)) {
3499 revokeObjectPreviewUrl(image.previewUrl);
3500 }
3501 continue;
3502 }
3503 dedupedIncoming.push(image);
3504 existingIds.add(image.id);
3505 existingDedupKeys.add(dedupKey);
3506 acceptedPreviewUrls.add(image.previewUrl);
3507 }
3508 if (dedupedIncoming.length === 0) {
3509 return state;
3510 }
3511 acceptedIds = dedupedIncoming.map((image) => image.id);
3512 return {
3513 draftsByThreadKey: {
3514 ...state.draftsByThreadKey,
3515 [threadKey]: {
3516 ...existing,
3517 images: [...existing.images, ...dedupedIncoming],
3518 },
3519 },
3520 };
3521 });
3522 return acceptedIds;
3523 },
3524 removeImage: (threadRef, imageId) => {
3525 const threadKey = resolveComposerDraftKey(get(), threadRef) ?? "";
3526 if (threadKey.length === 0) {
3527 return;
3528 }
3529 const existing = get().draftsByThreadKey[threadKey];
3530 if (!existing) {
3531 return;
3532 }
3533 const removedImage = existing.images.find((image) => image.id === imageId);
3534 if (removedImage) {
3535 revokeObjectPreviewUrl(removedImage.previewUrl);
3536 }
3537 set((state) => {
3538 const current = state.draftsByThreadKey[threadKey];
3539 if (!current) {
3540 return state;
3541 }
3542 const nextDraft: ComposerThreadDraftState = {
3543 ...current,
3544 prompt: removeInlineContextReference(
3545 current.prompt,
3546 toKindScopedComposerContextId("image", imageId),
3547 ).prompt,
3548 images: current.images.filter((image) => image.id !== imageId),
3549 nonPersistedImageIds: current.nonPersistedImageIds.filter((id) => id !== imageId),
3550 persistedAttachments: current.persistedAttachments.filter(
3551 (attachment) => attachment.id !== imageId,
3552 ),
3553 };
3554 const nextDraftsByThreadKey = { ...state.draftsByThreadKey };
3555 if (shouldRemoveDraft(nextDraft)) {
3556 delete nextDraftsByThreadKey[threadKey];
3557 } else {
3558 nextDraftsByThreadKey[threadKey] = nextDraft;
3559 }
3560 return { draftsByThreadKey: nextDraftsByThreadKey };
3561 });
3562 },
3563 addFiles: (threadRef, files, options) => {
3564 const threadKey = resolveComposerDraftKey(get(), threadRef) ?? "";
3565 if (threadKey.length === 0 || files.length === 0) {
3566 return [];
3567 }
3568 let acceptedIds: string[] = [];
3569 set((state) => {
3570 const existing = state.draftsByThreadKey[threadKey] ?? createEmptyThreadDraft();
3571 const knownIds = new Set(existing.files.map((file) => file.id));
3572 const knownFiles = new Map<string, ComposerFileAttachment>(
3573 existing.files.map((file) => [composerFileDedupKey(file), file]),
3574 );
3575 const accepted: ComposerFileAttachment[] = [];
3576 // Needs-reattach markers replaced in place by a re-pick, keyed by
3577 // the marker's id.
3578 const replacements = new Map<string, ComposerFileAttachment>();
3579 for (const file of files) {
3580 const key = composerFileDedupKey(file);
3581 if (knownIds.has(file.id)) {
3582 continue;
3583 }
3584 const duplicate = options?.allowDuplicates
3585 ? undefined
3586 : (knownFiles.get(key) ??
3587 existing.files.find(
3588 (candidate) =>
3589 composerFileNeedsReattach(candidate) &&
3590 !replacements.has(candidate.id) &&
3591 composerFileMatchesReattachMarker(candidate, file),
3592 ));
3593 if (duplicate) {
3594 // A needs-reattach marker is not a usable duplicate. Replace
3595 // it so the upload restarts.
3596 if (composerFileNeedsReattach(duplicate) && !replacements.has(duplicate.id)) {
3597 replacements.set(duplicate.id, file);
3598 knownIds.add(file.id);
3599 knownFiles.set(key, file);
3600 }
3601 continue;
3602 }
3603 if (
3604 existing.images.length + existing.files.length + accepted.length >=
3605 PROVIDER_SEND_TURN_MAX_ATTACHMENTS
3606 ) {
3607 break;
3608 }
3609 accepted.push(file);
3610 knownIds.add(file.id);
3611 knownFiles.set(key, file);
3612 }
3613 if (accepted.length === 0 && replacements.size === 0) {
3614 return state;
3615 }
3616 acceptedIds = accepted.map((file) => file.id);
3617 const retained = existing.files.map((file) => replacements.get(file.id) ?? file);
3618 // A replaced marker's chip follows the file to its new id.
3619 const prompt =
3620 replacements.size === 0
3621 ? existing.prompt
3622 : replaceComposerContextReferences(existing.prompt, (occurrence) => {
3623 const original = existing.files.find(
3624 (file) => fileContextReference(file).contextId === occurrence.contextId,
3625 );
3626 const replacement = original ? replacements.get(original.id) : undefined;
3627 return replacement
3628 ? formatInlineContextReference(fileContextReference(replacement))
3629 : occurrence.source;
3630 });
3631 return {
3632 draftsByThreadKey: {
3633 ...state.draftsByThreadKey,
3634 [threadKey]: {
3635 ...existing,
3636 prompt: options?.appendReference
3637 ? ensureInlineContextReferences(
3638 prompt,
3639 [...accepted, ...replacements.values()].map(fileContextReference),
3640 )
3641 : prompt,
3642 files: [...retained, ...accepted],
3643 },
3644 },
3645 };
3646 });
3647 return acceptedIds;
3648 },
3649 removeFile: (threadRef, fileId) => {
3650 const threadKey = resolveComposerDraftKey(get(), threadRef) ?? "";
3651 if (threadKey.length === 0) {
3652 return;
3653 }
3654 set((state) => {
3655 const current = state.draftsByThreadKey[threadKey];
3656 if (!current?.files.some((file) => file.id === fileId)) {
3657 return state;
3658 }
3659 const nextDraft = {
3660 ...current,
3661 prompt: removeInlineContextReference(
3662 current.prompt,
3663 toKindScopedComposerContextId("file", fileId),
3664 ).prompt,
3665 files: current.files.filter((file) => file.id !== fileId),
3666 } satisfies ComposerThreadDraftState;
3667 const nextDraftsByThreadKey = { ...state.draftsByThreadKey };
3668 if (shouldRemoveDraft(nextDraft)) {
3669 delete nextDraftsByThreadKey[threadKey];
3670 } else {
3671 nextDraftsByThreadKey[threadKey] = nextDraft;
3672 }
3673 return { draftsByThreadKey: nextDraftsByThreadKey };
3674 });
3675 },
3676 setFileUpload: (threadRef, fileId, environmentId, attachmentId) => {
3677 const threadKey = resolveComposerDraftKey(get(), threadRef) ?? "";
3678 if (threadKey.length === 0) {
3679 return;
3680 }
3681 set((state) => {
3682 const current = state.draftsByThreadKey[threadKey];
3683 const file = current?.files.find((entry) => entry.id === fileId);
3684 if (
3685 !current ||
3686 !file ||
3687 (file.uploadEnvironmentId === environmentId &&
3688 file.uploadedAttachmentId === attachmentId)
3689 ) {
3690 return state;
3691 }
3692 return {
3693 draftsByThreadKey: {
3694 ...state.draftsByThreadKey,
3695 [threadKey]: {
3696 ...current,
3697 files: current.files.map((entry) =>
3698 entry.id === fileId
3699 ? {
3700 ...entry,
3701 uploadedAttachmentId: attachmentId,
3702 uploadEnvironmentId: environmentId,
3703 }
3704 : entry,
3705 ),
3706 },
3707 },
3708 };
3709 });
3710 },
3711 markFileUploadMissing: (threadRef, fileId, environmentId, attachmentId) => {
3712 const threadKey = resolveComposerDraftKey(get(), threadRef) ?? "";
3713 if (threadKey.length === 0) {
3714 return false;
3715 }
3716 let markedMissing = false;
3717 set((state) => {
3718 const current = state.draftsByThreadKey[threadKey];
3719 const file = current?.files.find((entry) => entry.id === fileId);
3720 if (
3721 !current ||
3722 !file ||
3723 file.file !== null ||
3724 file.uploadEnvironmentId !== environmentId ||
3725 file.uploadedAttachmentId !== attachmentId
3726 ) {
3727 return state;
3728 }
3729 markedMissing = true;
3730 return {
3731 draftsByThreadKey: {
3732 ...state.draftsByThreadKey,
3733 [threadKey]: {
3734 ...current,
3735 files: current.files.map((entry) => {
3736 if (entry.id !== fileId) {
3737 return entry;
3738 }
3739 const marker = { ...entry };
3740 delete marker.uploadedAttachmentId;
3741 delete marker.uploadEnvironmentId;
3742 return marker;
3743 }),
3744 },
3745 },
3746 };
3747 });
3748 return markedMissing;
3749 },
3750 insertTerminalContext: (threadRef, prompt, context, index) => {
3751 const threadKey = resolveComposerDraftKey(get(), threadRef);
3752 const threadId = resolveComposerThreadId(get(), threadRef);
3753 if (!threadKey || !threadId) {
3754 return false;
3755 }
3756 let inserted = false;
3757 set((state) => {
3758 const existing = state.draftsByThreadKey[threadKey] ?? createEmptyThreadDraft();
3759 const normalizedContext = normalizeTerminalContextForThread(threadId, context);
3760 if (!normalizedContext) {
3761 return state;
3762 }
3763 const dedupKey = terminalContextDedupKey(normalizedContext);
3764 if (
3765 existing.terminalContexts.some((entry) => entry.id === normalizedContext.id) ||
3766 existing.terminalContexts.some((entry) => terminalContextDedupKey(entry) === dedupKey)
3767 ) {
3768 return state;
3769 }
3770 inserted = true;
3771 const boundedIndex = Math.max(0, Math.min(existing.terminalContexts.length, index));
3772 const nextDraft: ComposerThreadDraftState = {
3773 ...existing,
3774 prompt,
3775 terminalContexts: [
3776 ...existing.terminalContexts.slice(0, boundedIndex),
3777 normalizedContext,
3778 ...existing.terminalContexts.slice(boundedIndex),
3779 ],
3780 };
3781 return {
3782 draftsByThreadKey: {
3783 ...state.draftsByThreadKey,
3784 [threadKey]: nextDraft,
3785 },
3786 };
3787 });
3788 return inserted;
3789 },
3790 addTerminalContext: (threadRef, context) => {
3791 const threadKey = resolveComposerDraftKey(get(), threadRef);
3792 const threadId = resolveComposerThreadId(get(), threadRef);
3793 if (!threadKey || !threadId) {
3794 return;
3795 }
3796 get().addTerminalContexts(
3797 typeof threadRef === "string" ? DraftId.make(threadKey) : threadRef,
3798 [context],
3799 );
3800 },
3801 addTerminalContexts: (threadRef, contexts, options) => {
3802 const threadKey = resolveComposerDraftKey(get(), threadRef);
3803 const threadId = resolveComposerThreadId(get(), threadRef);
3804 if (!threadKey || !threadId || contexts.length === 0) {
3805 return;
3806 }
3807 const currentContexts = get().draftsByThreadKey[threadKey]?.terminalContexts ?? [];
3808 const incoming = normalizeTerminalContextsForThread(threadId, [
3809 ...currentContexts,
3810 ...contexts,
3811 ]).slice(currentContexts.length);
3812 if (incoming.length === 0) return;
3813 const placedAtCaret =
3814 options?.appendReference !== false &&
3815 options?.insertAtCaret !== false &&
3816 (contextInsertionHandlers.get(threadKey)?.(incoming.map(terminalContextReference)) ??
3817 false);
3818 set((state) => {
3819 const existing = state.draftsByThreadKey[threadKey] ?? createEmptyThreadDraft();
3820 const acceptedContexts = normalizeTerminalContextsForThread(threadId, [
3821 ...existing.terminalContexts,
3822 ...contexts,
3823 ]).slice(existing.terminalContexts.length);
3824 if (acceptedContexts.length === 0) {
3825 return state;
3826 }
3827 return {
3828 draftsByThreadKey: {
3829 ...state.draftsByThreadKey,
3830 [threadKey]: {
3831 ...existing,
3832 prompt:
3833 placedAtCaret || options?.appendReference === false
3834 ? existing.prompt
3835 : ensureInlineContextReferences(
3836 existing.prompt,
3837 [...existing.terminalContexts, ...acceptedContexts].map(
3838 terminalContextReference,
3839 ),
3840 ),
3841 terminalContexts: [...existing.terminalContexts, ...acceptedContexts],
3842 },
3843 },
3844 };
3845 });
3846 },
3847 removeTerminalContext: (threadRef, contextId) => {
3848 const threadKey = resolveComposerDraftKey(get(), threadRef) ?? "";
3849 if (threadKey.length === 0 || contextId.length === 0) {
3850 return;
3851 }
3852 set((state) => {
3853 const current = state.draftsByThreadKey[threadKey];
3854 if (!current) {
3855 return state;
3856 }
3857 const nextDraft: ComposerThreadDraftState = {
3858 ...current,
3859 prompt: removeInlineContextReference(
3860 current.prompt,
3861 toKindScopedComposerContextId("terminal", contextId),
3862 ).prompt,
3863 terminalContexts: current.terminalContexts.filter(
3864 (context) => context.id !== contextId,
3865 ),
3866 };
3867 const nextDraftsByThreadKey = { ...state.draftsByThreadKey };
3868 if (shouldRemoveDraft(nextDraft)) {
3869 delete nextDraftsByThreadKey[threadKey];
3870 } else {
3871 nextDraftsByThreadKey[threadKey] = nextDraft;
3872 }
3873 return { draftsByThreadKey: nextDraftsByThreadKey };
3874 });
3875 },
3876 clearTerminalContexts: (threadRef) => {
3877 const threadKey = resolveComposerDraftKey(get(), threadRef) ?? "";
3878 if (threadKey.length === 0) {
3879 return;
3880 }
3881 set((state) => {
3882 const current = state.draftsByThreadKey[threadKey];
3883 if (!current || current.terminalContexts.length === 0) {
3884 return state;
3885 }
3886 const nextDraft: ComposerThreadDraftState = {
3887 ...current,
3888 prompt: current.terminalContexts.reduce(
3889 (prompt, context) =>
3890 removeInlineContextReference(prompt, terminalContextReference(context).contextId)
3891 .prompt,
3892 current.prompt,
3893 ),
3894 terminalContexts: [],
3895 };
3896 const nextDraftsByThreadKey = { ...state.draftsByThreadKey };
3897 if (shouldRemoveDraft(nextDraft)) {
3898 delete nextDraftsByThreadKey[threadKey];
3899 } else {
3900 nextDraftsByThreadKey[threadKey] = nextDraft;
3901 }
3902 return { draftsByThreadKey: nextDraftsByThreadKey };
3903 });
3904 },
3905 addPreviewAnnotation: (threadRef, annotation, options) => {
3906 const threadKey = resolveComposerDraftKey(get(), threadRef);
3907 if (!threadKey) return;
3908 const compactAnnotation: PreviewAnnotationPayload = {
3909 ...annotation,
3910 screenshot: annotation.screenshot ? { ...annotation.screenshot, dataUrl: "" } : null,
3911 };
3912 const reference = previewAnnotationContextReference(compactAnnotation);
3913 const current = get().draftsByThreadKey[threadKey];
3914 const alreadyPresent =
3915 current?.previewAnnotations.some((entry) => entry.id === annotation.id) ?? false;
3916 // The handler updates the same draft through `setPrompt`. Run it before this store
3917 // update so the latest prompt is what the record update preserves. Calling it from
3918 // inside the updater lets the outer update overwrite the inserted reference.
3919 const placedAtCaret =
3920 !alreadyPresent &&
3921 options?.appendReference !== false &&
3922 options?.insertAtCaret !== false &&
3923 (contextInsertionHandlers.get(threadKey)?.([reference]) ?? false);
3924 set((state) => {
3925 const existing = state.draftsByThreadKey[threadKey] ?? createEmptyThreadDraft();
3926 const nextAnnotations = existing.previewAnnotations.filter(
3927 (entry) => entry.id !== annotation.id,
3928 );
3929 return {
3930 draftsByThreadKey: {
3931 ...state.draftsByThreadKey,
3932 [threadKey]: {
3933 ...existing,
3934 prompt:
3935 alreadyPresent || placedAtCaret || options?.appendReference === false
3936 ? existing.prompt
3937 : appendInlineContextReference(existing.prompt, reference),
3938 previewAnnotations: [...nextAnnotations, compactAnnotation],
3939 },
3940 },
3941 };
3942 });
3943 },
3944 setPreviewAnnotations: (threadRef, annotations) => {
3945 const threadKey = resolveComposerDraftKey(get(), threadRef);
3946 if (!threadKey) return;
3947 set((state) => {
3948 const existing = state.draftsByThreadKey[threadKey] ?? createEmptyThreadDraft();
3949 const retainedIds = new Set(annotations.map((annotation) => annotation.id));
3950 let prompt = existing.prompt;
3951 for (const previous of existing.previewAnnotations) {
3952 if (retainedIds.has(previous.id)) continue;
3953 prompt = removeInlineContextReference(
3954 prompt,
3955 previewAnnotationContextId(previous.id),
3956 ).prompt;
3957 }
3958 prompt = ensureInlineContextReferences(
3959 prompt,
3960 annotations.map(previewAnnotationContextReference),
3961 );
3962 const nextDraft = { ...existing, prompt, previewAnnotations: [...annotations] };
3963 const nextDraftsByThreadKey = { ...state.draftsByThreadKey };
3964 if (shouldRemoveDraft(nextDraft)) delete nextDraftsByThreadKey[threadKey];
3965 else nextDraftsByThreadKey[threadKey] = nextDraft;
3966 return { draftsByThreadKey: nextDraftsByThreadKey };
3967 });
3968 },
3969 removePreviewAnnotation: (threadRef, annotationId) => {
3970 const threadKey = resolveComposerDraftKey(get(), threadRef);
3971 if (!threadKey || !annotationId) return;
3972 set((state) => {
3973 const current = state.draftsByThreadKey[threadKey];
3974 if (!current) return state;
3975 const previewAnnotations = current.previewAnnotations.filter(
3976 (entry) => entry.id !== annotationId,
3977 );
3978 if (previewAnnotations.length === current.previewAnnotations.length) return state;
3979 const nextDraft = {
3980 ...current,
3981 prompt: removeInlineContextReference(
3982 current.prompt,
3983 previewAnnotationContextId(annotationId),
3984 ).prompt,
3985 previewAnnotations,
3986 images: current.images.filter((image) => image.id !== annotationId),
3987 persistedAttachments: current.persistedAttachments.filter(
3988 (image) => image.id !== annotationId,
3989 ),
3990 nonPersistedImageIds: current.nonPersistedImageIds.filter(
3991 (imageId) => imageId !== annotationId,
3992 ),
3993 };
3994 const nextDraftsByThreadKey = { ...state.draftsByThreadKey };
3995 if (shouldRemoveDraft(nextDraft)) delete nextDraftsByThreadKey[threadKey];
3996 else nextDraftsByThreadKey[threadKey] = nextDraft;
3997 return { draftsByThreadKey: nextDraftsByThreadKey };
3998 });
3999 },
4000 addReviewComment: (threadRef, comment, options) => {
4001 const threadKey = resolveComposerDraftKey(get(), threadRef);
4002 if (!threadKey || !isReviewCommentContext(comment)) return;
4003 const reference = reviewCommentContextReference(comment);
4004 const current = get().draftsByThreadKey[threadKey];
4005 const alreadyPresent =
4006 current?.reviewComments.some((entry) => entry.id === comment.id) ?? false;
4007 const shouldPlaceReference =
4008 options?.appendReference !== false &&
4009 (!alreadyPresent || options?.allowDuplicateReference === true);
4010 // See addPreviewAnnotation: editor insertion writes the prompt through this store and
4011 // must complete before the record update reads the draft it is extending.
4012 const placedAtCaret =
4013 shouldPlaceReference &&
4014 options?.insertAtCaret !== false &&
4015 (contextInsertionHandlers.get(threadKey)?.([reference]) ?? false);
4016 set((state) => {
4017 const existing = state.draftsByThreadKey[threadKey] ?? createEmptyThreadDraft();
4018 const reviewComments = existing.reviewComments.filter(
4019 (entry) => entry.id !== comment.id,
4020 );
4021 return {
4022 draftsByThreadKey: {
4023 ...state.draftsByThreadKey,
4024 [threadKey]: {
4025 ...existing,
4026 prompt:
4027 !shouldPlaceReference || placedAtCaret
4028 ? existing.prompt
4029 : appendInlineContextReference(existing.prompt, reference),
4030 reviewComments: [...reviewComments, { ...comment }],
4031 },
4032 },
4033 };
4034 });
4035 },
4036 setContextInsertionHandler: (threadRef, handler) => {
4037 const threadKey = resolveComposerDraftKey(get(), threadRef);
4038 if (!threadKey) return;
4039 if (handler) contextInsertionHandlers.set(threadKey, handler);
4040 else contextInsertionHandlers.delete(threadKey);
4041 return () => {
4042 if (contextInsertionHandlers.get(threadKey) === handler) {
4043 contextInsertionHandlers.delete(threadKey);
4044 }
4045 };
4046 },
4047 setReviewComments: (threadRef, comments) => {
4048 const threadKey = resolveComposerDraftKey(get(), threadRef);
4049 if (!threadKey) return;
4050 const reviewComments = comments
4051 .filter(isReviewCommentContext)
4052 .map((comment) => ({ ...comment }));
4053 set((state) => {
4054 const existing = state.draftsByThreadKey[threadKey] ?? createEmptyThreadDraft();
4055 const retainedIds = new Set(reviewComments.map((comment) => comment.id));
4056 let prompt = existing.prompt;
4057 for (const previous of existing.reviewComments) {
4058 if (retainedIds.has(previous.id)) continue;
4059 prompt = removeInlineContextReference(
4060 prompt,
4061 reviewCommentContextId(previous.id),
4062 ).prompt;
4063 }
4064 prompt = ensureInlineContextReferences(
4065 prompt,
4066 reviewComments.map(reviewCommentContextReference),
4067 );
4068 const nextDraft = { ...existing, prompt, reviewComments };
4069 const nextDraftsByThreadKey = { ...state.draftsByThreadKey };
4070 if (shouldRemoveDraft(nextDraft)) delete nextDraftsByThreadKey[threadKey];
4071 else nextDraftsByThreadKey[threadKey] = nextDraft;
4072 return { draftsByThreadKey: nextDraftsByThreadKey };
4073 });
4074 },
4075 addThreadContexts: (threadRef, records, options) => {
4076 const threadKey = resolveComposerDraftKey(get(), threadRef);
4077 if (!threadKey) return;
4078 const existingIds = new Set(
4079 (get().draftsByThreadKey[threadKey]?.threadContexts ?? []).map(
4080 (record) => record.contextId,
4081 ),
4082 );
4083 const incoming = records.filter((record) => {
4084 if (existingIds.has(record.contextId)) return false;
4085 existingIds.add(record.contextId);
4086 return true;
4087 });
4088 if (incoming.length === 0) return;
4089 const references = incoming.map(threadContextReference);
4090 const placedAtCaret =
4091 options?.appendReference !== false &&
4092 options?.insertAtCaret !== false &&
4093 (contextInsertionHandlers.get(threadKey)?.(references) ?? false);
4094 set((state) => {
4095 const existing = state.draftsByThreadKey[threadKey] ?? createEmptyThreadDraft();
4096 const known = new Set(existing.threadContexts.map((record) => record.contextId));
4097 const accepted = incoming.filter((record) => !known.has(record.contextId));
4098 if (accepted.length === 0) return state;
4099 return {
4100 draftsByThreadKey: {
4101 ...state.draftsByThreadKey,
4102 [threadKey]: {
4103 ...existing,
4104 prompt:
4105 placedAtCaret || options?.appendReference === false
4106 ? existing.prompt
4107 : ensureInlineContextReferences(
4108 existing.prompt,
4109 accepted.map(threadContextReference),
4110 ),
4111 threadContexts: [...existing.threadContexts, ...accepted],
4112 },
4113 },
4114 };
4115 });
4116 },
4117 setThreadContexts: (threadRef, records) => {
4118 const threadKey = resolveComposerDraftKey(get(), threadRef);
4119 if (!threadKey) return;
4120 const threadContexts = records
4121 .filter(isThreadContextRecord)
4122 .map((record) => ({ ...record }));
4123 set((state) => {
4124 const existing = state.draftsByThreadKey[threadKey] ?? createEmptyThreadDraft();
4125 const retainedIds = new Set(threadContexts.map((record) => record.contextId));
4126 let prompt = existing.prompt;
4127 for (const previous of existing.threadContexts) {
4128 if (retainedIds.has(previous.contextId)) continue;
4129 prompt = removeInlineContextReference(prompt, previous.contextId).prompt;
4130 }
4131 prompt = ensureInlineContextReferences(
4132 prompt,
4133 threadContexts.map(threadContextReference),
4134 );
4135 const nextDraft = { ...existing, prompt, threadContexts };
4136 const nextDraftsByThreadKey = { ...state.draftsByThreadKey };
4137 if (shouldRemoveDraft(nextDraft)) delete nextDraftsByThreadKey[threadKey];
4138 else nextDraftsByThreadKey[threadKey] = nextDraft;
4139 return { draftsByThreadKey: nextDraftsByThreadKey };
4140 });
4141 },
4142 removeReviewComment: (threadRef, commentId) => {
4143 const threadKey = resolveComposerDraftKey(get(), threadRef);
4144 if (!threadKey || !commentId) return;
4145 set((state) => {
4146 const current = state.draftsByThreadKey[threadKey];
4147 if (!current) return state;
4148 const reviewComments = current.reviewComments.filter((entry) => entry.id !== commentId);
4149 if (reviewComments.length === current.reviewComments.length) return state;
4150 const nextDraft = {
4151 ...current,
4152 prompt: removeInlineContextReference(
4153 current.prompt,
4154 reviewCommentContextId(commentId),
4155 ).prompt,
4156 reviewComments,
4157 };
4158 const nextDraftsByThreadKey = { ...state.draftsByThreadKey };
4159 if (shouldRemoveDraft(nextDraft)) delete nextDraftsByThreadKey[threadKey];
4160 else nextDraftsByThreadKey[threadKey] = nextDraft;
4161 return { draftsByThreadKey: nextDraftsByThreadKey };
4162 });
4163 },
4164 clearPersistedAttachments: (threadRef) => {
4165 const threadKey = resolveComposerDraftKey(get(), threadRef) ?? "";
4166 if (threadKey.length === 0) {
4167 return;
4168 }
4169 set((state) => {
4170 const current = state.draftsByThreadKey[threadKey];
4171 if (!current) {
4172 return state;
4173 }
4174 const nextDraft: ComposerThreadDraftState = {
4175 ...current,
4176 persistedAttachments: [],
4177 nonPersistedImageIds: [],
4178 };
4179 const nextDraftsByThreadKey = { ...state.draftsByThreadKey };
4180 if (shouldRemoveDraft(nextDraft)) {
4181 delete nextDraftsByThreadKey[threadKey];
4182 } else {
4183 nextDraftsByThreadKey[threadKey] = nextDraft;
4184 }
4185 return { draftsByThreadKey: nextDraftsByThreadKey };
4186 });
4187 },
4188 syncPersistedAttachments: async (threadRef, attachments) => {
4189 const threadKey = resolveComposerDraftKey(get(), threadRef);
4190 if (!threadKey) {
4191 return;
4192 }
4193 const attachmentIdSet = new Set(attachments.map((attachment) => attachment.id));
4194 set((state) => {
4195 const current = state.draftsByThreadKey[threadKey];
4196 if (!current) {
4197 return state;
4198 }
4199 const nextDraft: ComposerThreadDraftState = {
4200 ...current,
4201 // Stage attempted attachments so persist middleware can try writing them.
4202 persistedAttachments: attachments,
4203 nonPersistedImageIds: current.nonPersistedImageIds.filter(
4204 (id) => !attachmentIdSet.has(id),
4205 ),
4206 };
4207 const nextDraftsByThreadKey = { ...state.draftsByThreadKey };
4208 if (shouldRemoveDraft(nextDraft)) {
4209 delete nextDraftsByThreadKey[threadKey];
4210 } else {
4211 nextDraftsByThreadKey[threadKey] = nextDraft;
4212 }
4213 return { draftsByThreadKey: nextDraftsByThreadKey };
4214 });
4215 await Promise.resolve();
4216 verifyPersistedAttachments(threadKey, attachments, set);
4217 },
4218 clearComposerContent: (threadRef) => {
4219 const threadKey = resolveComposerDraftKey(get(), threadRef) ?? "";
4220 if (threadKey.length === 0) {
4221 return;
4222 }
4223 set((state) => {
4224 const current = state.draftsByThreadKey[threadKey];
4225 if (!current) {
4226 return state;
4227 }
4228 const nextDraft: ComposerThreadDraftState = {
4229 ...current,
4230 prompt: "",
4231 images: [],
4232 files: [],
4233 nonPersistedImageIds: [],
4234 persistedAttachments: [],
4235 terminalContexts: [],
4236 previewAnnotations: [],
4237 reviewComments: [],
4238 threadContexts: [],
4239 };
4240 const nextDraftsByThreadKey = { ...state.draftsByThreadKey };
4241 if (shouldRemoveDraft(nextDraft)) {
4242 delete nextDraftsByThreadKey[threadKey];
4243 } else {
4244 nextDraftsByThreadKey[threadKey] = nextDraft;
4245 }
4246 return { draftsByThreadKey: nextDraftsByThreadKey };
4247 });
4248 },
4249 clearComposerPromptAndImages: (threadRef) => {
4250 const threadKey = resolveComposerDraftKey(get(), threadRef) ?? "";
4251 if (threadKey.length === 0) {
4252 return;
4253 }
4254 set((state) => {
4255 const current = state.draftsByThreadKey[threadKey];
4256 if (!current) {
4257 return state;
4258 }
4259 for (const image of current.images) {
4260 revokeObjectPreviewUrl(image.previewUrl);
4261 }
4262 const nextDraft: ComposerThreadDraftState = {
4263 ...current,
4264 prompt: ensureInlineContextReferences("", [
4265 ...current.terminalContexts.map(terminalContextReference),
4266 ...current.reviewComments.map(reviewCommentContextReference),
4267 ...current.threadContexts.map(threadContextReference),
4268 ...current.previewAnnotations.map(previewAnnotationContextReference),
4269 ]),
4270 images: [],
4271 files: [],
4272 nonPersistedImageIds: [],
4273 persistedAttachments: [],
4274 };
4275 const nextDraftsByThreadKey = { ...state.draftsByThreadKey };
4276 if (shouldRemoveDraft(nextDraft)) {
4277 delete nextDraftsByThreadKey[threadKey];
4278 } else {
4279 nextDraftsByThreadKey[threadKey] = nextDraft;
4280 }
4281 return { draftsByThreadKey: nextDraftsByThreadKey };
4282 });
4283 },
4284 moveComposerPromptAndImages: (from, to) => {
4285 const fromKey = resolveComposerDraftKey(get(), from) ?? "";
4286 const toKey = resolveComposerDraftKey(get(), to) ?? "";
4287 if (fromKey.length === 0 || toKey.length === 0 || fromKey === toKey) {
4288 return;
4289 }
4290 set((state) => {
4291 const source = state.draftsByThreadKey[fromKey];
4292 if (!source) {
4293 return state;
4294 }
4295 const destination = state.draftsByThreadKey[toKey] ?? createEmptyThreadDraft();
4296 const destinationEnvironmentId =
4297 typeof to === "string"
4298 ? (state.draftThreadsByThreadKey[toKey]?.environmentId ??
4299 parseScopedThreadKey(toKey)?.environmentId ??
4300 null)
4301 : to.environmentId;
4302 // A file the destination already holds (same id, or same
4303 // metadata key) stays behind instead of duplicating there.
4304 const destinationFileIds = new Set(destination.files.map((file) => file.id));
4305 const destinationFileKeys = new Set(destination.files.map(composerFileDedupKey));
4306 const transferableFiles = source.files.filter(
4307 (file) =>
4308 (file.file !== null || file.uploadEnvironmentId === destinationEnvironmentId) &&
4309 !destinationFileIds.has(file.id) &&
4310 !destinationFileKeys.has(composerFileDedupKey(file)),
4311 );
4312 const remainingAttachmentSlots = Math.max(
4313 0,
4314 PROVIDER_SEND_TURN_MAX_ATTACHMENTS -
4315 destination.images.length -
4316 destination.files.length,
4317 );
4318 const movedImages = source.images.slice(0, remainingAttachmentSlots);
4319 const movedImageIds = new Set(movedImages.map((image) => image.id));
4320 const retainedImages = source.images.filter((image) => !movedImageIds.has(image.id));
4321 const movedFiles = transferableFiles
4322 .slice(0, remainingAttachmentSlots - movedImages.length)
4323 .map((file) => {
4324 // A byte-backed file moving across environments re-uploads at
4325 // the destination. Keeping the source-environment upload id
4326 // would mark it uploaded after a reload with no local bytes
4327 // and no valid upload anywhere the destination can reach. The
4328 // upload queue keeps the source upload as fallback, then
4329 // deletes it after the destination upload succeeds. Abandoned
4330 // uploads still expire through the server sweep.
4331 if (
4332 file.file === null ||
4333 file.uploadEnvironmentId === undefined ||
4334 file.uploadEnvironmentId === destinationEnvironmentId
4335 ) {
4336 return file;
4337 }
4338 const { uploadedAttachmentId: _a, uploadEnvironmentId: _e, ...rest } = file;
4339 return rest;
4340 });
4341 const movedFileIds = new Set(movedFiles.map((file) => file.id));
4342 const retainedFiles = source.files.filter((file) => !movedFileIds.has(file.id));
4343 // Inline placeholders reference the source's terminal contexts,
4344 // which stay behind; re-anchor the moved prompt to whatever
4345 // contexts the destination already holds.
4346 const movedPrompt = ensureInlineContextReferences(
4347 stripInlineContextReferences(source.prompt),
4348 destination.terminalContexts.map(terminalContextReference),
4349 );
4350 const nextDestination: ComposerThreadDraftState = {
4351 ...destination,
4352 prompt: movedPrompt,
4353 images: [...destination.images, ...movedImages],
4354 files: [...destination.files, ...movedFiles],
4355 nonPersistedImageIds: [
4356 ...destination.nonPersistedImageIds,
4357 ...source.nonPersistedImageIds.filter((imageId) => movedImageIds.has(imageId)),
4358 ],
4359 persistedAttachments: [
4360 ...destination.persistedAttachments,
4361 ...source.persistedAttachments.filter((attachment) =>
4362 movedImageIds.has(attachment.id),
4363 ),
4364 ],
4365 };
4366 // Same clearing shape as clearComposerPromptAndImages, but the
4367 // preview URLs are NOT revoked: the images moved and their blobs
4368 // are still referenced from the destination.
4369 const nextSource: ComposerThreadDraftState = {
4370 ...source,
4371 prompt: ensureInlineContextReferences(
4372 "",
4373 source.terminalContexts.map(terminalContextReference),
4374 ),
4375 images: retainedImages,
4376 files: retainedFiles,
4377 nonPersistedImageIds: source.nonPersistedImageIds.filter(
4378 (imageId) => !movedImageIds.has(imageId),
4379 ),
4380 persistedAttachments: source.persistedAttachments.filter(
4381 (attachment) => !movedImageIds.has(attachment.id),
4382 ),
4383 };
4384 const nextDraftsByThreadKey = { ...state.draftsByThreadKey };
4385 if (shouldRemoveDraft(nextSource)) {
4386 delete nextDraftsByThreadKey[fromKey];
4387 } else {
4388 nextDraftsByThreadKey[fromKey] = nextSource;
4389 }
4390 if (shouldRemoveDraft(nextDestination)) {
4391 delete nextDraftsByThreadKey[toKey];
4392 } else {
4393 nextDraftsByThreadKey[toKey] = nextDestination;
4394 }
4395 return { draftsByThreadKey: nextDraftsByThreadKey };
4396 });
4397 },
4398 };
4399 },
4400 {
4401 name: COMPOSER_DRAFT_STORAGE_KEY,
4402 version: COMPOSER_DRAFT_STORAGE_VERSION,
4403 storage: composerPersistStorage,
4404 migrate: migratePersistedComposerDraftStoreState,
4405 // Defer the draft walk and serialization until the storage write flushes.
4406 partialize: (state): ComposerPersistState => ({ capturedState: state }),
4407 merge: (persistedState, currentState) => {
4408 const normalizedPersisted =
4409 normalizeCurrentPersistedComposerDraftStoreState(persistedState);
4410 const draftsByThreadKey = Object.fromEntries(
4411 Object.entries(normalizedPersisted.draftsByThreadKey).map(([threadKey, draft]) => [
4412 threadKey,
4413 toHydratedThreadDraft(draft),
4414 ]),
4415 );
4416 const draftThreadsByThreadKey = Object.fromEntries(
4417 Object.entries(normalizedPersisted.draftThreadsByThreadKey).map(
4418 ([threadKey, draftThread]) => [threadKey, toHydratedDraftThreadState(draftThread)],
4419 ),
4420 ) as Record<string, DraftThreadState>;
4421 return {
4422 ...currentState,
4423 draftsByThreadKey,
4424 draftThreadsByThreadKey,
4425 logicalProjectDraftThreadKeyByLogicalProjectKey:
4426 normalizedPersisted.logicalProjectDraftThreadKeyByLogicalProjectKey,
4427 stickyModelSelectionByProvider: normalizedPersisted.stickyModelSelectionByProvider ?? {},
4428 stickyOptionsByModelByProvider: normalizedPersisted.stickyOptionsByModelByProvider ?? {},
4429 stickyActiveProvider: normalizedPersisted.stickyActiveProvider ?? null,
4430 };
4431 },
4432 },
4433 ),
4434);
4435
4436export const useComposerDraftStore = composerDraftStore;
4437
4438export function beginBackgroundDraftSubmissionByRef(threadRef: ScopedThreadRef): void {
4439 const threadKey = scopedThreadKey(threadRef);
4440 useComposerDraftStore.setState((state) => ({
4441 backgroundSubmissionThreadKeys: { ...state.backgroundSubmissionThreadKeys, [threadKey]: true },
4442 }));
4443}
4444
4445export function clearBackgroundDraftSubmissionByRef(threadRef: ScopedThreadRef): void {
4446 const threadKey = scopedThreadKey(threadRef);
4447 useComposerDraftStore.setState((state) => {
4448 if (!state.backgroundSubmissionThreadKeys[threadKey]) {
4449 return state;
4450 }
4451 const backgroundSubmissionThreadKeys = { ...state.backgroundSubmissionThreadKeys };
4452 delete backgroundSubmissionThreadKeys[threadKey];
4453 return { backgroundSubmissionThreadKeys };
4454 });
4455}
4456
4457export function useBackgroundDraftSubmissionPending(threadRef: ScopedThreadRef | null): boolean {
4458 const threadKey = threadRef ? scopedThreadKey(threadRef) : null;
4459 return useComposerDraftStore(
4460 (state) => threadKey !== null && state.backgroundSubmissionThreadKeys[threadKey] === true,
4461 );
4462}
4463
4464export function clearComposerDraftsEnvironment(environmentId: EnvironmentId): void {
4465 useComposerDraftStore.setState((state) => {
4466 const removedThreadKeys = new Set<string>();
4467
4468 for (const [threadKey, draftThread] of Object.entries(state.draftThreadsByThreadKey)) {
4469 if (draftThread.environmentId === environmentId) {
4470 removedThreadKeys.add(threadKey);
4471 }
4472 }
4473 for (const threadKey of Object.keys(state.draftsByThreadKey)) {
4474 if (parseScopedThreadKey(threadKey)?.environmentId === environmentId) {
4475 removedThreadKeys.add(threadKey);
4476 }
4477 }
4478 for (const [logicalProjectKey, threadKey] of Object.entries(
4479 state.logicalProjectDraftThreadKeyByLogicalProjectKey,
4480 )) {
4481 if (parseScopedProjectKey(logicalProjectKey)?.environmentId === environmentId) {
4482 removedThreadKeys.add(threadKey);
4483 }
4484 }
4485
4486 const nextLogicalMappings = Object.fromEntries(
4487 Object.entries(state.logicalProjectDraftThreadKeyByLogicalProjectKey).filter(
4488 ([logicalProjectKey, threadKey]) =>
4489 parseScopedProjectKey(logicalProjectKey)?.environmentId !== environmentId &&
4490 !removedThreadKeys.has(threadKey),
4491 ),
4492 ) as Record<string, string>;
4493 const nextDraftThreads = Object.fromEntries(
4494 Object.entries(state.draftThreadsByThreadKey).filter(
4495 ([threadKey, draftThread]) =>
4496 draftThread.environmentId !== environmentId && !removedThreadKeys.has(threadKey),
4497 ),
4498 ) as Record<string, DraftThreadState>;
4499 const nextDrafts = Object.fromEntries(
4500 Object.entries(state.draftsByThreadKey).filter(([threadKey, draft]) => {
4501 if (!removedThreadKeys.has(threadKey)) {
4502 return true;
4503 }
4504 revokeDraftThreadPreviewUrls(draft);
4505 return false;
4506 }),
4507 ) as Record<string, ComposerThreadDraftState>;
4508 const nextBackgroundSubmissionThreadKeys = Object.fromEntries(
4509 Object.entries(state.backgroundSubmissionThreadKeys).filter(
4510 ([threadKey]) => parseScopedThreadKey(threadKey)?.environmentId !== environmentId,
4511 ),
4512 ) as Record<string, true>;
4513
4514 return {
4515 draftsByThreadKey: nextDrafts,
4516 draftThreadsByThreadKey: nextDraftThreads,
4517 logicalProjectDraftThreadKeyByLogicalProjectKey: nextLogicalMappings,
4518 backgroundSubmissionThreadKeys: nextBackgroundSubmissionThreadKeys,
4519 rewindingThreadKeys: new Set(
4520 [...state.rewindingThreadKeys].filter(
4521 (threadKey) => parseScopedThreadKey(threadKey)?.environmentId !== environmentId,
4522 ),
4523 ),
4524 };
4525 });
4526 composerDebouncedStorage.flush();
4527}
4528
4529export function useComposerThreadDraft(threadRef: ComposerThreadTarget): ComposerThreadDraftState {
4530 return useComposerDraftStore((state) => {
4531 return getComposerDraftState(state, threadRef) ?? EMPTY_THREAD_DRAFT;
4532 });
4533}
4534
4535/**
4536 * True when a real thread's composer holds unsent user content. Selects a
4537 * boolean so the sidebar row that reads it re-renders only when the draft
4538 * appears or disappears, not on every keystroke.
4539 */
4540export function useThreadHasUnsentDraft(threadRef: ScopedThreadRef): boolean {
4541 return useComposerDraftStore((state) =>
4542 composerDraftHasUserContent(getComposerDraftState(state, threadRef)),
4543 );
4544}
4545
4546function useComposerDraftModelState(threadRef: ComposerThreadTarget): ComposerDraftModelState {
4547 return useComposerDraftStore(
4548 useShallow((state) => {
4549 const draft = getComposerDraftState(state, threadRef);
4550 return draft
4551 ? {
4552 activeProvider: draft.activeProvider,
4553 modelSelectionByProvider: draft.modelSelectionByProvider,
4554 }
4555 : EMPTY_COMPOSER_DRAFT_MODEL_STATE;
4556 }),
4557 );
4558}
4559
4560export function useEffectiveComposerModelState(input: {
4561 threadRef?: ComposerThreadTarget;
4562 draftId?: DraftId;
4563 providers: ReadonlyArray<ServerProvider>;
4564 selectedProvider: ProviderDriverKind;
4565 /**
4566 * When supplied, the draft's saved selection for this instance takes
4567 * precedence over the driver-kind bucket — so a custom `codex_personal`
4568 * instance reads its own model, not the default Codex's.
4569 */
4570 selectedInstanceId?: ProviderInstanceId | null | undefined;
4571 threadModelSelection: ModelSelection | null | undefined;
4572 projectModelSelection: ModelSelection | null | undefined;
4573 settings: UnifiedSettings;
4574}): EffectiveComposerModelState {
4575 const draft = useComposerDraftModelState(input.threadRef ?? input.draftId ?? DraftId.make(""));
4576
4577 return useMemo(
4578 () =>
4579 deriveEffectiveComposerModelState({
4580 draft,
4581 providers: input.providers,
4582 selectedProvider: input.selectedProvider,
4583 selectedInstanceId: input.selectedInstanceId,
4584 threadModelSelection: input.threadModelSelection,
4585 projectModelSelection: input.projectModelSelection,
4586 settings: input.settings,
4587 }),
4588 [
4589 draft,
4590 input.providers,
4591 input.settings,
4592 input.projectModelSelection,
4593 input.selectedInstanceId,
4594 input.selectedProvider,
4595 input.threadModelSelection,
4596 ],
4597 );
4598}
4599
4600export function markPromotedDraftThreadByRef(threadRef: ScopedThreadRef): void {
4601 const draftStore = useComposerDraftStore.getState();
4602 for (const [draftId, draftThread] of Object.entries(draftStore.draftThreadsByThreadKey)) {
4603 if (
4604 draftThread.environmentId === threadRef.environmentId &&
4605 draftThread.threadId === threadRef.threadId
4606 ) {
4607 draftStore.markDraftThreadPromoting(DraftId.make(draftId), threadRef);
4608 }
4609 }
4610}
4611
4612export function restoreFailedBackgroundDraftThread(
4613 draftId: DraftId,
4614 draftThread: DraftThreadState,
4615 threadId: ThreadId,
4616): void {
4617 useComposerDraftStore.setState((state) => ({
4618 draftThreadsByThreadKey: {
4619 ...state.draftThreadsByThreadKey,
4620 [draftId]: {
4621 ...draftThread,
4622 threadId,
4623 promotedTo: null,
4624 },
4625 },
4626 }));
4627}
4628
4629export function finalizePromotedDraftThreadByRef(threadRef: ScopedThreadRef): void {
4630 const draftStore = useComposerDraftStore.getState();
4631 for (const [draftId, draftThread] of Object.entries(draftStore.draftThreadsByThreadKey)) {
4632 const promotedRef = draftThread.promotedTo;
4633 const matches = promotedRef
4634 ? promotedRef.environmentId === threadRef.environmentId &&
4635 promotedRef.threadId === threadRef.threadId
4636 : draftThread.environmentId === threadRef.environmentId &&
4637 draftThread.threadId === threadRef.threadId;
4638 if (matches) {
4639 const target = DraftId.make(draftId);
4640 draftStore.markDraftThreadPromoting(target, threadRef);
4641 draftStore.finalizePromotedDraftThread(target);
4642 }
4643 }
4644 clearBackgroundDraftSubmissionByRef(threadRef);
4645}