Skip to content

🤖 fix: an edit keeps its text in memory, so the unsent draft survives a reload and stays out of other windows - #5801

Open
ThomasK33 wants to merge 11 commits into
mainfrom
fix/reload-during-edit-draft
Open

ThomasK33 wants to merge 11 commits into
mainfrom
fix/reload-during-edit-draft

Conversation

@ThomasK33

@ThomasK33 ThomasK33 commented Oct 6, 2026 •

Copy link
Copy Markdown
Member

Summary

Editing a sent message no longer writes the edit text into the workspace's shared, persisted draft. The edit text now lives only in the editing window's memory. A reload during an edit keeps the unsent draft (#5672), and an edit in one window no longer shows up in another window's composer (#5571). An open edit now also survives a workspace switch: switching away and back shows the same edit with its typed text and attachment changes, and the unsent draft stays intact (#5808, item 1). A reload drops the open edit itself. That is the chosen tradeoff (Option A).

Fixes #5672
Fixes #5571
Part of #5808 (item 1 only; items 2 and 3 existed on main and stay open)

Background

Before this change, entering edit mode saved a snapshot of the unsent draft (preEditDraft) and then replaced the shared draft with the message text. The shared draft is persisted and synced across windows. So a reload during an edit restored the edit text as the draft and lost the unsent draft. A second window showed the edit text in its normal composer.

Implementation

  • useComposerDraft holds a memory-only edit buffer, kept per workspace in module memory and read with useSyncExternalStore. While an edit is open, setInput and setAttachments write to that buffer, and the composer renders it. The shared draft in DraftStore is not touched. A buffer left behind by an edit that ended without settling is ignored.
  • ChatInputInner fills the buffer with beginEditDraft when an edit starts, and drops it with endEditDraft on cancel or accepted send. EditSession.preEditDraft and its restore code are gone, and edit entry no longer waits for draft attachment payloads.
  • Text typed into the edit buffer while an edit send was in flight joins the unsent draft after it, never replacing it.
  • ChatPane can end an edit without a composer handler running: the edited row was deleted or replaced, or a history-changed refresh found no target. releaseEndedEdit then keeps the edit's text, attachments and notes as a normal draft, after the unsent draft. On main they became the draft and replaced the unsent draft. An edit whose send is in flight is left to that send, because an accepted edit replaces its row before the reply. EditSession.sendInFlight (memory only) marks that state.
  • Attachments added or removed during an edit live only in the edit buffer. They never reach the shared draft or another window. tests/ui/chat/composerDraftsFormalRepro.test.ts › "keeps a staged attachment in the edit once across a refused send, out of the shared draft" shows it: a refused edit send keeps its text and its staged file once (staged one time, one chip), and both the in-memory and the backend shared draft hold only the unsent draft's attachment. It replaces the deleted test "keeps the staged copy of an attachment another window saved again as pending", whose premise (another window holds the edit) no longer exists.
  • useComposerDraft copies editMessageId into a ref in a useLayoutEffect, not in the edit-open handler. The two are not equivalent. An edit can end without any composer handler running (ChatPane clears it when the row is replaced or deleted). Then the ref must follow the prop, or later keystrokes go to the stale edit buffer and vanish. With the copy moved into beginEditDraft, typing after the row is deleted is lost (the row-deleted test fails). A render-time assignment would also expose an uncommitted render's ID, so this matches the existing editingMessageIdRef layout effect in ChatInputInner.
  • Workspace switch (🤖 fix: an open edit loses its typed changes on a workspace switch (#5801), plus two older edit races #5808 item 1). The first version of this PR lost an open edit's typed changes on a switch. Correction to how that was first described: on main, a switch already ended edit mode (ChatPane.tsx's [workspaceId] effect cleared the edit). The edit text then survived only because it had overwritten the unsent draft. This PR now keeps the edit's identity and content across a switch without touching the unsent draft:
    • ChatPane keeps the open edit per workspace in a module-level, memory-only store (editTargets, read with useSyncExternalStore), instead of one useState slot that a switch cleared. It is not React state, because WorkspaceShell shows a loading placeholder while a returning workspace loads, and that unmounts ChatPane. A no-op update returns the same object and notifies nobody. An entry goes when its edit ends.
    • ChatInput keeps the edit's buffer (editDrafts) and session (editSessions) per workspace in module memory too, so the remounted composer resumes the same edit. The edit's own notes are kept in the session when the composer unmounts.
    • Everything stays renderer-local and memory-only. A reload drops it, and another window never sees it.
    • ChatPane's "row replaced or deleted" check waits for a caught-up transcript, because a returning workspace replays its rows first.
    • With windowed replay (🤖 perf: replay a bounded history window so large chats don't materialize every row in the renderer #4961), the edited row can be older history that is not loaded. Choice: the edit stays open when its row is absent and hasOlderHistory is true. Its send still carries the rows precondition, so a row that really changed is refused with history-changed and handled as before.
    • An editing command (/compact) clears only its own edit buffer. It replaces its row before it clears, and the clear used to reach the unsent draft's files.
  • One preservation rule for a lost edit target. When an unsettled edit loses its target without a settle, Xum keeps the edit's text and files in the workspace's normal draft, after the unsent draft and never over it. Cancel and accepted sends settle first and leave no buffer, so the rule does not apply to them. ChatPane's edit-target store (setEditingByWorkspace) is the only writer of edit targets. It calls keepUnsettledEditInDraft (useComposerDraft.ts) for every target it clears or replaces. The rule takes the buffer first, so it runs at most once per edit. It adds no store and no persisted field. Audited target-clearing paths:
    • setEditingMessage(undefined) from Cancel/Escape (handleCancelEdit): already settled, no buffer left.
    • setEditingMessage(undefined) after an accepted send, including /compact: already settled.
    • The "row replaced or deleted" effect: the rule keeps the contents (releaseEndedEdit covers the composer's own session when it is mounted).
    • history-changed followed by target-not-found (onCancelEdit): the same, through the rule.
    • The workspace turns transcript-only (the transcriptOnly effect, and setEditingMessage while transcript-only): the rule keeps the contents. This was a review finding.
    • A second Edit on another row (beginEditingMessage → setEditingMessage): the first edit's contents are kept before the second fills its buffer. This was a review finding.
    • A workspace switch: the target is kept, not cleared, so the edit resumes.
  • Not touched: src/browser/utils/chatEditing.ts (the canEditDisplayedUserMessage guard from 🤖 fix: Token Budget warning rows are never turn triggers #5792 is unchanged), and edit target row selection. Assistant and streaming paths are unchanged.

Validation

  • New repros in tests/ui/chat/editKeepsUnsentDraft.test.ts: "a reload during an edit keeps the unsent draft (🤖 Reloading during a message edit replaces the unsent draft with the edit text #5672)" and "an edit in one window does not reach another window's composer (🤖 Composer: Edit in one window also loads the message into another window's composer #5571)". Both failed on main and pass here.
  • "a reload during an edit keeps the unsent draft (🤖 Reloading during a message edit replaces the unsent draft with the edit text #5672)" also types into the edit and asserts that DraftStore.setText and setAttachments are never called. A mutation that writes the store on each edit keystroke fails it.
  • Workspace switch (editKeepsUnsentDraft.test.ts), each failing before the change:
    • "an open edit keeps its typed text and attachments across a workspace switch": the message carries two files, and the edit removes one and changes the text. After switching away and back, the edit keeps its target, its text and the remaining file, the removed file stays removed, and the unsent draft is intact in memory and on the backend. An edit cannot add attachments (the product refuses), so removing a file is the supported attachment change.
    • "a kept edit whose row is deleted while another workspace is shown ends on return": the row is really gone, so the edit ends and its text joins the draft after the unsent draft.
    • "a kept edit whose row is outside the replayed window stays open on return": caught up, the row not loaded, hasOlderHistory true. The edit stays.
  • Lost target (editKeepsUnsentDraft.test.ts): "an edit keeps its text and files in the draft when the workspace turns transcript-only" and "a second Edit keeps the first edit's text and files in the draft". Mutation check: both fail with production at 72a15a9, and both fail when only the keepUnsettledEditInDraft call is removed.
  • 🤖 tests: phone repro editCancelKeepsDraft once showed an empty composer after Cancel #5810 check: tests/bugbash/repros/editCancelKeepsDraft.e2e.ts, phone target, 20 runs each (mock app AI, --retries 0). main (9bdcf1e): 20/20 passed. This branch: 20/20 passed on 2b0734913c. The pushed head 72a15a9 is that commit rebased onto main 752f962, whose only overlap is an unrelated keybind change in ChatInput; the repair files are identical. main did not fail, so this PR does not claim to fix 🤖 tests: phone repro editCancelKeepsDraft once showed an empty composer after Cancel #5810.
  • "an edit whose row is deleted stays as a normal draft, after the unsent draft" and "an edit whose target a history-changed refresh cannot find stays as a normal draft" cover edits that end without the composer settling them. Both fail without releaseEndedEdit.
  • The bug-bash repro knownFailureReloadDuringEdit.e2e.ts is renamed to reloadDuringEditKeepsDraft.e2e.ts and loses its known-failure tag.
  • make static-check passes. make check-react-compiler: 23/24 hot components compile (1 known skipped), the same as main.
  • Two-window dogfood on a bug-bash app (main vs this branch). Composer values were read from the accessibility snapshot.
Before (main) After
#5571, window B after Edit in window A 5571 before 5571 after
#5672, window A after reload during Edit 5672 before 5672 after
#5672 at 390x844 5672 before 390 5672 after 390

Before, window B's composer read "message to edit 81" and the reloaded window lost "my unsent draft". After, both kept "my unsent draft".

  • The formal model in formal/composer-drafts does not model edit mode. Edit mode now never writes the shared draft, so the modeled behavior is unchanged, and I did not rerun check.sh.

Size

The PR is +738/-169, above the usual 500-line review size. Most of it is tests (+466/-94). The workspace-switch repair adds +363/-44 (production +166/-43 in ChatPane.tsx, ChatInput/index.tsx and useComposerDraft.ts). It repairs a loss that this PR introduced, so it belongs here. The round-2 review fixes (releaseEndedEdit) repair defects that this PR introduced (an edit that ChatPane ends on its own lost its edit buffer), so they belong here. EditSession.sendInFlight is a memory-only field on the in-memory edit session. It is not persisted and adds no on-disk state.

Risks

Medium, limited to the composer's edit mode. A reload or crash now drops an open edit's text instead of keeping it. A workspace switch keeps it. The module-level edit stores are memory-only and keyed by workspace. An entry left by a workspace removed mid-edit is small and stays until reload. The unsent draft is the data this change protects. Send-failure put-back for edits now targets the edit buffer. The tests above cover refusal, cancel during a pending send, /compact edits, and replaced rows.


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

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 6, 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-07T08:13:10.629324Z e8acc69 New commits
🔒 Security Review ✅ Completed 2026-10-07T08:07:01.816341Z e8acc69 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: 76c52aa2bb

ℹ️ 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/useComposerDraft.ts
Comment thread src/browser/features/ChatInput/index.tsx Outdated
@ThomasK33

Copy link
Copy Markdown
Member Author

Parked at a0ca2001af. Do not merge.

  • Reviews: 7 used. That is 6 Codex reviews (rounds 1-3, code and security), plus slot 7, the final independent check. Round 2 found 2 P2s, which a0ca200 fixed. Round 3 is clean, and Codex gave a 👍 on this head. No review thread is open.
  • Checks: every required check passes on this head. GitHub reports the PR as mergeable with no conflicts.
  • Final check: READY, with no blocker in what it was asked to cover.
  • Why it is held: the final check found a new loss of typed input that this PR introduces. An open edit's typed changes (text and attachments) are lost when you switch to another workspace and come back. The edit reopens with the original message text. The accepted tradeoff covers reload only. Tracked as the first item of 🤖 fix: an open edit loses its typed changes on a workspace switch (#5801), plus two older edit races #5808. A repair needs explicit approval first.
  • tests/ui/chat/sendModeDropdown.test.ts failed once on 76c52aa, cause not established. It passed in CI on this head and 10 of 10 local runs. Tracked in 🤖 tests: sendModeDropdown 'send after step' waits only 1 s for the stream to start #5807.

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

@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: 72a15a919c

ℹ️ 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/components/ChatPane/ChatPane.tsx
Comment thread src/browser/components/ChatPane/ChatPane.tsx
Comment thread src/browser/features/ChatInput/useComposerDraft.ts
@ThomasK33

Copy link
Copy Markdown
Member Author

Parked at 72a15a919c. Do not merge.


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

@ThomasK33
ThomasK33 force-pushed the fix/reload-during-edit-draft branch from 72a15a9 to e8acc69 Compare October 7, 2026 08:02

@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: e8acc69cd8

ℹ️ 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 +1271 to +1273
const editSessionRef = useRef<EditSession | null>(
workspaceId ? (editSessions.get(workspaceId) ?? null) : null
);

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 Block duplicate edit sends after a composer remount

When the user switches away and back while an edit send is still resolving, the keyed ChatInput remounts and restores this session with sendInFlight: true, but its local sendingCount resets and canSend never checks the restored session flag. Because the original component deliberately continues submitted sends after unmount, the resumed edit can enable Send with the original buffer and launch a second edit request before the first settles, producing competing replacements or a spurious history conflict. The remounted composer needs to derive its send-disabled state from the persisted edit session.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Valid on e8acc69. The edit session survives the composer remount with sendInFlight: true, but sendingCount is local useState (index.tsx ~467), and canSend (~1024) never reads the restored session flag. So the remounted composer can send the same edit a second time. Not fixed: this is a new defect in the edit-preservation mechanism, and the hard stop applies. #5801 is parked, and the thread stays open.

Comment on lines 1298 to 1301
if (!session || session.settled) return false;
session.settled = true;
const { preEditDraft, preEditReviews } = session;
settleEditSession(session);
const { preEditReviews } = session;
if (dropEditReviews) setDraftReviews(null);

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 review state in the remounted composer

If an edit send starts, the user switches away and back before its reply, and the original send then succeeds, this runs in the now-unmounted composer: it marks the shared session settled and applies setDraftReviews only to that dead instance. The live composer subsequently sees the cleared edit target, but releaseEndedEdit refuses the already-settled session, leaving the edited message's review override active; the next ordinary draft can therefore send those notes again instead of restoring the pre-edit reviews. The successful completion must publish the review restoration to the currently mounted composer before settling the shared session.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Valid on e8acc69. A send accepted after a switch away and back settles the shared session in the unmounted composer, which applies setDraftReviews only there. The live composer's releaseEndedEdit then skips the settled session, so the edit's notes stay as the override. Not fixed: hard stop. #5801 is parked, and the thread stays open.

Comment on lines +426 to +427
const editingByWorkspace = useSyncExternalStore(subscribeEditTargets, readEditTargets);
const editingMessage = editingByWorkspace[workspaceId];

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 failed-refresh recovery across workspace switches

When a history-changed transcript refresh returns failed, its retry precondition is kept only in the current ChatInput's editRefreshRetry state, while this change preserves the invalidated edit target across workspace switches. Switching away and back after that failure remounts the composer, discards the retry state, and restores an edit with preconditionInvalidated: true; Send remains disabled, the indicator incorrectly says the refresh is still running, and no retry button is available. Persist the retry data with the edit target or restart the refresh when restoring the edit.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Valid on e8acc69. editRefreshRetry is local composer state (index.tsx ~399), while the invalidated edit target now survives the switch. After a failed refresh and a switch away and back, Send stays disabled with no retry button. Not fixed: hard stop. #5801 is parked, and the thread stays open.

@ThomasK33

Copy link
Copy Markdown
Member Author

Parked at e8acc69cd8 (hard stop). Do not merge.


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

This branch has not been deployed

No deployments
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