# Files changed · PR \#23046 · pingdotgg/t3code

[View on GitCafe](https://git.cafe/pingdotgg/t3code/pull/23046/diffs)

Repository: [pingdotgg/t3code](https://git.cafe/pingdotgg/t3code)

Visibility: public

[refactor\(web\): the prompt stash lives in useComposerStash](https://git.cafe/pingdotgg/t3code/pull/23046.md)

Version: 6

Head commit: 25fa572eba1b8a200711bee4bb5b3acc7e39e5c0

Base commit: 1807a1e9c9d9c7e2dd80b05c6c43041dc5172a8e

Comparison base: 1807a1e9c9d9c7e2dd80b05c6c43041dc5172a8e

Showing up to 20 entries from this revision. If the pull request changes, reopen the first page before continuing.

## apps/web/src/components/chat/ChatComposer.tsx

Status: modified; +43; −617

```diff
@@ -103,23 +103,14 @@
   type ComposerFileAttachment,
   type ComposerImageAttachment,
   type DraftId,
-  type PersistedComposerFileAttachment,
   type PersistedComposerImageAttachment,
-  composerFileDedupKey,
   composerFileMatchesReattachMarker,
   composerFileNeedsReattach,
   composerDraftHasUserContent,
   composerTargetKey,
-  hydrateImagesFromPersisted,
   useComposerDraftStore,
   useComposerThreadDraft,
 } from "../../composerDraftStore";
-import {
-  MAX_STASH_ENTRIES,
-  partitionStashAttachments,
-  usePromptStashStore,
-  type PromptStashEntry,
-} from "../../promptStashStore";
 import { ComposerStashBadge } from "./ComposerStashBadge";
 import { ComposerStashMenu } from "./ComposerStashMenu";
 import { useComposerTriggerState } from "./useComposerTriggerState";
@@ -146,7 +137,7 @@
   type ComposerBannerStackContent,
   type ComposerBannerStackItem,
 } from "./ComposerBannerStack";
-import { compressImageForStash, prepareImageForAttachment } from "../../lib/imageCompression";
+import { prepareImageForAttachment } from "../../lib/imageCompression";
 import {
   fileAttachmentTooLargeMessage,
   formatAttachmentSize,
@@ -162,27 +153,19 @@
   shouldHandleComposerAttachmentPaste,
 } from "./composerAttachmentFiles";
 import {
-  readAttachmentUpload,
   releaseAttachmentUpload,
   releaseDraftAttachment,
-  releasePersistedAttachmentUpload,
   startAttachmentUpload,
   useAttachmentUploadStore,
-  verifyStashedAttachmentUpload,
 } from "../../lib/attachmentUploadQueue";
 import { attachmentUploadBlockReason } from "../../lib/attachmentUploadState";
-import { isCommandPaletteOpen } from "../../commandPaletteBus";
-import { getTerminalFocusOwner } from "../../lib/terminalFocus";
 import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection";
-import { resolveShortcutCommand, shortcutLabelForCommand } from "../../keybindings";
+import { shortcutLabelForCommand } from "../../keybindings";
 import {
   type TerminalContextDraft,
   type TerminalContextSelection,
 } from "../../lib/terminalContext";
-import {
-  replaceComposerContextReferences,
-  toKindScopedComposerContextId,
-} from "@t3tools/shared/composerContextReferences";
+import { toKindScopedComposerContextId } from "@t3tools/shared/composerContextReferences";
 import {
   shouldUseCompactComposerPrimaryActions,
   shouldUseCompactComposerFooter,
@@ -292,7 +275,6 @@
   buildExpandedImagePreview,
   type ExpandedImagePreview,
 } from "./ExpandedImagePreview";
-import { resizeSnapShotSource } from "../../lib/snapShotSource";
 import { cn, isMacPlatform, randomUUID } from "~/lib/utils";
 import {
   getComposerPromptLengthValidationMessage,
@@ -301,10 +283,7 @@
 } from "./composerSubmission";
 import { ComposerPromptLengthValidation } from "./ComposerPromptLengthValidation";
 import { pendingDraftWork } from "./pendingDraftWork";
-import {
+import { importComposerThreadAttachment } from "./composerThreadImport";
-  importComposerThreadAttachment,
-  remainingComposerAttachmentSlots,
-} from "./composerThreadImport";
 import { isTimelineScrollTarget } from "./timelineScrollTarget";
 import {
   createComposerScrollGestureState,
@@ -367,6 +346,7 @@
 import { useProviderWorkspaceSnapshotRefresh } from "./useProviderWorkspaceSnapshotRefresh";
 import { useComposerProviderModel } from "./useComposerProviderModel";
 import { useComposerImageGroups } from "./useComposerImageGroups";
+import { useComposerStash } from "./useComposerStash";
 
 /** Reads and disarms a paste-as-text deadline: the shortcut applies to one paste only. */
 function consumePasteAsTextShortcut(deadline: { current: number }): boolean {
@@ -939,12 +919,6 @@
   const clearComposerDraftPersistedAttachments = useComposerDraftStore(
     (store) => store.clearPersistedAttachments,
   );
-  const clearComposerDraftTerminalContexts = useComposerDraftStore(
-    (store) => store.clearTerminalContexts,
-  );
-  const clearComposerDraftPromptAndImages = useComposerDraftStore(
-    (store) => store.clearComposerPromptAndImages,
-  );
   const syncComposerDraftPersistedAttachments = useComposerDraftStore(
     (store) => store.syncPersistedAttachments,
   );
@@ -1198,12 +1172,7 @@
   const [composerMenuAnchor, setComposerMenuAnchor] = useState<HTMLDivElement | null>(null);
   const hasWrappedPrompt = useComposerMultilinePrompt(composerMenuAnchor);
   const hasMultilinePrompt = prompt.includes("\n") || hasWrappedPrompt;
-  const [isStashMenuOpen, setIsStashMenuOpen] = useState(false);
   const [isTasksDrawerOpen, setIsTasksDrawerOpen] = useState(false);
-  const [stashPulse, setStashPulse] = useState<{ key: number; active: boolean }>({
-    key: 0,
-    active: false,
-  });
   // A pending question stays expanded: its card moves into the banner dock,
   // so collapsing would leave the surface empty and squashed beneath it.
   const isComposerCollapsedMobile =
@@ -1234,9 +1203,6 @@
   const composerScrollCollapseEligibleRef = useRef(false);
   const windowRefocusInFlightRef = useRef(false);
   const composerScrollGestureRef = useRef(createComposerScrollGestureState());
-  const stashPulseKeyRef = useRef(0);
-  const stashPulseTimeoutRef = useRef<number | null>(null);
-  const stashInFlightRef = useRef<Set<string>>(new Set());
   const pendingImageCompressionsRef = useRef<Map<string, number>>(new Map());
   const pendingThreadImportsRef = useRef<Map<string, number>>(new Map());
   const isRevertingCheckpointRef = useRef(isRevertingCheckpoint);
@@ -2786,536 +2752,44 @@
     return false;
   };
 
-  const stashQueue = usePromptStashStore((state) => state.entries);
-  const stashEntryToQueue = usePromptStashStore((state) => state.stashEntry);
-  const takeStashEntry = usePromptStashStore((state) => state.takeEntry);
-  const finalizeStashEntryImages = usePromptStashStore((state) => state.finalizeEntryImages);
-
-  useEffect(() => {
-    return () => {
-      if (stashPulseTimeoutRef.current !== null) {
-        window.clearTimeout(stashPulseTimeoutRef.current);
-      }
-    };
-  }, []);
-
-  const pulseStashBadge = () => {
-    stashPulseKeyRef.current += 1;
-    setStashPulse({ key: stashPulseKeyRef.current, active: true });
-    if (stashPulseTimeoutRef.current !== null) {
-      window.clearTimeout(stashPulseTimeoutRef.current);
-    }
-    stashPulseTimeoutRef.current = window.setTimeout(() => {
-      stashPulseTimeoutRef.current = null;
-      setStashPulse((current) => ({ ...current, active: false }));
-    }, 1200);
-  };
-
-  const restoreStashEntry = async (menuEntry: PromptStashEntry) => {
-    const filesToVerify = menuEntry.files ?? [];
-    if (filesToVerify.some((file) => file.environmentId !== environmentId)) {
-      toastManager.add({
-        type: "error",
-        title: "Stashed files belong to another environment",
-        description: "Restore this prompt in the environment that received its files.",
-      });
-      return;
-    }
-    setIsStashMenuOpen(false);
-
-    // An expired upload restores as a needs-reattach row instead of a reference
-    // the next send would fail to verify. Verify BEFORE taking: the take
-    // removes the entry from durable storage, and a tab closed during this
-    // await must still find it there after reload.
-    const verifications = await Promise.all(
-      filesToVerify.map((file) =>
-        verifyStashedAttachmentUpload({ environmentId, attachmentId: file.attachmentId }),
-      ),
-    );
-    const expiredAttachmentIds = new Set(
-      filesToVerify
-        .filter((_, index) => verifications[index]?.status === "missing")
-        .map((file) => file.attachmentId),
-    );
-
-    // A thread switch during the verify await would mix the new thread's
-    // prompt with this invocation's captured target. Nothing was taken yet,
-    // so abort and leave the entry restorable where the user now is.
-    if (
-      isRevertingCheckpointRef.current ||
-      composerTargetKey(composerDraftTarget) !== composerDraftTargetKeyRef.current
-    ) {
-      return;
-    }
-
-    const { entry, durable } = takeStashEntry(menuEntry.id);
-    if (!entry) return;
-    if (!durable) {
-      toastManager.add({
-        type: "warning",
-        title: "Restored prompt may reappear in the stash",
-        description:
-          "Browser storage rejected the update, so this entry could still be there after a reload.",
-        data: { hideCopyButton: true },
-      });
-    }
-
-    const rewrittenContextIds = entry.records
-      ? importContextRecords(entry.records, null, composerDraftTarget)
-      : new Map<string, string>();
-    const restoredPrompt = replaceComposerContextReferences(entry.prompt, (reference) => {
-      const contextId = rewrittenContextIds.get(reference.contextId);
-      return contextId
-        ? formatInlineContextReference({
-            ...reference,
-            contextId,
-            kind: reference.kind === "element" ? "preview-annotation" : reference.kind,
-          })
-        : reference.source;
-    });
-    const currentPrompt = promptRef.current;
-    const nextPrompt =
-      restoredPrompt.length === 0
-        ? currentPrompt
-        : currentPrompt.trim().length
-          ? `${currentPrompt.replace(/\s+$/, "")}\n\n${restoredPrompt}`
-          : restoredPrompt;
-    let promptChanged = nextPrompt !== currentPrompt;
-    if (promptChanged) {
-      promptRef.current = nextPrompt;
-      setComposerDraftPrompt(composerDraftTarget, nextPrompt);
-      setComposerCursor(collapseExpandedComposerCursor(nextPrompt, nextPrompt.length));
-      setComposerTrigger(null);
-    }
-
-    let unrestoredFileNames: string[] = [];
-    const expiredFileNames: string[] = [];
-    let restoredFileCount = 0;
-    const stashedFiles = entry.files ?? [];
-    if (stashedFiles.length > 0) {
-      const composerFilesNow = composerFilesRef.current;
-      const existingFileIds = new Set(composerFilesNow.map((file) => file.id));
-      const retainedUploadIds = new Set(
-        composerFilesNow.flatMap((file) =>
-          file.uploadedAttachmentId ? [file.uploadedAttachmentId] : [],
-        ),
-      );
-      const existingFileKeys = new Set(composerFilesNow.map(composerFileDedupKey));
-      const reattachMarkers = composerFilesNow.filter(composerFileNeedsReattach);
-      const restoredMarkerIds = new Set<string>();
-      const duplicateFiles: PersistedComposerFileAttachment[] = [];
-      const markerReplacements: ComposerFileAttachment[] = [];
-      const appendedFiles: ComposerFileAttachment[] = [];
-      for (const file of stashedFiles) {
-        const expired = expiredAttachmentIds.has(file.attachmentId);
-        const key = composerFileDedupKey(file);
-        const restored: ComposerFileAttachment = {
-          type: "file",
-          id: file.id,
-          name: file.name,
-          mimeType: file.mimeType,
-          sizeBytes: file.sizeBytes,
-          file: null,
-          ...(file.source ? { source: file.source } : {}),
-          ...(expired
-            ? {}
-            : { uploadedAttachmentId: file.attachmentId, uploadEnvironmentId: environmentId }),
-        };
-        if (existingFileIds.has(file.id)) {
-          if (!expired && !retainedUploadIds.has(file.attachmentId)) {
-            duplicateFiles.push(file);
-          }
-          continue;
-        }
-        const reattachMarker = reattachMarkers.find(
-          (marker) =>
-            !restoredMarkerIds.has(marker.id) && composerFileMatchesReattachMarker(marker, file),
-        );
-        if (reattachMarker) {
-          restoredMarkerIds.add(reattachMarker.id);
-          existingFileIds.add(file.id);
-          existingFileKeys.add(key);
-          if (expired) {
-            expiredFileNames.push(file.name);
-          } else {
-            retainedUploadIds.add(file.attachmentId);
-            markerReplacements.push(restored);
-          }
-          continue;
-        }
-        if (existingFileKeys.has(key)) {
-          if (!expired && !retainedUploadIds.has(file.attachmentId)) {
-            duplicateFiles.push(file);
-          }
-          continue;
-        }
-        existingFileIds.add(file.id);
-        existingFileKeys.add(key);
-        if (expired) {
-          expiredFileNames.push(file.name);
-        } else {
-          retainedUploadIds.add(file.attachmentId);
-        }
-        appendedFiles.push(restored);
-      }
-      const capacity = remainingComposerAttachmentSlots(
-        composerImagesRef.current.length + composerFilesNow.length,
-        pendingThreadImportsRef.current.get(composerTargetKey(composerDraftTarget)) ?? 0,
-      );
-      const filesToAppend = appendedFiles.slice(0, capacity);
-      const skippedFiles = appendedFiles.slice(capacity);
-      unrestoredFileNames = skippedFiles.map((file) => file.name);
-      // A non-durable take can resurrect the stash entry after a reload;
-      // deleting these uploads would leave it pointing at nothing.
-      if (durable) {
-        for (const file of duplicateFiles) {
-          releasePersistedAttachmentUpload({
-            id: file.id,
-            environmentId,
-            attachmentId: file.attachmentId,
-          });
-        }
-        for (const file of skippedFiles) {
-          if (file.uploadedAttachmentId) {
-            releasePersistedAttachmentUpload({
-              id: file.id,
-              environmentId,
-              attachmentId: file.uploadedAttachmentId,
-            });
-          }
-        }
-      }
-      const restoredFiles = [...markerReplacements, ...filesToAppend];
-      if (restoredFiles.length > 0) {
-        addComposerDraftFiles(composerDraftTarget, restoredFiles, { appendReference: true });
-        const restoredFilePrompt = getComposerDraft(composerDraftTarget)?.prompt;
-        if (restoredFilePrompt !== undefined && restoredFilePrompt !== promptRef.current) {
-          promptRef.current = restoredFilePrompt;
-          setComposerCursor(
-            collapseExpandedComposerCursor(restoredFilePrompt, restoredFilePrompt.length),
-          );
-          setComposerTrigger(null);
-          promptChanged = true;
-        }
-        restoredFileCount = filesToAppend.length;
-      }
-    }
-
-    let unrestoredImageNames: string[] = [];
-    if (entry.attachments.length > 0) {
-      const existingIds = new Set(composerImagesRef.current.map((image) => image.id));
-      // The draft store also dedupes by mimeType+sizeBytes+name, so filter
-      // on the same key here. Counting a duplicate against capacity would
-      // burn a slot the store then refuses to fill, pushing a genuinely
-      // unique image into the overflow list for nothing.
-      const existingDedupKeys = new Set(
-        composerImagesRef.current.map(
-          (image) => `${image.mimeType}\0${image.sizeBytes}\0${image.name}`,
-        ),
-      );
-      const capacity = remainingComposerAttachmentSlots(
-        composerImagesRef.current.length + composerFilesRef.current.length + restoredFileCount,
-        pendingThreadImportsRef.current.get(composerTargetKey(composerDraftTarget)) ?? 0,
-      );
-      const pending = entry.attachments.filter(
-        (attachment) =>
-          !existingIds.has(attachment.id) &&
-          !existingDedupKeys.has(
-            `${attachment.mimeType}\0${attachment.sizeBytes}\0${attachment.name}`,
-          ),
-      );
-      unrestoredImageNames = pending.slice(capacity).map((attachment) => attachment.name);
-      const restoredImages = hydrateImagesFromPersisted(pending.slice(0, capacity));
-      if (restoredImages.length > 0) {
-        addComposerDraftImages(composerDraftTarget, restoredImages);
-      }
-    }
-
-    const missingImageReasons: string[] = [];
-    if (entry.droppedImageNames.length > 0) {
-      missingImageReasons.push(
-        `${entry.droppedImageNames.join(", ")} exceeded the stash size limit when this prompt was saved.`,
-      );
-    }
-    if (entry.unreadableImageNames && entry.unreadableImageNames.length > 0) {
-      missingImageReasons.push(
-        `${entry.unreadableImageNames.join(", ")} could not be read when this prompt was saved.`,
-      );
-    }
-    if (unrestoredImageNames.length > 0) {
-      missingImageReasons.push(
-        `${unrestoredImageNames.join(", ")} could not be restored: the composer is at its ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS}-attachment limit.`,
-      );
-    }
-    if (unrestoredFileNames.length > 0) {
-      missingImageReasons.push(
-        `${unrestoredFileNames.join(", ")} could not be restored: the composer is at its ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS}-attachment limit.`,
-      );
-    }
-    if (expiredFileNames.length > 0) {
-      missingImageReasons.push(
-        `${expiredFileNames.join(", ")}: stashed files are kept for 24 hours and this upload expired. Attach the file again.`,
-      );
-    }
-    if (missingImageReasons.length > 0) {
-      toastManager.add({
-        type: "warning",
-        title: "Some attachments were not restored",
-        description: missingImageReasons.join(" "),
-      });
-    }
-
-    if (promptChanged) {
-      window.requestAnimationFrame(() => {
-        composerEditorRef.current?.focusAtEnd();
-      });
-    }
-  };
-  const restoreStashEntryStable = useStableCallback(restoreStashEntry);
-
-  const deleteStashEntry = (entry: PromptStashEntry) => {
-    const { entry: removed, durable } = takeStashEntry(entry.id);
-    if (!stashQueue.some((candidate) => candidate.id !== entry.id)) {
-      setIsStashMenuOpen(false);
-    }
-    if (durable && removed) {
-      for (const file of removed.files ?? []) {
-        releasePersistedAttachmentUpload({
-          id: file.id,
-          environmentId: file.environmentId,
-          attachmentId: file.attachmentId,
-        });
-      }
-    }
-    if (!durable) {
-      toastManager.add({
-        type: "warning",
-        title: "Stash entry may come back",
-        description:
-          "Browser storage rejected the delete, so this prompt could reappear after a reload.",
-        data: { hideCopyButton: true },
-      });
-    }
-  };
-  const deleteStashEntryStable = useStableCallback(deleteStashEntry);
-
-  const stashCurrentPrompt = async () => {
-    // Stashing clears the draft. A pasted attachment still downloading would then land in the
-    // emptied composer instead of travelling with the entry it belongs to.
-    if (pendingDraftWork.has(attachmentTargetKeyRef.current)) {
-      toastManager.add({
-        type: "info",
-        title: "Still bringing attached context into this message.",
-        description: "Stash again once its chip resolves.",
-      });
-      return;
-    }
-    const prompt = promptRef.current.trim();
-    const images = [...composerImagesRef.current];
-    const files = [...composerFilesRef.current];
-    const stashedRecords: ComposerContextRecord[] = [
-      ...composerThreadContexts,
-      ...composerTerminalContextsRef.current.map(terminalContextRecord),
-      ...composerReviewComments.map(reviewCommentContextRecord),
-      ...composerPreviewAnnotations.map((annotation) =>
-        previewAnnotationContextRecord(annotation, {
-          screenshotContextId: images.some((image) => image.id === annotation.id)
-            ? annotation.id
-            : undefined,
-        }),
-      ),
-    ];
-    if (prompt.length === 0 && images.length === 0 && files.length === 0) {
-      const entries = usePromptStashStore.getState().entries;
-      const entry = entries.length === 1 ? entries[0] : undefined;
-      if (entry && !entry.pendingImageCount) {
-        await restoreStashEntry(entry);
-      } else {
-        setIsStashMenuOpen((open) => !open);
-      }
-      return;
-    }
-    const stashedFiles: PersistedComposerFileAttachment[] = [];
-    for (const file of files) {
-      if (composerFileNeedsReattach(file)) {
-        toastManager.add({
-          type: "error",
-          title: "Attach dropped files again or remove them before stashing",
-        });
-        return;
-      }
-      const upload = readAttachmentUpload(file.id);
-      if (upload?.status !== "ready" || upload.environmentId !== environmentId) {
-        toastManager.add({
-          type: "error",
-          title: "Wait for file uploads before stashing this prompt",
-        });
-        return;
-      }
-      stashedFiles.push({
-        id: file.id,
-        name: file.name,
-        mimeType: file.mimeType,
-        sizeBytes: file.sizeBytes,
-        attachmentId: upload.attachmentId,
-        environmentId,
-        ...(file.source ? { source: file.source } : {}),
-      });
-    }
-    // A repeat ⌘S on the *same* still-unencoded snapshot would stash it
-    // twice. Guard on the snapshot itself rather than a bare boolean: once
-    // the composer has been cleared the user can type something genuinely
-    // new (or switch threads) while encoding continues, and that deserves its
-    // own entry.
-    const attachmentKey = images
-      .map((image) => `image:${image.id}`)
-      .concat(files.map((file) => `file:${file.id}`))
-      .join(",");
-    const snapshotKey = [String(composerDraftTarget), prompt, attachmentKey].join("\n");
-    if (stashInFlightRef.current.has(snapshotKey)) return;
-    stashInFlightRef.current.add(snapshotKey);
-
-    const stashTarget = composerDraftTarget;
-    const entryId = randomUUID();
-    const stashAndEncode = async () => {
-      // Ordering matters in both directions: writing before clearing means a
-      // crash or closed tab mid-encode still leaves the prompt recoverable,
-      // while clearing before the async image work means edits typed during
-      // encoding are not wiped.
-      const { evicted, written, durable } = stashEntryToQueue({
-        id: entryId,
-        createdAt: new Date().toISOString(),
-        prompt,
-        attachments: [],
-        ...(stashedFiles.length > 0 ? { files: stashedFiles } : {}),
-        droppedImageNames: [],
-        unreadableImageNames: [],
-        pendingImageCount: images.length,
-        ...(stashedRecords.length > 0 ? { records: stashedRecords } : {}),
-      });
-
-      // If it was rejected (quota) the store has already rolled itself back,
-      // so leave the composer untouched rather than making it the second
-      // casualty of a reload.
-      if (!written) {
-        toastManager.add({
-          type: "error",
-          title: "Could not stash this prompt",
-          description:
-            "Browser storage rejected the write, so the composer was left as-is. Free up site data and try again.",
-          data: { hideCopyButton: true },
-        });
-        return;
-      }
-      if (!durable) {
-        toastManager.add({
-          type: "warning",
-          title: "Stashed prompt will not survive a reload",
-          description:
-            "Browser storage is unavailable, so this stash is kept in memory only for this session.",
-          data: { hideCopyButton: true },
-        });
-      }
-
-      promptRef.current = "";
-      clearComposerDraftPromptAndImages(stashTarget);
-      clearComposerDraftTerminalContexts(stashTarget);
-      setComposerDraftThreadContexts(stashTarget, []);
-      for (const comment of composerReviewComments) {
-        removeComposerDraftReviewComment(stashTarget, comment.id);
-      }
-      for (const annotation of composerPreviewAnnotations) {
-        releaseAttachmentUpload(annotation.id);
-        removeComposerDraftPreviewAnnotation(stashTarget, annotation.id);
-      }
-      setComposerDraftPrompt(stashTarget, "");
-      for (const image of images) {
-        releaseAttachmentUpload(image.id);
-      }
-      setComposerCursor(0);
-      setComposerTrigger(null);
-      pulseStashBadge();
-
-      if (evicted) {
+  const {
+    stashQueue,
+    isStashMenuOpen,
+    setIsStashMenuOpen,
+    stashPulse,
+    restoreStashEntryStable,
+    deleteStashEntryStable,
+    toggleStashMenuStable,
+  } = useComposerStash({
+    environmentId,
+    composerDraftTarget,
-        for (const file of evicted.files ?? []) {
+    promptRef,
-          releasePersistedAttachmentUpload({
-            id: file.id,
-            environmentId: file.environmentId,
+    composerImagesRef,
+    composerFilesRef,
+    composerTerminalContextsRef,
+    keybindings,
+    terminalOpen,
+    projectSelectionRequired,
+    isRevertingCheckpoint,
+    pendingUserInputs,
+    activePendingProgress,
+    prompt,
+    composerMenuOpen,
+    isComposerModelPickerOpen,
+    isComposerCollapsedMobile,
+    composerPreviewAnnotations,
+    composerReviewComments,
+    composerThreadContexts,
+    attachmentTargetKeyRef,
+    composerDraftTargetKeyRef,
+    composerEditorRef,
+    isRevertingCheckpointRef,
+    pendingThreadImportsRef,
+    setComposerCursor,
+    setComposerTrigger,
+    importContextRecords,
+    expandMobileComposer: expandMobileComposerStable,
+  });
-            attachmentId: file.attachmentId,
-          });
-        }
-        toastManager.add({
-          type: "warning",
-          title: "Oldest stashed prompt discarded",
-          description: `The stash holds ${MAX_STASH_ENTRIES} prompts; the oldest was removed to make room.`,
-          data: { hideCopyButton: true },
-        });
-      }
-
-      // Images are re-encoded for the stash rather than stored verbatim: the
-      // composer allows up to 10MB per image, but localStorage gives the whole
-      // origin ~5MB.
-      const candidateAttachments: PersistedComposerImageAttachment[] = [];
-      const oversizedImageNames: string[] = [];
-      const unreadableImageNames: string[] = [];
-      for (const image of images) {
-        const result = await compressImageForStash(image.file);
-        if (!result.ok) {
-          (result.reason === "too-large" ? oversizedImageNames : unreadableImageNames).push(
-            image.name,
-          );
-          continue;
-        }
-        candidateAttachments.push({
-          id: image.id,
-          name: image.name,
-          mimeType: result.image.mimeType,
-          sizeBytes: result.image.sizeBytes,
-          dataUrl: result.image.dataUrl,
-          ...(image.source
-            ? { source: resizeSnapShotSource(image.source, result.image.imageSize) }
-            : {}),
-        });
-      }
-      const { kept, droppedNames } = partitionStashAttachments(candidateAttachments);
-
-      const { attached, durable: imagesDurable } = finalizeStashEntryImages(entryId, {
-        attachments: kept,
-        droppedImageNames: [...oversizedImageNames, ...droppedNames],
-        unreadableImageNames,
-      });
-      if (attached) {
-        if (!imagesDurable && durable && images.length > 0) {
-          toastManager.add({
-            type: "warning",
-            title: "Stashed images were not saved",
-            description:
-              "The prompt was stashed, but browser storage rejected its images. They will be missing if you reload.",
-            data: { hideCopyButton: true },
-          });
-        }
-      } else if (kept.length > 0) {
-        toastManager.add({
-          type: "warning",
-          title: "Stashed images did not attach",
-          description: `That prompt was restored or deleted before ${kept.length} image${kept.length === 1 ? "" : "s"} finished saving. Re-attach ${kept.length === 1 ? "it" : "them"} if you still need ${kept.length === 1 ? "it" : "them"}.`,
-          data: { hideCopyButton: true },
-        });
-      }
-    };
-    await stashAndEncode().finally(() => stashInFlightRef.current.delete(snapshotKey));
-  };
-  const stashCurrentPromptStable = useStableCallback(stashCurrentPrompt);
-
-  const toggleStashMenu = () => {
-    if (isComposerCollapsedMobile) {
-      expandMobileComposer();
-      setIsStashMenuOpen(true);
-      return;
-    }
-    setIsStashMenuOpen((open) => !open);
-  };
-  const toggleStashMenuStable = useStableCallback(toggleStashMenu);
   const toggleTasksDrawer = () => {
     setIsTasksDrawerOpen((open) => !open);
   };
@@ -3755,54 +3229,6 @@
   }
   if (tasksDrawerThreadId !== activeThreadId) setTasksDrawerThreadId(activeThreadId);
 
-  const [stashMenuClosesOn, setStashMenuClosesOn] = useState({ prompt, composerMenuOpen });
-  if (
-    stashMenuClosesOn.prompt !== prompt ||
-    stashMenuClosesOn.composerMenuOpen !== composerMenuOpen
-  ) {
-    setStashMenuClosesOn({ prompt, composerMenuOpen });
-    if (stashMenuClosesOn.prompt !== prompt || composerMenuOpen) setIsStashMenuOpen(false);
-  }
-
-  useEffect(() => {
-    const handler = (event: globalThis.KeyboardEvent) => {
-      const command = resolveShortcutCommand(event, keybindings, {
-        context: {
-          terminalFocus: getTerminalFocusOwner() !== null,
-          terminalOpen,
-          modelPickerOpen: isComposerModelPickerOpen,
-        },
-      });
-      if (command !== "composer.stash") return;
-      // Always claim the shortcut so the browser save dialog never opens,
-      // even when the composer is in a state that can't stash.
-      event.preventDefault();
-      event.stopPropagation();
-      if (isCommandPaletteOpen() || isRevertingCheckpoint) {
-        return;
-      }
-      if (pendingUserInputs.length > 0) {
-        setIsStashMenuOpen((open) => !open);
-        return;
-      }
-      if (projectSelectionRequired || activePendingProgress !== null) {
-        return;
-      }
-      void stashCurrentPromptStable();
-    };
-    window.addEventListener("keydown", handler, true);
-    return () => window.removeEventListener("keydown", handler, true);
-  }, [
-    activePendingProgress,
-    isComposerModelPickerOpen,
-    keybindings,
-    pendingUserInputs.length,
-    projectSelectionRequired,
-    stashCurrentPromptStable,
-    isRevertingCheckpoint,
-    terminalOpen,
-  ]);
-
   const insertComposerText = (
     text: string,
     position: "cursor" | "end",
```

## apps/web/src/components/chat/useComposerStash.ts

Status: added; +745; −0

```diff
@@ -1,0 +1,745 @@
+import type { ChatComposerProps } from "./ChatComposer";
+import {
+  type ComposerContextRecord,
+  type EnvironmentId,
+  PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
+  type PreviewAnnotationPayload,
+  type ThreadContextRecord,
+} from "@t3tools/contracts";
+import {
+  type ComposerFileAttachment,
+  type PersistedComposerFileAttachment,
+  type PersistedComposerImageAttachment,
+  composerFileDedupKey,
+  composerFileMatchesReattachMarker,
+  composerFileNeedsReattach,
+  composerTargetKey,
+  hydrateImagesFromPersisted,
+  useComposerDraftStore,
+} from "../../composerDraftStore";
+import { type ComposerPromptEditorHandle } from "../ComposerPromptEditor";
+import {
+  MAX_STASH_ENTRIES,
+  type PromptStashEntry,
+  partitionStashAttachments,
+  usePromptStashStore,
+} from "../../promptStashStore";
+import { type PendingUserInput } from "../../session-logic";
+import { type ReviewCommentContext } from "../../reviewCommentContext";
+import { collapseExpandedComposerCursor } from "../../composer-logic";
+import { compressImageForStash } from "../../lib/imageCompression";
+import { formatInlineContextReference } from "~/lib/composerContextReferences";
+import { getTerminalFocusOwner } from "../../lib/terminalFocus";
+import { isCommandPaletteOpen } from "../../commandPaletteBus";
+import { pendingDraftWork } from "./pendingDraftWork";
+import {
+  previewAnnotationContextRecord,
+  reviewCommentContextRecord,
+  terminalContextRecord,
+} from "~/lib/composerContextRecords";
+import { randomUUID } from "~/lib/utils";
+import {
+  readAttachmentUpload,
+  releaseAttachmentUpload,
+  releasePersistedAttachmentUpload,
+  verifyStashedAttachmentUpload,
+} from "../../lib/attachmentUploadQueue";
+import { remainingComposerAttachmentSlots } from "./composerThreadImport";
+import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences";
+import { resizeSnapShotSource } from "../../lib/snapShotSource";
+import { resolveShortcutCommand } from "../../keybindings";
+import { toastManager } from "../ui/toast";
+import type { useComposerTriggerState } from "./useComposerTriggerState";
+import {
+  type Dispatch,
+  type RefObject,
+  type SetStateAction,
+  useEffect,
+  useRef,
+  useState,
+} from "react";
+import { useStableCallback } from "../../hooks/useStableCallback";
+
+/**
+ * The prompt stash: saving the draft aside, restoring or deleting an entry, the badge pulse,
+ * and the stash shortcut. Its state lives here so the composer body only wires it up.
+ */
+export function useComposerStash(input: {
+  readonly environmentId: EnvironmentId;
+  readonly composerDraftTarget: ChatComposerProps["composerDraftTarget"];
+  readonly promptRef: ChatComposerProps["promptRef"];
+  readonly composerImagesRef: ChatComposerProps["composerImagesRef"];
+  readonly composerFilesRef: ChatComposerProps["composerFilesRef"];
+  readonly composerTerminalContextsRef: ChatComposerProps["composerTerminalContextsRef"];
+  readonly keybindings: ChatComposerProps["keybindings"];
+  readonly terminalOpen: boolean;
+  readonly projectSelectionRequired: boolean;
+  readonly isRevertingCheckpoint: boolean;
+  readonly pendingUserInputs: ReadonlyArray<PendingUserInput>;
+  readonly activePendingProgress: ChatComposerProps["activePendingProgress"];
+  readonly prompt: string;
+  readonly composerMenuOpen: boolean;
+  readonly isComposerModelPickerOpen: boolean;
+  readonly isComposerCollapsedMobile: boolean;
+  readonly composerPreviewAnnotations: ReadonlyArray<PreviewAnnotationPayload>;
+  readonly composerReviewComments: ReadonlyArray<ReviewCommentContext>;
+  readonly composerThreadContexts: ReadonlyArray<ThreadContextRecord>;
+  readonly attachmentTargetKeyRef: RefObject<string>;
+  readonly composerDraftTargetKeyRef: RefObject<string>;
+  readonly composerEditorRef: RefObject<ComposerPromptEditorHandle | null>;
+  readonly isRevertingCheckpointRef: RefObject<boolean>;
+  readonly pendingThreadImportsRef: RefObject<Map<string, number>>;
+  readonly setComposerCursor: Dispatch<SetStateAction<number>>;
+  readonly setComposerTrigger: ReturnType<typeof useComposerTriggerState>["setTrigger"];
+  readonly importContextRecords: (
+    records: ReadonlyArray<ComposerContextRecord>,
+    sourceEnvironmentId: EnvironmentId | null,
+    threadDraftTarget?: ChatComposerProps["composerDraftTarget"],
+  ) => ReadonlyMap<string, string>;
+  readonly expandMobileComposer: () => void;
+}) {
+  const {
+    environmentId,
+    composerDraftTarget,
+    promptRef,
+    composerImagesRef,
+    composerFilesRef,
+    composerTerminalContextsRef,
+    keybindings,
+    terminalOpen,
+    projectSelectionRequired,
+    isRevertingCheckpoint,
+    pendingUserInputs,
+    activePendingProgress,
+    prompt,
+    composerMenuOpen,
+    isComposerModelPickerOpen,
+    isComposerCollapsedMobile,
+    composerPreviewAnnotations,
+    composerReviewComments,
+    composerThreadContexts,
+    attachmentTargetKeyRef,
+    composerDraftTargetKeyRef,
+    composerEditorRef,
+    isRevertingCheckpointRef,
+    pendingThreadImportsRef,
+    setComposerCursor,
+    setComposerTrigger,
+    importContextRecords,
+    expandMobileComposer,
+  } = input;
+  const getComposerDraft = useComposerDraftStore((store) => store.getComposerDraft);
+  const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt);
+  const addComposerDraftImages = useComposerDraftStore((store) => store.addImages);
+  const addComposerDraftFiles = useComposerDraftStore((store) => store.addFiles);
+  const removeComposerDraftPreviewAnnotation = useComposerDraftStore(
+    (store) => store.removePreviewAnnotation,
+  );
+  const removeComposerDraftReviewComment = useComposerDraftStore(
+    (store) => store.removeReviewComment,
+  );
+  const clearComposerDraftTerminalContexts = useComposerDraftStore(
+    (store) => store.clearTerminalContexts,
+  );
+  const clearComposerDraftPromptAndImages = useComposerDraftStore(
+    (store) => store.clearComposerPromptAndImages,
+  );
+  const setComposerDraftThreadContexts = useComposerDraftStore((store) => store.setThreadContexts);
+  const [isStashMenuOpen, setIsStashMenuOpen] = useState(false);
+  const [stashPulse, setStashPulse] = useState<{ key: number; active: boolean }>({
+    key: 0,
+    active: false,
+  });
+  const stashPulseKeyRef = useRef(0);
+  const stashPulseTimeoutRef = useRef<number | null>(null);
+  const stashInFlightRef = useRef<Set<string>>(new Set());
+
+  const stashQueue = usePromptStashStore((state) => state.entries);
+  const stashEntryToQueue = usePromptStashStore((state) => state.stashEntry);
+  const takeStashEntry = usePromptStashStore((state) => state.takeEntry);
+  const finalizeStashEntryImages = usePromptStashStore((state) => state.finalizeEntryImages);
+
+  useEffect(() => {
+    return () => {
+      if (stashPulseTimeoutRef.current !== null) {
+        window.clearTimeout(stashPulseTimeoutRef.current);
+      }
+    };
+  }, []);
+
+  const pulseStashBadge = () => {
+    stashPulseKeyRef.current += 1;
+    setStashPulse({ key: stashPulseKeyRef.current, active: true });
+    if (stashPulseTimeoutRef.current !== null) {
+      window.clearTimeout(stashPulseTimeoutRef.current);
+    }
+    stashPulseTimeoutRef.current = window.setTimeout(() => {
+      stashPulseTimeoutRef.current = null;
+      setStashPulse((current) => ({ ...current, active: false }));
+    }, 1200);
+  };
+
+  const restoreStashEntry = async (menuEntry: PromptStashEntry) => {
+    const filesToVerify = menuEntry.files ?? [];
+    if (filesToVerify.some((file) => file.environmentId !== environmentId)) {
+      toastManager.add({
+        type: "error",
+        title: "Stashed files belong to another environment",
+        description: "Restore this prompt in the environment that received its files.",
+      });
+      return;
+    }
+    setIsStashMenuOpen(false);
+
+    // An expired upload restores as a needs-reattach row instead of a reference
+    // the next send would fail to verify. Verify BEFORE taking: the take
+    // removes the entry from durable storage, and a tab closed during this
+    // await must still find it there after reload.
+    const verifications = await Promise.all(
+      filesToVerify.map((file) =>
+        verifyStashedAttachmentUpload({ environmentId, attachmentId: file.attachmentId }),
+      ),
+    );
+    const expiredAttachmentIds = new Set(
+      filesToVerify
+        .filter((_, index) => verifications[index]?.status === "missing")
+        .map((file) => file.attachmentId),
+    );
+
+    // A thread switch during the verify await would mix the new thread's
+    // prompt with this invocation's captured target. Nothing was taken yet,
+    // so abort and leave the entry restorable where the user now is.
+    if (
+      isRevertingCheckpointRef.current ||
+      composerTargetKey(composerDraftTarget) !== composerDraftTargetKeyRef.current
+    ) {
+      return;
+    }
+
+    const { entry, durable } = takeStashEntry(menuEntry.id);
+    if (!entry) return;
+    if (!durable) {
+      toastManager.add({
+        type: "warning",
+        title: "Restored prompt may reappear in the stash",
+        description:
+          "Browser storage rejected the update, so this entry could still be there after a reload.",
+        data: { hideCopyButton: true },
+      });
+    }
+
+    const rewrittenContextIds = entry.records
+      ? importContextRecords(entry.records, null, composerDraftTarget)
+      : new Map<string, string>();
+    const restoredPrompt = replaceComposerContextReferences(entry.prompt, (reference) => {
+      const contextId = rewrittenContextIds.get(reference.contextId);
+      return contextId
+        ? formatInlineContextReference({
+            ...reference,
+            contextId,
+            kind: reference.kind === "element" ? "preview-annotation" : reference.kind,
+          })
+        : reference.source;
+    });
+    const currentPrompt = promptRef.current;
+    const nextPrompt =
+      restoredPrompt.length === 0
+        ? currentPrompt
+        : currentPrompt.trim().length
+          ? `${currentPrompt.replace(/\s+$/, "")}\n\n${restoredPrompt}`
+          : restoredPrompt;
+    let promptChanged = nextPrompt !== currentPrompt;
+    if (promptChanged) {
+      promptRef.current = nextPrompt;
+      setComposerDraftPrompt(composerDraftTarget, nextPrompt);
+      setComposerCursor(collapseExpandedComposerCursor(nextPrompt, nextPrompt.length));
+      setComposerTrigger(null);
+    }
+
+    let unrestoredFileNames: string[] = [];
+    const expiredFileNames: string[] = [];
+    let restoredFileCount = 0;
+    const stashedFiles = entry.files ?? [];
+    if (stashedFiles.length > 0) {
+      const composerFilesNow = composerFilesRef.current;
+      const existingFileIds = new Set(composerFilesNow.map((file) => file.id));
+      const retainedUploadIds = new Set(
+        composerFilesNow.flatMap((file) =>
+          file.uploadedAttachmentId ? [file.uploadedAttachmentId] : [],
+        ),
+      );
+      const existingFileKeys = new Set(composerFilesNow.map(composerFileDedupKey));
+      const reattachMarkers = composerFilesNow.filter(composerFileNeedsReattach);
+      const restoredMarkerIds = new Set<string>();
+      const duplicateFiles: PersistedComposerFileAttachment[] = [];
+      const markerReplacements: ComposerFileAttachment[] = [];
+      const appendedFiles: ComposerFileAttachment[] = [];
+      for (const file of stashedFiles) {
+        const expired = expiredAttachmentIds.has(file.attachmentId);
+        const key = composerFileDedupKey(file);
+        const restored: ComposerFileAttachment = {
+          type: "file",
+          id: file.id,
+          name: file.name,
+          mimeType: file.mimeType,
+          sizeBytes: file.sizeBytes,
+          file: null,
+          ...(file.source ? { source: file.source } : {}),
+          ...(expired
+            ? {}
+            : { uploadedAttachmentId: file.attachmentId, uploadEnvironmentId: environmentId }),
+        };
+        if (existingFileIds.has(file.id)) {
+          if (!expired && !retainedUploadIds.has(file.attachmentId)) {
+            duplicateFiles.push(file);
+          }
+          continue;
+        }
+        const reattachMarker = reattachMarkers.find(
+          (marker) =>
+            !restoredMarkerIds.has(marker.id) && composerFileMatchesReattachMarker(marker, file),
+        );
+        if (reattachMarker) {
+          restoredMarkerIds.add(reattachMarker.id);
+          existingFileIds.add(file.id);
+          existingFileKeys.add(key);
+          if (expired) {
+            expiredFileNames.push(file.name);
+          } else {
+            retainedUploadIds.add(file.attachmentId);
+            markerReplacements.push(restored);
+          }
+          continue;
+        }
+        if (existingFileKeys.has(key)) {
+          if (!expired && !retainedUploadIds.has(file.attachmentId)) {
+            duplicateFiles.push(file);
+          }
+          continue;
+        }
+        existingFileIds.add(file.id);
+        existingFileKeys.add(key);
+        if (expired) {
+          expiredFileNames.push(file.name);
+        } else {
+          retainedUploadIds.add(file.attachmentId);
+        }
+        appendedFiles.push(restored);
+      }
+      const capacity = remainingComposerAttachmentSlots(
+        composerImagesRef.current.length + composerFilesNow.length,
+        pendingThreadImportsRef.current.get(composerTargetKey(composerDraftTarget)) ?? 0,
+      );
+      const filesToAppend = appendedFiles.slice(0, capacity);
+      const skippedFiles = appendedFiles.slice(capacity);
+      unrestoredFileNames = skippedFiles.map((file) => file.name);
+      // A non-durable take can resurrect the stash entry after a reload;
+      // deleting these uploads would leave it pointing at nothing.
+      if (durable) {
+        for (const file of duplicateFiles) {
+          releasePersistedAttachmentUpload({
+            id: file.id,
+            environmentId,
+            attachmentId: file.attachmentId,
+          });
+        }
+        for (const file of skippedFiles) {
+          if (file.uploadedAttachmentId) {
+            releasePersistedAttachmentUpload({
+              id: file.id,
+              environmentId,
+              attachmentId: file.uploadedAttachmentId,
+            });
+          }
+        }
+      }
+      const restoredFiles = [...markerReplacements, ...filesToAppend];
+      if (restoredFiles.length > 0) {
+        addComposerDraftFiles(composerDraftTarget, restoredFiles, { appendReference: true });
+        const restoredFilePrompt = getComposerDraft(composerDraftTarget)?.prompt;
+        if (restoredFilePrompt !== undefined && restoredFilePrompt !== promptRef.current) {
+          promptRef.current = restoredFilePrompt;
+          setComposerCursor(
+            collapseExpandedComposerCursor(restoredFilePrompt, restoredFilePrompt.length),
+          );
+          setComposerTrigger(null);
+          promptChanged = true;
+        }
+        restoredFileCount = filesToAppend.length;
+      }
+    }
+
+    let unrestoredImageNames: string[] = [];
+    if (entry.attachments.length > 0) {
+      const existingIds = new Set(composerImagesRef.current.map((image) => image.id));
+      // The draft store also dedupes by mimeType+sizeBytes+name, so filter
+      // on the same key here. Counting a duplicate against capacity would
+      // burn a slot the store then refuses to fill, pushing a genuinely
+      // unique image into the overflow list for nothing.
+      const existingDedupKeys = new Set(
+        composerImagesRef.current.map(
+          (image) => `${image.mimeType}\0${image.sizeBytes}\0${image.name}`,
+        ),
+      );
+      const capacity = remainingComposerAttachmentSlots(
+        composerImagesRef.current.length + composerFilesRef.current.length + restoredFileCount,
+        pendingThreadImportsRef.current.get(composerTargetKey(composerDraftTarget)) ?? 0,
+      );
+      const pending = entry.attachments.filter(
+        (attachment) =>
+          !existingIds.has(attachment.id) &&
+          !existingDedupKeys.has(
+            `${attachment.mimeType}\0${attachment.sizeBytes}\0${attachment.name}`,
+          ),
+      );
+      unrestoredImageNames = pending.slice(capacity).map((attachment) => attachment.name);
+      const restoredImages = hydrateImagesFromPersisted(pending.slice(0, capacity));
+      if (restoredImages.length > 0) {
+        addComposerDraftImages(composerDraftTarget, restoredImages);
+      }
+    }
+
+    const missingImageReasons: string[] = [];
+    if (entry.droppedImageNames.length > 0) {
+      missingImageReasons.push(
+        `${entry.droppedImageNames.join(", ")} exceeded the stash size limit when this prompt was saved.`,
+      );
+    }
+    if (entry.unreadableImageNames && entry.unreadableImageNames.length > 0) {
+      missingImageReasons.push(
+        `${entry.unreadableImageNames.join(", ")} could not be read when this prompt was saved.`,
+      );
+    }
+    if (unrestoredImageNames.length > 0) {
+      missingImageReasons.push(
+        `${unrestoredImageNames.join(", ")} could not be restored: the composer is at its ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS}-attachment limit.`,
+      );
+    }
+    if (unrestoredFileNames.length > 0) {
+      missingImageReasons.push(
+        `${unrestoredFileNames.join(", ")} could not be restored: the composer is at its ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS}-attachment limit.`,
+      );
+    }
+    if (expiredFileNames.length > 0) {
+      missingImageReasons.push(
+        `${expiredFileNames.join(", ")}: stashed files are kept for 24 hours and this upload expired. Attach the file again.`,
+      );
+    }
+    if (missingImageReasons.length > 0) {
+      toastManager.add({
+        type: "warning",
+        title: "Some attachments were not restored",
+        description: missingImageReasons.join(" "),
+      });
+    }
+
+    if (promptChanged) {
+      window.requestAnimationFrame(() => {
+        composerEditorRef.current?.focusAtEnd();
+      });
+    }
+  };
+  const restoreStashEntryStable = useStableCallback(restoreStashEntry);
+
+  const deleteStashEntry = (entry: PromptStashEntry) => {
+    const { entry: removed, durable } = takeStashEntry(entry.id);
+    if (!stashQueue.some((candidate) => candidate.id !== entry.id)) {
+      setIsStashMenuOpen(false);
+    }
+    if (durable && removed) {
+      for (const file of removed.files ?? []) {
+        releasePersistedAttachmentUpload({
+          id: file.id,
+          environmentId: file.environmentId,
+          attachmentId: file.attachmentId,
+        });
+      }
+    }
+    if (!durable) {
+      toastManager.add({
+        type: "warning",
+        title: "Stash entry may come back",
+        description:
+          "Browser storage rejected the delete, so this prompt could reappear after a reload.",
+        data: { hideCopyButton: true },
+      });
+    }
+  };
+  const deleteStashEntryStable = useStableCallback(deleteStashEntry);
+
+  const stashCurrentPrompt = async () => {
+    // Stashing clears the draft. A pasted attachment still downloading would then land in the
+    // emptied composer instead of travelling with the entry it belongs to.
+    if (pendingDraftWork.has(attachmentTargetKeyRef.current)) {
+      toastManager.add({
+        type: "info",
+        title: "Still bringing attached context into this message.",
+        description: "Stash again once its chip resolves.",
+      });
+      return;
+    }
+    const prompt = promptRef.current.trim();
+    const images = [...composerImagesRef.current];
+    const files = [...composerFilesRef.current];
+    const stashedRecords: ComposerContextRecord[] = [
+      ...composerThreadContexts,
+      ...composerTerminalContextsRef.current.map(terminalContextRecord),
+      ...composerReviewComments.map(reviewCommentContextRecord),
+      ...composerPreviewAnnotations.map((annotation) =>
+        previewAnnotationContextRecord(annotation, {
+          screenshotContextId: images.some((image) => image.id === annotation.id)
+            ? annotation.id
+            : undefined,
+        }),
+      ),
+    ];
+    if (prompt.length === 0 && images.length === 0 && files.length === 0) {
+      const entries = usePromptStashStore.getState().entries;
+      const entry = entries.length === 1 ? entries[0] : undefined;
+      if (entry && !entry.pendingImageCount) {
+        await restoreStashEntry(entry);
+      } else {
+        setIsStashMenuOpen((open) => !open);
+      }
+      return;
+    }
+    const stashedFiles: PersistedComposerFileAttachment[] = [];
+    for (const file of files) {
+      if (composerFileNeedsReattach(file)) {
+        toastManager.add({
+          type: "error",
+          title: "Attach dropped files again or remove them before stashing",
+        });
+        return;
+      }
+      const upload = readAttachmentUpload(file.id);
+      if (upload?.status !== "ready" || upload.environmentId !== environmentId) {
+        toastManager.add({
+          type: "error",
+          title: "Wait for file uploads before stashing this prompt",
+        });
+        return;
+      }
+      stashedFiles.push({
+        id: file.id,
+        name: file.name,
+        mimeType: file.mimeType,
+        sizeBytes: file.sizeBytes,
+        attachmentId: upload.attachmentId,
+        environmentId,
+        ...(file.source ? { source: file.source } : {}),
+      });
+    }
+    // A repeat ⌘S on the *same* still-unencoded snapshot would stash it
+    // twice. Guard on the snapshot itself rather than a bare boolean: once
+    // the composer has been cleared the user can type something genuinely
+    // new (or switch threads) while encoding continues, and that deserves its
+    // own entry.
+    const attachmentKey = images
+      .map((image) => `image:${image.id}`)
+      .concat(files.map((file) => `file:${file.id}`))
+      .join(",");
+    const snapshotKey = [String(composerDraftTarget), prompt, attachmentKey].join("\n");
+    if (stashInFlightRef.current.has(snapshotKey)) return;
+    stashInFlightRef.current.add(snapshotKey);
+
+    const stashTarget = composerDraftTarget;
+    const entryId = randomUUID();
+    const stashAndEncode = async () => {
+      // Ordering matters in both directions: writing before clearing means a
+      // crash or closed tab mid-encode still leaves the prompt recoverable,
+      // while clearing before the async image work means edits typed during
+      // encoding are not wiped.
+      const { evicted, written, durable } = stashEntryToQueue({
+        id: entryId,
+        createdAt: new Date().toISOString(),
+        prompt,
+        attachments: [],
+        ...(stashedFiles.length > 0 ? { files: stashedFiles } : {}),
+        droppedImageNames: [],
+        unreadableImageNames: [],
+        pendingImageCount: images.length,
+        ...(stashedRecords.length > 0 ? { records: stashedRecords } : {}),
+      });
+
+      // If it was rejected (quota) the store has already rolled itself back,
+      // so leave the composer untouched rather than making it the second
+      // casualty of a reload.
+      if (!written) {
+        toastManager.add({
+          type: "error",
+          title: "Could not stash this prompt",
+          description:
+            "Browser storage rejected the write, so the composer was left as-is. Free up site data and try again.",
+          data: { hideCopyButton: true },
+        });
+        return;
+      }
+      if (!durable) {
+        toastManager.add({
+          type: "warning",
+          title: "Stashed prompt will not survive a reload",
+          description:
+            "Browser storage is unavailable, so this stash is kept in memory only for this session.",
+          data: { hideCopyButton: true },
+        });
+      }
+
+      promptRef.current = "";
+      clearComposerDraftPromptAndImages(stashTarget);
+      clearComposerDraftTerminalContexts(stashTarget);
+      setComposerDraftThreadContexts(stashTarget, []);
+      for (const comment of composerReviewComments) {
+        removeComposerDraftReviewComment(stashTarget, comment.id);
+      }
+      for (const annotation of composerPreviewAnnotations) {
+        releaseAttachmentUpload(annotation.id);
+        removeComposerDraftPreviewAnnotation(stashTarget, annotation.id);
+      }
+      setComposerDraftPrompt(stashTarget, "");
+      for (const image of images) {
+        releaseAttachmentUpload(image.id);
+      }
+      setComposerCursor(0);
+      setComposerTrigger(null);
+      pulseStashBadge();
+
+      if (evicted) {
+        for (const file of evicted.files ?? []) {
+          releasePersistedAttachmentUpload({
+            id: file.id,
+            environmentId: file.environmentId,
+            attachmentId: file.attachmentId,
+          });
+        }
+        toastManager.add({
+          type: "warning",
+          title: "Oldest stashed prompt discarded",
+          description: `The stash holds ${MAX_STASH_ENTRIES} prompts; the oldest was removed to make room.`,
+          data: { hideCopyButton: true },
+        });
+      }
+
+      // Images are re-encoded for the stash rather than stored verbatim: the
+      // composer allows up to 10MB per image, but localStorage gives the whole
+      // origin ~5MB.
+      const candidateAttachments: PersistedComposerImageAttachment[] = [];
+      const oversizedImageNames: string[] = [];
+      const unreadableImageNames: string[] = [];
+      for (const image of images) {
+        const result = await compressImageForStash(image.file);
+        if (!result.ok) {
+          (result.reason === "too-large" ? oversizedImageNames : unreadableImageNames).push(
+            image.name,
+          );
+          continue;
+        }
+        candidateAttachments.push({
+          id: image.id,
+          name: image.name,
+          mimeType: result.image.mimeType,
+          sizeBytes: result.image.sizeBytes,
+          dataUrl: result.image.dataUrl,
+          ...(image.source
+            ? { source: resizeSnapShotSource(image.source, result.image.imageSize) }
+            : {}),
+        });
+      }
+      const { kept, droppedNames } = partitionStashAttachments(candidateAttachments);
+
+      const { attached, durable: imagesDurable } = finalizeStashEntryImages(entryId, {
+        attachments: kept,
+        droppedImageNames: [...oversizedImageNames, ...droppedNames],
+        unreadableImageNames,
+      });
+      if (attached) {
+        if (!imagesDurable && durable && images.length > 0) {
+          toastManager.add({
+            type: "warning",
+            title: "Stashed images were not saved",
+            description:
+              "The prompt was stashed, but browser storage rejected its images. They will be missing if you reload.",
+            data: { hideCopyButton: true },
+          });
+        }
+      } else if (kept.length > 0) {
+        toastManager.add({
+          type: "warning",
+          title: "Stashed images did not attach",
+          description: `That prompt was restored or deleted before ${kept.length} image${kept.length === 1 ? "" : "s"} finished saving. Re-attach ${kept.length === 1 ? "it" : "them"} if you still need ${kept.length === 1 ? "it" : "them"}.`,
+          data: { hideCopyButton: true },
+        });
+      }
+    };
+    await stashAndEncode().finally(() => stashInFlightRef.current.delete(snapshotKey));
+  };
+  const stashCurrentPromptStable = useStableCallback(stashCurrentPrompt);
+
+  const toggleStashMenu = () => {
+    if (isComposerCollapsedMobile) {
+      expandMobileComposer();
+      setIsStashMenuOpen(true);
+      return;
+    }
+    setIsStashMenuOpen((open) => !open);
+  };
+  const toggleStashMenuStable = useStableCallback(toggleStashMenu);
+
+  const [stashMenuClosesOn, setStashMenuClosesOn] = useState({ prompt, composerMenuOpen });
+  if (
+    stashMenuClosesOn.prompt !== prompt ||
+    stashMenuClosesOn.composerMenuOpen !== composerMenuOpen
+  ) {
+    setStashMenuClosesOn({ prompt, composerMenuOpen });
+    if (stashMenuClosesOn.prompt !== prompt || composerMenuOpen) setIsStashMenuOpen(false);
+  }
+
+  useEffect(() => {
+    const handler = (event: globalThis.KeyboardEvent) => {
+      const command = resolveShortcutCommand(event, keybindings, {
+        context: {
+          terminalFocus: getTerminalFocusOwner() !== null,
+          terminalOpen,
+          modelPickerOpen: isComposerModelPickerOpen,
+        },
+      });
+      if (command !== "composer.stash") return;
+      // Always claim the shortcut so the browser save dialog never opens,
+      // even when the composer is in a state that can't stash.
+      event.preventDefault();
+      event.stopPropagation();
+      if (isCommandPaletteOpen() || isRevertingCheckpoint) {
+        return;
+      }
+      if (pendingUserInputs.length > 0) {
+        setIsStashMenuOpen((open) => !open);
+        return;
+      }
+      if (projectSelectionRequired || activePendingProgress !== null) {
+        return;
+      }
+      void stashCurrentPromptStable();
+    };
+    window.addEventListener("keydown", handler, true);
+    return () => window.removeEventListener("keydown", handler, true);
+  }, [
+    activePendingProgress,
+    isComposerModelPickerOpen,
+    keybindings,
+    pendingUserInputs.length,
+    projectSelectionRequired,
+    stashCurrentPromptStable,
+    isRevertingCheckpoint,
+    terminalOpen,
+  ]);
+
+  return {
+    stashQueue,
+    isStashMenuOpen,
+    setIsStashMenuOpen,
+    stashPulse,
+    restoreStashEntryStable,
+    deleteStashEntryStable,
+    toggleStashMenuStable,
+  };
+}
```
