Repository navigation
fix(app): order messages by position, not by ID string - #57581
kitlangton wants to merge 2 commits into
Conversation
0540c1d to
a7437ac
Compare
a7437ac to
c33a824
Compare
|
the issue is real and very bothering |
|
This also fixes #61189 for sessions that use plugins posting synthetic messages with their own IDs. oh-my-opencode-slim uses |
|
Confirming this on the 2.2.7 web UI (Edge on Windows, server on Linux). After Undo on a user message, the assistant reply from the reverted turn stays visible and is grouped into the previous turn. It disappears only when the next prompt commits the revert; a full reload does not fix it. DB evidence from the affected session (462 messages, staged revert):
This is a native ID, not a fork/plugin/iOS ID: the suffix inverted the order within the same millisecond. The position-based Happy to provide a sanitized repro or a smaller fixture if useful. |
What it does
Fixes a bug where Rewind in a forked session (or one with messages from the iOS app) hid the wrong messages, and committing the revert could drop the whole loaded transcript. The client decided message order by comparing message IDs as strings, but the server orders by
seq, and IDs don't sort that way:msg_<fork>_<seq>, somsg_x_10sorts beforemsg_x_9. Staging a revert atmsg_x_10hidmsg_x_9too.msg_A47D…sorts before every lowercase ID, so a revert there hid all earlier messages.session.revert.committed, the splice looked for the first ID>=the boundary, cut at index 0 and dropped the loaded history.The TUI transcript had the same string comparison, and the app's turn list dropped every turn when a revert was staged on an assistant message. Found in code review; each case is reproduced in the new tests.
How it works
Order comes from position in the message list, which is already in server
seqorder.messagesBefore(messages, id)insession-domain.tsreturns the messages before the boundary. The staged-revert filters (selectVisibleSessionUserMessages,visibleTimelineMessages) and the new-tail-turn check ininteraction.tsuse it. A boundary not in the loaded list counts as older than all of it.selectVisibleSessionUserMessagesnow cuts the full list before picking user messages, so a boundary on an assistant message works.revert.committedin@opencode/clientcuts at the boundary's index. When the boundary isn't loaded, it cuts everything only if older pages exist; with the whole history loaded, a missing boundary means a sync already applied the revert, so nothing is cut. A cut from the first loaded message deletes the paging cursor's anchor, so the client rereads the message list instead of stranding older history.rows.tsuses a matchingmessagesBefore, shared withindex.tsx, which already sliced by position. Inbox items can't be ordered against a message ID, so pending items that were cut with the messages are removed, and the inbox is reread from the server to learn which others it kept.Risk
Client only (desktop app and TUI): what the timeline shows during and after Rewind, and the pending-inbox list after a committed revert. A wrong cut would show too many or too few messages after a rewind. The extra inbox reread happens only when items are pending at commit time. Each predicate is still one linear pass over the list; I didn't run the timeline benchmarks.
Proof
New tests cover forked (
msg_fork_9/10/11) and mixed-case (msg_d4f1,msg_A47D) IDs. They also cover the paging cursor (boundary at the first loaded message, or in an unloaded page), a boundary already removed from fully loaded history, and an assistant-message boundary. Against the old code the revert-visibility and committed-revert tests fail, and the new cursor tests fail against the previous head; on the branch they pass.bun test src/session/session-domain.test.ts src/session/timeline/controller-projection.test.tsinpackages/app: 20 passbun test test/solid-data.test.tsinpackages/client: 36 passbun test test/cli/tui/session-rows.test.tsinpackages/tui: 28 passbun typecheckinpackages/app,packages/clientandpackages/tui: cleanbun script/lint-changed.ts origin/dev: 0 problemsdev: all 17 checks pass