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
- 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.
- 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. - 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 likecodex_work). Default view shows the current method's queue; a toggle reveals all. - 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:
| Need | Already 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 registry | VS 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. images | Zustand 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 conventions | Effect-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" identity | ComposerThreadDraftState.activeProvider: ProviderInstanceId | null (composerDraftStore.ts:273), driver kinds codex / claudeAgent / cursor / opencode (packages/contracts/src/providerInstance.ts:70). |
| Picker UI | cmdk 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
Design decisions
| Decision | Choice | Why |
|---|---|---|
| Clear composer on stash? | Yes, with Undo toast | Matches Claude Code; the whole point is "park this, write something else now." Undo makes it safe. |
| Queue or library? | Queue: restore removes the entry | Keeps the mental model simple and the storage bounded. A saved-prompts library (named, permanent) is a different feature. |
| Scope key | ProviderInstanceId, 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 visibility | Current method by default; "All methods" toggle | You 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 toast | localStorage 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 + ⌘S | Opens the stash picker instead of saving | One 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.
- Pro: zero-discovery recall — the queue is physically where your prompt went. Strong spatial metaphor.
- Con: costs vertical space when non-empty; needs a collapse affordance past ~4 items.
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.
- Pro: reuses
ComposerCommandMenu+ cmdk primitives wholesale; keyboard flow is ⌘S → arrows → ⏎, hands never leave home row. - Con: invisible until invoked — users who forget they stashed something get no reminder.
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.
Prompt stash 4
- Pro: room for management (reorder, delete, cross-scope browsing); natural home if this evolves into a named-prompt library.
- Con: heaviest build; a whole panel for what's usually 1–3 parked prompts is overkill for v1.
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.
- Pro: makes an invisible action legible — you see where the prompt went, and the badge doubles as the recall trigger.
- Note: repo rule — no continuous CSS animations (120 Hz perf). This is a one-shot, event-driven 550 ms animation, which complies; it must also respect
prefers-reduced-motion.
Implementation plan
- Commands & bindings. Add
composer.stashandcomposer.stashMenu.toggletoSTATIC_KEYBINDING_COMMANDS(packages/contracts/src/keybindings.ts:50); default{ key: "mod+s", command: "composer.stash" }inpackages/shared/src/keybindings.ts. Rebindable for free via the existing registry. - Store. New
apps/web/src/promptStashStore.ts: Zustand +persist, keyt3code:prompt-stash:v1, Effect-Schema-validatedRecord<ProviderInstanceId | "__none__", StashEntry[]>, debounced storage +beforeunloadflush — cloned from thecomposerDraftStore.ts:2178+pattern. Actions:stash(entry),pop(scope, id),remove(scope, id),restoreLastRemoved()(Undo). - Snapshot path. On
composer.stash:event.preventDefault()in theChatView.tsxcapture handler (branch near:4260); read the live draft viagetComposerDraft(target); convert runtimeFiles todataUrls withFileReader(thePersistedComposerImageAttachmentcodec already exists); enforce size caps; write entry;clearComposerContent(target); fire toast. Empty composer → dispatchcomposer.stashMenu.toggleinstead. - Rehydration path. On restore:
setPrompt(entry.prompt); for each imagefetch(dataUrl) → blob → new File(...)→addImages()(regeneratesblob:preview URLs through the normal path, so revocation bookkeeping atcomposerDraftStore.ts:1030keeps working); apply savedmodelSelectionByProvideronly if the provider still exists;pop()the entry. - 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. - 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
- ⌘S capture ordering: the ChatView listener early-returns when the command palette is open (
ChatView.tsx:4237) — ⌘S should probably still stash-nothing/no-op there rather than fall through to the browser dialog. Needs an explicitpreventDefaulteven on the no-op path. - Quota pressure: the composer draft store shares the same ~5 MB origin quota and also persists base64 images. The stash write must catch
QuotaExceededErrorand degrade to text-only with a visible warning, never a silent throw inside the persist middleware. - Rich content beyond text+images: the draft also carries
terminalContexts,elementContexts,reviewComments. v1 stashes prompt + images + provider/model only; contexts reference live sessions and would dangle. Worth revisiting. - Open: should sending a prompt auto-pop the next stashed entry into the composer (true queue semantics, à la "queued messages"), or is that too aggressive? Leaning no for v1 — recall stays explicit.
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.