fix(web): render one Claude response-viewer message per model message - #369
Open
shenlvkang-collab wants to merge 1 commit into
Open
Conversation
The Claude reader concatenated every assistant row between two human prompts
into one card, fusing up to 74 distinct model messages into a single card, and
it never read the attachment rows that hold a prompt typed while the agent was
working. Measured over 57 real transcripts on 2026-09-01, the viewer shows
1,806 messages instead of 356 and 353 user cards instead of 178, with the
assistant text sequence unchanged row for row and the response without
?context=full byte-identical on all 57 files.
One assistant row IS one whole model message: in that corpus no assistant row
carries more than one content block and no message id carries more than one
text block, so there was nothing to reassemble. Each row becomes its own
message carrying an additive {kind, label, turn}, and the frontend renders a
same-role run inside one turn as badge-less continuation segments — which is
what keeps a p90 of 11 messages per turn from reading as card spam. A numeric
turn gates that rendering, so Codex, the external-CLI pane parser and an older
server keep one badge per card.
A prompt typed while Claude is working is recorded ONLY as an
attachment/queued_command row. Taking it when origin.kind is 'human' and
commandMode is 'prompt' recovers 162 user cards from 163 such rows — one is a
verbatim repeat inside an unanswered user run and is collapsed by the existing
dedup guard — and restores the turn boundary whose absence let the assistant
runs fuse. The CLI's own queue entries are cleanly separable: of 322
queued_command rows, 159 are commandMode 'task-notification' and not one of
them carries an origin key.
This narrows Ark0N#169 rather than reverting it: sidechain exclusion, the
restored-<uuid8> rebind, replayed-snapshot dedup and synthetic-row filtering
are all unchanged and still asserted.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
fix(web): render one Claude response-viewer message per model message
Closes #365.
The bug
Open the eye on a Claude pane that has been running a while, tap More. A 1,566-row
session with 522 assistant rows renders as "Conversation (6 messages)" — three "You" cards
and three "Claude" cards, one of them a 12,771-character block holding 72 separate model
messages spanning 92 minutes. Corpus-wide, half the prompts the user actually typed are
absent entirely.
Two independent causes, which compound:
parseClaudeResponseTranscript()concatenates.previous.text += '\n\n' + textforevery consecutive assistant row, so the grouping unit is the human turn — my own overshoot
in fix(web): normalize Claude response viewer turns #169, which fixed per-row fragmentation by going all the way to per-human-turn.
records it ONLY as an
attachment/queued_commandrow; it never re-emits it as auserrow. The parser dispatches on
'user'/'assistant'only, so those prompts vanish — andwith them the turn boundary that would have stopped the fusion above.
#367 is independent of this one: different functions, and both cherry-pick onto
mastercleanly on their own. They overlap only in
test/routes/session-routes-claude-last-response.test.tsanddocs/architecture-invariants.md, so I will rebase whichever lands second — merge them ineither order.
Codex avoids (2) because
readCodexLastResponse()reads real user turns from a dedicatedevent_msg/user_messagechannel that records mid-turn submissions, and avoids (1) because itpushes one message per
response_itemunconditionally.attachment/queued_commandisClaude's equivalent channel and was sitting unread.
Why splitting is safe (the load-bearing claim)
#169's rationale — "one logical turn spans many rows" — is true about
message.ids but doesnot imply the cards must be joined. Measured across 57 real transcripts (CLI 2.1.220–2.1.251,
snapshot 2026-09-01):
message.idcarriesmore than one
textblock. One assistant row already IS one whole model message.extractClaudeText()already returns''for thinking/tool_use rows, so the merge onlyever fused prose the model emitted separately.
0 table continuations, 0 list continuations, 0 rows leaving an unclosed code fence.
The first two are also pinned by the fixture in
test/routes/session-routes-claude-last-response.test.ts, which you can run without mytranscripts.
Measurements
totals drift by a few dozen between runs. The invariants below the table do not.
data.{text,timestamp}differingqueued_commandcensusorigin)Stable regardless of drift: the assistant text sequence is unchanged row for row; the only
character delta is the removed
\n\njoiners; the brief response is byte-identical on everyfile; and 162 of 163 human queued rows become cards (the one exception is a verbatim repeat
inside a still-unanswered turn, collapsed by #169's retained dedup guard).
Why the frontend change is in the same PR
A naked per-message card list is the card spam #169 was reacting to. Post-split distribution:
p50 67 characters, 58% of assistant messages under 80; same-speaker runs per turn
p50 3, p90 11, max 51. So the server carries a
turnindex and the viewer renders asame-speaker run inside one turn as continuation segments under one badge — the turn stays
the semantic unit (#169's insight), the model's separate messages stop being
string-concatenated into a blob. Splitting the two changes would ship a regression-shaped
intermediate state.
turn, never same-role adjacency alone: Codex, theexternal-CLI pane parser and an older server all emit adjacent same-role messages with no
turnand must keep one badge per card. Pinned intest/frontend-public-tooling.test.ts.API — additive only
GET /api/sessions/:id/last-response?context=full→.data.messages[]gainskind: 'prompt'|'response',label: 'Prompt'|'Response',turn: number, andqueued: trueon a prompt recovered from an attachment row.
rolekeeps its two-value domain,textkeepsits meaning,
timestampis now the row's own instead of the last fused fragment's. The routedeclares no Fastify
schema:, so new fields are not stripped; perdocs/versioning-policy.mdadditive fields are non-breaking.
data.text(no?context=full) is frozen and stays derived from the last assistantrow, never
messages.at(-1)— agent pollers hash it (skills/codeman/preamble.shlast_text()), and the last message can now be the user's own queued prompt. Pinned by adedicated test.
labelalso makes the shippedskills/codeman/reference/recipes.mdjq recipestop printing
[null]for Claude sessions.The
messages[]shape differs per reader, so the skills reference now says so explicitly:only
{role,text}is common to all four;kind/labelare absent for codex,timestampfordeepseek and the pane parser, and
turn/queuedare claude-only.What this deliberately does NOT fix
<uuid>.jsonl;/clear, respawnand auto-clear mint a new conversation id. Separate mechanism, separate PR (fix(session): learn the live Claude conversation from the CLI's own hook #367 / Response viewer stays pinned to the launch conversation for a pane driven from tmux #366).
any code change — Claude Code prunes
~/.claude/projectsitself, and only 21 of the 96conversations that folder's
history.jsonlnames still have a file on disk.can carry credential material.
pre-existing cost respectively; this diff adds no second pass.
Tests
test/routes/session-routes-claude-last-response.test.ts— the existing fixture rows are keptbyte-for-byte (they are the only coverage of
isMeta/<command-name>/ teammate-message /<task-notification>/isCompactSummary/isSidechainfiltering in one assertion); onlythe expected
messagesarray narrows. Four new cases, including the must-fail-on-master one:surfaces a prompt the user queued while Claude was working— onorigin/masteritfails on both counts at once, i.e. both user symptoms in one assertion:
renders an absorbed prompt once when the CLI also writes it as a user rowkeeps the brief response on the last assistant row while a turn is in flight— freezes thepoller contract
groups a burst of queued prompts into one turnNew
test/response-viewer-turn-segments.test.ts(jsdom +vm, same harness asresponse-viewer-file-links.test.ts) pins badge suppression, the DOM-only queued marker, thatthe 4th argument stays optional, and that an empty full-context result does not stack
duplicate notices.
Verification
Run in an isolated worktree with a clean
npm ci:npm run test:mobileis environment-dependent as CONTRIBUTING says, so I diffed its FAIL listagainst
origin/masterinstead of reading it as pass/fail: 41 distinct failures on master, 40on this branch, zero of them introduced here (the one difference is a codex test that
fails on master and passed here — a flake, not a fix).
I have been running this build as my daily driver; on a real session it takes the viewer from
4 messages / 2 prompts to 84 messages / 4 prompts, with
.data.textunchanged.