Prompt Stash · ⌘S

Copy Claude Code's "cmd+S to stash a prompt" into the T3 Code web composer: snapshot the in-progress prompt — text and image attachments — into a queue you can pull from later. Client-only, localStorage, scoped per connection method (provider instance).

What the user gets

  1. Save: mid-composition, press ⌘S (Ctrl+S off-mac). The composer's current prompt, images, and provider/model selection snapshot into the stash. The composer clears (Claude Code behavior), a toast confirms with Undo.
  2. Recall: open the stash (keyboard or click — see mocks), pick an entry, and the composer rehydrates: text, images (rebuilt into live Files from base64), and the saved model selection. Restoring removes the entry from the queue; re-stash puts it back.
  3. Scoped per connection method: stash entries are keyed by the composer's active ProviderInstanceId (Claude Code vs Codex vs Cursor vs opencode, and per-instance like codex_work). Default view shows the current method's queue; a toggle reveals all.
  4. Survives everything client-side: refresh, tab close, cross-tab (the storage layer already broadcasts changes). Never touches the server.

Why this is easy here (repo findings)

The exploration confirmed every primitive already exists:

NeedAlready in repo
⌘S free?Yes — no mod+s binding exists; browser save-dialog fires today. The global handler is a capture-phase window keydown in apps/web/src/components/ChatView.tsx:4355, so it sees ⌘S before Lexical or the browser — one preventDefault() and it's ours.
Keybinding registryVS Code-style: commands in packages/contracts/src/keybindings.ts:50, defaults (mod+*) in packages/shared/src/keybindings.ts:22, resolver resolveShortcutCommand in apps/web/src/keybindings.ts:201.
Composer state incl. imagesZustand store composerDraftStore.ts. Persistable image shape already defined: PersistedComposerImageAttachment { id, name, mimeType, sizeBytes, dataUrl } (base64) at composerDraftStore.ts:81. Runtime shape holds a live File + blob: preview URL.
localStorage conventionsEffect-Schema-validated everywhere; keys are t3code:<feature>:v<N>; Zustand persist + debounced storage + beforeunload flush + versioned migrate (see composerDraftStore.ts:2178+); cross-tab sync via storage + t3code:local_storage_change events.
"Connection method" identityComposerThreadDraftState.activeProvider: ProviderInstanceId | null (composerDraftStore.ts:273), driver kinds codex / claudeAgent / cursor / opencode (packages/contracts/src/providerInstance.ts:70).
Picker UIcmdk primitives (components/ui/command.tsx), the composer $/@// menu (ComposerCommandMenu.tsx), global palette (CommandPalette.tsx).

One thing "draft" can't mean: the composer already auto-persists a single live draft per thread (t3code:composer-drafts:v1). The stash is a separate, multi-slot, explicitly-saved queue layered on top — new store, new key, no changes to draft semantics.

Data flow

Composer (Lexical) prompt: string images: File + blob: URL activeProvider, model composerDraftStore ⌘S stash StashEntry (schema) id, createdAt, prompt images: dataUrl (base64) providerInstanceId modelSelection File → FileReader → dataUrl size-capped, LRU-evicted persist (debounced) localStorage t3code:prompt-stash:v1 claudeAgent → [e1, e2] codex_work → [e3] queues keyed by ProviderInstanceId recall: dataUrl → fetch() → File → addImages() + setPrompt()

Design decisions

DecisionChoiceWhy
Clear composer on stash?Yes, with Undo toastMatches Claude Code; the whole point is "park this, write something else now." Undo makes it safe.
Queue or library?Queue: restore removes the entryKeeps the mental model simple and the storage bounded. A saved-prompts library (named, permanent) is a different feature.
Scope keyProviderInstanceId, with a "__none__" bucket when no provider is selected"The method you're connecting with" = the agent harness instance. Finer than driver kind (separates codex_work/codex_personal), and it's already sitting in the composer draft state.
Cross-scope visibilityCurrent method by default; "All methods" toggleYou stashed it somewhere; don't let a provider switch make it look lost.
Image budget~1.5 MB per entry after base64, 20 entries per queue, evict oldest with a warning toastlocalStorage is ~5 MB total and base64 inflates ×1.33. Oversized images: stash the text + keep a placeholder chip noting the dropped image, never silently truncate.
Empty composer + ⌘SOpens the stash picker instead of savingOne key, two intents, zero conflict — save when there's something to save, browse when there isn't.

UI mocks

Four directions. A/B/C are retrieval surfaces; D is the save-moment micro-interaction and composes with any of them. Recommendation: D + A for v1 (delightful save, glanceable recall, zero new navigation), with B as a fast-follow since the command-menu plumbing already exists.

Mock A — The Shelf Recommended

A horizontal strip of stash cards that appears above the composer only when the current method's queue is non-empty. Cards show a 2-line snippet, image thumbnails, and age. Click (or ⌘S on empty composer, then arrows) to restore.

Mock A · shelf above composer · Claude Code scope · 3 stashed
Refactor the sidebar snooze logic so settled threads keep their scroll position when…
1 image · 2m ago
Write a migration that backfills provider instance ids for legacy drafts
12m ago
Why does the diff panel flash on theme change? Repro attached.
2 images · 1h ago
⌘S stashes here
Ask Claude Code anything…
+ attach Claude Code · Fable 5

Mock B — Command-menu group Fast-follow

Stashed prompts appear as a group in the existing composer command menu (the $/@// picker), and ⌘S on an empty composer opens it pre-filtered. Keyboard-first, no new chrome.

Mock B · stash group in the composer command menu
Stashed prompts — Claude Code
Refactor the sidebar snooze logic so settled threads keep… 2m ⏎ restore
Write a migration that backfills provider instance ids… 12m
Why does the diff panel flash on theme change? Repro… 1h ⌫ delete
Other methods · 1 stashed in Codex (work)
⌘S on an empty composer opens this picker…
Claude Code · Fable 5

Mock C — The Stash drawer If it grows into a library

A slide-out panel (right side, peer of the diff/preview panels) with full cards: bigger snippets, image thumbnails, provider scope tabs, drag-to-reorder. The heavyweight option.

Mock C · right-side drawer with scope tabs
Ask Claude Code anything…
Claude Code · Fable 5

Prompt stash 4

Claude Code · 3 Codex · 1 All
Refactor the sidebar snooze logic so settled threads keep their scroll position
1 image · 2m ago · Fable 5
Write a migration that backfills provider instance ids for legacy drafts
12m ago · Fable 5
Why does the diff panel flash on theme change? Repro attached.
2 images · 1h ago · Opus 4.8

Mock D — The save moment Composes with A or B

On ⌘S, a ghost of the prompt flies into a bookmark badge perched on the composer's shoulder; the count pops; a toast offers Undo. The badge is the persistent entry point when the shelf is collapsed. Press ⌘S (or click the composer below) to try it live.

Mock D · interactive — press ⌘S / Ctrl+S
Fix the flaky pairing-token test, screenshot attached…
Fix the flaky pairing-token test, screenshot attached…
IMG Claude Code · Fable 5
Stashed to Claude Code queue Undo

Implementation plan

  1. Commands & bindings. Add composer.stash and composer.stashMenu.toggle to STATIC_KEYBINDING_COMMANDS (packages/contracts/src/keybindings.ts:50); default { key: "mod+s", command: "composer.stash" } in packages/shared/src/keybindings.ts. Rebindable for free via the existing registry.
  2. Store. New apps/web/src/promptStashStore.ts: Zustand + persist, key t3code:prompt-stash:v1, Effect-Schema-validated Record<ProviderInstanceId | "__none__", StashEntry[]>, debounced storage + beforeunload flush — cloned from the composerDraftStore.ts:2178+ pattern. Actions: stash(entry), pop(scope, id), remove(scope, id), restoreLastRemoved() (Undo).
  3. Snapshot path. On composer.stash: event.preventDefault() in the ChatView.tsx capture handler (branch near :4260); read the live draft via getComposerDraft(target); convert runtime Files to dataUrls with FileReader (the PersistedComposerImageAttachment codec already exists); enforce size caps; write entry; clearComposerContent(target); fire toast. Empty composer → dispatch composer.stashMenu.toggle instead.
  4. Rehydration path. On restore: setPrompt(entry.prompt); for each image fetch(dataUrl) → blob → new File(...)addImages() (regenerates blob: preview URLs through the normal path, so revocation bookkeeping at composerDraftStore.ts:1030 keeps working); apply saved modelSelectionByProvider only if the provider still exists; pop() the entry.
  5. UI. Build Mock D (badge + fly animation + Undo toast) and Mock A (shelf, collapsed to the badge past 4 items). Shelf reads the store filtered by the composer's current activeProvider.
  6. Verify. bun run typecheck / lint; unit tests for the store (caps, eviction, scope bucketing, migration seam); manual pass for ⌘S capture vs browser save-dialog and Lexical focus. Note: per project memory, authenticated visual verification needs a paired profile or user eyeballs.

Sketch of the storage shape

// t3code:prompt-stash:v1 — Effect-Schema validated, versioned like every other store
const StashEntry = Schema.Struct({
  id: StashEntryId,
  createdAt: Schema.Number,
  prompt: Schema.String,
  images: Schema.Array(PersistedComposerImageAttachment), // { id, name, mimeType, sizeBytes, dataUrl }
  providerInstanceId: Schema.NullOr(ProviderInstanceId),
  modelSelection: Schema.optional(ModelSelection),
  droppedImageNames: Schema.Array(Schema.String), // images over the size cap — shown as placeholder chips
});

const PromptStashStorage = Schema.Struct({
  version: Schema.Literal(1),
  queues: Schema.Record({ key: Schema.String, value: Schema.Array(StashEntry) }), // ProviderInstanceId | "__none__"
});

Risks & open questions

Verdict: low-risk, well-contained feature. Everything hard (keybinding plumbing, schema-validated persistence, base64 image codec, provider identity, picker UI) already exists in the codebase; v1 is one new store, one command branch, and the Mock D + A surfaces.