import { useAtomValue } from "@effect/atom-react";import { EnvironmentId as EnvironmentIdSchema, ModelSelection as ModelSelectionSchema, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, ProjectId as ProjectIdSchema, ProviderInteractionMode as ProviderInteractionModeSchema, RuntimeMode as RuntimeModeSchema, type EnvironmentId, type ModelSelection, type ProjectId, type ProviderInteractionMode, type RuntimeMode,} from "@t3tools/contracts";import * as Schema from "effect/Schema";import { useEffect } from "react";import { Atom } from "effect/unstable/reactivity"; import { writeFileAtomically } from "../lib/atomic-file";import { DraftComposerAttachmentSchema } from "../lib/composer-image-schema";import { composerAttachmentFileReferenceKey, isComposerAttachmentFileRetained, retainComposerAttachmentFile,} from "../lib/composerAttachmentFiles";import type { DraftComposerAttachment, FileBackedComposerAttachment } from "../lib/composerImages";import { SerializedAsyncQueue } from "../lib/serialized-async-queue";import { appAtomRegistry } from "./atom-registry";import { isNewTaskDraftKey, newTaskDraftKey, parseLegacyNewTaskDraftKey,} from "./new-task-draft-key";import { decodeQueuedThreadMessage, encodeQueuedThreadMessage, QueuedThreadMessageSchema, type QueuedThreadMessage,} from "./thread-outbox-model";import { flushThreadOutbox, threadOutboxManager } from "./thread-outbox";import { composerDraftEnvironmentId } from "../lib/composerAttachmentUploadQueue"; const COMPOSER_DRAFTS_SCHEMA_VERSION = 1;const COMPOSER_DRAFTS_DIRECTORY = "composer-drafts";const COMPOSER_DRAFTS_FILE = "drafts.json";const PERSIST_DEBOUNCE_MS = 200; export class ComposerDraftPersistenceError extends Schema.TaggedError<ComposerDraftPersistenceError>()( "ComposerDraftPersistenceError", { operation: Schema.Literals(["open", "read", "decode", "encode", "write", "hydrate"]), directory: Schema.String, fileName: Schema.String, cause: Schema.Defect(), },) { override get message(): string { return `Composer draft persistence operation ${this.operation} failed for ${this.directory}/${this.fileName}.`; }} export interface ComposerDraft { readonly text: string; readonly attachments: ReadonlyArray<DraftComposerAttachment>; readonly importedShareIds?: ReadonlyArray<string>; readonly modelSelection?: ModelSelection; readonly runtimeMode?: RuntimeMode; readonly interactionMode?: ProviderInteractionMode; readonly workspaceSelection?: ComposerDraftWorkspaceSelection; /** * Set on new-task drafts only. The project is stored here rather than in * the key so a project can hold any number of drafts and a draft can be * retargeted to another project without changing identity. */ readonly project?: ComposerDraftProject;} export interface ComposerDraftProject { readonly environmentId: EnvironmentId; readonly projectId: ProjectId; readonly createdAt: string;} export interface ComposerDraftContent { readonly text: string; readonly attachments: ReadonlyArray<DraftComposerAttachment>; readonly sourceShareId?: string;} export interface ComposerDraftWorkspaceSelection { readonly mode: "local" | "worktree"; readonly branch: string | null; readonly worktreePath: string | null; readonly startFromOrigin?: boolean;} export type ComposerDraftSettingsUpdate = Pick< ComposerDraft, "modelSelection" | "runtimeMode" | "interactionMode" | "workspaceSelection" | "project">; const ComposerDraftWorkspaceSelectionSchema = Schema.Struct({ mode: Schema.Literals(["local", "worktree"]), branch: Schema.NullOr(Schema.String), worktreePath: Schema.NullOr(Schema.String), startFromOrigin: Schema.optional(Schema.Boolean),}); const ComposerDraftProjectSchema = Schema.Struct({ environmentId: EnvironmentIdSchema, projectId: ProjectIdSchema, createdAt: Schema.String,}); const ComposerDraftSchema = Schema.Struct({ text: Schema.String, attachments: Schema.Array(DraftComposerAttachmentSchema), importedShareIds: Schema.optional(Schema.Array(Schema.String)), modelSelection: Schema.optional(ModelSelectionSchema), runtimeMode: Schema.optional(RuntimeModeSchema), interactionMode: Schema.optional(ProviderInteractionModeSchema), workspaceSelection: Schema.optional(ComposerDraftWorkspaceSelectionSchema), project: Schema.optional(ComposerDraftProjectSchema),}); const PersistedComposerDraftsSchema = Schema.Struct({ schemaVersion: Schema.Literal(COMPOSER_DRAFTS_SCHEMA_VERSION), drafts: Schema.Record(Schema.String, ComposerDraftSchema), stickyModelSelection: Schema.optional(ModelSelectionSchema), cloudAccountId: Schema.optional(Schema.String), signedOutDrafts: Schema.optional( Schema.Record( Schema.String, Schema.Struct({ drafts: Schema.Record(Schema.String, ComposerDraftSchema), queuedMessages: Schema.Array(QueuedThreadMessageSchema), }), ), ),}); const decodePersistedComposerDraftsDocument = Schema.decodeUnknownSync( PersistedComposerDraftsSchema,); const EMPTY_DRAFT: ComposerDraft = { text: "", attachments: [],}; export const composerDraftsAtom = Atom.make<Record<string, ComposerDraft>>({}).pipe( Atom.keepAlive, Atom.withLabel("mobile:composer-drafts"),); export const stickyComposerModelSelectionAtom = Atom.make<ModelSelection | null>(null).pipe( Atom.keepAlive, Atom.withLabel("mobile:sticky-composer-model-selection"),); interface SignedOutDrafts { readonly drafts: Record<string, ComposerDraft>; readonly queuedMessages: ReadonlyArray<QueuedThreadMessage>;} interface ComposerCloudDraftState { readonly accountId: string | null; readonly signedOut: Record<string, SignedOutDrafts>;} export const composerCloudDraftsAtom = Atom.make<ComposerCloudDraftState>({ accountId: null, signedOut: {},}).pipe(Atom.keepAlive); let loadPromise: Promise<void> | null = null;let persistTimer: ReturnType<typeof setTimeout> | null = null;let persistRetryNeeded = false;const persistenceQueue = new SerializedAsyncQueue(); /** Resets module-level state between test runs. */export function resetComposerDraftsLoadState(): void { loadPromise = null; persistRetryNeeded = false;} function normalizeDraft(draft: ComposerDraft | undefined): ComposerDraft { if (!draft) { return EMPTY_DRAFT; } return { ...draft, text: draft.text, attachments: draft.attachments, };} export function getComposerDraftSnapshot(draftKey: string): ComposerDraft { return normalizeDraft(appAtomRegistry.get(composerDraftsAtom)[draftKey]);} export function isComposerDraftEmpty(draft: ComposerDraft): boolean { return isEmptyDraft(draft);} // The project stamp is identity, not content: a new-task draft with nothing// else in it is still empty and gets dropped like any other.function isEmptyDraft(draft: ComposerDraft): boolean { return ( draft.text.length === 0 && draft.attachments.length === 0 && draft.modelSelection === undefined && draft.runtimeMode === undefined && draft.interactionMode === undefined && draft.workspaceSelection === undefined );} /** * Writes a draft back, dropping it once empty. A new-task draft keeps its * entry while the composer is bound to it (the project stamp is what the * composer binds to); the persist sweep still leaves empty ones off disk. */function withComposerDraft( current: Record<string, ComposerDraft>, draftKey: string, draft: ComposerDraft,): Record<string, ComposerDraft> { if (isEmptyDraft(draft) && draft.project === undefined) { const next = { ...current }; delete next[draftKey]; return next; } return { ...current, [draftKey]: draft };} export { isNewTaskDraftKey, newTaskDraftKey } from "./new-task-draft-key"; // Draft ids only need to be unique within this device's draft file. Deriving// them from time plus randomness keeps this module free of native imports,// which the persistence tests rely on.function newDraftId(): string { return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;} /** * Project-keyed new-task drafts from earlier builds are rewritten on load into * id-keyed drafts with the project stamped in, so existing drafts survive the * switch to many-per-project. */export function migrateLegacyNewTaskDraft( key: string, draft: ComposerDraft, now: string,): readonly [key: string, draft: ComposerDraft] { const legacy = draft.project === undefined ? parseLegacyNewTaskDraftKey(key) : null; if (legacy === null) { return [key, draft]; } return [ newTaskDraftKey(newDraftId()), { ...draft, project: { environmentId: EnvironmentIdSchema.make(legacy.environmentId), projectId: ProjectIdSchema.make(legacy.projectId), createdAt: now, }, }, ];} export function decodePersistedComposerState(value: unknown): { readonly drafts: Record<string, ComposerDraft>; readonly stickyModelSelection: ModelSelection | null; readonly cloudDrafts: ComposerCloudDraftState;} { const parsed = decodePersistedComposerDraftsDocument(value); const now = new Date().toISOString(); return { drafts: Object.fromEntries( Object.entries(parsed.drafts) .map(([key, draft]) => migrateLegacyNewTaskDraft( key, // Stale new-task drafts left on disk by builds before the // model-precedence fix carry a bare modelSelection with no // other selector settings. Strip it so the next compose pass // re-resolves project → sticky → provider defaults. Drafts // with runtime/interaction/workspace settings or actual text / // attachments were deliberately configured and are left alone. isNewTaskDraftKey(key) && draft.modelSelection && draft.text.length === 0 && draft.attachments.length === 0 && draft.runtimeMode === undefined && draft.interactionMode === undefined && draft.workspaceSelection === undefined ? { ...draft, modelSelection: undefined } : draft, now, ), ) // importedShareIds are share-import receipts: a contentless draft // carrying one is not empty, or the same native share would be // re-imported after restart. .filter(([, draft]) => !isEmptyDraft(draft) || (draft.importedShareIds?.length ?? 0) > 0), ), stickyModelSelection: parsed.stickyModelSelection ?? null, cloudDrafts: { accountId: parsed.cloudAccountId ?? null, signedOut: Object.fromEntries( Object.entries(parsed.signedOutDrafts ?? {}).map(([id, saved]) => [ id, { // Archived drafts come back through restoreCloudComposerDrafts // without another decode, so they get the same key migration. drafts: Object.fromEntries( Object.entries(saved.drafts).map(([key, draft]) => migrateLegacyNewTaskDraft(key, draft, now), ), ), queuedMessages: saved.queuedMessages.map(decodeQueuedThreadMessage), }, ]), ), }, };} async function getComposerDraftsFile() { const { Directory, File, Paths } = await import("expo-file-system"); const directory = new Directory(Paths.document, COMPOSER_DRAFTS_DIRECTORY); directory.create({ idempotent: true, intermediates: true }); return new File(directory, COMPOSER_DRAFTS_FILE);} async function loadPersistedComposerState(): Promise< ReturnType<typeof decodePersistedComposerState>> { let operation: ComposerDraftPersistenceError["operation"] = "open"; try { const file = await getComposerDraftsFile(); if (!file.exists) { return { drafts: {}, stickyModelSelection: null, cloudDrafts: { accountId: null, signedOut: {} }, }; } operation = "read"; const raw = await file.text(); operation = "decode"; return decodePersistedComposerState(JSON.parse(raw) as unknown); } catch (cause) { throw new ComposerDraftPersistenceError({ operation, directory: COMPOSER_DRAFTS_DIRECTORY, fileName: COMPOSER_DRAFTS_FILE, cause, }); }} async function writePersistedComposerState( drafts: Record<string, ComposerDraft>, stickyModelSelection: ModelSelection | null, cloudDrafts = appAtomRegistry.get(composerCloudDraftsAtom),): Promise<void> { let operation: ComposerDraftPersistenceError["operation"] = "open"; try { const file = await getComposerDraftsFile(); operation = "encode"; const nonEmptyDrafts = Object.fromEntries( Object.entries(drafts).filter(([, draft]) => !isEmptyDraft(draft)), ); const document = { schemaVersion: COMPOSER_DRAFTS_SCHEMA_VERSION, drafts: nonEmptyDrafts, ...(stickyModelSelection ? { stickyModelSelection } : {}), ...(cloudDrafts.accountId ? { cloudAccountId: cloudDrafts.accountId } : {}), ...(Object.keys(cloudDrafts.signedOut).length > 0 ? { signedOutDrafts: Object.fromEntries( Object.entries(cloudDrafts.signedOut).map(([id, saved]) => [ id, { drafts: saved.drafts, queuedMessages: saved.queuedMessages.map(encodeQueuedThreadMessage), }, ]), ), } : {}), } as const; const encoded = JSON.stringify(document); operation = "write"; await writeFileAtomically(file, encoded); } catch (cause) { throw new ComposerDraftPersistenceError({ operation, directory: COMPOSER_DRAFTS_DIRECTORY, fileName: COMPOSER_DRAFTS_FILE, cause, }); }} /** * Lands any debounced or in-flight draft write before the JS runtime is torn * down (app update restart), so the freshest draft state survives it. A write * failure propagates so the caller can decide whether the restart may proceed. */export async function flushComposerDrafts(): Promise<void> { // Never land a pre-hydration snapshot: persisted state must merge into the // atoms first, or this write would clobber disk with partial data. ensureComposerDraftsLoaded(); if (loadPromise !== null) { await loadPromise; } // An edit during an awaited write schedules another debounced write, so // keep landing snapshots until no debounce is pending after a queue drain. do { while (persistTimer !== null || persistRetryNeeded) { if (persistTimer !== null) clearTimeout(persistTimer); persistTimer = null; persistRetryNeeded = false; try { await persistenceQueue.run(() => writePersistedComposerState( appAtomRegistry.get(composerDraftsAtom), appAtomRegistry.get(stickyComposerModelSelectionAtom), ), ); } catch (error) { persistRetryNeeded = true; throw error; } } // Draining also waits for an already-fired debounce whose write is still // gated behind its own hydration await inside the queue. await persistenceQueue.run(() => Promise.resolve()); } while (persistTimer !== null || persistRetryNeeded);} function signedOutAttachmentOwners() { return Object.values(appAtomRegistry.get(composerCloudDraftsAtom).signedOut).flatMap((saved) => [ ...Object.values(saved.drafts), ...saved.queuedMessages, ]);} function isComposerAttachmentFileReferenced(fileUri: string): boolean { if (isComposerAttachmentFileRetained(fileUri)) { return true; } const referenceKey = composerAttachmentFileReferenceKey(fileUri); const drafts = Object.values(appAtomRegistry.get(composerDraftsAtom)); const queuedMessages = Object.values( appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom), ).flat(); return [...drafts, ...queuedMessages, ...signedOutAttachmentOwners()].some((owner) => owner.attachments.some( (attachment) => attachment.fileUri !== undefined && composerAttachmentFileReferenceKey(attachment.fileUri) === referenceKey, ), );} function isComposerAttachmentUploadReferenced( environmentId: EnvironmentId, attachmentId: string,): boolean { const drafts = Object.values(appAtomRegistry.get(composerDraftsAtom)); const queuedMessages = Object.values( appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom), ).flat(); return [...drafts, ...queuedMessages, ...signedOutAttachmentOwners()].some((owner) => owner.attachments.some( (attachment) => attachment.uploadEnvironmentId === environmentId && attachment.uploadedAttachmentId === attachmentId, ), );} export async function releaseUnusedComposerAttachmentFiles( attachments: ReadonlyArray<DraftComposerAttachment>,): Promise<void> { const candidates = new Set( attachments.flatMap((attachment) => attachment.fileUri !== undefined ? [attachment.fileUri] : [], ), ); const uploadCandidates = new Map<EnvironmentId, Set<string>>(); for (const attachment of attachments) { if ( attachment.uploadEnvironmentId === undefined || attachment.uploadedAttachmentId === undefinedLines 1–500 of 1493. Load more
import { useAtomValue } from "@effect/atom-react"; import { EnvironmentId as EnvironmentIdSchema, ModelSelection as ModelSelectionSchema, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, ProjectId as ProjectIdSchema, ProviderInteractionMode as ProviderInteractionModeSchema, RuntimeMode as RuntimeModeSchema, type EnvironmentId, type ModelSelection, type ProjectId, type ProviderInteractionMode, type RuntimeMode, } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; import { useEffect } from "react"; import { Atom } from "effect/unstable/reactivity"; import { writeFileAtomically } from "../lib/atomic-file"; import { DraftComposerAttachmentSchema } from "../lib/composer-image-schema"; import { composerAttachmentFileReferenceKey, isComposerAttachmentFileRetained, retainComposerAttachmentFile, } from "../lib/composerAttachmentFiles"; import type { DraftComposerAttachment, FileBackedComposerAttachment } from "../lib/composerImages"; import { SerializedAsyncQueue } from "../lib/serialized-async-queue"; import { appAtomRegistry } from "./atom-registry"; import { isNewTaskDraftKey, newTaskDraftKey, parseLegacyNewTaskDraftKey, } from "./new-task-draft-key"; import { decodeQueuedThreadMessage, encodeQueuedThreadMessage, QueuedThreadMessageSchema, type QueuedThreadMessage, } from "./thread-outbox-model"; import { flushThreadOutbox, threadOutboxManager } from "./thread-outbox"; import { composerDraftEnvironmentId } from "../lib/composerAttachmentUploadQueue"; const COMPOSER_DRAFTS_SCHEMA_VERSION = 1; const COMPOSER_DRAFTS_DIRECTORY = "composer-drafts"; const COMPOSER_DRAFTS_FILE = "drafts.json"; const PERSIST_DEBOUNCE_MS = 200; export class ComposerDraftPersistenceError extends Schema.TaggedError<ComposerDraftPersistenceError>()( "ComposerDraftPersistenceError", { operation: Schema.Literals(["open", "read", "decode", "encode", "write", "hydrate"]), directory: Schema.String, fileName: Schema.String, cause: Schema.Defect(), }, ) { override get message(): string { return `Composer draft persistence operation ${this.operation} failed for ${this.directory}/${this.fileName}.`; } } export interface ComposerDraft { readonly text: string; readonly attachments: ReadonlyArray<DraftComposerAttachment>; readonly importedShareIds?: ReadonlyArray<string>; readonly modelSelection?: ModelSelection; readonly runtimeMode?: RuntimeMode; readonly interactionMode?: ProviderInteractionMode; readonly workspaceSelection?: ComposerDraftWorkspaceSelection; /** * Set on new-task drafts only. The project is stored here rather than in * the key so a project can hold any number of drafts and a draft can be * retargeted to another project without changing identity. */ readonly project?: ComposerDraftProject; } export interface ComposerDraftProject { readonly environmentId: EnvironmentId; readonly projectId: ProjectId; readonly createdAt: string; } export interface ComposerDraftContent { readonly text: string; readonly attachments: ReadonlyArray<DraftComposerAttachment>; readonly sourceShareId?: string; } export interface ComposerDraftWorkspaceSelection { readonly mode: "local" | "worktree"; readonly branch: string | null; readonly worktreePath: string | null; readonly startFromOrigin?: boolean; } export type ComposerDraftSettingsUpdate = Pick< ComposerDraft, "modelSelection" | "runtimeMode" | "interactionMode" | "workspaceSelection" | "project" >; const ComposerDraftWorkspaceSelectionSchema = Schema.Struct({ mode: Schema.Literals(["local", "worktree"]), branch: Schema.NullOr(Schema.String), worktreePath: Schema.NullOr(Schema.String), startFromOrigin: Schema.optional(Schema.Boolean), }); const ComposerDraftProjectSchema = Schema.Struct({ environmentId: EnvironmentIdSchema, projectId: ProjectIdSchema, createdAt: Schema.String, }); const ComposerDraftSchema = Schema.Struct({ text: Schema.String, attachments: Schema.Array(DraftComposerAttachmentSchema), importedShareIds: Schema.optional(Schema.Array(Schema.String)), modelSelection: Schema.optional(ModelSelectionSchema), runtimeMode: Schema.optional(RuntimeModeSchema), interactionMode: Schema.optional(ProviderInteractionModeSchema), workspaceSelection: Schema.optional(ComposerDraftWorkspaceSelectionSchema), project: Schema.optional(ComposerDraftProjectSchema), }); const PersistedComposerDraftsSchema = Schema.Struct({ schemaVersion: Schema.Literal(COMPOSER_DRAFTS_SCHEMA_VERSION), drafts: Schema.Record(Schema.String, ComposerDraftSchema), stickyModelSelection: Schema.optional(ModelSelectionSchema), cloudAccountId: Schema.optional(Schema.String), signedOutDrafts: Schema.optional( Schema.Record( Schema.String, Schema.Struct({ drafts: Schema.Record(Schema.String, ComposerDraftSchema), queuedMessages: Schema.Array(QueuedThreadMessageSchema), }), ), ), }); const decodePersistedComposerDraftsDocument = Schema.decodeUnknownSync( PersistedComposerDraftsSchema, ); const EMPTY_DRAFT: ComposerDraft = { text: "", attachments: [], }; export const composerDraftsAtom = Atom.make<Record<string, ComposerDraft>>({}).pipe( Atom.keepAlive, Atom.withLabel("mobile:composer-drafts"), ); export const stickyComposerModelSelectionAtom = Atom.make<ModelSelection | null>(null).pipe( Atom.keepAlive, Atom.withLabel("mobile:sticky-composer-model-selection"), ); interface SignedOutDrafts { readonly drafts: Record<string, ComposerDraft>; readonly queuedMessages: ReadonlyArray<QueuedThreadMessage>; } interface ComposerCloudDraftState { readonly accountId: string | null; readonly signedOut: Record<string, SignedOutDrafts>; } export const composerCloudDraftsAtom = Atom.make<ComposerCloudDraftState>({ accountId: null, signedOut: {}, }).pipe(Atom.keepAlive); let loadPromise: Promise<void> | null = null; let persistTimer: ReturnType<typeof setTimeout> | null = null; let persistRetryNeeded = false; const persistenceQueue = new SerializedAsyncQueue(); /** Resets module-level state between test runs. */ export function resetComposerDraftsLoadState(): void { loadPromise = null; persistRetryNeeded = false; } function normalizeDraft(draft: ComposerDraft | undefined): ComposerDraft { if (!draft) { return EMPTY_DRAFT; } return { ...draft, text: draft.text, attachments: draft.attachments, }; } export function getComposerDraftSnapshot(draftKey: string): ComposerDraft { return normalizeDraft(appAtomRegistry.get(composerDraftsAtom)[draftKey]); } export function isComposerDraftEmpty(draft: ComposerDraft): boolean { return isEmptyDraft(draft); } // The project stamp is identity, not content: a new-task draft with nothing // else in it is still empty and gets dropped like any other. function isEmptyDraft(draft: ComposerDraft): boolean { return ( draft.text.length === 0 && draft.attachments.length === 0 && draft.modelSelection === undefined && draft.runtimeMode === undefined && draft.interactionMode === undefined && draft.workspaceSelection === undefined ); } /** * Writes a draft back, dropping it once empty. A new-task draft keeps its * entry while the composer is bound to it (the project stamp is what the * composer binds to); the persist sweep still leaves empty ones off disk. */ function withComposerDraft( current: Record<string, ComposerDraft>, draftKey: string, draft: ComposerDraft, ): Record<string, ComposerDraft> { if (isEmptyDraft(draft) && draft.project === undefined) { const next = { ...current }; delete next[draftKey]; return next; } return { ...current, [draftKey]: draft }; } export { isNewTaskDraftKey, newTaskDraftKey } from "./new-task-draft-key"; // Draft ids only need to be unique within this device's draft file. Deriving // them from time plus randomness keeps this module free of native imports, // which the persistence tests rely on. function newDraftId(): string { return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; } /** * Project-keyed new-task drafts from earlier builds are rewritten on load into * id-keyed drafts with the project stamped in, so existing drafts survive the * switch to many-per-project. */ export function migrateLegacyNewTaskDraft( key: string, draft: ComposerDraft, now: string, ): readonly [key: string, draft: ComposerDraft] { const legacy = draft.project === undefined ? parseLegacyNewTaskDraftKey(key) : null; if (legacy === null) { return [key, draft]; } return [ newTaskDraftKey(newDraftId()), { ...draft, project: { environmentId: EnvironmentIdSchema.make(legacy.environmentId), projectId: ProjectIdSchema.make(legacy.projectId), createdAt: now, }, }, ]; } export function decodePersistedComposerState(value: unknown): { readonly drafts: Record<string, ComposerDraft>; readonly stickyModelSelection: ModelSelection | null; readonly cloudDrafts: ComposerCloudDraftState; } { const parsed = decodePersistedComposerDraftsDocument(value); const now = new Date().toISOString(); return { drafts: Object.fromEntries( Object.entries(parsed.drafts) .map(([key, draft]) => migrateLegacyNewTaskDraft( key, // Stale new-task drafts left on disk by builds before the // model-precedence fix carry a bare modelSelection with no // other selector settings. Strip it so the next compose pass // re-resolves project → sticky → provider defaults. Drafts // with runtime/interaction/workspace settings or actual text / // attachments were deliberately configured and are left alone. isNewTaskDraftKey(key) && draft.modelSelection && draft.text.length === 0 && draft.attachments.length === 0 && draft.runtimeMode === undefined && draft.interactionMode === undefined && draft.workspaceSelection === undefined ? { ...draft, modelSelection: undefined } : draft, now, ), ) // importedShareIds are share-import receipts: a contentless draft // carrying one is not empty, or the same native share would be // re-imported after restart. .filter(([, draft]) => !isEmptyDraft(draft) || (draft.importedShareIds?.length ?? 0) > 0), ), stickyModelSelection: parsed.stickyModelSelection ?? null, cloudDrafts: { accountId: parsed.cloudAccountId ?? null, signedOut: Object.fromEntries( Object.entries(parsed.signedOutDrafts ?? {}).map(([id, saved]) => [ id, { // Archived drafts come back through restoreCloudComposerDrafts // without another decode, so they get the same key migration. drafts: Object.fromEntries( Object.entries(saved.drafts).map(([key, draft]) => migrateLegacyNewTaskDraft(key, draft, now), ), ), queuedMessages: saved.queuedMessages.map(decodeQueuedThreadMessage), }, ]), ), }, }; } async function getComposerDraftsFile() { const { Directory, File, Paths } = await import("expo-file-system"); const directory = new Directory(Paths.document, COMPOSER_DRAFTS_DIRECTORY); directory.create({ idempotent: true, intermediates: true }); return new File(directory, COMPOSER_DRAFTS_FILE); } async function loadPersistedComposerState(): Promise< ReturnType<typeof decodePersistedComposerState> > { let operation: ComposerDraftPersistenceError["operation"] = "open"; try { const file = await getComposerDraftsFile(); if (!file.exists) { return { drafts: {}, stickyModelSelection: null, cloudDrafts: { accountId: null, signedOut: {} }, }; } operation = "read"; const raw = await file.text(); operation = "decode"; return decodePersistedComposerState(JSON.parse(raw) as unknown); } catch (cause) { throw new ComposerDraftPersistenceError({ operation, directory: COMPOSER_DRAFTS_DIRECTORY, fileName: COMPOSER_DRAFTS_FILE, cause, }); } } async function writePersistedComposerState( drafts: Record<string, ComposerDraft>, stickyModelSelection: ModelSelection | null, cloudDrafts = appAtomRegistry.get(composerCloudDraftsAtom), ): Promise<void> { let operation: ComposerDraftPersistenceError["operation"] = "open"; try { const file = await getComposerDraftsFile(); operation = "encode"; const nonEmptyDrafts = Object.fromEntries( Object.entries(drafts).filter(([, draft]) => !isEmptyDraft(draft)), ); const document = { schemaVersion: COMPOSER_DRAFTS_SCHEMA_VERSION, drafts: nonEmptyDrafts, ...(stickyModelSelection ? { stickyModelSelection } : {}), ...(cloudDrafts.accountId ? { cloudAccountId: cloudDrafts.accountId } : {}), ...(Object.keys(cloudDrafts.signedOut).length > 0 ? { signedOutDrafts: Object.fromEntries( Object.entries(cloudDrafts.signedOut).map(([id, saved]) => [ id, { drafts: saved.drafts, queuedMessages: saved.queuedMessages.map(encodeQueuedThreadMessage), }, ]), ), } : {}), } as const; const encoded = JSON.stringify(document); operation = "write"; await writeFileAtomically(file, encoded); } catch (cause) { throw new ComposerDraftPersistenceError({ operation, directory: COMPOSER_DRAFTS_DIRECTORY, fileName: COMPOSER_DRAFTS_FILE, cause, }); } } /** * Lands any debounced or in-flight draft write before the JS runtime is torn * down (app update restart), so the freshest draft state survives it. A write * failure propagates so the caller can decide whether the restart may proceed. */ export async function flushComposerDrafts(): Promise<void> { // Never land a pre-hydration snapshot: persisted state must merge into the // atoms first, or this write would clobber disk with partial data. ensureComposerDraftsLoaded(); if (loadPromise !== null) { await loadPromise; } // An edit during an awaited write schedules another debounced write, so // keep landing snapshots until no debounce is pending after a queue drain. do { while (persistTimer !== null || persistRetryNeeded) { if (persistTimer !== null) clearTimeout(persistTimer); persistTimer = null; persistRetryNeeded = false; try { await persistenceQueue.run(() => writePersistedComposerState( appAtomRegistry.get(composerDraftsAtom), appAtomRegistry.get(stickyComposerModelSelectionAtom), ), ); } catch (error) { persistRetryNeeded = true; throw error; } } // Draining also waits for an already-fired debounce whose write is still // gated behind its own hydration await inside the queue. await persistenceQueue.run(() => Promise.resolve()); } while (persistTimer !== null || persistRetryNeeded); } function signedOutAttachmentOwners() { return Object.values(appAtomRegistry.get(composerCloudDraftsAtom).signedOut).flatMap((saved) => [ ...Object.values(saved.drafts), ...saved.queuedMessages, ]); } function isComposerAttachmentFileReferenced(fileUri: string): boolean { if (isComposerAttachmentFileRetained(fileUri)) { return true; } const referenceKey = composerAttachmentFileReferenceKey(fileUri); const drafts = Object.values(appAtomRegistry.get(composerDraftsAtom)); const queuedMessages = Object.values( appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom), ).flat(); return [...drafts, ...queuedMessages, ...signedOutAttachmentOwners()].some((owner) => owner.attachments.some( (attachment) => attachment.fileUri !== undefined && composerAttachmentFileReferenceKey(attachment.fileUri) === referenceKey, ), ); } function isComposerAttachmentUploadReferenced( environmentId: EnvironmentId, attachmentId: string, ): boolean { const drafts = Object.values(appAtomRegistry.get(composerDraftsAtom)); const queuedMessages = Object.values( appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom), ).flat(); return [...drafts, ...queuedMessages, ...signedOutAttachmentOwners()].some((owner) => owner.attachments.some( (attachment) => attachment.uploadEnvironmentId === environmentId && attachment.uploadedAttachmentId === attachmentId, ), ); } export async function releaseUnusedComposerAttachmentFiles( attachments: ReadonlyArray<DraftComposerAttachment>, ): Promise<void> { const candidates = new Set( attachments.flatMap((attachment) => attachment.fileUri !== undefined ? [attachment.fileUri] : [], ), ); const uploadCandidates = new Map<EnvironmentId, Set<string>>(); for (const attachment of attachments) { if ( attachment.uploadEnvironmentId === undefined || attachment.uploadedAttachmentId === undefined