1import { useAtomValue } from "@effect/atom-react";
2import {
3 EnvironmentId as EnvironmentIdSchema,
4 ModelSelection as ModelSelectionSchema,
5 ComposerContextId,
6 ComposerContextRecord,
7 COMPOSER_CONTEXT_MAX_RECORDS,
8 ForwardCompatibleArray,
9 OrchestrationMessageContext,
10 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
11 ProjectId as ProjectIdSchema,
12 ProviderInteractionMode as ProviderInteractionModeSchema,
13 ProviderOptionSelection as ProviderOptionSelectionSchema,
14 RuntimeMode as RuntimeModeSchema,
15 type EnvironmentId,
16 type ModelSelection,
17 type ProjectId,
18 type ProviderInteractionMode,
19 type ProviderOptionSelection,
20 type RuntimeMode,
21} from "@t3tools/contracts";
22import * as Schema from "effect/Schema";
23import { useEffect } from "react";
24import { Atom } from "effect/reactivity";
25
26import { writeFileAtomically } from "../lib/atomic-file";
27import { createComposerContextHistory, referencedComposerContext } from "../lib/composerContext";
28import { isQueuedEditDraftKey } from "./queued-edit-draft-key";
29import {
30 collectComposerContextReferences,
31 formatComposerContextReference,
32 sanitizeComposerContextLabel,
33 replaceComposerContextReferences,
34} from "@t3tools/shared/composerContextReferences";
35import { imageMimeType } from "@t3tools/shared/image";
36import { videoMimeType } from "@t3tools/shared/video";
37import { DraftComposerAttachmentSchema } from "../lib/composer-image-schema";
38import {
39 composerAttachmentFileReferenceKey,
40 isComposerAttachmentFileRetained,
41} from "../lib/composerAttachmentFiles";
42import {
43 registerComposerAttachmentUnusedHandler,
44 retainComposerAttachmentFileForPreview,
45} from "../lib/composerAttachmentPreviewRetention";
46import type { DraftComposerAttachment, FileBackedComposerAttachment } from "../lib/composerImages";
47import { SerializedAsyncQueue } from "../lib/serialized-async-queue";
48import { appAtomRegistry } from "./atom-registry";
49import {
50 isNewTaskDraftKey,
51 newTaskDraftKey,
52 parseLegacyNewTaskDraftKey,
53} from "./new-task-draft-key";
54import {
55 decodeQueuedThreadMessage,
56 encodeQueuedThreadMessage,
57 QueuedThreadMessageSchema,
58 type QueuedThreadMessage,
59} from "./thread-outbox-model";
60import { flushThreadOutbox, threadOutboxManager } from "./thread-outbox";
61import { composerDraftEnvironmentId } from "../lib/composerAttachmentUploadQueue";
62
63const COMPOSER_DRAFTS_SCHEMA_VERSION = 1;
64const COMPOSER_DRAFTS_DIRECTORY = "composer-drafts";
65const COMPOSER_DRAFTS_FILE = "drafts.json";
66const PERSIST_DEBOUNCE_MS = 200;
67
68export const composerContextImportsAtom = Atom.make<Record<string, boolean>>({}).pipe(
69 Atom.keepAlive,
70);
71
72export function setComposerContextImporting(draftKey: string, importing: boolean): void {
73 const next = { ...appAtomRegistry.get(composerContextImportsAtom) };
74 if (importing) next[draftKey] = true;
75 else delete next[draftKey];
76 appAtomRegistry.set(composerContextImportsAtom, next);
77}
78
79let lastComposerSelection: { draftKey: string; text: string; start: number; end: number } | null =
80 null;
81
82/** Retain the last focused caret while a picker or review sheet is open. */
83export function rememberComposerDraftSelection(
84 draftKey: string,
85 text: string,
86 selection: { start: number; end: number },
87): void {
88 lastComposerSelection = { draftKey, text, ...selection };
89}
90
91/**
92 * The caret an insert left behind, for the text it produced. Inserting a chip moves the caret
93 * past it here; without reading this back the editor would restore the pre-insert offset and
94 * leave the caret sitting before the chip the user just added.
95 */
96export function readComposerDraftSelection(
97 draftKey: string,
98 text: string,
99): { start: number; end: number } | null {
100 if (lastComposerSelection?.draftKey !== draftKey || lastComposerSelection.text !== text) {
101 return null;
102 }
103 return { start: lastComposerSelection.start, end: lastComposerSelection.end };
104}
105
106export interface ComposerDraftInsertion {
107 readonly text: string;
108 readonly start: number;
109 readonly end: number;
110}
111
112/** Capture the paste target before any clipboard reads, downloads, or file writes. */
113export function captureComposerDraftInsertion(
114 draftKey: string,
115 selection?: { start: number; end: number },
116): ComposerDraftInsertion {
117 const { text } = getComposerDraftSnapshot(draftKey);
118 return {
119 text,
120 ...(selection ??
121 readComposerDraftSelection(draftKey, text) ?? { start: text.length, end: text.length }),
122 };
123}
124
125function contextInsertionRange(
126 draftKey: string,
127 draft: ComposerDraft,
128 target?: ComposerDraftInsertion,
129) {
130 const captured =
131 target ?? (lastComposerSelection?.draftKey === draftKey ? lastComposerSelection : null);
132 // Edits made during a file write must not be replaced using stale offsets. A changed
133 // draft receives the file at its end; moving only the caret preserves the captured range.
134 const selection = captured?.text === draft.text ? captured : null;
135 const start = Math.max(0, Math.min(selection?.start ?? draft.text.length, draft.text.length));
136 return { start, end: Math.max(start, Math.min(selection?.end ?? start, draft.text.length)) };
137}
138
139function draftWithoutInsertionSelection(
140 draftKey: string,
141 draft: ComposerDraft,
142 target?: ComposerDraftInsertion,
143) {
144 const { start, end } = contextInsertionRange(draftKey, draft, target);
145 const text = `${draft.text.slice(0, start)} ${draft.text.slice(end)}`;
146 return withReferencedContextFiles(draft, text, referencedComposerContext(text, draft.context));
147}
148
149export function countComposerDraftAttachmentsAfterSelection(
150 draftKey: string,
151 target: ComposerDraftInsertion,
152): number {
153 return getComposerDraftAfterSelection(draftKey, target).attachments.length;
154}
155
156export function getComposerDraftAfterSelection(
157 draftKey: string,
158 target: ComposerDraftInsertion,
159): ComposerDraft {
160 return draftWithoutInsertionSelection(draftKey, getComposerDraftSnapshot(draftKey), target);
161}
162
163function withReferencedContextFiles(
164 draft: ComposerDraft,
165 text: string,
166 context: OrchestrationMessageContext | undefined,
167): ComposerDraft {
168 const previousIds = new Set(
169 draft.context?.records.flatMap((record) =>
170 "attachmentId" in record ? [record.attachmentId] : [],
171 ),
172 );
173 const retainedIds = new Set(
174 context?.records.flatMap((record) => ("attachmentId" in record ? [record.attachmentId] : [])),
175 );
176 return {
177 ...draft,
178 text,
179 context,
180 attachments: draft.attachments.filter(
181 (attachment) =>
182 attachment.type === "image" ||
183 !previousIds.has(attachment.id) ||
184 retainedIds.has(attachment.id),
185 ),
186 };
187}
188
189/** Retains file bytes while native text undo can restore their references. */
190export function createComposerDraftContextHistory() {
191 const restoreContext = createComposerContextHistory();
192 const files = new Map<
193 string,
194 { attachment: FileBackedComposerAttachment; release: () => void }
195 >();
196 return {
197 restore(text: string, draft: ComposerDraft) {
198 for (const attachment of draft.attachments) {
199 if (attachment.type !== "file") continue;
200 const previous = files.get(attachment.id);
201 files.delete(attachment.id);
202 if (previous?.attachment.fileUri === attachment.fileUri) {
203 previous.attachment = attachment;
204 files.set(attachment.id, previous);
205 } else {
206 previous?.release();
207 files.set(attachment.id, {
208 attachment,
209 release: retainComposerAttachmentFileForPreview(attachment),
210 });
211 }
212 }
213 const limit = Math.max(COMPOSER_CONTEXT_MAX_RECORDS, draft.attachments.length);
214 while (files.size > limit) {
215 const oldest = files.keys().next().value!;
216 files.get(oldest)!.release();
217 files.delete(oldest);
218 }
219 const context = restoreContext(text, draft.context);
220 const liveIds = new Set(draft.attachments.map((attachment) => attachment.id));
221 const attachments = (context?.records ?? []).flatMap((record) => {
222 if (
223 record.kind !== "file" ||
224 !("attachmentId" in record) ||
225 liveIds.has(record.attachmentId)
226 )
227 return [];
228 const saved = files.get(record.attachmentId)?.attachment;
229 liveIds.add(record.attachmentId);
230 // Removing the file can release its old pending upload. Undo reuploads the retained bytes.
231 return saved
232 ? [{ ...saved, uploadedAttachmentId: undefined, uploadEnvironmentId: undefined }]
233 : [];
234 });
235 return { context, attachments };
236 },
237 dispose() {
238 for (const file of files.values()) file.release();
239 files.clear();
240 },
241 };
242}
243
244export function setComposerDraftContext(
245 draftKey: string,
246 context: OrchestrationMessageContext | undefined,
247): void {
248 updateComposerDrafts((current) => ({
249 ...current,
250 [draftKey]: { ...normalizeDraft(current[draftKey]), context },
251 }));
252}
253
254export function insertComposerDraftContext(
255 draftKey: string,
256 content: {
257 text: string;
258 context: OrchestrationMessageContext;
259 attachments?: ReadonlyArray<DraftComposerAttachment>;
260 },
261 target?: ComposerDraftInsertion,
262): boolean {
263 let inserted = false;
264 let removed: ReadonlyArray<DraftComposerAttachment> = [];
265 updateComposerDrafts((current) => {
266 const draft = normalizeDraft(current[draftKey]);
267 const attachments = content.attachments ?? [];
268 const retained = draftWithoutInsertionSelection(draftKey, draft, target);
269 if (
270 attachments.length > 0 &&
271 retained.attachments.length + attachments.length > PROVIDER_SEND_TURN_MAX_ATTACHMENTS
272 )
273 return current;
274 const nextDraft = draftWithInsertedContext(
275 draftKey,
276 { ...draft, attachments: [...draft.attachments, ...attachments] },
277 content,
278 target,
279 );
280 if (!nextDraft) return current;
281 inserted = true;
282 removed = draft.attachments.filter((attachment) => !nextDraft.attachments.includes(attachment));
283 return { ...current, [draftKey]: nextDraft };
284 });
285 scheduleUnusedComposerAttachmentCleanup(inserted ? removed : (content.attachments ?? []));
286 return inserted;
287}
288
289function draftWithInsertedContext(
290 draftKey: string,
291 draft: ComposerDraft,
292 content: { text: string; context: OrchestrationMessageContext },
293 target?: ComposerDraftInsertion,
294): ComposerDraft | null {
295 const { start, end } = contextInsertionRange(draftKey, draft, target);
296 const before = draft.text.slice(0, start);
297 const after = draft.text.slice(end);
298 const insertion = `${before.length > 0 && !/\s$/.test(before) && !/^\s/.test(content.text) ? " " : ""}${content.text}${!/\s$/.test(content.text) && (after.length === 0 || !/^\s/.test(after)) ? " " : ""}`;
299 const text = before + insertion + after;
300 const records = new Map(draft.context?.records.map((record) => [record.contextId, record]));
301 for (const record of content.context.records) records.set(record.contextId, record);
302 const context = referencedComposerContext(text, { version: 1, records: [...records.values()] });
303 if ((context?.records.length ?? 0) > COMPOSER_CONTEXT_MAX_RECORDS) return null;
304 lastComposerSelection = {
305 draftKey,
306 text,
307 start: start + insertion.length,
308 end: start + insertion.length,
309 };
310 return withReferencedContextFiles(draft, text, context);
311}
312
313export class ComposerDraftPersistenceError extends Schema.TaggedError<ComposerDraftPersistenceError>()(
314 "ComposerDraftPersistenceError",
315 {
316 operation: Schema.Literals(["open", "read", "decode", "encode", "write", "hydrate"]),
317 directory: Schema.String,
318 fileName: Schema.String,
319 cause: Schema.Defect(),
320 },
321) {
322 override get message(): string {
323 return `Composer draft persistence operation ${this.operation} failed for ${this.directory}/${this.fileName}.`;
324 }
325}
326
327export interface ComposerDraft {
328 readonly text: string;
329 readonly context?: OrchestrationMessageContext;
330 readonly attachments: ReadonlyArray<DraftComposerAttachment>;
331 readonly importedShareIds?: ReadonlyArray<string>;
332 readonly modelSelection?: ModelSelection;
333 readonly runtimeMode?: RuntimeMode;
334 readonly interactionMode?: ProviderInteractionMode;
335 readonly workspaceSelection?: ComposerDraftWorkspaceSelection;
336 /**
337 * Set on new-task drafts only. The project is stored here rather than in
338 * the key so a project can hold any number of drafts and a draft can be
339 * retargeted to another project without changing identity.
340 */
341 readonly project?: ComposerDraftProject;
342}
343
344export interface ComposerDraftProject {
345 readonly environmentId: EnvironmentId;
346 readonly projectId: ProjectId;
347 readonly createdAt: string;
348}
349
350export interface ComposerDraftContent {
351 readonly text: string;
352 readonly context?: OrchestrationMessageContext;
353 readonly attachments: ReadonlyArray<DraftComposerAttachment>;
354 readonly sourceShareId?: string;
355}
356
357export interface ComposerDraftWorkspaceSelection {
358 readonly mode: "local" | "worktree";
359 readonly branch: string | null;
360 readonly worktreePath: string | null;
361 readonly startFromOrigin?: boolean;
362 /** Resolve and check out this PR when the queued task is sent. */
363 readonly pullRequestReference?: string;
364}
365
366export type ComposerDraftSettingsUpdate = Pick<
367 ComposerDraft,
368 "modelSelection" | "runtimeMode" | "interactionMode" | "workspaceSelection" | "project"
369>;
370
371const ComposerDraftWorkspaceSelectionSchema = Schema.Struct({
372 mode: Schema.Literals(["local", "worktree"]),
373 branch: Schema.NullOr(Schema.String),
374 worktreePath: Schema.NullOr(Schema.String),
375 startFromOrigin: Schema.optional(Schema.Boolean),
376 pullRequestReference: Schema.optional(Schema.String),
377});
378
379const ComposerDraftProjectSchema = Schema.Struct({
380 environmentId: EnvironmentIdSchema,
381 projectId: ProjectIdSchema,
382 createdAt: Schema.String,
383});
384
385// Recovery can merge two individually valid drafts beyond the send limit, just like
386// attachments. Keep every payload reloadable; send guards ask the user to trim the draft.
387const PersistedComposerContextSchema = Schema.Struct({
388 version: Schema.Literal(1),
389 records: ForwardCompatibleArray(ComposerContextRecord),
390});
391
392const ComposerDraftSchema = Schema.Struct({
393 text: Schema.String,
394 context: Schema.optional(PersistedComposerContextSchema),
395 attachments: Schema.Array(DraftComposerAttachmentSchema),
396 importedShareIds: Schema.optional(Schema.Array(Schema.String)),
397 modelSelection: Schema.optional(ModelSelectionSchema),
398 runtimeMode: Schema.optional(RuntimeModeSchema),
399 interactionMode: Schema.optional(ProviderInteractionModeSchema),
400 workspaceSelection: Schema.optional(ComposerDraftWorkspaceSelectionSchema),
401 project: Schema.optional(ComposerDraftProjectSchema),
402});
403
404const PersistedComposerDraftsSchema = Schema.Struct({
405 schemaVersion: Schema.Literal(COMPOSER_DRAFTS_SCHEMA_VERSION),
406 drafts: Schema.Record(Schema.String, ComposerDraftSchema),
407 stickyModelSelection: Schema.optional(ModelSelectionSchema),
408 modelOptionMemory: Schema.optional(
409 Schema.Record(
410 Schema.String,
411 Schema.Record(Schema.String, Schema.Array(ProviderOptionSelectionSchema)),
412 ),
413 ),
414 cloudAccountId: Schema.optional(Schema.String),
415 signedOutDrafts: Schema.optional(
416 Schema.Record(
417 Schema.String,
418 Schema.Struct({
419 drafts: Schema.Record(Schema.String, ComposerDraftSchema),
420 queuedMessages: Schema.Array(QueuedThreadMessageSchema),
421 }),
422 ),
423 ),
424});
425
426const decodePersistedComposerDraftsDocument = Schema.decodeUnknownSync(
427 PersistedComposerDraftsSchema,
428);
429
430const EMPTY_DRAFT: ComposerDraft = {
431 text: "",
432 attachments: [],
433};
434
435export const composerDraftsAtom = Atom.make<Record<string, ComposerDraft>>({}).pipe(
436 Atom.keepAlive,
437 Atom.withLabel("mobile:composer-drafts"),
438);
439
440export const stickyComposerModelSelectionAtom = Atom.make<ModelSelection | null>(null).pipe(
441 Atom.keepAlive,
442 Atom.withLabel("mobile:sticky-composer-model-selection"),
443);
444
445export type ModelOptionMemoryState = Readonly<
446 Record<string, Readonly<Record<string, ReadonlyArray<ProviderOptionSelection>>>>
447>;
448
449export const modelOptionMemoryAtom = Atom.make<ModelOptionMemoryState>({}).pipe(
450 Atom.keepAlive,
451 Atom.withLabel("mobile:model-option-memory"),
452);
453
454interface SignedOutDrafts {
455 readonly drafts: Record<string, ComposerDraft>;
456 readonly queuedMessages: ReadonlyArray<QueuedThreadMessage>;
457}
458
459interface ComposerCloudDraftState {
460 readonly accountId: string | null;
461 readonly signedOut: Record<string, SignedOutDrafts>;
462}
463
464export const composerCloudDraftsAtom = Atom.make<ComposerCloudDraftState>({
465 accountId: null,
466 signedOut: {},
467}).pipe(Atom.keepAlive);
468
469let loadPromise: Promise<void> | null = null;
470let persistTimer: ReturnType<typeof setTimeout> | null = null;
471let persistRetryNeeded = false;
472const persistenceQueue = new SerializedAsyncQueue();
473
474/** Resets module-level state between test runs. */
475export function resetComposerDraftsLoadState(): void {
476 loadPromise = null;
477 persistRetryNeeded = false;
478}
479
480function attachmentContextRecord(
481 attachment: DraftComposerAttachment,
482 contextId = ComposerContextId.make(attachment.id),
483) {
484 const common = {
485 version: 1 as const,
486 contextId,
487 label: sanitizeComposerContextLabel(attachment.name, attachment.type),
488 attachmentId: attachment.id,
489 name: attachment.name,
490 mimeType: attachment.mimeType,
491 sizeBytes: attachment.sizeBytes,
492 };
493 // A picture picked through the document picker is typed as a plain file, but the
494 // record has to say what it is or no client will offer to open it as an image.
495 return attachment.type === "image" || imageMimeType(attachment) !== null
496 ? { ...common, kind: "image" as const }
497 : { ...common, kind: "file" as const };
498}
499
500/** Older drafts stored documents only in the attachment strip. Restore their missing chips. */
501function restoreMissingComposerFileReferences(draft: ComposerDraft): ComposerDraft {
502 const records = [...(draft.context?.records ?? [])];
503 const usedIds = new Set<string>(records.map((record) => record.contextId));
504 const referenced = new Set(
505 collectComposerContextReferences(draft.text).map(
506 (reference) => `${reference.kind}:${reference.contextId}`,
507 ),
508 );
509 let text = draft.text;
510 let changed = false;
511 for (const attachment of draft.attachments) {
512 if (
513 attachment.type === "image" ||
514 imageMimeType(attachment) !== null ||
515 videoMimeType(attachment) !== null
516 )
517 continue;
518 let record = records.find(
519 (candidate) =>
520 candidate.kind === "file" &&
521 "attachmentId" in candidate &&
522 candidate.attachmentId === attachment.id,
523 );
524 if (!record) {
525 const baseId = attachment.id.replace(/[^a-z0-9_-]/gi, "_").slice(0, 110) || "file";
526 let contextId = baseId;
527 for (let suffix = 2; usedIds.has(contextId); suffix += 1) contextId = `${baseId}_${suffix}`;
528 usedIds.add(contextId);
529 record = attachmentContextRecord(attachment, ComposerContextId.make(contextId));
530 records.push(record);
531 changed = true;
532 }
533 const key = `${record.kind}:${record.contextId}`;
534 if (referenced.has(key)) continue;
535 text += `${text.length > 0 && !/\s$/.test(text) ? " " : ""}${formatComposerContextReference(record)} `;
536 referenced.add(key);
537 changed = true;
538 }
539 return changed ? { ...draft, text, context: { version: 1, records } } : draft;
540}
541
542function normalizeDraft(draft: ComposerDraft | undefined): ComposerDraft {
543 if (!draft) {
544 return EMPTY_DRAFT;
545 }
546 return {
547 ...draft,
548 text: draft.text,
549 attachments: draft.attachments,
550 };
551}
552
553export function getComposerDraftSnapshot(draftKey: string): ComposerDraft {
554 return normalizeDraft(appAtomRegistry.get(composerDraftsAtom)[draftKey]);
555}
556
557export function isComposerDraftEmpty(draft: ComposerDraft): boolean {
558 return isEmptyDraft(draft);
559}
560
561// The project stamp is identity, not content: a new-task draft with nothing
562// else in it is still empty and gets dropped like any other.
563function isEmptyDraft(draft: ComposerDraft): boolean {
564 return (
565 draft.text.length === 0 &&
566 draft.attachments.length === 0 &&
567 draft.modelSelection === undefined &&
568 draft.runtimeMode === undefined &&
569 draft.interactionMode === undefined &&
570 draft.workspaceSelection === undefined
571 );
572}
573
574/**
575 * Writes a draft back, dropping it once empty. A new-task draft keeps its
576 * entry while the composer is bound to it (the project stamp is what the
577 * composer binds to); the persist sweep still leaves empty ones off disk.
578 */
579function withComposerDraft(
580 current: Record<string, ComposerDraft>,
581 draftKey: string,
582 draft: ComposerDraft,
583): Record<string, ComposerDraft> {
584 if (isEmptyDraft(draft) && draft.project === undefined) {
585 const next = { ...current };
586 delete next[draftKey];
587 return next;
588 }
589 return { ...current, [draftKey]: draft };
590}
591
592export { isNewTaskDraftKey, newTaskDraftKey } from "./new-task-draft-key";
593
594// Draft ids only need to be unique within this device's draft file. Deriving
595// them from time plus randomness keeps this module free of native imports,
596// which the persistence tests rely on.
597function newDraftId(): string {
598 return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
599}
600
601/**
602 * Project-keyed new-task drafts from earlier builds are rewritten on load into
603 * id-keyed drafts with the project stamped in, so existing drafts survive the
604 * switch to many-per-project.
605 */
606export function migrateLegacyNewTaskDraft(
607 key: string,
608 draft: ComposerDraft,
609 now: string,
610): readonly [key: string, draft: ComposerDraft] {
611 const restored = restoreMissingComposerFileReferences(draft);
612 const legacy = draft.project === undefined ? parseLegacyNewTaskDraftKey(key) : null;
613 if (legacy === null) {
614 return [key, restored];
615 }
616 return [
617 newTaskDraftKey(newDraftId()),
618 {
619 ...restored,
620 project: {
621 environmentId: EnvironmentIdSchema.make(legacy.environmentId),
622 projectId: ProjectIdSchema.make(legacy.projectId),
623 createdAt: now,
624 },
625 },
626 ];
627}
628
629export function decodePersistedComposerState(value: unknown): {
630 readonly drafts: Record<string, ComposerDraft>;
631 readonly stickyModelSelection: ModelSelection | null;
632 readonly modelOptionMemory: ModelOptionMemoryState;
633 readonly cloudDrafts: ComposerCloudDraftState;
634} {
635 const parsed = decodePersistedComposerDraftsDocument(value);
636 const now = new Date().toISOString();
637 return {
638 drafts: Object.fromEntries(
639 Object.entries(parsed.drafts)
640 .map(([key, draft]) =>
641 migrateLegacyNewTaskDraft(
642 key,
643 // Stale new-task drafts left on disk by builds before the
644 // model-precedence fix carry a bare modelSelection with no
645 // other selector settings. Strip it so the next compose pass
646 // re-resolves project → sticky → provider defaults. Drafts
647 // with runtime/interaction/workspace settings or actual text /
648 // attachments were deliberately configured and are left alone.
649 isNewTaskDraftKey(key) &&
650 draft.modelSelection &&
651 draft.text.length === 0 &&
652 draft.attachments.length === 0 &&
653 draft.runtimeMode === undefined &&
654 draft.interactionMode === undefined &&
655 draft.workspaceSelection === undefined
656 ? { ...draft, modelSelection: undefined }
657 : draft,
658 now,
659 ),
660 )
661 // importedShareIds are share-import receipts: a contentless draft
662 // carrying one is not empty, or the same native share would be
663 // re-imported after restart.
664 .filter(([, draft]) => !isEmptyDraft(draft) || (draft.importedShareIds?.length ?? 0) > 0)
665 // Queued-message edits are in-memory sessions. Their draft outlives a
666 // restart on disk, but the edit record that says which run it belongs
667 // to does not, so the draft would be unreachable and invisible.
668 .filter(([key]) => !isQueuedEditDraftKey(key)),
669 ),
670 stickyModelSelection: parsed.stickyModelSelection ?? null,
671 modelOptionMemory: parsed.modelOptionMemory ?? {},
672 cloudDrafts: {
673 accountId: parsed.cloudAccountId ?? null,
674 signedOut: Object.fromEntries(
675 Object.entries(parsed.signedOutDrafts ?? {}).map(([id, saved]) => [
676 id,
677 {
678 // Archived drafts come back through restoreCloudComposerDrafts
679 // without another decode, so they get the same key migration.
680 drafts: Object.fromEntries(
681 Object.entries(saved.drafts).map(([key, draft]) =>
682 migrateLegacyNewTaskDraft(key, draft, now),
683 ),
684 ),
685 queuedMessages: saved.queuedMessages.map(decodeQueuedThreadMessage),
686 },
687 ]),
688 ),
689 },
690 };
691}
692
693async function getComposerDraftsFile() {
694 const { Directory, File, Paths } = await import("expo-file-system");
695 const directory = new Directory(Paths.document, COMPOSER_DRAFTS_DIRECTORY);
696 directory.create({ idempotent: true, intermediates: true });
697 return new File(directory, COMPOSER_DRAFTS_FILE);
698}
699
700async function loadPersistedComposerState(): Promise<
701 ReturnType<typeof decodePersistedComposerState>
702> {
703 let operation: ComposerDraftPersistenceError["operation"] = "open";
704 try {
705 const file = await getComposerDraftsFile();
706 if (!file.exists) {
707 return {
708 drafts: {},
709 stickyModelSelection: null,
710 modelOptionMemory: {},
711 cloudDrafts: { accountId: null, signedOut: {} },
712 };
713 }
714 operation = "read";
715 const raw = await file.text();
716 operation = "decode";
717 return decodePersistedComposerState(JSON.parse(raw) as unknown);
718 } catch (cause) {
719 throw new ComposerDraftPersistenceError({
720 operation,
721 directory: COMPOSER_DRAFTS_DIRECTORY,
722 fileName: COMPOSER_DRAFTS_FILE,
723 cause,
724 });
725 }
726}
727
728async function writePersistedComposerState(
729 drafts: Record<string, ComposerDraft>,
730 stickyModelSelection: ModelSelection | null,
731 cloudDrafts = appAtomRegistry.get(composerCloudDraftsAtom),
732): Promise<void> {
733 let operation: ComposerDraftPersistenceError["operation"] = "open";
734 try {
735 const file = await getComposerDraftsFile();
736 operation = "encode";
737 const nonEmptyDrafts = Object.fromEntries(
738 Object.entries(drafts).filter(([, draft]) => !isEmptyDraft(draft)),
739 );
740 const document = {
741 schemaVersion: COMPOSER_DRAFTS_SCHEMA_VERSION,
742 drafts: nonEmptyDrafts,
743 ...(stickyModelSelection ? { stickyModelSelection } : {}),
744 ...(Object.keys(appAtomRegistry.get(modelOptionMemoryAtom)).length > 0
745 ? { modelOptionMemory: appAtomRegistry.get(modelOptionMemoryAtom) }
746 : {}),
747 ...(cloudDrafts.accountId ? { cloudAccountId: cloudDrafts.accountId } : {}),
748 ...(Object.keys(cloudDrafts.signedOut).length > 0
749 ? {
750 signedOutDrafts: Object.fromEntries(
751 Object.entries(cloudDrafts.signedOut).map(([id, saved]) => [
752 id,
753 {
754 drafts: saved.drafts,
755 queuedMessages: saved.queuedMessages.map(encodeQueuedThreadMessage),
756 },
757 ]),
758 ),
759 }
760 : {}),
761 } as const;
762 const encoded = JSON.stringify(document);
763 operation = "write";
764 await writeFileAtomically(file, encoded);
765 } catch (cause) {
766 throw new ComposerDraftPersistenceError({
767 operation,
768 directory: COMPOSER_DRAFTS_DIRECTORY,
769 fileName: COMPOSER_DRAFTS_FILE,
770 cause,
771 });
772 }
773}
774
775/**
776 * Lands any debounced or in-flight draft write before the JS runtime is torn
777 * down (app update restart), so the freshest draft state survives it. A write
778 * failure propagates so the caller can decide whether the restart may proceed.
779 */
780export async function flushComposerDrafts(): Promise<void> {
781 // Never land a pre-hydration snapshot: persisted state must merge into the
782 // atoms first, or this write would clobber disk with partial data.
783 ensureComposerDraftsLoaded();
784 if (loadPromise !== null) {
785 await loadPromise;
786 }
787 // An edit during an awaited write schedules another debounced write, so
788 // keep landing snapshots until no debounce is pending after a queue drain.
789 do {
790 while (persistTimer !== null || persistRetryNeeded) {
791 if (persistTimer !== null) clearTimeout(persistTimer);
792 persistTimer = null;
793 persistRetryNeeded = false;
794 try {
795 await persistenceQueue.run(() =>
796 writePersistedComposerState(
797 appAtomRegistry.get(composerDraftsAtom),
798 appAtomRegistry.get(stickyComposerModelSelectionAtom),
799 ),
800 );
801 } catch (error) {
802 persistRetryNeeded = true;
803 throw error;
804 }
805 }
806 // Draining also waits for an already-fired debounce whose write is still
807 // gated behind its own hydration await inside the queue.
808 await persistenceQueue.run(() => Promise.resolve());
809 } while (persistTimer !== null || persistRetryNeeded);
810}
811
812function signedOutAttachmentOwners() {
813 return Object.values(appAtomRegistry.get(composerCloudDraftsAtom).signedOut).flatMap((saved) => [
814 ...Object.values(saved.drafts),
815 ...saved.queuedMessages,
816 ]);
817}
818
819/** Clipboard fragments can refer to a local file that has not finished uploading yet. */
820export function findLocalComposerClipboardAttachment(
821 environmentId: EnvironmentId,
822 id: string,
823): DraftComposerAttachment | undefined {
824 const queuedMessages = Object.values(
825 appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom),
826 ).flat();
827 for (const [key, draft] of Object.entries(appAtomRegistry.get(composerDraftsAtom))) {
828 if (composerDraftEnvironmentId(key, queuedMessages, draft) !== environmentId) continue;
829 const attachment = draft.attachments.find((entry) => entry.id === id);
830 if (attachment) return attachment;
831 }
832 return queuedMessages
833 .filter((message) => message.environmentId === environmentId)
834 .flatMap((message) => message.attachments)
835 .find((attachment) => attachment.id === id);
836}
837
838function isComposerAttachmentFileReferenced(fileUri: string): boolean {
839 if (isComposerAttachmentFileRetained(fileUri)) {
840 return true;
841 }
842 const referenceKey = composerAttachmentFileReferenceKey(fileUri);
843 const drafts = Object.values(appAtomRegistry.get(composerDraftsAtom));
844 const queuedMessages = Object.values(
845 appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom),
846 ).flat();
847 return [...drafts, ...queuedMessages, ...signedOutAttachmentOwners()].some((owner) =>
848 owner.attachments.some(
849 (attachment) =>
850 attachment.fileUri !== undefined &&
851 composerAttachmentFileReferenceKey(attachment.fileUri) === referenceKey,
852 ),
853 );
854}
855
856function isComposerAttachmentUploadReferenced(
857 environmentId: EnvironmentId,
858 attachmentId: string,
859): boolean {
860 const drafts = Object.values(appAtomRegistry.get(composerDraftsAtom));
861 const queuedMessages = Object.values(
862 appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom),
863 ).flat();
864 return [...drafts, ...queuedMessages, ...signedOutAttachmentOwners()].some((owner) =>
865 owner.attachments.some(
866 (attachment) =>
867 attachment.uploadEnvironmentId === environmentId &&
868 attachment.uploadedAttachmentId === attachmentId,
869 ),
870 );
871}
872
873export async function releaseUnusedComposerAttachmentFiles(
874 attachments: ReadonlyArray<DraftComposerAttachment>,
875): Promise<void> {
876 const candidates = new Set(
877 attachments.flatMap((attachment) =>
878 attachment.fileUri !== undefined ? [attachment.fileUri] : [],
879 ),
880 );
881 const uploadCandidates = new Map<EnvironmentId, Set<string>>();
882 for (const attachment of attachments) {
883 if (
884 attachment.uploadEnvironmentId === undefined ||
885 attachment.uploadedAttachmentId === undefined
886 ) {
887 continue;
888 }
889 const ids = uploadCandidates.get(attachment.uploadEnvironmentId) ?? new Set<string>();
890 ids.add(attachment.uploadedAttachmentId);
891 uploadCandidates.set(attachment.uploadEnvironmentId, ids);
892 }
893 if (candidates.size === 0 && uploadCandidates.size === 0) {
894 return;
895 }
896
897 // Persisted drafts must hydrate before the reference scan. On a cold start
898 // the atom is still empty, and every file a persisted draft owns would look
899 // unused. Hydrate before flushing so a pending pre-hydration write cannot
900 // land an incomplete snapshot either.
901 await waitForComposerDraftsLoaded();
902 await flushComposerDrafts();
903 if (!(await threadOutboxManager.load())) {
904 // An unreadable outbox store must not look like an empty queue: deleting
905 // now would take bytes a persisted queued message still needs. Skip the
906 // sweep; the next one retries hydration.
907 return;
908 }
909 await flushThreadOutbox();
910
911 const allFilesReferenced = [...candidates].every(isComposerAttachmentFileReferenced);
912 const allUploadsReferenced = [...uploadCandidates].every(([environmentId, attachmentIds]) =>
913 [...attachmentIds].every((attachmentId) =>
914 isComposerAttachmentUploadReferenced(environmentId, attachmentId),
915 ),
916 );
917 if (allFilesReferenced && allUploadsReferenced) {
918 return;
919 }
920
921 let incomingShareFileUris: ReadonlySet<string>;
922 try {
923 const { loadIncomingShareDrafts } = await import("../features/sharing/incoming-share-storage");
924 const incomingShares = await loadIncomingShareDrafts({ strict: true });
925 incomingShareFileUris = new Set(
926 incomingShares.flatMap((share) =>
927 share.attachments.flatMap((attachment) =>
928 attachment.fileUri !== undefined
929 ? [composerAttachmentFileReferenceKey(attachment.fileUri)]
930 : [],
931 ),
932 ),
933 );
934 } catch (error) {
935 console.warn("[composer-attachments] could not verify incoming share ownership", error);
936 return;
937 }
938
939 const { removePersistedComposerAttachmentFile } = await import("../lib/composerImages");
940 for (const fileUri of candidates) {
941 // Re-check ownership immediately before each deletion: a restore or edit
942 // can re-own a file after an earlier scan decided it was unused.
943 if (
944 isComposerAttachmentFileReferenced(fileUri) ||
945 incomingShareFileUris.has(composerAttachmentFileReferenceKey(fileUri))
946 ) {
947 continue;
948 }
949 await removePersistedComposerAttachmentFile(fileUri);
950 }
951
952 if (uploadCandidates.size > 0) {
953 const { releasePendingAttachmentUploads } = await import("../lib/attachmentUpload");
954 for (const [environmentId, attachmentIds] of uploadCandidates) {
955 for (const attachmentId of attachmentIds) {
956 // A different draft or queued message can reuse the same pending
957 // upload with another local URI. Re-check the server-side ownership
958 // key immediately before deletion.
959 if (isComposerAttachmentUploadReferenced(environmentId, attachmentId)) {
960 continue;
961 }
962 try {
963 await releasePendingAttachmentUploads(environmentId, [attachmentId]);
964 } catch (error) {
965 // The server expires stale pending uploads. Local discard must still
966 // complete when the environment is disconnected or deletion fails.
967 console.warn("[composer-attachments] could not remove pending upload", {
968 environmentId,
969 attachmentId,
970 error,
971 });
972 }
973 }
974 }
975 }
976}
977
978export function scheduleUnusedComposerAttachmentCleanup(
979 attachments: ReadonlyArray<DraftComposerAttachment>,
980): void {
981 if (
982 !attachments.some(
983 (attachment) =>
984 attachment.fileUri !== undefined || attachment.uploadedAttachmentId !== undefined,
985 )
986 ) {
987 return;
988 }
989 void releaseUnusedComposerAttachmentFiles(attachments).catch((error) => {
990 console.warn("[composer-attachments] could not remove unused files", error);
991 });
992}
993
994/**
995 * Owner-side cleanup hook for the shared preview-retention helper: releasing
996 * the last preview/upload lease retries the unused-file sweep. Registered here
997 * because this module owns the draft and outbox references the sweep reads.
998 */
999registerComposerAttachmentUnusedHandler((attachment) => {
1000 scheduleUnusedComposerAttachmentCleanup([attachment]);
1001});
1002
1003export function schedulePersistComposerState(): void {
1004 if (persistTimer !== null) {
1005 clearTimeout(persistTimer);
1006 }
1007 persistTimer = setTimeout(() => {
1008 persistTimer = null;
1009 // The write enters the serialization queue before waiting on hydration,
1010 // so flushComposerDrafts' queue drain cannot resolve ahead of it.
1011 void persistenceQueue.run(async () => {
1012 try {
1013 await waitForComposerDraftsLoaded();
1014 await writePersistedComposerState(
1015 appAtomRegistry.get(composerDraftsAtom),
1016 appAtomRegistry.get(stickyComposerModelSelectionAtom),
1017 );
1018 persistRetryNeeded = false;
1019 } catch (error) {
1020 // A failed debounce has no timer left. A later final flush must retry
1021 // these edits after persisted ownership can be read safely.
1022 persistRetryNeeded = true;
1023 console.warn("[composer-drafts] failed to persist drafts", error);
1024 // Draft persistence is best-effort; in-memory drafts still keep working.
1025 }
1026 });
1027 }, PERSIST_DEBOUNCE_MS);
1028}
1029
1030export function ensureComposerDraftsLoaded(): void {
1031 if (loadPromise !== null) {
1032 return;
1033 }
1034 const loading = loadPersistedComposerState().then((persisted) => {
1035 appAtomRegistry.set(composerCloudDraftsAtom, persisted.cloudDrafts);
1036 if (Object.keys(persisted.drafts).length > 0) {
1037 const current = appAtomRegistry.get(composerDraftsAtom);
1038 appAtomRegistry.set(composerDraftsAtom, {
1039 ...persisted.drafts,
1040 ...current,
1041 });
1042 }
1043 if (
1044 persisted.stickyModelSelection !== null &&
1045 appAtomRegistry.get(stickyComposerModelSelectionAtom) === null
1046 ) {
1047 appAtomRegistry.set(stickyComposerModelSelectionAtom, persisted.stickyModelSelection);
1048 }
1049 if (Object.keys(persisted.modelOptionMemory).length > 0) {
1050 const current = appAtomRegistry.get(modelOptionMemoryAtom);
1051 appAtomRegistry.set(modelOptionMemoryAtom, {
1052 ...persisted.modelOptionMemory,
1053 ...Object.fromEntries(
1054 Object.entries(current).map(([instanceId, models]) => [
1055 instanceId,
1056 { ...(persisted.modelOptionMemory[instanceId] ?? {}), ...models },
1057 ]),
1058 ),
1059 });
1060 }
1061 });
1062 loadPromise = loading;
1063 // Handle fire-and-forget hook loads without swallowing failures from the
1064 // write and cleanup callers that await this same promise. A later call retries.
1065 void loading.catch((cause) => {
1066 if (loadPromise === loading) loadPromise = null;
1067 console.warn(
1068 "[composer-drafts] failed to hydrate drafts",
1069 cause instanceof ComposerDraftPersistenceError
1070 ? cause
1071 : new ComposerDraftPersistenceError({
1072 operation: "hydrate",
1073 directory: COMPOSER_DRAFTS_DIRECTORY,
1074 fileName: COMPOSER_DRAFTS_FILE,
1075 cause,
1076 }),
1077 );
1078 });
1079}
1080
1081/** Wait until persisted drafts have been merged into the in-memory composer state. */
1082export async function waitForComposerDraftsLoaded(): Promise<void> {
1083 ensureComposerDraftsLoaded();
1084 if (loadPromise !== null) {
1085 await loadPromise;
1086 }
1087}
1088
1089export async function getComposerCloudAccountId(): Promise<string | null> {
1090 await waitForComposerDraftsLoaded();
1091 return appAtomRegistry.get(composerCloudDraftsAtom).accountId;
1092}
1093
1094/** Save an account's local work before its relay environments are removed. */
1095export async function archiveCloudComposerDrafts(
1096 accountId: string | null,
1097 environmentIds: ReadonlySet<EnvironmentId>,
1098): Promise<void> {
1099 await waitForComposerDraftsLoaded();
1100 if (!(await threadOutboxManager.load())) throw new Error("Could not preserve queued messages.");
1101 await flushThreadOutbox();
1102 const cloud = appAtomRegistry.get(composerCloudDraftsAtom);
1103 const owner = accountId ?? cloud.accountId;
1104 if (owner === null) return;
1105 const queued = Object.values(
1106 appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom),
1107 ).flat();
1108 const current = appAtomRegistry.get(composerDraftsAtom);
1109 const remaining = { ...current };
1110 const savedDrafts = { ...cloud.signedOut[owner]?.drafts };
1111 for (const [key, draft] of Object.entries(current)) {
1112 const environmentId = composerDraftEnvironmentId(key, queued, draft);
1113 if (environmentId !== null && environmentIds.has(environmentId)) {
1114 savedDrafts[key] = draft;
1115 delete remaining[key];
1116 }
1117 }
1118 const savedMessages = new Map(
1119 (cloud.signedOut[owner]?.queuedMessages ?? []).map((message) => [message.messageId, message]),
1120 );
1121 for (const message of queued) {
1122 if (environmentIds.has(message.environmentId)) savedMessages.set(message.messageId, message);
1123 }
1124 appAtomRegistry.set(composerDraftsAtom, remaining);
1125 appAtomRegistry.set(composerCloudDraftsAtom, {
1126 // Keep the owner through removal. A crash or failed cleanup can retry it
1127 // on cold start before a different account activates.
1128 accountId: owner,
1129 signedOut: {
1130 ...cloud.signedOut,
1131 [owner]: { drafts: savedDrafts, queuedMessages: [...savedMessages.values()] },
1132 },
1133 });
1134 schedulePersistComposerState();
1135 await flushComposerDrafts();
1136}
1137
1138function sameDraftAttachmentIds(
1139 left: ReadonlyArray<DraftComposerAttachment>,
1140 right: ReadonlyArray<DraftComposerAttachment>,
1141): boolean {
1142 return (
1143 left.length === right.length &&
1144 left.every((attachment, index) => attachment.id === right[index]?.id)
1145 );
1146}
1147
1148/** An in-flight delivery can finish after sign-out took its snapshot. */
1149export async function removeDeliveredCloudQueuedMessage(
1150 message: QueuedThreadMessage,
1151): Promise<void> {
1152 await waitForComposerDraftsLoaded();
1153 const cloud = appAtomRegistry.get(composerCloudDraftsAtom);
1154 const signedOut = { ...cloud.signedOut };
1155 let changed = false;
1156 for (const [accountId, saved] of Object.entries(signedOut)) {
1157 const archived = saved.queuedMessages.find(
1158 (candidate) =>
1159 candidate.environmentId === message.environmentId &&
1160 candidate.messageId === message.messageId,
1161 );
1162 if (
1163 !archived ||
1164 archived.commandId !== message.commandId ||
1165 archived.threadId !== message.threadId ||
1166 archived.text !== message.text ||
1167 !sameDraftAttachmentIds(archived.attachments, message.attachments)
1168 )
1169 continue;
1170 // Upload ids may change during preparation; user edits must remain recoverable.
1171 if (
1172 JSON.stringify([
1173 archived.modelSelection,
1174 archived.runtimeMode,
1175 archived.interactionMode,
1176 archived.creation,
1177 ]) !==
1178 JSON.stringify([
1179 message.modelSelection,
1180 message.runtimeMode,
1181 message.interactionMode,
1182 message.creation,
1183 ])
1184 )
1185 continue;
1186 const editorKey = `pending-task:${message.messageId}`;
1187 const editor = saved.drafts[editorKey];
1188 if (
1189 editor &&
1190 (editor.text !== message.text ||
1191 !sameDraftAttachmentIds(editor.attachments, message.attachments) ||
1192 (editor.modelSelection !== undefined &&
1193 JSON.stringify(editor.modelSelection) !== JSON.stringify(message.modelSelection)) ||
1194 (editor.runtimeMode !== undefined && editor.runtimeMode !== message.runtimeMode) ||
1195 (editor.interactionMode !== undefined &&
1196 editor.interactionMode !== message.interactionMode) ||
1197 (editor.workspaceSelection !== undefined &&
1198 (editor.workspaceSelection.mode !== message.creation?.workspaceMode ||
1199 editor.workspaceSelection.branch !== message.creation?.branch ||
1200 editor.workspaceSelection.worktreePath !== message.creation?.worktreePath ||
1201 editor.workspaceSelection.pullRequestReference !==
1202 message.creation?.pullRequestReference ||
1203 (editor.workspaceSelection.startFromOrigin ?? false) !==
1204 (message.creation?.startFromOrigin ?? false))))
1205 )
1206 continue;
1207 const drafts = { ...saved.drafts };
1208 delete drafts[editorKey];
1209 signedOut[accountId] = {
1210 drafts,
1211 queuedMessages: saved.queuedMessages.filter((candidate) => candidate !== archived),
1212 };
1213 changed = true;
1214 }
1215 if (!changed) return;
1216 appAtomRegistry.set(composerCloudDraftsAtom, { ...cloud, signedOut });
1217 schedulePersistComposerState();
1218 try {
1219 await flushComposerDrafts();
1220 } catch (error) {
1221 // The live outbox can still remove this acknowledged message. Keep the
1222 // archive update pending so a later successful flush lands it too.
1223 schedulePersistComposerState();
1224 throw error;
1225 }
1226}
1227
1228/** Restores only this account, before its connections can deliver queued turns. */
1229export async function restoreCloudComposerDrafts(accountId: string): Promise<void> {
1230 await waitForComposerDraftsLoaded();
1231 const cloud = appAtomRegistry.get(composerCloudDraftsAtom);
1232 const saved = cloud.signedOut[accountId];
1233 if (saved) {
1234 if (!(await threadOutboxManager.load())) throw new Error("Could not restore queued messages.");
1235 for (const message of saved.queuedMessages) {
1236 const alreadyQueued = Object.values(
1237 appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom),
1238 )
1239 .flat()
1240 .some((current) => current.messageId === message.messageId);
1241 if (!alreadyQueued) await threadOutboxManager.enqueue(message);
1242 }
1243 updateComposerDrafts((current) => {
1244 const restored = { ...current };
1245 for (const [key, draft] of Object.entries(saved.drafts)) {
1246 const existing = current[key];
1247 const attachmentIds = new Set(existing?.attachments.map((attachment) => attachment.id));
1248 restored[key] = existing
1249 ? {
1250 ...draft,
1251 ...existing,
1252 text: mergeComposerDraftText(existing.text, draft.text),
1253 context: mergeReferencedComposerContext(
1254 mergeComposerDraftText(existing.text, draft.text),
1255 draft.context,
1256 existing.context,
1257 ),
1258 // A concurrent import must not lose files, even above the send limit.
1259 attachments: [
1260 ...existing.attachments,
1261 ...draft.attachments.filter((attachment) => !attachmentIds.has(attachment.id)),
1262 ],
1263 importedShareIds: [
1264 ...new Set([
1265 ...(existing.importedShareIds ?? []),
1266 ...(draft.importedShareIds ?? []),
1267 ]),
1268 ],
1269 }
1270 : draft;
1271 }
1272 return restored;
1273 });
1274 }
1275 const signedOut = { ...cloud.signedOut };
1276 delete signedOut[accountId];
1277 appAtomRegistry.set(composerCloudDraftsAtom, { accountId, signedOut });
1278 schedulePersistComposerState();
1279 await flushComposerDrafts();
1280}
1281
1282function updateComposerDrafts(
1283 update: (current: Record<string, ComposerDraft>) => Record<string, ComposerDraft>,
1284): void {
1285 const current = appAtomRegistry.get(composerDraftsAtom);
1286 const next = update(current);
1287 if (next === current) {
1288 return;
1289 }
1290 appAtomRegistry.set(composerDraftsAtom, next);
1291 schedulePersistComposerState();
1292}
1293
1294export function setStickyComposerModelSelection(modelSelection: ModelSelection): void {
1295 appAtomRegistry.set(stickyComposerModelSelectionAtom, modelSelection);
1296 schedulePersistComposerState();
1297}
1298
1299export function setComposerDraftText(draftKey: string, value: string): void {
1300 let removed: ReadonlyArray<DraftComposerAttachment> = [];
1301 updateComposerDrafts((current) => {
1302 const existing = normalizeDraft(current[draftKey]);
1303 const context = referencedComposerContext(value, existing.context);
1304 const draft = withReferencedContextFiles(existing, value, context);
1305 removed = existing.attachments.filter((attachment) => !draft.attachments.includes(attachment));
1306 return withComposerDraft(current, draftKey, draft);
1307 });
1308 scheduleUnusedComposerAttachmentCleanup(removed);
1309}
1310
1311export function insertComposerDraftText(
1312 draftKey: string,
1313 value: string,
1314 target: ComposerDraftInsertion,
1315): void {
1316 const draft = getComposerDraftSnapshot(draftKey);
1317 const { start, end } = contextInsertionRange(draftKey, draft, target);
1318 const text = draft.text.slice(0, start) + value + draft.text.slice(end);
1319 setComposerDraftText(draftKey, text);
1320 rememberComposerDraftSelection(draftKey, text, {
1321 start: start + value.length,
1322 end: start + value.length,
1323 });
1324}
1325
1326export function appendComposerDraftText(draftKey: string, value: string): void {
1327 updateComposerDrafts((current) => {
1328 const existing = normalizeDraft(current[draftKey]);
1329 return {
1330 ...current,
1331 [draftKey]: {
1332 ...existing,
1333 text: `${existing.text}${value}`,
1334 },
1335 };
1336 });
1337}
1338
1339/**
1340 * Appends attachments to a draft, capped at the send limit against the draft's
1341 * live state (callers may have counted before an await; the picker can race
1342 * concurrent adds). Overflowed file attachments are released. Returns how many
1343 * were rejected. Restore paths pass allowOverflow so a failed send never drops
1344 * the message's own attachments.
1345 */
1346export function appendComposerDraftAttachments(
1347 draftKey: string,
1348 attachments: ReadonlyArray<DraftComposerAttachment>,
1349 options?: {
1350 readonly allowOverflow?: boolean;
1351 readonly appendReference?: boolean;
1352 readonly insertion?: ComposerDraftInsertion;
1353 readonly maxAttachments?: number;
1354 },
1355): number {
1356 if (attachments.length === 0) {
1357 return 0;
1358 }
1359 let rejected: ReadonlyArray<DraftComposerAttachment> = [];
1360 let removed: ReadonlyArray<DraftComposerAttachment> = [];
1361 updateComposerDrafts((current) => {
1362 const existing = normalizeDraft(current[draftKey]);
1363 const retained = options?.appendReference
1364 ? draftWithoutInsertionSelection(draftKey, existing, options.insertion)
1365 : existing;
1366 const remaining = options?.allowOverflow
1367 ? attachments.length
1368 : Math.max(
1369 0,
1370 Math.min(
1371 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
1372 options?.maxAttachments ?? PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
1373 ) - retained.attachments.length,
1374 );
1375 const contextCapacity = options?.appendReference
1376 ? Math.max(0, COMPOSER_CONTEXT_MAX_RECORDS - (retained.context?.records.length ?? 0))
1377 : attachments.length;
1378 const accepted = attachments.slice(0, Math.min(remaining, contextCapacity));
1379 rejected = attachments.slice(accepted.length);
1380 if (accepted.length === 0) {
1381 return current;
1382 }
1383 let draft = { ...existing, attachments: [...existing.attachments, ...accepted] };
1384 if (options?.appendReference) {
1385 const records = accepted.map((attachment) => attachmentContextRecord(attachment));
1386 const inserted = draftWithInsertedContext(
1387 draftKey,
1388 draft,
1389 {
1390 text: records.map(formatComposerContextReference).join(" "),
1391 context: { version: 1, records },
1392 },
1393 options?.insertion,
1394 );
1395 if (!inserted) {
1396 rejected = attachments;
1397 return current;
1398 }
1399 draft = { ...inserted, attachments: [...inserted.attachments] };
1400 removed = existing.attachments.filter(
1401 (attachment) => !draft.attachments.includes(attachment),
1402 );
1403 }
1404 return {
1405 ...current,
1406 [draftKey]: draft,
1407 };
1408 });
1409 scheduleUnusedComposerAttachmentCleanup([...rejected, ...removed]);
1410 return rejected.length;
1411}
1412
1413export function replaceComposerDraftAttachments(
1414 draftKey: string,
1415 attachments: ReadonlyArray<DraftComposerAttachment>,
1416): void {
1417 const previousAttachments = getComposerDraftSnapshot(draftKey).attachments;
1418 const retainedIds = new Set(attachments.map((attachment) => attachment.id));
1419 updateComposerDrafts((current) => {
1420 const existing = normalizeDraft(current[draftKey]);
1421 // An attachment that is no longer here must take its chip and context record with it, or
1422 // the draft keeps a reference pointing at a file it no longer holds.
1423 const droppedContextIds = new Set(
1424 existing.context?.records
1425 .filter((record) => "attachmentId" in record && !retainedIds.has(record.attachmentId))
1426 .map((record) => record.contextId),
1427 );
1428 const text = replaceComposerContextReferences(existing.text, (ref) =>
1429 droppedContextIds.has(ref.contextId) ? "" : ref.source,
1430 );
1431 const draft = {
1432 ...existing,
1433 text,
1434 context: referencedComposerContext(text, existing.context),
1435 attachments,
1436 };
1437 return withComposerDraft(current, draftKey, draft);
1438 });
1439 scheduleUnusedComposerAttachmentCleanup(
1440 previousAttachments.filter((attachment) => !retainedIds.has(attachment.id)),
1441 );
1442}
1443
1444export function removeComposerDraftAttachment(draftKey: string, imageId: string): void {
1445 const previousAttachments = getComposerDraftSnapshot(draftKey).attachments;
1446 updateComposerDrafts((current) => {
1447 const existing = normalizeDraft(current[draftKey]);
1448 const removedIds = new Set(
1449 existing.context?.records
1450 .filter((record) => "attachmentId" in record && record.attachmentId === imageId)
1451 .map((record) => record.contextId),
1452 );
1453 const text = replaceComposerContextReferences(existing.text, (ref) =>
1454 removedIds.has(ref.contextId) ? "" : ref.source,
1455 );
1456 const draft = {
1457 ...existing,
1458 text,
1459 context: referencedComposerContext(text, existing.context),
1460 attachments: existing.attachments.filter((image) => image.id !== imageId),
1461 };
1462 return withComposerDraft(current, draftKey, draft);
1463 });
1464 scheduleUnusedComposerAttachmentCleanup(
1465 previousAttachments.filter((attachment) => attachment.id === imageId),
1466 );
1467}
1468
1469/** Stamps a finished upload without overwriting text, removals, or newer attachments. */
1470export function setComposerDraftAttachmentUpload(
1471 draftKey: string,
1472 attachment: DraftComposerAttachment,
1473): boolean {
1474 let previous: DraftComposerAttachment | undefined;
1475 updateComposerDrafts((current) => {
1476 const draft = current[draftKey];
1477 previous = draft?.attachments.find((candidate) => candidate.id === attachment.id);
1478 if (!draft || !previous) return current;
1479 if (
1480 previous.uploadedAttachmentId === attachment.uploadedAttachmentId &&
1481 previous.uploadEnvironmentId === attachment.uploadEnvironmentId
1482 )
1483 return current;
1484 return {
1485 ...current,
1486 [draftKey]: {
1487 ...draft,
1488 attachments: draft.attachments.map((candidate) =>
1489 candidate.id === attachment.id
1490 ? {
1491 ...candidate,
1492 uploadedAttachmentId: attachment.uploadedAttachmentId,
1493 uploadEnvironmentId: attachment.uploadEnvironmentId,
1494 }
1495 : candidate,
1496 ),
1497 },
1498 };
1499 });
1500 if (previous) scheduleUnusedComposerAttachmentCleanup([previous]);
1501 return previous !== undefined;
1502}
1503
1504export function updateComposerDraftSettings(
1505 draftKey: string,
1506 settings: Partial<ComposerDraftSettingsUpdate>,
1507): void {
1508 updateComposerDrafts((current) => {
1509 const draft = {
1510 ...normalizeDraft(current[draftKey]),
1511 ...settings,
1512 };
1513 return withComposerDraft(current, draftKey, draft);
1514 });
1515}
1516
1517export function clearComposerDraftContentState(
1518 current: Record<string, ComposerDraft>,
1519 draftKey: string,
1520 options?: {
1521 readonly clearModelSelection?: boolean;
1522 readonly clearWorkspaceSelection?: boolean;
1523 },
1524): Record<string, ComposerDraft> {
1525 const existing = current[draftKey];
1526 if (!existing) {
1527 return current;
1528 }
1529 // Clearing content is the "this draft is done" moment (sent, queued, or
1530 // discarded), so the project stamp goes too and an otherwise-empty new-task
1531 // draft leaves the store rather than lingering as a blank row.
1532 const {
1533 importedShareIds: _importedShareIds,
1534 context: _context,
1535 modelSelection,
1536 workspaceSelection,
1537 project: _project,
1538 ...retained
1539 } = existing;
1540 const draft = {
1541 ...retained,
1542 ...(options?.clearModelSelection || modelSelection === undefined ? {} : { modelSelection }),
1543 ...(options?.clearWorkspaceSelection || workspaceSelection === undefined
1544 ? {}
1545 : { workspaceSelection }),
1546 text: "",
1547 attachments: [],
1548 };
1549 if (isEmptyDraft(draft)) {
1550 const next = { ...current };
1551 delete next[draftKey];
1552 return next;
1553 }
1554 return {
1555 ...current,
1556 [draftKey]: draft,
1557 };
1558}
1559
1560export function restoreComposerDraftSnapshotState(
1561 current: Record<string, ComposerDraft>,
1562 draftKey: string,
1563 snapshot: ComposerDraft,
1564): Record<string, ComposerDraft> {
1565 const next = { ...current };
1566 if (isEmptyDraft(snapshot)) {
1567 delete next[draftKey];
1568 } else {
1569 next[draftKey] = snapshot;
1570 }
1571 return next;
1572}
1573
1574function stripAttachmentUploadReference(
1575 attachment: DraftComposerAttachment,
1576): DraftComposerAttachment {
1577 const { uploadedAttachmentId: _id, uploadEnvironmentId: _environmentId, ...rest } = attachment;
1578 return rest;
1579}
1580
1581function mergeComposerDraftText(existing: string, incoming: string): string {
1582 if (incoming.length === 0) {
1583 return existing;
1584 }
1585 if (existing.length === 0) {
1586 return incoming;
1587 }
1588 // Import retries are possible after an interrupted native handoff. Keep the
1589 // operation idempotent when the same shared text is already present.
1590 if (existing === incoming || existing.endsWith(`\n\n${incoming}`)) {
1591 return existing;
1592 }
1593 return `${existing}\n\n${incoming}`;
1594}
1595
1596function mergeReferencedComposerContext(
1597 text: string,
1598 first?: OrchestrationMessageContext,
1599 second?: OrchestrationMessageContext,
1600) {
1601 const records = new Map((first?.records ?? []).map((record) => [record.contextId, record]));
1602 for (const record of second?.records ?? []) records.set(record.contextId, record);
1603 if (records.size === 0) return undefined;
1604 return referencedComposerContext(text, { version: 1, records: [...records.values()] });
1605}
1606
1607export function mergeComposerDraftContentState(
1608 current: Record<string, ComposerDraft>,
1609 draftKey: string,
1610 content: ComposerDraftContent,
1611): Record<string, ComposerDraft> {
1612 const existing = normalizeDraft(current[draftKey]);
1613 if (content.sourceShareId && existing.importedShareIds?.includes(content.sourceShareId)) {
1614 return current;
1615 }
1616 const attachmentIds = new Set(existing.attachments.map((attachment) => attachment.id));
1617 const incomingAttachments = content.attachments.filter((attachment) => {
1618 if (attachmentIds.has(attachment.id)) {
1619 return false;
1620 }
1621 attachmentIds.add(attachment.id);
1622 return true;
1623 });
1624 const attachments = [...existing.attachments, ...incomingAttachments].slice(
1625 0,
1626 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
1627 );
1628 const text = mergeComposerDraftText(existing.text, content.text);
1629 const context = mergeReferencedComposerContext(text, existing.context, content.context);
1630 const importedShareIds = content.sourceShareId
1631 ? [...(existing.importedShareIds ?? []), content.sourceShareId]
1632 : existing.importedShareIds;
1633 if (
1634 text === existing.text &&
1635 attachments.length === existing.attachments.length &&
1636 content.context === undefined &&
1637 importedShareIds === existing.importedShareIds
1638 ) {
1639 return current;
1640 }
1641 return {
1642 ...current,
1643 [draftKey]: {
1644 ...existing,
1645 text,
1646 attachments,
1647 context,
1648 ...(importedShareIds ? { importedShareIds } : {}),
1649 },
1650 };
1651}
1652
1653/**
1654 * Atomically moves an incoming share into a project-scoped composer draft.
1655 * The durable write happens before the share inbox item can be acknowledged.
1656 */
1657export async function mergeComposerDraftContent(
1658 draftKey: string,
1659 content: ComposerDraftContent,
1660): Promise<{ readonly skippedAttachmentCount: number }> {
1661 ensureComposerDraftsLoaded();
1662 if (loadPromise !== null) {
1663 await loadPromise;
1664 }
1665 if (persistTimer !== null) {
1666 clearTimeout(persistTimer);
1667 persistTimer = null;
1668 }
1669 const current = appAtomRegistry.get(composerDraftsAtom);
1670 const next = mergeComposerDraftContentState(current, draftKey, content);
1671 const currentAttachmentIds = new Set(
1672 normalizeDraft(current[draftKey]).attachments.map((attachment) => attachment.id),
1673 );
1674 const nextAttachmentIds = new Set(
1675 normalizeDraft(next[draftKey]).attachments.map((attachment) => attachment.id),
1676 );
1677 const skippedAttachmentCount = content.attachments.filter(
1678 (attachment) =>
1679 !currentAttachmentIds.has(attachment.id) && !nextAttachmentIds.has(attachment.id),
1680 ).length;
1681 // Publish the content and its import receipt together before the filesystem
1682 // await. Typing during persistence then builds on the receipt-bearing state,
1683 // and its debounced write is serialized after this transaction.
1684 if (next !== current) {
1685 appAtomRegistry.set(composerDraftsAtom, next);
1686 }
1687 await persistenceQueue.run(() =>
1688 writePersistedComposerState(next, appAtomRegistry.get(stickyComposerModelSelectionAtom)),
1689 );
1690 return { skippedAttachmentCount };
1691}
1692
1693/** Restores the exact content/settings captured before an interrupted import. */
1694export async function restoreComposerDraftSnapshot(
1695 draftKey: string,
1696 snapshot: ComposerDraft,
1697): Promise<void> {
1698 ensureComposerDraftsLoaded();
1699 if (loadPromise !== null) {
1700 await loadPromise;
1701 }
1702 if (persistTimer !== null) {
1703 clearTimeout(persistTimer);
1704 persistTimer = null;
1705 }
1706 const next = restoreComposerDraftSnapshotState(
1707 appAtomRegistry.get(composerDraftsAtom),
1708 draftKey,
1709 snapshot,
1710 );
1711 appAtomRegistry.set(composerDraftsAtom, next);
1712 await persistenceQueue.run(() =>
1713 writePersistedComposerState(next, appAtomRegistry.get(stickyComposerModelSelectionAtom)),
1714 );
1715}
1716
1717export function sameComposerDraftState(a: ComposerDraft, b: ComposerDraft): boolean {
1718 return (
1719 a.text === b.text &&
1720 a.attachments === b.attachments &&
1721 a.context === b.context &&
1722 a.importedShareIds === b.importedShareIds &&
1723 a.modelSelection === b.modelSelection &&
1724 a.runtimeMode === b.runtimeMode &&
1725 a.interactionMode === b.interactionMode &&
1726 a.workspaceSelection === b.workspaceSelection
1727 );
1728}
1729
1730/**
1731 * Undoes an abandoned mergeComposerDraftContent. When the draft is untouched
1732 * since `merged` (the state captured right after the merge), the pre-merge
1733 * snapshot comes back exactly. When the user edited the draft during the
1734 * merge's awaits, only what the merge inserted (the appended text and the new
1735 * attachments) is taken back out, so the user's edits survive the rollback.
1736 */
1737export function undoComposerDraftMergeState(
1738 current: Record<string, ComposerDraft>,
1739 draftKey: string,
1740 snapshot: ComposerDraft,
1741 merged: ComposerDraft,
1742): Record<string, ComposerDraft> {
1743 const existing = normalizeDraft(current[draftKey]);
1744 if (sameComposerDraftState(existing, merged)) {
1745 return restoreComposerDraftSnapshotState(current, draftKey, snapshot);
1746 }
1747 const insertedText = merged.text.startsWith(snapshot.text)
1748 ? merged.text.slice(snapshot.text.length)
1749 : "";
1750 const snapshotAttachmentIds = new Set(snapshot.attachments.map((attachment) => attachment.id));
1751 const insertedAttachmentIds = new Set(
1752 merged.attachments
1753 .filter((attachment) => !snapshotAttachmentIds.has(attachment.id))
1754 .map((attachment) => attachment.id),
1755 );
1756 // A setting still holding the merge's value is the merge's doing: restore
1757 // the snapshot's. One the user changed since the merge stays theirs.
1758 const undoSetting = <
1759 K extends "modelSelection" | "runtimeMode" | "interactionMode" | "workspaceSelection",
1760 >(
1761 key: K,
1762 ): ComposerDraft[K] => (existing[key] === merged[key] ? snapshot[key] : existing[key]);
1763 const text =
1764 insertedText.length > 0 && existing.text.startsWith(merged.text)
1765 ? snapshot.text + existing.text.slice(merged.text.length)
1766 : insertedText.length > 0 && existing.text.endsWith(insertedText)
1767 ? existing.text.slice(0, existing.text.length - insertedText.length)
1768 : existing.text;
1769 const draft = {
1770 ...existing,
1771 text,
1772 context: referencedComposerContext(text, existing.context),
1773 attachments: existing.attachments.filter(
1774 (attachment) => !insertedAttachmentIds.has(attachment.id),
1775 ),
1776 modelSelection: undoSetting("modelSelection"),
1777 runtimeMode: undoSetting("runtimeMode"),
1778 interactionMode: undoSetting("interactionMode"),
1779 workspaceSelection: undoSetting("workspaceSelection"),
1780 };
1781 return withComposerDraft(current, draftKey, draft);
1782}
1783
1784/** Applies undoComposerDraftMergeState and lands it durably. */
1785export async function undoComposerDraftMerge(
1786 draftKey: string,
1787 snapshot: ComposerDraft,
1788 merged: ComposerDraft,
1789): Promise<void> {
1790 ensureComposerDraftsLoaded();
1791 if (loadPromise !== null) {
1792 await loadPromise;
1793 }
1794 if (persistTimer !== null) {
1795 clearTimeout(persistTimer);
1796 persistTimer = null;
1797 }
1798 const next = undoComposerDraftMergeState(
1799 appAtomRegistry.get(composerDraftsAtom),
1800 draftKey,
1801 snapshot,
1802 merged,
1803 );
1804 appAtomRegistry.set(composerDraftsAtom, next);
1805 await persistenceQueue.run(() =>
1806 writePersistedComposerState(next, appAtomRegistry.get(stickyComposerModelSelectionAtom)),
1807 );
1808}
1809
1810export function clearComposerDraftContent(
1811 draftKey: string,
1812 options?: {
1813 readonly clearModelSelection?: boolean;
1814 readonly clearWorkspaceSelection?: boolean;
1815 // Send clears the draft while the durable outbox write is still in
1816 // flight. Sweeping then would race the write: a failed enqueue rolls the
1817 // message out of the queue mid-sweep and its files get deleted right
1818 // before the failure handler restores them. The sender re-schedules
1819 // cleanup once the write settles.
1820 readonly deferAttachmentCleanup?: boolean;
1821 },
1822): void {
1823 const previousAttachments = getComposerDraftSnapshot(draftKey).attachments;
1824 updateComposerDrafts((current) => clearComposerDraftContentState(current, draftKey, options));
1825 if (!options?.deferAttachmentCleanup) {
1826 scheduleUnusedComposerAttachmentCleanup(previousAttachments);
1827 }
1828}
1829
1830export function clearComposerDraft(
1831 draftKey: string,
1832 options?: { readonly deferAttachmentCleanup?: boolean },
1833): void {
1834 const previousAttachments = getComposerDraftSnapshot(draftKey).attachments;
1835 updateComposerDrafts((current) => {
1836 if (!current[draftKey]) {
1837 return current;
1838 }
1839 const next = { ...current };
1840 delete next[draftKey];
1841 return next;
1842 });
1843 if (!options?.deferAttachmentCleanup) {
1844 scheduleUnusedComposerAttachmentCleanup(previousAttachments);
1845 }
1846}
1847
1848export function removeComposerDraftsForEnvironment(
1849 drafts: Record<string, ComposerDraft>,
1850 environmentId: EnvironmentId,
1851): Record<string, ComposerDraft> {
1852 const environmentPrefix = `${environmentId}:`;
1853 return Object.fromEntries(
1854 Object.entries(drafts).filter(
1855 ([draftKey, draft]) =>
1856 !draftKey.startsWith(environmentPrefix) && draft.project?.environmentId !== environmentId,
1857 ),
1858 );
1859}
1860
1861/**
1862 * Mints a new-task draft for a project. The entry is published immediately so
1863 * the composer can bind to its key before the user types; it stays out of the
1864 * list until it has content, and the empty-draft sweep drops it on persist if
1865 * nothing is ever written.
1866 */
1867export function createNewTaskDraft(project: {
1868 readonly environmentId: EnvironmentId;
1869 readonly projectId: ProjectId;
1870}): string {
1871 const draftKey = newTaskDraftKey(newDraftId());
1872 const stamp: ComposerDraftProject = {
1873 environmentId: project.environmentId,
1874 projectId: project.projectId,
1875 createdAt: new Date().toISOString(),
1876 };
1877 updateComposerDrafts((current) => ({
1878 ...current,
1879 [draftKey]: { ...EMPTY_DRAFT, project: stamp },
1880 }));
1881 return draftKey;
1882}
1883
1884/**
1885 * Points an existing new-task draft at a different project, keeping its
1886 * content and identity. Workspace selection is project-specific (branch,
1887 * worktree), so it is cleared; model and mode choices carry over.
1888 */
1889export function retargetNewTaskDraft(
1890 draftKey: string,
1891 project: { readonly environmentId: EnvironmentId; readonly projectId: ProjectId },
1892): void {
1893 updateComposerDrafts((current) => {
1894 const existing = current[draftKey];
1895 const stamp = existing?.project;
1896 if (
1897 stamp !== undefined &&
1898 stamp.environmentId === project.environmentId &&
1899 stamp.projectId === project.projectId
1900 ) {
1901 return current;
1902 }
1903 const { workspaceSelection: _workspaceSelection, ...retained } = normalizeDraft(existing);
1904 // Pending uploads live on one server. Crossing environments keeps the
1905 // local bytes (the upload worker re-sends them to the new environment)
1906 // but drops the old stamp, so it cannot pin the source environment's
1907 // pending upload alive from the moved draft.
1908 const attachments = retained.attachments.map((attachment) =>
1909 attachment.uploadEnvironmentId !== undefined &&
1910 attachment.uploadEnvironmentId !== project.environmentId
1911 ? stripAttachmentUploadReference(attachment)
1912 : attachment,
1913 );
1914 return {
1915 ...current,
1916 [draftKey]: {
1917 ...retained,
1918 attachments,
1919 project: {
1920 environmentId: project.environmentId,
1921 projectId: project.projectId,
1922 createdAt: stamp?.createdAt ?? new Date().toISOString(),
1923 },
1924 },
1925 };
1926 });
1927}
1928
1929/** New-task drafts for a project, newest first. */
1930export function findNewTaskDraftKeys(
1931 drafts: Readonly<Record<string, ComposerDraft>>,
1932 project: { readonly environmentId: EnvironmentId; readonly projectId: ProjectId },
1933): ReadonlyArray<string> {
1934 return Object.entries(drafts)
1935 .filter(
1936 ([key, draft]) =>
1937 isNewTaskDraftKey(key) &&
1938 draft.project?.environmentId === project.environmentId &&
1939 draft.project.projectId === project.projectId,
1940 )
1941 .sort(([, left], [, right]) =>
1942 (right.project?.createdAt ?? "").localeCompare(left.project?.createdAt ?? ""),
1943 )
1944 .map(([key]) => key);
1945}
1946
1947export async function clearComposerDraftsEnvironment(environmentId: EnvironmentId): Promise<void> {
1948 ensureComposerDraftsLoaded();
1949 if (loadPromise !== null) {
1950 await loadPromise;
1951 }
1952
1953 const current = appAtomRegistry.get(composerDraftsAtom);
1954 const next = removeComposerDraftsForEnvironment(current, environmentId);
1955 const removedAttachments = Object.entries(current)
1956 .filter(([draftKey]) => next[draftKey] === undefined)
1957 .flatMap(([, draft]) => draft.attachments);
1958
1959 if (persistTimer !== null) {
1960 clearTimeout(persistTimer);
1961 persistTimer = null;
1962 }
1963 appAtomRegistry.set(composerDraftsAtom, next);
1964 await persistenceQueue.run(() =>
1965 writePersistedComposerState(next, appAtomRegistry.get(stickyComposerModelSelectionAtom)),
1966 );
1967 await releaseUnusedComposerAttachmentFiles(removedAttachments);
1968}
1969
1970export function useComposerDraft(draftKey: string | null): ComposerDraft {
1971 const drafts = useAtomValue(composerDraftsAtom);
1972 useEffect(() => {
1973 ensureComposerDraftsLoaded();
1974 }, []);
1975 return draftKey ? normalizeDraft(drafts[draftKey]) : EMPTY_DRAFT;
1976}
1977
1978export function useStickyComposerModelSelection(): ModelSelection | null {
1979 const selection = useAtomValue(stickyComposerModelSelectionAtom);
1980 useEffect(() => {
1981 ensureComposerDraftsLoaded();
1982 }, []);
1983 return selection;
1984}