import { useAtomValue } from "@effect/atom-react";import { ModelSelection as ModelSelectionSchema, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, ProviderInteractionMode as ProviderInteractionModeSchema, RuntimeMode as RuntimeModeSchema, type EnvironmentId, type ModelSelection, 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 { DraftComposerImageAttachmentSchema } from "../lib/composer-image-schema";import type { DraftComposerImageAttachment } from "../lib/composerImages";import { SerializedAsyncQueue } from "../lib/serialized-async-queue";import { appAtomRegistry } from "./atom-registry"; 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.TaggedErrorClass<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<DraftComposerImageAttachment>; readonly importedShareIds?: ReadonlyArray<string>; readonly modelSelection?: ModelSelection; readonly runtimeMode?: RuntimeMode; readonly interactionMode?: ProviderInteractionMode; readonly workspaceSelection?: ComposerDraftWorkspaceSelection;} export interface ComposerDraftContent { readonly text: string; readonly attachments: ReadonlyArray<DraftComposerImageAttachment>; 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">; 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 ComposerDraftSchema = Schema.Struct({ text: Schema.String, attachments: Schema.Array(DraftComposerImageAttachmentSchema), importedShareIds: Schema.optional(Schema.Array(Schema.String)), modelSelection: Schema.optional(ModelSelectionSchema), runtimeMode: Schema.optional(RuntimeModeSchema), interactionMode: Schema.optional(ProviderInteractionModeSchema), workspaceSelection: Schema.optional(ComposerDraftWorkspaceSelectionSchema),}); const PersistedComposerDraftsSchema = Schema.Struct({ schemaVersion: Schema.Literal(COMPOSER_DRAFTS_SCHEMA_VERSION), drafts: Schema.Record(Schema.String, ComposerDraftSchema),}); 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"),); let loadPromise: Promise<void> | null = null;let persistTimer: ReturnType<typeof setTimeout> | null = null;const persistenceQueue = new SerializedAsyncQueue(); 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);} 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 );} export function decodePersistedComposerDrafts(value: unknown): Record<string, ComposerDraft> { const parsed = decodePersistedComposerDraftsDocument(value); return Object.fromEntries( Object.entries(parsed.drafts).filter(([, draft]) => !isEmptyDraft(draft)), );} 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 loadPersistedComposerDrafts(): Promise<Record<string, ComposerDraft>> { let operation: ComposerDraftPersistenceError["operation"] = "open"; try { const file = await getComposerDraftsFile(); if (!file.exists) { return {}; } operation = "read"; const raw = await file.text(); operation = "decode"; return decodePersistedComposerDrafts(JSON.parse(raw) as unknown); } catch (cause) { console.warn( "[composer-drafts] ignored persisted draft failure", new ComposerDraftPersistenceError({ operation, directory: COMPOSER_DRAFTS_DIRECTORY, fileName: COMPOSER_DRAFTS_FILE, cause, }), ); return {}; }} async function writePersistedComposerDrafts(drafts: Record<string, ComposerDraft>): 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, } 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, }); }} async function savePersistedComposerDrafts(drafts: Record<string, ComposerDraft>): Promise<void> { try { await persistenceQueue.run(() => writePersistedComposerDrafts(drafts)); } catch (error) { console.warn("[composer-drafts] failed to persist drafts", error); // Draft persistence is best-effort; in-memory drafts still keep working. }} /** * 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> { // 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) { clearTimeout(persistTimer); persistTimer = null; await persistenceQueue.run(() => writePersistedComposerDrafts(appAtomRegistry.get(composerDraftsAtom)), ); } await persistenceQueue.run(() => Promise.resolve()); } while (persistTimer !== null);} function schedulePersistComposerDrafts(drafts: Record<string, ComposerDraft>): void { if (persistTimer !== null) { clearTimeout(persistTimer); } persistTimer = setTimeout(() => { persistTimer = null; void savePersistedComposerDrafts(drafts); }, PERSIST_DEBOUNCE_MS);} export function ensureComposerDraftsLoaded(): void { if (loadPromise !== null) { return; } loadPromise = loadPersistedComposerDrafts() .then((persistedDrafts) => { if (Object.keys(persistedDrafts).length === 0) { return; } const current = appAtomRegistry.get(composerDraftsAtom); appAtomRegistry.set(composerDraftsAtom, { ...persistedDrafts, ...current, }); }) .catch((cause) => { console.warn( "[composer-drafts] failed to hydrate drafts", new ComposerDraftPersistenceError({ operation: "hydrate", directory: COMPOSER_DRAFTS_DIRECTORY, fileName: COMPOSER_DRAFTS_FILE, cause, }), ); // Draft loading is best-effort; in-memory drafts still keep working. });} function updateComposerDrafts( update: (current: Record<string, ComposerDraft>) => Record<string, ComposerDraft>,): void { const current = appAtomRegistry.get(composerDraftsAtom); const next = update(current); if (next === current) { return; } appAtomRegistry.set(composerDraftsAtom, next); schedulePersistComposerDrafts(next);} export function setComposerDraftText(draftKey: string, value: string): void { updateComposerDrafts((current) => { const draft = { ...normalizeDraft(current[draftKey]), text: value, }; if (isEmptyDraft(draft)) { const next = { ...current }; delete next[draftKey]; return next; } return { ...current, [draftKey]: draft, }; });} export function appendComposerDraftText(draftKey: string, value: string): void { updateComposerDrafts((current) => { const existing = normalizeDraft(current[draftKey]); return { ...current, [draftKey]: { ...existing, text: `${existing.text}${value}`, }, }; });} export function appendComposerDraftAttachments( draftKey: string, attachments: ReadonlyArray<DraftComposerImageAttachment>,): void { if (attachments.length === 0) { return; } updateComposerDrafts((current) => { const existing = normalizeDraft(current[draftKey]); return { ...current, [draftKey]: { ...existing, attachments: [...existing.attachments, ...attachments], }, }; });} export function replaceComposerDraftAttachments( draftKey: string, attachments: ReadonlyArray<DraftComposerImageAttachment>,): void { updateComposerDrafts((current) => { const draft = { ...normalizeDraft(current[draftKey]), attachments, }; if (isEmptyDraft(draft)) { const next = { ...current }; delete next[draftKey]; return next; } return { ...current, [draftKey]: draft, }; });} export function removeComposerDraftAttachment(draftKey: string, imageId: string): void { updateComposerDrafts((current) => { const existing = normalizeDraft(current[draftKey]); const draft = { ...existing, attachments: existing.attachments.filter((image) => image.id !== imageId), }; if (isEmptyDraft(draft)) { const next = { ...current }; delete next[draftKey]; return next; } return { ...current, [draftKey]: draft, }; });} export function updateComposerDraftSettings( draftKey: string, settings: Partial<ComposerDraftSettingsUpdate>,): void { updateComposerDrafts((current) => { const draft = { ...normalizeDraft(current[draftKey]), ...settings, }; if (isEmptyDraft(draft)) { const next = { ...current }; delete next[draftKey]; return next; } return { ...current, [draftKey]: draft, }; });} export function clearComposerDraftContentState( current: Record<string, ComposerDraft>, draftKey: string, options?: { readonly clearWorkspaceSelection?: boolean },): Record<string, ComposerDraft> { const existing = current[draftKey]; if (!existing) { return current; } const { importedShareIds: _importedShareIds, workspaceSelection, ...retained } = existing; const draft = { ...retained, ...(options?.clearWorkspaceSelection || workspaceSelection === undefined ? {} : { workspaceSelection }), text: "", attachments: [], }; if (isEmptyDraft(draft)) { const next = { ...current }; delete next[draftKey]; return next; } return { ...current, [draftKey]: draft, };} export function restoreComposerDraftSnapshotState( current: Record<string, ComposerDraft>, draftKey: string, snapshot: ComposerDraft,): Record<string, ComposerDraft> { const next = { ...current }; if (isEmptyDraft(snapshot)) { delete next[draftKey]; } else { next[draftKey] = snapshot; } return next;} export function copyComposerDraftContentState( current: Record<string, ComposerDraft>, sourceDraftKey: string, targetDraftKey: string,): Record<string, ComposerDraft> { if (sourceDraftKey === targetDraftKey) { return current; } const source = normalizeDraft(current[sourceDraftKey]); const target = normalizeDraft(current[targetDraftKey]); const sourceHasContent = source.text.length > 0 || source.attachments.length > 0 || (source.importedShareIds?.length ?? 0) > 0; const targetHasContent = target.text.length > 0 || target.attachments.length > 0 || (target.importedShareIds?.length ?? 0) > 0; if (!sourceHasContent || targetHasContent) { return current; } return { ...current, [targetDraftKey]: { ...target, text: source.text, attachments: source.attachments, ...(source.importedShareIds ? { importedShareIds: source.importedShareIds } : {}), }, };} export async function copyComposerDraftContentIfEmpty( sourceDraftKey: string, targetDraftKey: string,): Promise<void> { ensureComposerDraftsLoaded(); if (loadPromise !== null) { await loadPromise; } updateComposerDrafts((current) => copyComposerDraftContentState(current, sourceDraftKey, targetDraftKey), );} function mergeComposerDraftText(existing: string, incoming: string): string { if (incoming.length === 0) { return existing; } if (existing.length === 0) { return incoming; } // Import retries are possible after an interrupted native handoff. Keep the // operation idempotent when the same shared text is already present. if (existing === incoming || existing.endsWith(`\n\n${incoming}`)) { return existing; } return `${existing}\n\n${incoming}`;} export function mergeComposerDraftContentState( current: Record<string, ComposerDraft>, draftKey: string, content: ComposerDraftContent,Lines 1–500 of 657. Load more
import { useAtomValue } from "@effect/atom-react"; import { ModelSelection as ModelSelectionSchema, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, ProviderInteractionMode as ProviderInteractionModeSchema, RuntimeMode as RuntimeModeSchema, type EnvironmentId, type ModelSelection, 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 { DraftComposerImageAttachmentSchema } from "../lib/composer-image-schema"; import type { DraftComposerImageAttachment } from "../lib/composerImages"; import { SerializedAsyncQueue } from "../lib/serialized-async-queue"; import { appAtomRegistry } from "./atom-registry"; 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.TaggedErrorClass<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<DraftComposerImageAttachment>; readonly importedShareIds?: ReadonlyArray<string>; readonly modelSelection?: ModelSelection; readonly runtimeMode?: RuntimeMode; readonly interactionMode?: ProviderInteractionMode; readonly workspaceSelection?: ComposerDraftWorkspaceSelection; } export interface ComposerDraftContent { readonly text: string; readonly attachments: ReadonlyArray<DraftComposerImageAttachment>; 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" >; 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 ComposerDraftSchema = Schema.Struct({ text: Schema.String, attachments: Schema.Array(DraftComposerImageAttachmentSchema), importedShareIds: Schema.optional(Schema.Array(Schema.String)), modelSelection: Schema.optional(ModelSelectionSchema), runtimeMode: Schema.optional(RuntimeModeSchema), interactionMode: Schema.optional(ProviderInteractionModeSchema), workspaceSelection: Schema.optional(ComposerDraftWorkspaceSelectionSchema), }); const PersistedComposerDraftsSchema = Schema.Struct({ schemaVersion: Schema.Literal(COMPOSER_DRAFTS_SCHEMA_VERSION), drafts: Schema.Record(Schema.String, ComposerDraftSchema), }); 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"), ); let loadPromise: Promise<void> | null = null; let persistTimer: ReturnType<typeof setTimeout> | null = null; const persistenceQueue = new SerializedAsyncQueue(); 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); } 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 ); } export function decodePersistedComposerDrafts(value: unknown): Record<string, ComposerDraft> { const parsed = decodePersistedComposerDraftsDocument(value); return Object.fromEntries( Object.entries(parsed.drafts).filter(([, draft]) => !isEmptyDraft(draft)), ); } 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 loadPersistedComposerDrafts(): Promise<Record<string, ComposerDraft>> { let operation: ComposerDraftPersistenceError["operation"] = "open"; try { const file = await getComposerDraftsFile(); if (!file.exists) { return {}; } operation = "read"; const raw = await file.text(); operation = "decode"; return decodePersistedComposerDrafts(JSON.parse(raw) as unknown); } catch (cause) { console.warn( "[composer-drafts] ignored persisted draft failure", new ComposerDraftPersistenceError({ operation, directory: COMPOSER_DRAFTS_DIRECTORY, fileName: COMPOSER_DRAFTS_FILE, cause, }), ); return {}; } } async function writePersistedComposerDrafts(drafts: Record<string, ComposerDraft>): 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, } 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, }); } } async function savePersistedComposerDrafts(drafts: Record<string, ComposerDraft>): Promise<void> { try { await persistenceQueue.run(() => writePersistedComposerDrafts(drafts)); } catch (error) { console.warn("[composer-drafts] failed to persist drafts", error); // Draft persistence is best-effort; in-memory drafts still keep working. } } /** * 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> { // 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) { clearTimeout(persistTimer); persistTimer = null; await persistenceQueue.run(() => writePersistedComposerDrafts(appAtomRegistry.get(composerDraftsAtom)), ); } await persistenceQueue.run(() => Promise.resolve()); } while (persistTimer !== null); } function schedulePersistComposerDrafts(drafts: Record<string, ComposerDraft>): void { if (persistTimer !== null) { clearTimeout(persistTimer); } persistTimer = setTimeout(() => { persistTimer = null; void savePersistedComposerDrafts(drafts); }, PERSIST_DEBOUNCE_MS); } export function ensureComposerDraftsLoaded(): void { if (loadPromise !== null) { return; } loadPromise = loadPersistedComposerDrafts() .then((persistedDrafts) => { if (Object.keys(persistedDrafts).length === 0) { return; } const current = appAtomRegistry.get(composerDraftsAtom); appAtomRegistry.set(composerDraftsAtom, { ...persistedDrafts, ...current, }); }) .catch((cause) => { console.warn( "[composer-drafts] failed to hydrate drafts", new ComposerDraftPersistenceError({ operation: "hydrate", directory: COMPOSER_DRAFTS_DIRECTORY, fileName: COMPOSER_DRAFTS_FILE, cause, }), ); // Draft loading is best-effort; in-memory drafts still keep working. }); } function updateComposerDrafts( update: (current: Record<string, ComposerDraft>) => Record<string, ComposerDraft>, ): void { const current = appAtomRegistry.get(composerDraftsAtom); const next = update(current); if (next === current) { return; } appAtomRegistry.set(composerDraftsAtom, next); schedulePersistComposerDrafts(next); } export function setComposerDraftText(draftKey: string, value: string): void { updateComposerDrafts((current) => { const draft = { ...normalizeDraft(current[draftKey]), text: value, }; if (isEmptyDraft(draft)) { const next = { ...current }; delete next[draftKey]; return next; } return { ...current, [draftKey]: draft, }; }); } export function appendComposerDraftText(draftKey: string, value: string): void { updateComposerDrafts((current) => { const existing = normalizeDraft(current[draftKey]); return { ...current, [draftKey]: { ...existing, text: `${existing.text}${value}`, }, }; }); } export function appendComposerDraftAttachments( draftKey: string, attachments: ReadonlyArray<DraftComposerImageAttachment>, ): void { if (attachments.length === 0) { return; } updateComposerDrafts((current) => { const existing = normalizeDraft(current[draftKey]); return { ...current, [draftKey]: { ...existing, attachments: [...existing.attachments, ...attachments], }, }; }); } export function replaceComposerDraftAttachments( draftKey: string, attachments: ReadonlyArray<DraftComposerImageAttachment>, ): void { updateComposerDrafts((current) => { const draft = { ...normalizeDraft(current[draftKey]), attachments, }; if (isEmptyDraft(draft)) { const next = { ...current }; delete next[draftKey]; return next; } return { ...current, [draftKey]: draft, }; }); } export function removeComposerDraftAttachment(draftKey: string, imageId: string): void { updateComposerDrafts((current) => { const existing = normalizeDraft(current[draftKey]); const draft = { ...existing, attachments: existing.attachments.filter((image) => image.id !== imageId), }; if (isEmptyDraft(draft)) { const next = { ...current }; delete next[draftKey]; return next; } return { ...current, [draftKey]: draft, }; }); } export function updateComposerDraftSettings( draftKey: string, settings: Partial<ComposerDraftSettingsUpdate>, ): void { updateComposerDrafts((current) => { const draft = { ...normalizeDraft(current[draftKey]), ...settings, }; if (isEmptyDraft(draft)) { const next = { ...current }; delete next[draftKey]; return next; } return { ...current, [draftKey]: draft, }; }); } export function clearComposerDraftContentState( current: Record<string, ComposerDraft>, draftKey: string, options?: { readonly clearWorkspaceSelection?: boolean }, ): Record<string, ComposerDraft> { const existing = current[draftKey]; if (!existing) { return current; } const { importedShareIds: _importedShareIds, workspaceSelection, ...retained } = existing; const draft = { ...retained, ...(options?.clearWorkspaceSelection || workspaceSelection === undefined ? {} : { workspaceSelection }), text: "", attachments: [], }; if (isEmptyDraft(draft)) { const next = { ...current }; delete next[draftKey]; return next; } return { ...current, [draftKey]: draft, }; } export function restoreComposerDraftSnapshotState( current: Record<string, ComposerDraft>, draftKey: string, snapshot: ComposerDraft, ): Record<string, ComposerDraft> { const next = { ...current }; if (isEmptyDraft(snapshot)) { delete next[draftKey]; } else { next[draftKey] = snapshot; } return next; } export function copyComposerDraftContentState( current: Record<string, ComposerDraft>, sourceDraftKey: string, targetDraftKey: string, ): Record<string, ComposerDraft> { if (sourceDraftKey === targetDraftKey) { return current; } const source = normalizeDraft(current[sourceDraftKey]); const target = normalizeDraft(current[targetDraftKey]); const sourceHasContent = source.text.length > 0 || source.attachments.length > 0 || (source.importedShareIds?.length ?? 0) > 0; const targetHasContent = target.text.length > 0 || target.attachments.length > 0 || (target.importedShareIds?.length ?? 0) > 0; if (!sourceHasContent || targetHasContent) { return current; } return { ...current, [targetDraftKey]: { ...target, text: source.text, attachments: source.attachments, ...(source.importedShareIds ? { importedShareIds: source.importedShareIds } : {}), }, }; } export async function copyComposerDraftContentIfEmpty( sourceDraftKey: string, targetDraftKey: string, ): Promise<void> { ensureComposerDraftsLoaded(); if (loadPromise !== null) { await loadPromise; } updateComposerDrafts((current) => copyComposerDraftContentState(current, sourceDraftKey, targetDraftKey), ); } function mergeComposerDraftText(existing: string, incoming: string): string { if (incoming.length === 0) { return existing; } if (existing.length === 0) { return incoming; } // Import retries are possible after an interrupted native handoff. Keep the // operation idempotent when the same shared text is already present. if (existing === incoming || existing.endsWith(`\n\n${incoming}`)) { return existing; } return `${existing}\n\n${incoming}`; } export function mergeComposerDraftContentState( current: Record<string, ComposerDraft>, draftKey: string, content: ComposerDraftContent,