1493 lines · 50.9 KB · TypeScript
RawDownload
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

Lines 1–500 of 1493.