feat(chat): per-conversation composer drafts (no more cross-chat leaks) (#3739)
* fix(chat): clear composer (text + images + attachments) on chat switch
loadConversation was clearing attachedDocs / pendingDocs but leaking
input (draft text), pastedImages (thumbnail), and pendingAttachmentsRef
(single-shot send stash) across chat switches. A user who typed a draft
or staged an image in chat A and clicked chat B in the sidebar would
see their draft sitting in B's composer — one wrong send away from
shooting the message into the wrong conversation.
Make loadConversation symmetric with startNewConversation, which
already clears the full composer. Also reset pendingAttachmentsRef
via a conversationId-keyed effect in the panel so the single-shot
send-time stash can't ride along on the next chat's first message.
Phase 1 of two: clears on switch (matches Cursor-style baseline).
Phase 2 (per-conversation draft restore, ChatGPT/Claude parity) is a
follow-up and will reuse the chat-store snapshot pattern already used
for messages + streaming state.
Adds an e2e spec (chat-composer-isolation.spec.ts) that types a draft
into chat A, switches to B via chat-load-conversation, and asserts B's
composer is empty (and stays empty when switching back to A under
Phase 1 semantics).
* feat(chat): per-conversation composer drafts (restore on return)
Phase 2 of the composer-isolation fix. Phase 1 (prior commit) stopped
the leak by clearing the composer on chat switch \u2014 matches Cursor
baseline but loses unsent text when you come back. This commit adds
per-conversation draft restore so switching A\u2192B\u2192A puts you right
back where you left off. Matches ChatGPT / Claude / Slack / iMessage.
Implementation:
- chat-store: add SessionDraft + composerDraft slot on SessionRecord
(in-memory only, never persisted to disk \u2014 same lifecycle as
messages/streamingText). New action setComposerDraft drops
empty drafts so we don't accumulate stale objects.
- use-chat-conversations: snapshot the OUTGOING composer into the
store's composerDraft alongside the existing snapshotSession
call, and restore the INCOMING composerDraft after switching.
Wired via four value refs (input / pastedImages / attachedDocs /
pendingDocs) so the hook's deps stay stable and event handlers
don't rebind every keystroke.
- standalone-chat: mirror the live composer into the store on a
250ms debounce so an unswitched close-and-relaunch (or a panel
hide without an explicit switch) still preserves the draft. The
same mirror clears the saved draft after send for free \u2014
setInput("") triggers a mirror tick with empty input, which
setComposerDraft treats as "drop draft".
Behavior:
- Switch A\u2192B: B's composer is empty (or restores B's saved draft).
- Switch back B\u2192A: A's text + image thumbnails + doc chips return.
- Send a message: draft is cleared (composer empties, mirror tick
drops the saved draft, return to chat shows empty composer).
- Relaunch app: drafts are gone (in-memory only \u2014 deliberate, keeps
on-disk schema simple; can persist in a future patch if needed).
E2E spec updated: now asserts the Phase-2 contract (A\u2192B is empty,
B\u2192A restores the original draft marker). Existing 22 chat-store
unit tests still pass; no new TS errors.
* chore(chat): drop phase-1/phase-2 framing from composer-draft code
Strip internal rollout-phase language from comments and the e2e spec
header. The composer-draft restore is just the feature now \u2014 no need
to leak the iteration history into the code. Pre-existing 'Phase 1 /
Phase 2' strings in chat-store.ts (older store migration) and the LLM
system prompt in standalone-chat.tsx are unrelated and left alone.
No behavior change.
* chore(e2e): unify composer-isolation spec into one feature flow
Spec was structured as two sequential assertions (no-leak, then
restore) carried over from the original two-pass iteration. Reframe
as a single end-to-end feature test that walks the full A\u2192B\u2192A loop:
type a draft in A, verify B is clean, verify A restores. Same
coverage, clearer intent.
Also renames the describe/it titles so they describe the feature
("drafts are scoped per conversation" / "keeps each chat's draft\nisolated and restores it on return") instead of just the no-leak\nhalf.\n\nNo behavior change.
* test(e2e): seed chats in the store before driving the composer
The composer-draft snapshot/restore + the panel's mirror effect are
all gated on the session record existing in the chat store \u2014
setComposerDraft is a no-op for unknown ids (intentional: the real
sidebar / disk-load paths always upsert first). The e2e spec used
synthetic ids that never hit disk and never went through any path
that upserts, so the snapshot, the restore, AND the mirror were all
silent no-ops. A\u2192B looked fine (composer was always empty in B),
but B\u2192A read back "" because A's draft was never persisted to the
store in the first place.
Fix: seed both chats via the existing __e2eSeedUserMessage hook
(which upserts into the store) inside the before() block. Mirrors
how chat-switch-context-loss.spec.ts already bootstraps its
sessions. No production-code change needed \u2014 the contract is real,
the test just wasn't exercising the path it claimed to. A
Ansh Grover committed
d930eb5c242dc32fdc9ee5a676de8e1c6a136fe7
Parent: 7691a5f
Committed by GitHub <noreply@github.com>
on 6/1/2026, 1:18:56 PM