Skip to content

Add message editing with conversation branches - #45

Merged
milind-soni merged 2 commits into
milind-soni:mainfrom
guilimasp:feat/message-edit-branching
Aug 13, 2026
Merged

Add message editing with conversation branches#45
milind-soni merged 2 commits into
milind-soni:mainfrom
guilimasp:feat/message-edit-branching

Conversation

@guilimasp

@guilimasp guilimasp commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

What

Editing a user message forks the conversation, ChatGPT/Claude style. Hover a message for a pencil icon, or press ArrowUp in an empty composer to edit your last message. Enter resends, Esc cancels, and edited messages get ‹ 2/2 › controls to flip between versions — each version keeps its own continuation, so nothing is lost.

How

  • Tree model — messages carry a parentId and each thread stores an activeLeafId; the visible chat is the root→leaf path. Existing flat messages-<threadId>.json files migrate transparently on load (rows chain in array order, last row becomes the leaf).
  • New endpointsPOST /api/bots/:id/messages/:msgId/edit (fork + rerun the turn) and POST /api/bots/:id/active-branch (switch versions, no new turn). GET /api/bots now includes activeLeafId.
  • Provider context after a rewind — the provider's native session still holds the abandoned branch, so resume cursors are invalidated and the surviving path is replayed inline for cursor-resuming drivers (claude/codex/gemini); the grok driver keeps receiving it via transcript as before.

No double generation, by construction

The classic failure mode of edit-in-place chat UIs is two conversation tails generating at once. This PR prevents it server-side:

  • An edit interrupts the in-flight turn and waits for it to settle before forking (the timeout outlasts every driver's own cancel window, ACP's 5s included).
  • Straggler events from a dead turn are dropped at the bus (turnId fence + busy guard), so they can never fold into the new branch.
  • Edits serialize per bot; sending while busy stays 409; switching versions during a live turn is refused.

Testing

  • Store unit tests: fork/switch/persistence/legacy-file migration.
  • API tests against the real server: validation and no-mutation on failure.
  • New server/branching.test.ts e2e on the fake ACP CLI: real turn → edit → reply lands on the new branch → version switching; plus an edit against a hung turn proving the interrupt path never runs two turns at once.
  • pnpm typecheck, pnpm test, pnpm build all green; also smoke-tested in the browser against the dev server.

🤖 Generated with Claude Code

https://claude.ai/code/session_01634YWxEfsreuTSoDLCSy7b

guilimasp and others added 2 commits August 12, 2026 22:11
Editing a user message forks the conversation, ChatGPT/Claude style:
messages form a tree (parentId per message, activeLeafId per thread) and
the visible chat is the path from root to the active leaf. Old branches
stay reachable through a per-message version switcher.

UI: a pencil icon on hover (or ArrowUp in an empty composer) turns the
bubble into an inline editor — Enter resends, Esc cancels — and edited
messages grow "< 2/2 >" controls to flip between versions.

Server guarantees no two conversation tails ever generate at once:
- an edit interrupts the in-flight turn and waits for it to settle
  (outlasting every driver's own cancel window) before forking
- straggler events from a dead turn are dropped at the bus (turnId fence
  plus a busy guard), so they can never fold into the new branch
- edits serialize per bot; branch switching is refused while a turn runs

After a rewind the provider's native session still holds the abandoned
branch, so resume cursors are invalidated and the surviving path is
replayed inline for cursor-resuming drivers (transcript-replay drivers
get it through the transcript as before).

Legacy flat transcript files migrate on load: rows chain in array order
and the last message becomes the active leaf.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01634YWxEfsreuTSoDLCSy7b
Editing a live thread tried to interrupt, wait, force-settle, fence the
dead turn's stragglers, and drain for 200ms before forking. That machinery
had the failure it existed to prevent: an edit landing in the window before
sendTurn (box provisioning can take ~90s) found nothing to interrupt, so
the original turn started AFTER the fork and its reply landed on the new
branch. Force-settling also only cleared the harness's busy flag while the
driver still held the thread, so the edit's own turn was then rejected.

Rewinding now requires an idle thread — the rule the version switcher
already used, and the one the reference implementation uses (its revert
action is disabled while the agent is working). The edit endpoint's checks
through startTurn are synchronous, so two racing edits can't both start a
turn, and liveTurns/staleTurns, waitForIdle, enqueueEdit, the force-settle
and the drain window are all deleted (-100 lines).

Also fixed:
- the bus guard that dropped every event of an idle bot swallowed
  turn.completed, leaking the 4s screen poller into idle; deleted with the
  rest of the fencing.
- `rewound` was cleared before dispatch, so a turn that failed early left
  the next message with no cursor AND no replay — total context loss. It
  now clears only once sendTurn resolves, and a rewound turn passes no
  resume cursor rather than relying on the wipe landing first.
- sidebar preview and mascot state read bot.messages' tail, which is a
  hidden branch after a version switch; both now read the visible path.
- the edit pencil is hidden while the bot works, matching the server rule.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@milind-soni
milind-soni merged commit aac0cf2 into milind-soni:main Aug 13, 2026
3 checks passed
@milind-soni

Copy link
Copy Markdown
Owner

Looks great! Thanks for the contribution

milind-soni added a commit to guilimasp/OpenMausBot that referenced this pull request Aug 13, 2026
Both sides changed the composer's key handling: main added ArrowUp-on-empty
to edit the last message (milind-soni#45), this branch added Shift+Enter for newlines.
Kept both — ArrowUp first (it returns), then the Enter guard replacing the
old unconditional send. The mention picker still intercepts Enter/Tab ahead
of either, so tagging is unaffected.
milind-soni added a commit to guilimasp/OpenMausBot that referenced this pull request Aug 13, 2026
Both sides changed the same Composer render: main passes onEditLast (milind-soni#45),
this branch keys it by bot.id. Kept both — the key still remounts on switch
so drafts don't follow you between conversations.
milind-soni pushed a commit that referenced this pull request Aug 13, 2026
Thanks @guilimasp — a draft now belongs to the conversation it was typed in. Merged main in; the composer keeps the onEditLast wiring from #45 alongside the per-bot key.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants