Skip to content

🤖 fix: keep an open edit out of the shared draft - #5893

Closed
ThomasK33 wants to merge 8 commits into
mainfrom
fix/edit-draft-window-local
Closed

ThomasK33 wants to merge 8 commits into
mainfrom
fix/edit-draft-window-local

Conversation

@ThomasK33

@ThomasK33 ThomasK33 commented Oct 8, 2026 •

Copy link
Copy Markdown
Member

Summary

An open edit's text and files now stay in the composer's memory and never reach the shared, persisted draft. A reload during an edit then keeps the unsent draft (#5672), and a second window on the same workspace never sees the edit (#5571). While an edit is visible, every insert goes into the edit or is refused, so nothing writes the hidden draft.

Fixes #5672
Fixes #5571

Background

On main, entering an edit copies the unsent draft into a React ref and writes the edit text and files into the workspace's shared DraftStore draft (applyDraftFromPending in the edit-entry effect). The DraftStore persists that to draft.json and broadcasts it to every window. A reload or a second window therefore shows the edit text as the draft, and the unsent draft lives only in the lost ref.

This PR replaces #5801, which is parked at its final check and is now a draft. #5801 used 14 assessments. Its final-check blocker was a queued message restored during an edit send that made the draft appear twice. Here the test a queued message restored during an edit send shows once next to the unsent draft (T3, two cases) covers that flow. This PR starts from main, not from the #5801 branch.

Implementation

Rule: normal draft APIs always read and write the workspace's normal draft. Edit input lives only in the window that opened the edit and changes only through explicit edit operations. No setter redirects a normal draft write into the edit buffer.

  1. useComposerDraft gets an in-memory edit buffer (useState plus a ref) and explicit operations: beginEditDraft, setEditText, setEditAttachments, dropEditDraft, consumeEditDraftIntoDraft and appendSnapshotToDraft. No new store, module or persisted field.
  2. ChatInput shows the buffer while the edit is visible, and the draft otherwise. Typing, voice and suggestions route by the visible edit at call time.
  3. An edit that ends without a send (workspace switch, unmount, second Edit, deleted row, lost target, transcript-only) appends its text and files once after the unsent draft. Nothing is lost.
  4. An in-flight edit send or editing /compact owns its submitted snapshot. Acceptance consumes it. Refusal puts it back once: into the edit if the same edit is still open, else after the unsent draft.
  5. Queued-message restores during an edit merge in front of the unsent draft, and their notes go to the review store.

Decisions

  • D1-A: notes when an edit ends without a send behave as on main. Notes added during an edit are dropped on a switch or reload. The edited message keeps its own notes.
  • D2-B: "Quote in input" goes into the open edit through the new ChatInputAPI.appendToVisibleInput. Palette / inserts and MCP App messages use it too, so they land where the user sees them, as on main.
  • D3-A: during an edit send the composer shows the unsent draft instead of an empty box. Text typed then joins the unsent draft.
  • D4-B: review budget of at most 2 pushes and 6 reviews, the final check included.

Scope reductions (vs #5801's approved behavior)

#5801 behavior This PR What the user loses
Notes move with the edit on every move Notes as on main (D1-A) Notes added or changed during an edit are dropped on a switch or reload.
ChatPane replay guards (caught-up, hasOlderHistory) none, as on main A replay that hides the row ends the edit. Its text goes after the unsent draft, so nothing is lost.
(main) the composer is empty during an edit send The unsent draft is shown (D3-A) Main's order "unsent draft comes back after the send" is gone.

Insert paths while an edit is open

"Visible" means the composer shows the edit. During an edit send the edit is dismissed and the composer shows the unsent draft (D3-A), so writes there are visible.

Path While the edit is visible During an edit send
Quote in input (transcript menu) into the edit into the unsent draft
Palette / insert (UPDATE append) into the edit into the unsent draft
MCP App message (UPDATE append, with workspaceId) into the edit into the unsent draft
UPDATE append with files or notes (no dispatcher today) dropped as on main
Snippets no such feature –
Paste, drop, file picker refused with main's toast into the unsent draft
A file add that lands after Edit opens refused with the toast; writes nothing into the unsent draft
Remove a file chip removed from the edit removed from the unsent draft
Typing, voice, / and @ suggestions into the edit into the unsent draft
Native text drag into the textarea blocked, as on main blocked
Queued card Edit refused before the dequeue; the message stays queued merged in front of the unsent draft
ArrowUp edit-last with a queued message cannot fire merged in front, only when the draft text is empty
Stop or queue held-input restore dropped, input stays held, as on main merged in front of the unsent draft
Analytics drill-down, Compact & retry (UPDATE replace) dropped, as on main replaces the unsent draft, as in a normal composer
Reset Context, Preserve History clears the edit; the unsent draft stays clears the unsent draft
Review note from the diff panel review store, as on main as on main

Two known windows (both visible, nothing hidden is written):

  1. Queued-card race: an edit opened during the queued card's clearQueue round trip gets the queued message merged in front of the unsent draft behind it. Nothing is lost, and the message shows when the edit ends. On main the same race replaced the new edit's text.
  2. After the backend accepts an edit (the edited row replaces the target) and before the reply settles, the queued card's Edit replaces the shown unsent draft, as in a normal composer. Main kept the unsent draft in a ref there and put it back after the send.

Size

Production diff vs main: +244/−55 in 5 files. The user raised the ceiling from +225/−55 to +231/−55 for the React Compiler fix below, and to +244/−55 for the review-round-1 fix (see "Review round 1").

File + −
src/browser/features/ChatInput/index.tsx 174 53
src/browser/features/ChatInput/useComposerDraft.ts 58 0
src/browser/features/ChatInput/useComposerAttachments.ts 5 1
src/browser/features/ChatInput/types.ts 4 0
src/browser/components/ChatPane/ChatPane.tsx 3 1

editSessionRef and appliedEditIdRef are declared above the unmount cleanup that clears them. React Compiler skips a component that writes a ref before its declaration, and the move counts +5/−5.

Perf

  • Typing in an edit makes one useState update and one ref write. No DraftStore write and no backend save. The test a reload during an edit keeps the unsent draft and its files (#5672) (T1) spies on DraftStore.setText and setAttachments while it types into an edit and expects 0 calls.
  • No new effect except one unmount cleanup with an empty dependency list.
  • appendToVisibleInput, isEditVisible and the attachFiles landing check run per action only.

make check-react-compiler:

React Compiler coverage OK: 23/24 hot components compile (1 known skipped).

Review round 1

Codex found one valid P2 defect that this PR introduced (thread at index.tsx:2137). An editing slash command that clears only its text, such as /vim or /model, dropped the edit's file. The command takes the edit's text and files when it starts (plan step 9), and on "consume" nothing put the files back. On main the edit keeps them.

Fix (a12dad2c3f, +13 lines): a command consumes only what it clears. clear-input clears its text, and clear-attachments clears its files. On "consume", and on "restore-if-empty" when the edit already holds new text, the parts the command did not clear go back once: into the same edit, or after the unsent draft.

Tests: T24 "a text-only command typed into an edit keeps the edit's file" (real /vim; it failed before the fix), T25 (5 cases, "a held text-only command (, ) returns the edit's file once") and T26 (2 cases, "a held command that clears attachments () brings no file back"). Mutations M28-M32 each make at least one of them fail.

Known limit (untested): no command sends clear-attachments together with "restore-if-empty". The 3 commands that clear files all end with "consume". If one ever did, and the edit text was empty, the whole snapshot would come back, files included. On main the files would stay cleared.

Validation

  1. Tests first. The tests commit ran on main's production code. All 31 runnable new cases match the plan: the 5 cases of T3 (both), T11 and T15 (both) pass on main, and every other case fails. T18 tests a new API. The 5 adjusted existing tests also fail on main, for the planned reasons.
  2. Mutations. Each of the 27 planned mutations makes its test fail, and so do the review-round mutations M28-M32. Two notes:
    • M12 as planned (route typing by the edit buffer) is an equivalent mutant. Typing is impossible while the two routings differ. The stricter M12s (route typing by the unsettled edit session) fails T12.
    • M22 removes the .catch restore on both /compact phase calls, because the fix catches and rethrows instead of try/finally.
  3. Gates on eda9ae1 (push 1). Push 2 (e1cddc014a) reran make static-check, make check-react-compiler and the edit test files, all exit 0:
    • make static-check: exit 0.
    • make check-react-compiler: exit 0 (output under Perf).
    • editKeepsUnsentDraft.test.ts and composerDraftsFormalRepro.test.ts: 48/48, exit 0.
    • tests/ui/chat plus tests/ui/layout/rightSidebar.test.ts: 134 passed, 1 failed, 23 skipped. The failure is sendModeDropdown "send after step waits for every tool call emitted in the current step" with "Expected the source turn to be streaming before queueing", the 1 s stream-start wait tracked in 🤖 tests: sendModeDropdown 'send after step' waits only 1 s for the stream to start #5807. Reruns of that file alone: all tests passed in 3 of 3 runs on this branch and 3 of 3 runs on main.
    • bun tests in src/browser/features/ChatInput, src/browser/components/ChatPane and useAIViewKeybinds: 215 passed, 1 failed. The failure (useAIViewKeybinds "desktop canvas owns capture-phase resume, chat focus, editor, and terminal shortcuts") also fails on main's code locally.
    • BUGBASH_AI=mock make test-bugbash-repros: exit 0, 50 tests selected across 2 phases.
  4. tests/ui/chat/newChatStreamingFlash.test.ts fails locally on main too (cd2837ab91: 2 of 3 tests fail with "Created workspace not found yet"). On this branch it failed 2 of 3 in each of two reruns before the final gate run, with the same message, and passed in the final gate run. Main CI passed it in its last 5 runs, so CI is the check of record for this file.
  5. 🤖 tests: phone repro editCancelKeepsDraft once showed an empty composer after Cancel #5810 phone loop (tests/bugbash/repros/editCancelKeepsDraft.e2e.ts, phone target, --repeat-each 20 --retries 0 --workers 1, mock AI):
    • main cd2837ab91: 20 executed, 20 passed, 0 failed, 0 flaky.
    • this branch eda9ae1: 20 executed, 20 passed, 0 failed, 0 flaky.
  6. tests/bugbash/repros/knownFailureReloadDuringEdit.e2e.ts loses its known-failure tag and header, and passes in the repro run above.
Test notes
  • New file tests/ui/chat/editKeepsUnsentDraft.test.ts scenarios T1-T26 (40 cases). T18 is in src/browser/features/ChatInput/useComposerDraft.test.ts.
  • 11 existing edit tests now type with typeIntoEdit instead of getDraftStore().setText. Tests 402, 534 and 572 change their assertions for D3-A. Two tests in composerDraftsFormalRepro.test.ts are replaced by T2 and T5, because an edit can no longer merge with edit text that another window saved.
  • renderApp and createAppHarness gain a strictMode option (T11).
  • T10 keeps ChatPane mounted, so main fails at "the other workspace's edit stays open".
  • T17(b) holds the edit send in refuse mode, because the busy stream that the queued card needs changes the history.
  • T21 stops the one rejection at the send's outer runWithFinally. jest's sandboxed process does not see real unhandled rejections.
  • T22(a) installs DOMRect, which the context menu needs and happy-dom lacks.

Risks

Medium. The change touches the composer's edit lifecycle, which #5801 found hard to get right. Affected areas: edit mode, queued-message restores during an edit, /compact edits, and composer inserts. Failure modes are lost or duplicated draft text. The tests cover every move of the edit text, and each mutation proves its test. Notes keep main's behavior (D1-A), and no persisted format changes, so an upgrade or downgrade needs no migration.

Follow-ups


The implementation plan (sha256 427d0c55411aae85140272a71ce2a9e48710d95d94ee6d452682071864d2f865) is in this comment: #5893 (comment) (GitHub's body size limit no longer fits it here).


Generated with xum • Model: anthropic:claude-opus-5-5 • Thinking: high • Cost: $154.71

Adds T1-T23 (32 cases) for keeping an open edit's text and files in the
composer's memory (#5672, #5571), a strictMode option for renderApp and
createAppHarness, and the changed existing edit tests (typeIntoEdit, D3).
Replaces the two #5501 edit put-back repros in composerDraftsFormalRepro.

Production code is unchanged in this commit: most new tests fail on main.

---

_Generated with `xum` • Model: `anthropic:claude-opus-5-5` • Thinking: `high`_
While an edit is open, its text and files live only in the composer
(window-local). The shared persisted draft keeps the unsent draft, so a
reload, a second window, a workspace switch or a crash during an edit no
longer loses it (#5672, #5571).

- useComposerDraft holds the edit's text and files in local state and
  writes nothing to DraftStore while the edit is visible. When the
  composer unmounts with an open edit, the edit is appended after the
  unsent draft once.
- Inserts while an edit is visible (Quote in input, palette, MCP App
  messages) append to the edit; file appends and late file adds are
  refused instead of replacing the draft behind the edit.
- Restores during an edit send (queued card Edit, backend restores) merge
  in front of the shown unsent draft; their notes go to the review store.
- A rejected command phase puts an editing command's input back once.

---

_Generated with `xum` • Model: `anthropic:claude-opus-5-5` • Thinking: `high`_
- T10: edit in the other workspace before coming back, so ChatPane
  stays mounted while the send is pending.
- T17(b): hold the edit send before the backend (its target still
  shows); the busy stream changes the history, so the send is refused.
- T21: the rejected phase still leaves `void handleSend()` unhandled, as
  on main; the test stops that rejection at the send's outer wrapper.
- T22a: install DOMRect for the context-menu popover.

---

_Generated with `xum` • Model: `anthropic:claude-opus-5-5` • Thinking: `high`_
With the edit's target still shown, the edit keeps its own buffer
behind the shown unsent draft, so the quote test now tells "the
visible edit" apart from "the real target" (M25). T17(b) uses the
same helper.

---

_Generated with `xum` • Model: `anthropic:claude-opus-5-5` • Thinking: `high`_
The fix keeps an open edit out of the shared draft, so the #5672 repro
passes. It loses its known-failure tag and header.

---

_Generated with `xum` • Model: `anthropic:claude-opus-5-5` • Thinking: `high`_
React Compiler skips ChatInputInner when it writes a ref before its declaration.
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-09T04:52:32.536124Z e1cddc0 New commits
🔒 Security Review ✅ Completed 2026-10-09T04:47:55.081350Z e1cddc0 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: eda9ae1e3f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/browser/features/ChatInput/index.tsx
A text-only command such as /vim emits clear-input only. As on main, the edit keeps its files.
@ThomasK33

Copy link
Copy Markdown
Member Author
📋 Implementation Plan (sha256 427d0c55…f865)

Option C plan: keep the edit out of the shared draft (#5672, #5571)

1. Summary

The plan fixes #5672 and #5571 with one rule: an open edit's text and files stay in the composer's memory and never reach the shared, persisted draft. The unsent draft then survives a reload, and a second window never sees the edit. While an edit is visible, every insert goes into the edit or is refused, so nothing writes the hidden draft (section 3, insert-path audit). It is a new PR from main, not a repair of #5801's edit lifecycle.

Item Value
Base current origin/main (read at abfa4e32d5 and 7ba0f933d7). Nothing comes from the #5801 branch except test helpers.
Production diff (estimate) about +194/−44 (net +150) in 5 files: ChatInput/index.tsx, useComposerDraft.ts, ChatInput/types.ts, useComposerAttachments.ts and two functions in ChatPane.tsx. The size gate is +225/−55. R2 on #5801 was +228/−67 (net +161).
Test diff (estimate) about +1,030/−160
Tests 23 new scenarios (T1-T23, 32 cases), 27 mutations, 3 existing tests changed, 2 replaced
Not touched chatEditing.ts, DraftStore.ts, useComposerSuggestions.ts and the backend. Notes change only for restores during an edit (section 3, step 11).
New store, module or persisted field none
Delivery a new PR that links #5801. #5801 closes as superseded after the merge.
Review budget D4-B: at most 2 pushes and 6 assessments, then a hard stop
Decisions decided: D1-A, D2-B, D3-A and D4-B (section 12)

2. Causes on main

I read main at abfa4e32d5, and the insert audit read 7ba0f933d7. The composer and ChatPane files are unchanged since 752a624e15, where the line numbers below come from. CI = src/browser/features/ChatInput/index.tsx. UCD = useComposerDraft.ts in the same folder. DS = src/browser/stores/DraftStore.ts. CP = src/browser/components/ChatPane/ChatPane.tsx.

One cause, three symptoms: entering an edit writes the edit into the shared draft.

  1. The edit-entry effect (CI:1406-1446) snapshots the unsent draft into a React ref: editSessionRef.current = { preEditDraft: getDraft(), … } (CI:1422-1427).
  2. Then it calls applyDraftFromPending(editingMessage.pending, …) (CI:1428). That calls setDraft (CI:1189-1197), which calls setInput and setAttachments (UCD:139-142).
  3. Both write the workspace scope of the shared DraftStore (UCD:55-63). So the edit text and files replace the unsent draft in the store. The unsent draft lives only in the ref.
  4. The DraftStore persists every change through the backend: setText (DS:759-767) schedules a 300 ms flush (DS:1911-1918) to client.drafts.update (DS:1984), which writes sessions/<id>/draft.json (src/node/services/draftService.ts:80, 881-888).
Symptom Path on main
#5672: a reload during an edit shows the edit text as the draft. The unsent draft is gone. After the reload, applySnapshot (DS:1618-1634) hydrates the draft from draft.json. The ref and ChatPane's editingState (CP:381) are gone, so nothing restores the unsent draft.
#5571: a second window on the same workspace shows the edit text as its draft. Every window subscribes with client.drafts.subscribe (DS:1679). A changed event reaches applyServerState (DS:1636-1652), which replaces that window's text.
Same bug, no issue: a workspace switch during an edit loses the unsent draft. The switch ends the edit (CP:893-896), and the composer is keyed by workspace (CP:2011), so it remounts and the ref is gone. The store still holds the edit text.

While an edit is open, 6 programmatic paths on main write into the edit (in the shared draft) with no edit guard: restoreDraft (CI:1206, 1222), restoreText (1297), appendText (1306), prependText (1319), UPDATE append (1710-1720) and CLEAR_CHAT_COMPOSER (1751). Only UPDATE restore (1638-1640) and replace (1701) skip an open edit.

3. Rule and design

Rule.

  1. Normal draft APIs always read and write the workspace's normal draft in the DraftStore.
  2. Edit input lives only in the window that opened the edit, and changes only through explicit edit operations.
  3. No setter redirects a normal draft write into the edit buffer.
  4. Queue restoration (restoreQueueToInput, then UPDATE restore) never writes through such a setter.
  5. An in-flight send owns its submitted snapshot. Acceptance consumes it. Refusal restores it once.

One owner at a time. The unsent draft never leaves the DraftStore. The edit's text and files have exactly one owner:

  • the edit buffer of the composer that opened the edit, tied to that edit's existing EditSession object (editSessionRef), or
  • the in-flight edit send or editing command that took them (its snapshot), or
  • the workspace's normal draft, after a move. A move appends after the unsent draft, once.

useComposerDraft.ts (UCD)

  • input, setInput, attachments, setAttachments, getDraft and setDraft stay as on main. They always mean the normal draft.
  • New edit buffer: editDraft ({ id, text, attachments } | null, useState) and editDraftRef. Each edit operation writes both, so completions and the unmount cleanup read the live value.
  • New explicit edit operations. Each is a no-op without a buffer:
    • beginEditDraft(id, pending), setEditText(updater), setEditAttachments(updater), dropEditDraft().
    • consumeEditDraftIntoDraft() appends the live buffer after the unsent draft (joinDraftText(current, text), then files with missing ids). Then it clears the buffer.
    • appendSnapshotToDraft(snapshot) appends a detached send or command snapshot the same way. It never reads or clears the live buffer.
    • Only these two edit operations write the normal draft.

ChatInput/index.tsx (CI)

  1. Visible and normal values. editOpen is true when editingMessageForUi is set and the buffer has its id. In CI, input and attachments become the visible values: the buffer when editOpen, else the draft. A normal-draft write reads the normal draft only through a functional updater, getDraft() or getDraftStore().getText(draftScope). The PR body lists every CI read that feeds a normal-draft write, with its source, destination and owning session.
  2. User input. setTypedText decides when it is called, from refs. It never uses the render that created the callback. It calls setEditText only while the edit is visible: the existing layout effect (CI:384-386) also stores the visible edit id (editOpen ? id : undefined) in a ref. During an edit send the edit is dismissed, so typing goes to the normal draft that the composer shows, even though the hidden buffer may still hold text typed during preparation. Otherwise it calls setInput, so late input is never dropped. Callers: typing (CI:592, 605), voice onTranscript (647) and suggestion select (useComposerSuggestions.ts:399). A voice transcript therefore lands in what the composer shows when it arrives, as on main. Steps 15-17 read the same ref.
  3. Files. useComposerAttachments keeps main's normal setAttachments. Paste, drop and the file picker refuse an add while an edit is visible, as on main (hook 126, 135, 149, with the toast "Attachments cannot be added while editing a message."). An add that started before the edit and lands after it is refused too (step 17). With editOpen, CI's remove handler calls setEditAttachments instead.
  4. Enter (effect CI:1406-1446). The effect uses the real target props.editingMessage, not editingMessageForUi, because an optimistic dismissal is not a target loss. First, if the buffer holds another id or no edit is open, it calls consumeEditDraftIntoDraft(). That covers a second Edit, a deleted row (CP:1378), a target that is not found (CI:2211) and transcript-only. Then beginEditDraft(id, pending) replaces the snapshot and applyDraftFromPending (CI:1422-1428). EditSession drops preEditDraft and keeps preEditReviews and settled. getDraft leaves the dependency list.
  5. Cancel (restorePreEditDraft, CI:1254-1260). setDraft(session.preEditDraft) becomes dropEditDraft(). The notes line stays.
  6. Accepted (restorePreEditDraftAfterSend, CI:1268-1289). Remove the text and file lines (1276-1279). Settling and notes stay.
  7. Take (CI:2893-2906, edit sends). The send captured input, the files and editSessionForSend at CI:2689-2694. At the take it records taken = { text, attachments } from that capture. Only when editSessionRef.current === editSessionForSend does it also remove the captured text and file ids from the buffer, with main's removeSentText and id filter. Text typed during preparation stays in the buffer.
    • Stop rule. At the take, if the session is not settled but is no longer the same edit (step 8), the composer unmounted or the target was lost, so the edit's text is in, or moving to, the normal draft. The send then stops before submission and takes nothing. A cancelled edit (settled) is still sent, as on main (test 477).
  8. Refusal or throw (putBackTaken, CI:2664-2688; callers 2956, 3036). "Same edit" means three things: editSessionRef.current === editSessionForSend, the session is not settled, and editingMessageIdRef.current is its id. For the same edit, main's merge runs on the buffer: joinDraftText(taken.text, current), missing file ids first. Otherwise appendSnapshotToDraft(taken) runs. Then taken is cleared. A history-changed refusal starts the transcript refresh (CI:2962-2971) only for the same edit: with no open edit there is nothing to refresh. Non-edit sends are unchanged.
  9. Editing command (/compact while editing, CI:2056-2146). When commandEditSession is set, the command takes the buffer's text and files into its snapshot synchronously, before processSlashCommand starts. So no window exists in which a switch or Cancel can separate the command from its input. clear-input and clear-attachments then find nothing to clear. Acceptance (consume, cancel-edit) discards the snapshot. A restore disposition, or restore-if-empty with an empty buffer, puts it back by the step 8 rule. For an editing command the dispositions never call setInput(restoreInput). executeParsedCommand (CI:1966) has no try today, and handleSend only wraps it in runWithFinally (CI:2266-2269). So a try/finally around the phase loop restores a snapshot that no disposition settled, once, by the step 8 rule, when processSlashCommand or result.continue() rejects. Commands without an edit are unchanged.
  10. Unmount. A cleanup with an empty dependency list calls consumeEditDraftIntoDraft(). Then it sets editSessionRef.current = null and appliedEditIdRef.current = null.
    • A send or command still in flight then fails the "same edit" test and appends its snapshot after the unsent draft.
    • isOpenEditOrNone (CI:1291-1292) also requires editSessionRef.current !== null. A dead composer then calls no onCancelEdit or commandOnCancelEdit. Without that term, a target cleared before the unmount makes editingMessageIdRef.current === undefined pass.
    • startEditTranscriptRefresh (CI:2176-2258) captures editSessionRef.current when it starts. isSameEdit (CI:2188) then requires that exact, non-null session, not only the message id. The "Retry refresh" toast button and the editRefreshRetry state carry the same session, and act only while it is current. So neither a refresh that completes after the unmount nor an old Retry after Cancel and reopening the same row can act on another edit. ChatPane's cancelTranscriptRefresh on every target change (CP:387-398) does not revoke a Retry button that already exists.
    • Invariant: ChatInput never mounts with an edit open. CP:385-386 hides another workspace's edit in the first render, the transcript-only effect (CP:435-438) clears the edit before the composer can mount again, and a remounted ChatPane starts with no edit. So React StrictMode's extra cleanup at mount moves nothing. T11 runs under StrictMode. The routing does not read isMountedRef.
  11. Restores while an edit is open or in flight. "Open or in flight" means an unsettled EditSession whose target ChatPane still shows.
    • restoreDraft (CI:1200-1230): its no-retained-sends branch merges Q's text and files in front of the unsent draft instead of replacing the draft. The retained-sends branch, with its send-id filtering, is unchanged. During an edit send the composer shows U, so a replace would drop it. Main kept U in a ref there.
    • restoreDraft adds Q's notes to the review store with onAddReviewForRestore instead of replacing the override.
    • UPDATE restore takes its existing no-override branch (CI:1650-1666), which adds the notes to the review store and counts them in the durability check and the held-input acknowledgement (CI:1679-1697).
    • The edit keeps its own notes, and a refusal's setDraftReviews(preSendReviews) cannot drop restored notes.
  12. CLEAR_CHAT_COMPOSER (CI:1751-1752). With editOpen, it clears the buffer's text and files. That is main's visible effect, and the unsent draft stays. Notes as on main.
  13. UPDATE append with files or notes (CI:1710-1716). No dispatcher sends files or notes with append (the audit below). While the edit is visible, this branch returns before it writes, like the replace guard (CI:1701). So it cannot replace U's files behind the edit. With no visible edit it runs as on main.
  14. Other programmatic writes stay on the normal draft: restoreText (the creation page's /init and UPDATE replace), appendText (called only by step 15 when no edit is visible) and prependText (no caller outside ChatInput). The UPDATE restore and replace guards stay: while an edit is visible they drop the event, as on main.
  15. Visible inserts (D2-B). New CI operation appendToVisibleInput(text), added to ChatInputAPI (types.ts:15) and to the API object (CI:1336-1349).
    • It decides when it is called, from the step 2 ref. While the edit is visible, it calls the explicit edit operation setEditText with appendText's join (CI:1302-1313: a blank line only when the text before is not empty). Otherwise it calls main's appendText.
    • It never calls setInput while the edit is visible. appendText stays a normal-draft API, and no setter redirects.
    • ChatPane's handleQuoteText (CP:875-877, the transcript menu item "Quote in input") calls it instead of appendText.
    • The UPDATE append text branch (CI:1720) calls it instead of appendText. So a palette / insert (CommandPalette.tsx:276) and an MCP App message (McpAppFrame.tsx:276-278) land where the user sees them, as on main.
    • The delivery scope stays main's: a window mounts one ChatInput (App.tsx:1505-1566), and an event without a workspaceId reaches that one (filter at CI:1551).
  16. Queued card Edit (audit). On main the click dequeues Q and then replaces the open edit's text, files and notes (CI:1206). New ChatInputAPI.isEditVisible() reads the step 2 ref. restoreQueuedDraft (CP:938) calls it after it reads the API (CP:940-941) and before clearQueue (CP:943). While the edit is visible it returns, so Q stays queued and visible, and nothing is written.
    • During an edit send the edit is not visible. The card then works, and step 11 merges Q in front of the shown U.
    • ArrowUp (CP:1057) cannot fire while an edit is visible (CI:3138-3146).
    • Residual race: an edit opened during the clearQueue round trip gets Q merged in front of U behind it (step 11). Nothing is lost, and Q shows when the edit ends. On main the same race replaces the new edit's text with Q. The PR body names this race.
  17. A late file add. An add checks for an edit once, before its awaits: CI:527 passes the render value, and the awaits sit at hook 92 and attachmentsHandling.ts 106-170, including stageAttachment. The hook gets one more option, isEditVisible(), and calls it right before setAttachments (hook 102). While an edit is visible, the add is refused with main's toast and writes nothing to the edit or the draft. The staged file stays on disk, as after main's chip removal (hook 153-155). Main has no unstage call or cleanup, and this plan adds none.

Not changed: chatEditing.ts (the canEditDisplayedUserMessage guard), DraftStore.ts, useComposerSuggestions.ts, the backend, and the other notes logic. In ChatPane.tsx only handleQuoteText and restoreQueuedDraft change. In useComposerAttachments.ts only attachFiles changes. No new store, module or persisted field.

Insert paths while an edit is open (audit)

Source: the Diff Inventory Auditor's report (81ede99432) on origin/main at 7ba0f933d7. "Visible" means that the composer shows the edit. During an edit send the edit is dismissed, so the composer shows U (D3-A), and a write to U is visible.

# Path Entry While the edit is visible During an edit send
1 Quote in input (transcript right-click menu) useTranscriptContextMenu.tsx:114-121, CP:875 into the edit (step 15) into U
2 Palette / insert CommandPalette.tsx:276, UPDATE append, text into the edit (step 15) into U
3 MCP App message, after the user consents McpAppFrame.tsx:276-278, UPDATE append, text and workspaceId into the edit (step 15) into U
4 UPDATE append with files or notes CI:1710-1716, no dispatcher dropped (step 13) as on main
5 Snippets none: no snippet, template or saved-prompt feature exists – –
6 Paste files CI:3410, hook 121-126 refused with the toast, as on main into U
7 Drop files on the composer CI:3413, hook 145-149 refused, as on main into U
8 File picker CI:3633, hook 134-135 (the button is disabled, CI:3634) refused, as on main into U
9 A file add that lands after Edit hook 92-102 refused with the toast (step 17) into U
10 Remove a file chip hook 153-155 removes it from the edit (step 3) removes it from U
11 Typing, voice, / and @ suggestions, text paste CI:592, 605, 647, useComposerSuggestions.ts:399 into the edit (step 2) into U
12 Native text drag into the textarea hook 145-146 blocked: handleDrop calls preventDefault(), as on main (read from code) blocked
13 Queued card Edit QueuedMessage.tsx:128, CP:951, 938 refused before the dequeue, and Q stays queued (step 16) Q in front of U (step 11)
14 ArrowUp edit-last with a queued message CI:3138-3146, CP:1057 cannot fire Q in front of U, only when U's text is empty
15 Stop or queue held-input restore WorkspaceStore.ts:1327, UPDATE restore dropped: the input stays held and shows "Not sent", as on main Q in front of U
16 Analytics drill-down, Compact & retry AnalyticsQueryToolCall.tsx:136, useCompactAndRetry.ts:250, UPDATE replace dropped, as on main (CI:1701) replaces U, as in a normal composer (main kept U in a ref)
17 Reset Context, Preserve History sources.ts:1323, CLEAR_CHAT_COMPOSER clears the edit's text and files (step 12), and U stays clears U (main kept U in a ref)
18 Review note from the diff or review panel CP:921, review store to the review store, as on main (D1-A), not the draft as on main
19 /init on the creation page ProjectPage.tsx:273, 299 no edit exists there –
20 prependText no caller outside ChatInput – –

Result: while an edit is visible, no path writes U. The one write to U behind an edit is the step 16 race, and it keeps Q.

4. Behavior

U is the unsent draft, E the edit's text and files, Q a restored queued message. "After U" means appended once, below the unsent draft. "Expected" values for main come from reading the code. The failing-first runs (section 9, step 3) confirm them.

Scenario Text and files with this plan Notes with this plan Main today (expected)
Reload during an idle edit The draft shows U with its files. The edit is closed. Changes typed into the edit are gone (memory only). The original message is unchanged. U's notes in the review store stay. In-memory overrides are gone, as on main: the edit's notes, and any override U had before the edit (preEditReviews, CI:1422-1427). The draft shows E. U is lost (#5672).
Reload during an edit send U is kept. An accepted send is in the transcript. A refusal that lands after the reload is lost with the window. as above U is lost unless the send was accepted.
Two windows, one opens an edit The other window keeps showing U and can change it. E never reaches the store or draft.json. The editing window shows U's changes when its edit ends. The override is per window. The other window shows E (#5571).
Workspace switch during an idle edit The switch ends the edit (as on main). E goes after U once. The edit's notes are dropped (D1). U's notes in the review store stay. An override U had before the edit (preEditReviews) is lost, as on main (#5808 item 5). U is lost.
Edit send accepted The transcript has the edited message once. The draft is U plus anything typed during the send. Text typed during preparation goes after U when the edit closes. as on main same result, but U waits in a ref
Edit send refused, same edit still open The edit opens again: E, then text typed during preparation (main's merge). U is unchanged. preSendReviews back in the override, as on main E is back, and U is still in the ref.
Edit send refused after a switch, a lost target or a Cancel The snapshot goes after U once. As on main, setDraftReviews(preSendReviews) runs. After a switch it does nothing. After a lost target or a Cancel it puts the edit's notes into the override. U is lost after a switch.
A switch or a lost target while the edit send is still preparing The send stops before submission. E goes after U once. Nothing is sent. as above The edit is sent, and U is lost.
Cancel while the edit send is still preparing The send still goes out, as on main (test 477). Cancel drops the buffer, so a refusal puts the snapshot after U once. as on main same, but the put-back goes in front of U
Q restored by the backend during an edit send (the checker's repro) The guard is open (the edit is dismissed), so Q merges into U: Q, then U. Accepted: nothing more is written, so U appears once. Refused: the edit opens with E, and the draft "Q, U" waits behind it. Q's notes go to the review store (step 11). U, then Q. R2 (c7e7fdbe76): U twice.
Q restored by the backend during an idle edit The guard drops the event. Q stays held and shows "Not sent". as on the left same
The queued card's Edit during an open edit Refused before the dequeue (step 16). Q stays queued, and E and U do not change. no change Q replaces the edit's text, files and notes. A Cancel then drops Q.
The queued card's Edit during an edit send Q merges in front of the shown U, and U keeps its files (step 11). Q's notes go to the review store. The composer shows Q, and U comes back in front after the send.
Row deleted, target not found, second Edit, transcript-only E goes after U once. A second Edit then opens its own text. The override stays, as on main. U is lost, and E becomes the draft.
/compact while editing The command takes the text and the edit's files when it starts. Accepted: U is kept, and the snapshot is discarded. Failed: the snapshot goes back into the same edit, else after U. A switch while it is pending moves nothing, because the command owns the snapshot. as on main U waits in the ref.
A quote, a palette / insert or an MCP App message during an open edit The text goes into the open edit (step 15), and U does not change. During an edit send it goes into the shown U. – The text goes into the edit, which is the shared draft.
A file add that lands after Edit opens Refused with main's toast (step 17). Neither the edit nor U changes. – The file lands in the edit.
A voice transcript that arrives late It lands in what the composer shows when it arrives, as on main. – same

Visible changes from main. Each comes from a decision or from the audit in section 3:

  • D3: while an edit send is in flight, the composer shows U instead of an empty box. Text typed then joins U. A follow-up sent then includes U, because the user sees it. A drill-down or Reset Context in that time also acts on U (audit rows 16 and 17).
  • Audit row 13: the queued card's Edit does nothing while an edit is visible, and Q stays queued. On main it replaced the edit.
  • Audit row 9: a file add that lands after Edit opens is refused with main's toast. On main it landed in the edit.

D2-B keeps main's destination for quotes, palette inserts and MCP App messages: the visible edit.

5. Kept from R2, and scope reductions

R2 is the behavior the user approved for #5801: a switch ends the edit, and its text, files and notes move once into the draft.

Kept:

  1. No text or file is lost on a workspace switch. A switch ends the edit, and E goes after U once (section 3, step 10).
  2. Transcript-only and a second Edit keep E after U (R4 findings on 🤖 fix: an edit keeps its text in memory, so the unsent draft survives a reload and stays out of other windows #5801).
  3. No edit state survives a composer remount.
  4. Edit text and files live only in memory. No new persisted field.
  5. Typing in an edit writes nothing to the DraftStore or the backend. This is stronger than R2.
  6. An accepted edit never comes back. A refused edit comes back once.
  7. A composer that unmounted never changes the composer now shown: it calls no onCancelEdit and starts no transcript refresh (steps 8 and 10).
  8. A /compact edit owns its text and files until it settles (step 9).
  9. The canEditDisplayedUserMessage guard (chatEditing.ts) and the 🤖 feat: composer-draft follow-ups that need new protocol or mechanism #5226 guard (CP:417-418) are untouched.

Scope reductions. Keeping these would need R2's notes routing and ChatPane guards.

R2 behavior This plan What the user loses
Notes move with the edit on every move (R2 decision N2) Notes as on main (D1) Notes added or changed during an edit are dropped on a switch or reload. The edited message keeps its own notes, so a new Edit shows them again. An in-memory override that U had before the edit is also lost, as on main. Review-store notes are never lost.
ChatPane replay guards (caught-up, hasOlderHistory) no replay guards, as on main A replay in the same workspace that hides the row ends the edit. E goes after U, so nothing is lost.
(main) the composer is empty during an edit send U is shown (D3) Main's order "U comes back after the send" is gone. Text typed during the send joins U.

6. Tests

New UI tests go in tests/ui/chat/editKeepsUnsentDraft.test.ts. T18 goes in src/browser/features/ChatInput/useComposerDraft.test.ts. Helpers copied from the #5801 branch: typeIntoEdit (types into the "Edit message" textarea; it ran in #5801 CI), otherRenderer (composerSendIds.test.ts:95-101), showWorkspace (heldQueuedMessage.test.ts:159) and the transcript-only setup. Main already has holdSendReplies, holdNextSendBeforeClear and restoreQueuedMessageWithNote.

"Expected on main" comes from reading the code. Section 9, step 3 runs the tests on main before the fix and records the real result. Each mutation is applied to the fix commit and must make its test fail.

# Test name Expected on main Mutation that must make it fail
T1 a reload during an edit keeps the unsent draft and its files (#5672) fails: the store holds E M1: beginEditDraft also writes the edit into the store
T2 a second window keeps the unsent draft while the first edits (#5571) fails M2: setTypedText calls setInput while editing
T3 restore-to-input during an edit send: (a) short transcript, (b) older history. The checker's repro, copied as is. passes; it failed on R2 c7e7fdbe76 M3: acceptance also puts back a pre-edit copy of U (main's CI:1276-1279)
T4 a workspace switch during an edit puts the edit text and files after the unsent draft fails: U is lost M4: remove the unmount cleanup's consume
T5 a refused edit send reopens the edit with its text and file, and the draft stays U fails: the store holds E M5: the put-back always appends after U
T6 a history-changed refusal after a switch puts the snapshot after U once, starts no transcript refresh, and leaves an edit opened in the other workspace open fails: U is lost M6: the put-back skips the "same edit" test and merges into the buffer. M9: the refresh starts for every refusal.
T7 an edit that loses its target keeps its text after U: (a) the row is deleted, and the edit's notes stay; (b) a refused edit's refresh finds no target (CI:2211) fails: U is lost M7: drop the "no edit open" consume in the enter effect
T8 a second Edit keeps the first edit's text after the unsent draft fails: E1 replaces U M8: drop the "other id" consume
T9 an edit keeps its text after U when the workspace turns transcript-only fails: U is lost M4
T10 an edit send accepted after a switch: the edited message appears once, Edit stays refused until it settles (ChatPane stays mounted in this test), no edit notes reach the new composer, and an edit opened in the other workspace stays open. Cases: (a) the target is still shown at the switch; (b) the row was deleted before the switch. fails: main's dead composer calls onCancelEdit M10: the unmount cleanup keeps editSessionRef (a). M20: drop the editSessionRef.current !== null term in isOpenEditOrNone (b).
T11 under React StrictMode, a refused edit send reopens the edit passes M11: route the put-back by isMountedRef instead of the session
T12 while an edit send is pending the composer shows U, and typing then changes the normal draft, not the hidden buffer (replaces test 402's draft check) fails: main shows an empty box (D3) M12: setTypedText routes by the real target instead of the visible edit
T13 a /compact edit accepted after a switch leaves U only, without the edit's files fails: U is lost M13: the command takes only the text, so the cleanup moves the files
T14 a /compact edit that fails after a switch puts its text and files after U once fails: U is lost M14: the restore disposition keeps main's setInput(restoreInput)
T15 text typed into the edit during send preparation survives: (a) accepted, it goes after U; (b) refused, it follows E in the edit passes M15: the take clears the whole buffer
T16 a switch while the edit send is still preparing (holdNextSendBeforeClear) sends nothing and puts E after U once fails: the edit is sent and U is lost M16: drop the stop rule
T17 the queued card's Edit: (a) while an edit is visible, it leaves Q queued and changes neither E nor U; (b) during an edit send, it merges Q in front of the shown U, keeps U's file, and adds Q's notes to the review store fails: (a) Q replaces the edit; (b) the composer shows Q without U M27: remove the isEditVisible check in restoreQueuedDraft (a). M17: restoreDraft replaces the draft and override during an edit send (b).
T18 appendSnapshotToDraft leaves a live buffer unchanged; consumeEditDraftIntoDraft appends once and clears the buffer (unit test) new API M18: appendSnapshotToDraft clears the live buffer
T19 an old refresh never acts on another edit: (a) a refresh held, then completed with "target not found" after a switch, leaves an edit opened in the other workspace open; (b) after a failed refresh, Cancel and reopening the same row, the old Retry starts no refresh and leaves the reopened edit unchanged fails: (a) main's dead composer calls onCancelEdit; (b) main matches by id M19: isSameEdit checks the message id only
T20 an UPDATE append with a file during an open edit changes neither E nor U. No dispatcher sends this, so the test dispatches it. fails: Q replaces the edit's text and files M21: remove the visible-edit return in that branch
T21 an editing /compact whose request rejects puts its text and files back into the edit once fails: the cleared text is lost M22: remove the try/finally around the phase loop
T22 visible inserts (D2-B): during an open edit, (a) "Quote in input", (b) a palette / insert (UPDATE append without a workspaceId) and (c) an MCP App message (UPDATE append with the workspaceId) each append to E, and U's text and files in the store do not change; (d) a quote during an edit send appends to the shown U fails: (a)-(c) the store holds E; (d) the composer shows the quote without U M23: handleQuoteText keeps appendText (a). M24: the UPDATE append text branch keeps appendText (b, c). M25: appendToVisibleInput routes by the real target, not the visible edit (d).
T23 a file add that lands after Edit opens is refused with the toast and writes neither the edit nor U. The test holds the stageAttachment reply (a spy on the harness client) until the edit is open. fails: the file lands in the edit M26: remove the landing check in attachFiles

Test 477 shows that the #5226 guard refuses a reopened edit while the send waits. So no replacement edit can exist during a pending edit send, and no test needs one.

T1 details. The test spies on DraftStore.setText while it types several characters into the edit, and expects 0 calls for the workspace scope. Then it flushes and reads the draft back from a fresh otherRenderer and from draftService.get. Both hold U's text and file ids.

T11 is a required gate. renderApp (tests/ui/renderReviewPanel.tsx:37) and createAppHarness get a strictMode?: boolean option that wraps <AppLoader> in <React.StrictMode>. If the app cannot run under it in happy-dom, the implementer stops and reports to the coordinator before going on.

Changed existing tests. The Test Impact Analyst report (52197d4053) found that 11 of the 12 tests in the edit file type the edit with getDraftStore().setText. With this plan that writes U.

  1. They switch to typeIntoEdit (in sendEdit and 8 inline sites). The assertions of tests 80, 98, 134, 183, 440, 477, 504 and 631 stay. Test 158 needs no change.
  2. Test 402 keeps "no new edit starts". Its ArrowUp check moves to a case with no U, because ArrowUp edits only from an empty composer (CI:3141). Its draft check becomes T12.
  3. Test 534: L550 expects "follow-up", a check that the composer shows U comes before L544, and L564 goes (D3).
  4. Test 572: L599 expects "second follow-up" and L613 expects "". The note check at L615 stays (D3).
  5. composerDraftsFormalRepro.test.ts:242 and :275 test a refused edit that merges with edit text another window saved. That cannot happen when the edit never reaches the store: T2 replaces 242, and T5 replaces 275.

Kept unchanged: draftPayloadSend.test.ts:104, heldQueuedMessage.test.ts:511, stopRestoreHeldInput.test.ts:103 and tests/bugbash/repros/editCancelKeepsDraft.e2e.ts. knownFailureReloadDuringEdit.e2e.ts loses its known-failure tag and header. No formal/* model covers edit mode.

T22 (a) opens the transcript menu with a contextmenu event and picks "Quote in input". If happy-dom cannot open that menu, the implementer stops and reports before going on, as for T11.

Counts. 23 new scenarios (T1-T23) with 32 cases: T3, T7, T10, T15, T17 and T19 have 2 each, and T22 has 4. 27 mutations (M1-M27; M4 covers T4 and T9, M9 is T6's refresh check, and M24 covers T22 b and c). T21 makes the API call that /compact uses reject. 3 existing tests change their assertions (402, 534, 572), 8 only switch helpers, and 2 are replaced.

7. Earlier #5801 findings and this design

#5801 used 14 assessments (an assessment is one review or check run). "Applies" means the same user flow exists with this plan.

Assessment and finding Applies? With this plan Test (section 6)
3-4: an edit whose target is not found drops the edit text yes Step 4 moves E after U. T7, case b
3-4: a deleted row leaves the edit's notes attached yes, as on main E moves into the draft with its notes, so the notes match the text there. T7, case a (asserts the notes stay)
7: a workspace switch drops the edit text yes Step 10 moves E after U. T4
8-9: transcript-only loses the edit yes Step 10 (the composer unmounts). T9
8-9: a second Edit overwrites the first yes Step 4 moves the first E after U. T8
8-9: hasOlderHistory replay tradeoff no No edit survives a switch, and ChatPane's edit handling is main's. –
10-11: a remount resets sendingCount, so an edit can be sent twice no The edit ends on a switch. The #5226 guard lives in ChatPane, which stays mounted in that flow. T10 (Edit stays refused until the held send settles)
10-11: an accepted send in a dead composer leaves edit notes in the live one no Each composer has its own override, and a dead composer calls no onCancelEdit (step 10). T10
10-11: a failed refresh's retry is lost after a remount no The edit and its retry end with the switch, as on main. A refusal that lands after the switch starts no refresh (step 8). T6
14 (final check): Q restored during an edit send makes U appear twice yes Rules 3 and 4: setInput always writes the normal draft. T3 (the checker's repro)

Residuals that exist on main and stay out of scope (already in #5808): the #5226 guard is lost when ChatPane itself remounts, for example behind the WorkspaceShell placeholder (item 4), and a non-edit notes override is lost on unmount (item 5). Step 11 sends notes restored during an edit to the review store, which likely also resolves item 6 (Cancel drops notes restored into an open edit). Item 6 stays unconfirmed: the PR claims it only if T17 also covers a Cancel and shows that the restored notes survive once. Main's late onCancelEdit from a dead composer is fixed by step 10 (T10). The backend refuses a second edit of the same message after a guard loss: truncateAfterMessage checks the precondition under the write lock (src/node/services/historyService.ts:5673), so the second send gets history-changed.

8. Size (estimate vs main)

Part Estimate R2 on #5801 (c7e7fdbe76)
useComposerDraft.ts about +45/−1 +77/−10
ChatInput/index.tsx about +136/−42 +145/−57
ChatInput/types.ts about +4/−0 0
useComposerAttachments.ts about +5/−0 0
ChatPane.tsx about +4/−1 +6/−0
Production total about +194/−44 (net +150) +228/−67 (net +161)
Tests about +1,030/−160 +1,169/−99

The first draft estimated +100/−30. Four advisor passes added about 66 lines: send and command ownership, the stop rule, the command try/finally, the snapshot and live-buffer split, the restore and append merges, the notes rule, the refresh gate, the null-session term and the refresh's session capture. I did not verify these numbers. That gave +166/−40.

D2-B and the insert audit add about +28/−4: appendToVisibleInput and its join, isEditVisible, the API type and object, the UPDATE append changes, the late-add check, and the two ChatPane functions. Dropping step 13's old merge saves about 6 lines, and that saving is already counted. I did not verify these numbers.

The size gate (section 9, step 6) is a proposed ceiling of +225/−55: the estimate plus about 15%. It was +190/−50 before D2-B. The ceiling is close to R2's +228/−67. The net estimate (+150) is below R2's net (+161), but the plan does not claim that the change is well under R2. The perf clearance must accept both the estimate and the ceiling.

9. Delivery and review budget

  1. Branch. New branch fix/edit-draft-window-local from current origin/main. Nothing comes from the 🤖 fix: an edit keeps its text in memory, so the unsent draft survives a reload and stays out of other windows #5801 branch except test helpers copied by hand (otherRenderer, showWorkspace, the transcript-only setup).
  2. Perf clearance first. Send section 10 to the perf owner (8e18f4aada). Write no code before its OK, and copy the reply to the coordinator.
  3. Tests first. Commit the new tests alone. Run them on main and record which fail (section 6). Then commit the fix.
  4. Mutations. Run each mutation from section 6 under trap … EXIT (never set -e), on a commit, and restore the file. Record the result for each.
  5. Gates on the final tree: make static-check, check-react-compiler, the edit test files, the sibling tests of every touched production file, and BUGBASH_AI=mock make test-bugbash-repros.
  6. Size gate. Stop and report to the coordinator if the production diff exceeds +225/−55 (section 8).
  7. One new PR. Title: "composer: keep an open edit out of the shared draft". The body says "Fixes 🤖 Reloading during a message edit replaces the unsent draft with the edit text #5672" and "Fixes 🤖 Composer: Edit in one window also loads the message into another window's composer #5571". It links 🤖 fix: an edit keeps its text in memory, so the unsent draft survives a reload and stays out of other windows #5801 and says: 14 assessments used, parked on the final check's blocker (a queued message restored during an edit send made the draft appear twice, R2 c7e7fdbe76). It names the test that now covers that flow (T3), the decisions D1-D4, the scope reductions from section 5, the read audit table (section 3, step 1), the insert-path audit (section 3), the step 16 race and the mutation results.
  8. 🤖 fix: an edit keeps its text in memory, so the unsent draft survives a reload and stays out of other windows #5801. It stays open and parked. After the new PR merges, close 🤖 fix: an edit keeps its text in memory, so the unsent draft survives a reload and stays out of other windows #5801 as superseded, with a comment that links the merged PR and its merge SHA, copied from gh.
  9. 🤖 fix: an open edit loses its typed changes on a workspace switch (#5801), plus two older edit races #5808 and 🤖 tests: phone repro editCancelKeepsDraft once showed an empty composer after Cancel #5810. After the merge, comment on 🤖 fix: an open edit loses its typed changes on a workspace switch (#5801), plus two older edit races #5808 which items the new PR resolves (item 1, the switch, and item 6 only if T17 confirms it as section 7 says) and which stay open. 🤖 tests: phone repro editCancelKeepsDraft once showed an empty composer after Cancel #5810 stays open.

Review budget (D4-B, decided).

  • At most 2 pushes and 6 assessments: normal and security review per push, plus one independent final check with a clean context.
  • The second push may only fix defects this PR introduced, inside the steps of section 3.
  • Merge through the merge queue with --match-head-commit, only when the final check says READY, Codex approved the exact head, all threads are resolved and required CI is green. No --admin, no --auto, no force-push.

Hard stop. Park the PR with a comment and send a terminal report when any of these happens: a valid finding that needs a new mechanism (a store, gate, guard or persisted field), a third push, a final-check blocker, or a failing required check that is not a proven flake. The GO is then revoked.

10. For the perf owner (8e18f4aada)

Production files and functions this plan changes:

File Function or site Change
src/browser/features/ChatInput/useComposerDraft.ts useComposerDraft One new useState (the edit buffer) and one ref. New operations: beginEditDraft, setEditText, setEditAttachments, dropEditDraft, consumeEditDraftIntoDraft, appendSnapshotToDraft. No new effect.
src/browser/features/ChatInput/index.tsx visible input and attachments One id comparison per render picks the buffer or the draft.
same new setTypedText Reads one ref per call (the visible edit id, stored by the existing layout effect at CI:384-386). Used by handleInputChange (CI:592, 605), voice onTranscript (647) and the useComposerSuggestions wiring.
same attachment remove handler Calls setEditAttachments while an edit is open.
same new unmount cleanup Empty dependency list. It runs once, on unmount.
same edit-entry effect (CI:1406-1446) Replaces the snapshot and applyDraftFromPending with beginEditDraft, after a consume of a stale buffer. Drops getDraft from its dependency list.
same restorePreEditDraft, restorePreEditDraftAfterSend (CI:1254-1289) Remove the text and file restore.
same restoreDraft (CI:1200-1230) While an edit is open or in flight: merge instead of replace, notes to the review store.
same UPDATE restore notes branch (CI:1650-1666) Same notes rule.
same sendComposerInput: take and putBackTaken (CI:2664-2906, 2956-2971, 3036) The take checks the session and can stop the send. The put-back routes by session. The refresh starts only for the same edit.
same isOpenEditOrNone (CI:1291-1292) Gains editSessionRef.current !== null.
same startEditTranscriptRefresh, isSameEdit, the Retry toast and editRefreshRetry (CI:376-380, 2176-2258) Capture the session at refresh start and require it.
same executeParsedCommand, command actions and dispositions (CI:1966, 2056-2146) An editing command takes its snapshot when it starts, restores it by session, and restores it in a try/finally on rejection.
same CLEAR_CHAT_COMPOSER listener (CI:1751-1752) Clears the buffer while an edit is open.
same UPDATE append branches (CI:1710-1720) Text calls appendToVisibleInput. Files or notes return while the edit is visible. Per event only.
same new appendToVisibleInput and isEditVisible, the API object (CI:1336-1349), appendText's join (CI:1302-1313) One ref read per call. They run per quote, insert, app message or queued-card click, never per keystroke.
same useComposerAttachments options (CI:527) Also passes isEditVisible.
src/browser/features/ChatInput/types.ts ChatInputAPI (line 15) Two new methods: appendToVisibleInput and isEditVisible.
src/browser/features/ChatInput/useComposerAttachments.ts attachFiles (hook 91-103) One isEditVisible() call per file, right before setAttachments.
src/browser/components/ChatPane/ChatPane.tsx handleQuoteText (CP:875-877) Calls appendToVisibleInput instead of appendText.
same restoreQueuedDraft (CP:938-946) Returns before clearQueue while isEditVisible() is true. No new state, effect or memo.

Not touched: everything else in ChatPane.tsx, including editingState and the editSessionRef handling. Also chatEditing.ts, DraftStore.ts, useComposerSuggestions.ts, and every backend file. isMountedRef is unchanged.

Size to clear: about +194/−44 (net +150), with a ceiling of +225/−55 (section 8). The ceiling is close to R2's +228/−67.

Per-keystroke path.

  • Typing in an edit calls setTypedText, which reads one ref and calls setEditText: one useState update and one ref write. No DraftStore write, no backend save, no transcript-size work.
  • Typing in the normal draft adds the same ref read, then runs main's setInput.
  • T1 spies on DraftStore.setText while several characters are typed into an edit and expects 0 calls.
  • The new operations run per action (a quote, an insert, a click, a file add), never per keystroke.
  • check-react-compiler must stay at 23/24, with ChatInputInner and ChatPaneContent compiled. The PR body shows its output.

Test-only files: tests/ui/chat/editKeepsUnsentDraft.test.ts, tests/ui/chat/composerDraftsFormalRepro.test.ts (2 tests replaced), src/browser/features/ChatInput/useComposerDraft.test.ts (T18), tests/ui/renderReviewPanel.tsx and tests/ui/harness/createAppHarness.ts (the strictMode option), and tests/bugbash/repros/knownFailureReloadDuringEdit.e2e.ts (tag removal).

11. Acceptance criteria and dogfooding

Acceptance criteria.

  1. T1-T23 pass on the branch (32 cases). The failing-first run on main records each result. Expected: T1, T2, T4-T10, T12-T14, T16, T17 and T19-T23 fail, and T3, T11 and T15 pass. T3 must pass on both. T18 tests a new API.
  2. Each of the 27 mutations makes its test fail, and the log shows it.
  3. All other tests named in section 6 pass. knownFailureReloadDuringEdit.e2e.ts passes without its known-failure tag. editCancelKeepsDraft.e2e.ts passes on the web and phone targets.
  4. make static-check passes. check-react-compiler stays at 23/24.
  5. The production diff is at most +225/−55.
  6. Dogfood evidence (below) is attached to the PR.

Dogfooding. Build once with make build-main build-renderer build-static. Start the bug-bash app with bun tests/bugbash/startApp.ts --port <p> (mock AI). Take every step on a main build first (before) and on the branch (after). Use agent-browser with its own session, at desktop width and at 390 px.

  1. Reload (🤖 Reloading during a message edit replaces the unsent draft with the edit text #5672). Type "unsent draft" and attach a file. Click Edit on an older message and type. Reload. Expected: the composer shows "unsent draft" with its file. Screenshot before and after.

  2. Two windows (🤖 Composer: Edit in one window also loads the message into another window's composer #5571). Open the workspace in two sessions. In session 1, click Edit and type. Expected: session 2 still shows "unsent draft". Screenshot both.

  3. Switch. Edit and type, switch to another workspace, then come back. Expected: "unsent draft", then the edit text, once. Screenshot.

  4. Edit send with a queued restore (T3's flow). Start a reply that holds at stream start ([mock:wait-start]), and queue a follow-up message. With "unsent draft" typed, edit an older message and send it. The edit's truncation restores the queued message to the input (agentSession.ts restoreQueueToInput). Record a video with agent-browser record. Expected: the queued text and "unsent draft" each appear once.

  5. Quote and palette insert during an edit (D2-B). With "unsent draft" typed, click Edit on an older message. Right-click transcript text and pick "Quote in input". Then pick a / command in the palette. Expected: both appear in the edit. Cancel. Expected: the composer shows "unsent draft" without them. Screenshot each step.

  6. Queued card Edit during an edit. Hold a reply with [mock:wait-start] and queue a follow-up. Click Edit on an older message, then click Edit on the queued card. Expected: nothing changes, and the follow-up stays queued. Screenshot.

The dogfood run writes evidence to $XUM_SCRATCH_DIR/c/dogfood/. Check each screenshot before the PR cites it.

12. Decisions

D1. Notes when an edit ends without a send

  • Detail: on a switch, reload or lost target, the edit's notes behave as on main instead of moving with its text.
  • Choices:
    • D1-A As on main: the edit's own notes are dropped on a switch or reload and stay attached when the target is lost, review-store notes are never touched, and an in-memory override the unsent draft had before the edit is lost on a switch or reload.
    • D1-B Move to the review store: every move also adds the edit's notes once to the workspace's review store (about +15 production lines and 1 test).
  • Recommended: D1-A
  • Reason: it keeps the plan to the two bugs, and the edited message keeps its notes, so a new Edit shows them again.
  • Decided: D1-A, the user's choice.

D2. Inserts while an edit is open

  • Detail: where a quote, a palette insert or an app insert goes while an edit is open.
  • Choices:
    • D2-A Into the draft: inserts follow the rule as written and appear in the unsent draft when the edit ends (0 lines).
    • D2-B Quote into the edit: the quote action calls a new explicit edit operation, so quoted text appears in the open edit as on main (about +10 lines, touches ChatPane.tsx).
  • Recommended: D2-A
  • Reason: no text is lost, it adds no code, and it keeps normal draft APIs on the normal draft.
  • Decided: D2-B, the user's choice. Section 3, steps 15-17, and the insert-path audit implement it.
  • Change to the choice text: it first said "other inserts still go to the draft". The audit replaces that. The draft is hidden while an edit is open, so palette / inserts and MCP App messages also go into the visible edit, as on main.

D3. The composer while an edit send is in flight

  • Detail: after Send, the edit closes at once as on main, and the composer then shows the unsent draft instead of an empty box.
  • Choices:
    • D3-A Show the unsent draft: text typed during the send joins the unsent draft, and a follow-up sent then includes it (tests 402, 534 and 572 change).
    • D3-B Keep the edit shown: edit sends stop closing at once, so the composer shows the submitted edit read-only until the backend answers, and nobody can type during an edit send.
  • Recommended: D3-A
  • Reason: it needs no extra state and follows the rule, and the user always sees what a follow-up will send.
  • Decided: D3-A, the user's choice.

D4. Review budget for the new PR

  • Detail: how many pushes and assessments the new PR may use before the hard stop in section 9.
  • Choices:
    • D4-A One push: 1 push and 3 assessments (normal, security, final check), and any valid finding parks the PR.
    • D4-B One fix round: at most 2 pushes and 6 assessments, the second push only fixes defects this PR introduced inside section 3, and any need for a new mechanism parks the PR.
  • Recommended: D4-B
  • Reason: one bounded fix round avoids parking the PR for a small in-scope defect, and the hard stop still prevents another repair loop.
  • Decided: D4-B, the user's choice.

Generated with xum • Model: anthropic:claude-opus-5-5 • Thinking: high

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e1cddc014a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +516 to +518
editingMessageForUi && editDraft?.id === editingMessageForUi.id ? editDraft : null;
const input = visibleEdit ? visibleEdit.text : draft.input;
const attachments = visibleEdit ? visibleEdit.attachments : draft.attachments;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Gate editing until the edit buffer exists

When the persisted draft has attachments whose payloads are still loading, the edit-entry effect waits at draftPayloadsLoaded, so editingMessageForUi is set while editDraft remains null. These fallback lines consequently show the normal unsent draft under the “Editing message” UI, and setTypedText writes any typing into that shared, persisted draft; once ensurePayloads() finishes, the historical edit suddenly replaces it. Initialize the edit buffer independently of the normal draft payload load, or disable the edit textarea until the buffer is ready.

Useful? React with 👍 / 👎.

Comment on lines +2774 to +2776
// An edit that ended meanwhile (switch, Cancel, lost target): after the unsent draft.
if (!isLiveEdit(editSessionForSend)) {
return appendSnapshotToDraft({ text, attachments: sendAttachments });

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Restore the taken prefix before later edit text

If the user appends text while an edit send is preparing, the take removes only the captured prefix and leaves the new text in the edit buffer. When the edit then ends after the take—such as by switching workspaces—and the backend subsequently refuses the send, cleanup has already appended that newer text to the draft, and this branch appends the failed prefix after it. That reverses old edit → later text into later text → old edit, unlike the live-edit restoration path immediately below; the taken snapshot needs to be inserted before the moved remainder.

Useful? React with 👍 / 👎.

}
}
setDraftReviews(pending.reviews);
if (duringEdit && onAddReviewForRestore) pending.reviews.forEach(onAddReviewForRestore);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve restored notes alongside a pre-edit override

When an edit starts while the normal draft already has a non-null review override—for example after editing a queued message into the composer—a queued or held-input restore during that edit puts its text into the shared draft but sends its notes only to the review store here. Cancel then restores session.preEditReviews; because useComposerDraft gives that override precedence over attached store reviews, the newly restored notes become hidden and are omitted when the merged draft is sent. Merge these notes into the saved pre-edit override, or move all of that override into the review store before restoring it.

Useful? React with 👍 / 👎.

@ThomasK33

Copy link
Copy Markdown
Member Author

Parked at the review budget's hard stop. This PR stays open as a draft.

  • Head: e1cddc014a26e1cdc3e1c672425cced184f0478f. 2 of 2 pushes used, 4 reviews done (rounds 1 and 2). No third push and no final readiness check will run.
  • Stop reason: the round-2 thread at index.tsx:2776 is a valid defect that this PR introduced. An edit send is refused after its edit ended (for example, by a workspace switch), and text was typed into the edit while the send prepared. The unsent draft then reads "unsent draft, later text, taken edit text" instead of "taken edit text, later text". Nothing is lost, but the order is wrong. It sits in the same snapshot take-and-put-back mechanism as round 1's finding, so it triggers the hard stop. That thread stays open.
  • The other two round-2 threads (index.tsx:518 and :1266) are not classified yet. They stay open.

Generated with xum • Model: anthropic:claude-opus-5-5 • Thinking: high

@ThomasK33

Copy link
Copy Markdown
Member Author

Closing unmerged. The user decided to close this PR without merging (relayed by the coordinator desk on 2026-10-09).

Record

  • Final head: e1cddc014a26e1cdc3e1c672425cced184f0478f (draft).
  • Pushes: 2 of 2 used (eda9ae1e3f, then e1cddc014a).
  • Reviews: 4 of 6 used. Round 1 (code and security) ran on eda9ae1e3f, round 2 (code and security) on e1cddc014a. No final check ran.
  • Stop reason: F2 (thread at index.tsx:2776). A refused edit send, after the edit ended, puts the sent text after text typed later: "unsent draft, later text, sent text". Nothing is lost, but this PR introduced the wrong order. It is the second ownership defect in the take-and-put-back mechanism (round 1 found the first: a text-only command dropped the edit's file). A repeated finding in one mechanism is a hard stop in this PR's budget (park comment).

The other two open findings

Conclusions of the simplification report

  1. One rule covers every finding so far: the edit buffer of the composer that opened the edit owns the edit's text and files until the backend accepts the edit.
  2. The best simpler candidate ("S1") drops the take and put-back. The edit stays read-only while its send runs. That removes F1 and F2 by construction.
  3. S1 still needs a busy gate, a decision when the target disappears during the send, and one merge for a queued-card race. It reverses one earlier user decision (the composer showed the unsent draft during an edit send). It can leave a visible duplicate in the draft after a switch during an accepted edit send. Estimate: about +185/−60 production lines, against this PR's +244/−55.
  4. Recommendation: stop. S1 is not materially simpler.

What stays


Generated with xum • Model: anthropic:claude-opus-5-5 • Thinking: high • Cost: $173.34

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

Labels

None yet

Projects

None yet

1 participant