Skip to content

feat(web): prompt stash — cmd+S saves the composer to a per-provider queue#4453

Open
t3dotgg wants to merge 1 commit into
mainfrom
t3code/prompt-save-queue
Open

feat(web): prompt stash — cmd+S saves the composer to a per-provider queue#4453
t3dotgg wants to merge 1 commit into
mainfrom
t3code/prompt-save-queue

Conversation

@t3dotgg

@t3dotgg t3dotgg commented Jul 24, 2026

Copy link
Copy Markdown
Member

What

Copies Claude Code's "cmd+S to stash a prompt" flow into the web composer. Pressing ⌘S / Ctrl+S snapshots the in-progress prompt — text and image attachments plus the model selection — into a client-only stash queue, then clears the composer with an Undo toast. The queue is stored in localStorage and scoped per connection method (ProviderInstanceId, so Claude Code / Codex / Cursor / custom instances each get their own queue).

Design + interactive mocks: https://puxe56fxtfjc.postplan.dev (this ships mocks D + B).

UX

  • ⌘S with content → stash: composer clears, a ghost of the prompt flies into a bookmark badge on the composer's shoulder (one-shot 550ms animation, prefers-reduced-motion respected), success toast with Undo.
  • ⌘S on an empty composer (or clicking the badge) → opens the stash picker above the composer: arrows to navigate, Enter to restore, ⌘⌫ or the hover ✕ to delete, Esc to close.
  • Restore rehydrates everything: prompt (appended if the composer already has text), images rebuilt into live Files through the existing persisted-attachment codec, and the saved model selection (only if that provider instance is still enabled). Restoring removes the entry — it's a queue, not a library.
  • Picker shows a hint when other connection methods have stashed prompts.

Implementation

  • composer.stash command in the keybinding contract + default mod+s (!terminalFocus) — rebindable via the existing registry. The handler runs capture-phase on window and always preventDefault()s so the browser save dialog never appears, even in states that can't stash (approvals, plan questions, palette open).
  • New promptStashStore (t3code:prompt-stash:v1): zustand persist + Effect-Schema validation + debounced storage + beforeunload flush, cloned from the composer draft store's conventions. 20 entries per queue (oldest evicted with a toast note), ~2MB per-entry attachment budget with dropped images surfaced by name, quota-safe writes that degrade to in-memory instead of throwing in the debounce timer.
  • Terminal/element contexts are intentionally not stashed — they reference live sessions and would dangle; inline placeholders are stripped from the stashed prompt.

Testing

  • 8 new unit tests for the store (scoping incl. __none__ bucket, LIFO order, eviction at cap, take-once semantics, attachment budget partitioning).
  • Full monorepo typecheck clean; web unit suite 1513 passed; shared/contracts suites pass; lint clean on touched files.
  • Not visually verified — automated browsers can't see authenticated views here, so the badge/fly/picker need an eyeball pass.

🤖 Generated with Claude Code


Note

Low Risk
Client-only localStorage UX with guarded shortcut handling and existing draft-store patterns; no server, auth, or send-path changes. Main caveat is shared origin storage quota with composer drafts.

Overview
Adds a prompt stash to the web composer, modeled on Claude Code’s cmd+S flow. ⌘S / Ctrl+S (composer.stash, default mod+s when the terminal isn’t focused) snapshots the in-progress prompt, image attachments, and model selection into a client-only queue in localStorage, scoped per provider instance, then clears the composer. The shortcut always calls preventDefault() so the browser save dialog never opens.

Stash saves strip inline terminal-context placeholders, enforce per-entry attachment size limits, and cap each queue at 20 entries (oldest evicted). Restore merges prompt text, rehydrates images via exported hydrateImagesFromPersisted, and reapplies model selection when the instance is still available; entries are removed on restore (queue semantics). Empty composer + ⌘S or the shoulder bookmark badge opens a keyboard-navigable picker (ComposerStashMenu); save shows a fly-into-badge animation and an Undo toast.

New promptStashStore (zustand persist, schema validation, debounced quota-safe storage) plus unit tests; UI in ComposerStashBadge / ComposerStashMenu wired from ChatComposer.

Reviewed by Cursor Bugbot for commit 7d14ced. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Add prompt stash feature to chat composer with Cmd+S per-provider queue

  • Pressing Mod+S in the composer (when terminal is not focused) stashes the current prompt and attachments to a per-provider queue via the new composer.stash keybinding, clearing the composer and showing an Undo toast.
  • A new ComposerStashBadge pill shows the stash count and plays a one-shot ghost animation on stash; clicking it opens a ComposerStashMenu popover for keyboard- and mouse-navigable restore/delete.
  • Stash state is persisted via a new Zustand store (promptStashStore.ts) backed by quota-safe debounced localStorage, with per-provider queues capped in length and attachments capped by size budget.
  • Restoring a stash entry merges the prompt text and non-duplicate images back into the composer and optionally restores the model selection if still available.
  • Risk: oversized images are silently dropped when stashing if they exceed the attachment size budget; a warning is shown on restore if images were lost.
📊 Macroscope summarized 7d14ced. 7 files reviewed, 0 issues evaluated, 0 issues filtered, 0 comments posted

🗂️ Filtered Issues

No issues evaluated.

…queue

Pressing mod+S snapshots the composer (prompt + image attachments +
model selection) into a localStorage-backed stash queue scoped to the
active provider instance, then clears the composer with an Undo toast.
A bookmark badge on the composer shoulder shows the queue count with a
one-shot ghost-fly animation on save; mod+S on an empty composer (or
clicking the badge) opens a picker to restore or delete entries.

- composer.stash keybinding command, default mod+s (!terminalFocus)
- promptStashStore: zustand persist under t3code:prompt-stash:v1,
  schema-validated, 20 entries/queue, per-entry attachment budget with
  dropped-image tracking, quota-safe writes
- restore rehydrates images to live Files via the existing persisted
  attachment codec and reapplies the saved model selection when the
  provider is still available

Client-only; terminal/element contexts are not stashed since they
reference live sessions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 14dba508-0424-4d4a-a33c-6f5b0efe782d

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch t3code/prompt-save-queue

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:XL 500-999 changed lines (additions + deletions). labels Jul 24, 2026

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 4 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 7d14ced. Configure here.

},
},
data: { hideCopyButton: true },
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Async stash wipes newer composer input

High Severity

The stashCurrentPrompt function clears the composer after asynchronously processing images, without an in-flight guard. This allows user edits or subsequent stash attempts during the async operation to be lost or cause duplicate stashes.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 7d14ced. Configure here.

).slice(0, Math.max(0, capacity));
if (restoredImages.length > 0) {
addComposerDraftImages(composerDraftTarget, restoredImages);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Restore drops excess images permanently

Medium Severity

When restoring a stashed prompt, restoreStashEntry removes the entry from the queue before processing attachments. If the restored attachments exceed the provider's limit, the excess are silently discarded and permanently lost without user notification.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 7d14ced. Configure here.

attachments: kept,
providerInstanceId: stashScopeInstanceId,
modelSelection: noProviderAvailable ? null : selectedModelSelection,
droppedImageNames: allDroppedNames,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unreadable images blamed on size limit

Low Severity

Names from failed readFileAsDataUrl calls are merged into droppedImageNames with true attachment-budget drops. Stash and restore toasts then always describe those files as too large for the stash, even when the real failure was an unreadable file.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 7d14ced. Configure here.

flushPromptStashStorage();

promptRef.current = "";
clearComposerDraftContent(composerDraftTarget);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stash wipes unrestorable contexts

Medium Severity

Stash clears the composer via clearComposerContent, which also drops terminal contexts, element contexts, preview annotations, and review comments. Those are not stored on the stash entry, so Undo and restore cannot bring them back.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 7d14ced. Configure here.

queuesByScopeKey: {},
stashEntry: (entry) => {
const scopeKey = promptStashScopeKey(entry.providerInstanceId);
const queue = get().queuesByScopeKey[scopeKey] ?? [];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium src/promptStashStore.ts:128

When providerInstanceId is __proto__ (a valid custom slug), stashEntry looks up queuesByScopeKey["__proto__"] which returns Object.prototype instead of undefined. Spreading Object.prototype with [entry, ...queue] then throws a TypeError because it is not iterable, so stashing a prompt for that provider crashes. The same prototype-pollution issue affects takeEntry's lookup. Consider guarding with Object.prototype.hasOwnProperty.call or using a Map/null-prototype object for queuesByScopeKey.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/web/src/promptStashStore.ts around line 128:

When `providerInstanceId` is `__proto__` (a valid custom slug), `stashEntry` looks up `queuesByScopeKey["__proto__"]` which returns `Object.prototype` instead of `undefined`. Spreading `Object.prototype` with `[entry, ...queue]` then throws a `TypeError` because it is not iterable, so stashing a prompt for that provider crashes. The same prototype-pollution issue affects `takeEntry`'s lookup. Consider guarding with `Object.prototype.hasOwnProperty.call` or using a `Map`/null-prototype object for `queuesByScopeKey`.

const decodePersistedPromptStashState = Schema.decodeUnknownSync(PersistedPromptStashState);

/** Maps the composer's active provider instance to a stash queue bucket. */
export function promptStashScopeKey(instanceId: ProviderInstanceId | null | undefined): string {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium src/promptStashStore.ts:44

promptStashScopeKey returns the literal string __none__ for both stashed-unscoped entries and any provider whose ID is __none__, so both buckets share the same queue. A __none__ provider's stashed prompts appear under the unscoped bucket and can be restored or deleted out of the wrong scope. Use a collision-proof representation (e.g. prefix provider keys with a sigil like provider:__none__ while keeping PROMPT_STASH_UNSCOPED_KEY distinct) or escape provider IDs so the unscoped key can never collide.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/web/src/promptStashStore.ts around line 44:

`promptStashScopeKey` returns the literal string `__none__` for both stashed-unscoped entries and any provider whose ID is `__none__`, so both buckets share the same queue. A `__none__` provider's stashed prompts appear under the unscoped bucket and can be restored or deleted out of the wrong scope. Use a collision-proof representation (e.g. prefix provider keys with a sigil like `provider:__none__` while keeping `PROMPT_STASH_UNSCOPED_KEY` distinct) or escape provider IDs so the unscoped key can never collide.

Comment on lines +67 to +73
if (event.key === "Enter") {
if (!highlightedEntry) return;
event.preventDefault();
event.stopPropagation();
onRestore(highlightedEntry);
return;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium chat/ComposerStashMenu.tsx:67

When the stash menu is open and keyboard focus is on the delete Button, pressing Enter restores the highlighted entry instead of activating the button's onClick. The capture-phase keydown handler matches Enter unconditionally and calls event.preventDefault() + event.stopPropagation() + onRestore(highlightedEntry), so the focused button never receives the activation. Check event.target (or document.activeElement) and skip the handler when the event originates from a nested interactive element like the delete button.

+      if (event.key === "Enter") {
+        if (event.target instanceof HTMLButtonElement) return;
         if (!highlightedEntry) return;
         event.preventDefault();
         event.stopPropagation();
         onRestore(highlightedEntry);
         return;
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/web/src/components/chat/ComposerStashMenu.tsx around lines 67-73:

When the stash menu is open and keyboard focus is on the delete `Button`, pressing Enter restores the highlighted entry instead of activating the button's `onClick`. The capture-phase `keydown` handler matches `Enter` unconditionally and calls `event.preventDefault()` + `event.stopPropagation()` + `onRestore(highlightedEntry)`, so the focused button never receives the activation. Check `event.target` (or `document.activeElement`) and skip the handler when the event originates from a nested interactive element like the delete button.

@macroscopeapp

macroscopeapp Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

3 blocking correctness issues found. New feature introducing new components, state management, and user-facing workflows. Multiple unresolved review comments including a high-severity race condition require human attention.

You can customize Macroscope's approvability policy. Learn more.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XL 500-999 changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant