Response viewer fuses a Claude turn's replies into one card, and never shows a prompt typed mid-turn
Version 1.24.1 · Install git clone · Mode claude (CLI 2.1.220–2.1.251) · OS Linux (WSL2) · Browser Chrome + Android WebView · Surface Response Viewer (eye → More)
What you see
Open the eye on a Claude pane that has been running a while and tap More. One session
here has 1,566 transcript rows and 522 assistant rows, and renders as
"Conversation (6 messages)" — three "You" cards and three "Claude" cards, one of which is
a single 12,771-character block covering 92 minutes of work. The viewer shows 3 of my
prompts; that transcript actually holds 28 user messages, so 25 of them — everything I typed
while Claude was working — never appear at all.
The same viewer on a Codex pane reads correctly, which is what made the Claude side look
broken rather than merely terse.
Two independent causes, which compound
1. Every assistant row between two human prompts is concatenated into one card
parseClaudeResponseTranscript() in src/web/routes/session-routes.ts does
previous.text += '\n\n' + text whenever the previous card is also the assistant's, so the
grouping unit is the human turn and an autonomous run becomes one card.
Replayed over 57 real transcripts (snapshot 2026-09-01; the corpus is append-live, so
totals drift a little between runs):
|
|
| viewer cards produced |
356 |
| distinct assistant model messages inside them |
1,453 |
| worst fusion, by message count |
74 model messages in one card |
| an illustrative card |
72 model messages, 07:41:21Z → 09:13:45Z, 12,771 characters |
The merge is not justified by the data. Across that corpus no assistant row carries
more than one content block, and no message.id carries more than one text block — one
assistant row already is one whole model message, structurally identical to a Codex
response_item/message/assistant, which readCodexLastResponse() pushes unconditionally.
extractClaudeText() already returns '' for thinking and tool_use rows, so the merge only
ever fused prose the model emitted separately. Splitting also cannot cut markdown: across
every adjacent pair of assistant text rows there are 0 table continuations, 0 list
continuations and 0 rows leaving an unclosed code fence.
This is a regression I introduced in #169, which fixed per-row fragmentation by
overshooting to per-human-turn.
2. A prompt typed while Claude is working is never read
The CLI absorbs a mid-turn prompt and records it only as an attachment row; it never
re-emits it as a user row:
{"type":"attachment","timestamp":"…",
"attachment":{"type":"queued_command","prompt":"actually use PowerShell",
"source_uuid":"…","commandMode":"prompt","origin":{"kind":"human"},"timestamp":"…"}}
parseClaudeResponseTranscript() dispatches only on 'user' and 'assistant', so those
prompts vanish. Census over the same 57 transcripts: 322 queued_command rows — 163 with
commandMode:'prompt' + origin.kind:'human', 159 with commandMode:'task-notification',
and not one of those 159 carries an origin key. The two classes separate cleanly on
either field.
Reading the 163 human rows adds 162 user cards (one is a verbatim repeat inside a
still-unanswered turn and is collapsed by #169's retained dedup guard). With the user-row
concatenation dropped as well, user cards go from 178 to 353.
The two causes compound. A lost prompt is also a lost turn boundary, which is what lets
an assistant run keep fusing. Codex does not have this problem because it reads real user
turns from a dedicated event_msg/user_message channel that records mid-turn submissions;
attachment/queued_command is Claude's equivalent and is sitting unread in every transcript
on disk.
Proposed fix
Emit one message per model message, carry an additive turn index instead of concatenating,
and read attachment/queued_command rows with origin.kind === 'human' as user messages.
The frontend renders a same-speaker run inside one turn as continuation segments under one
badge — necessary, because the post-split distribution is p50 67 characters, 58% of messages
under 80, and p50 3 / p90 11 / max 51 messages per turn.
Additive only: role keeps its two-value domain, text keeps its meaning, and the response
without ?context=full stays byte-identical (0 of 57 transcripts differ) because agent
pollers hash .data.text.
Branch is ready with tests and the full gate green — happy to open the PR.
Response viewer fuses a Claude turn's replies into one card, and never shows a prompt typed mid-turn
Version 1.24.1 · Install git clone · Mode claude (CLI 2.1.220–2.1.251) · OS Linux (WSL2) · Browser Chrome + Android WebView · Surface Response Viewer (eye → More)
What you see
Open the eye on a Claude pane that has been running a while and tap More. One session
here has 1,566 transcript rows and 522 assistant rows, and renders as
"Conversation (6 messages)" — three "You" cards and three "Claude" cards, one of which is
a single 12,771-character block covering 92 minutes of work. The viewer shows 3 of my
prompts; that transcript actually holds 28 user messages, so 25 of them — everything I typed
while Claude was working — never appear at all.
The same viewer on a Codex pane reads correctly, which is what made the Claude side look
broken rather than merely terse.
Two independent causes, which compound
1. Every assistant row between two human prompts is concatenated into one card
parseClaudeResponseTranscript()insrc/web/routes/session-routes.tsdoesprevious.text += '\n\n' + textwhenever the previous card is also the assistant's, so thegrouping unit is the human turn and an autonomous run becomes one card.
Replayed over 57 real transcripts (snapshot 2026-09-01; the corpus is append-live, so
totals drift a little between runs):
The merge is not justified by the data. Across that corpus no assistant row carries
more than one content block, and no
message.idcarries more than onetextblock — oneassistant row already is one whole model message, structurally identical to a Codex
response_item/message/assistant, whichreadCodexLastResponse()pushes unconditionally.extractClaudeText()already returns''for thinking and tool_use rows, so the merge onlyever fused prose the model emitted separately. Splitting also cannot cut markdown: across
every adjacent pair of assistant text rows there are 0 table continuations, 0 list
continuations and 0 rows leaving an unclosed code fence.
This is a regression I introduced in #169, which fixed per-row fragmentation by
overshooting to per-human-turn.
2. A prompt typed while Claude is working is never read
The CLI absorbs a mid-turn prompt and records it only as an
attachmentrow; it neverre-emits it as a
userrow:{"type":"attachment","timestamp":"…", "attachment":{"type":"queued_command","prompt":"actually use PowerShell", "source_uuid":"…","commandMode":"prompt","origin":{"kind":"human"},"timestamp":"…"}}parseClaudeResponseTranscript()dispatches only on'user'and'assistant', so thoseprompts vanish. Census over the same 57 transcripts: 322
queued_commandrows — 163 withcommandMode:'prompt'+origin.kind:'human', 159 withcommandMode:'task-notification',and not one of those 159 carries an
originkey. The two classes separate cleanly oneither field.
Reading the 163 human rows adds 162 user cards (one is a verbatim repeat inside a
still-unanswered turn and is collapsed by #169's retained dedup guard). With the user-row
concatenation dropped as well, user cards go from 178 to 353.
The two causes compound. A lost prompt is also a lost turn boundary, which is what lets
an assistant run keep fusing. Codex does not have this problem because it reads real user
turns from a dedicated
event_msg/user_messagechannel that records mid-turn submissions;attachment/queued_commandis Claude's equivalent and is sitting unread in every transcripton disk.
Proposed fix
Emit one message per model message, carry an additive
turnindex instead of concatenating,and read
attachment/queued_commandrows withorigin.kind === 'human'as user messages.The frontend renders a same-speaker run inside one
turnas continuation segments under onebadge — necessary, because the post-split distribution is p50 67 characters, 58% of messages
under 80, and p50 3 / p90 11 / max 51 messages per turn.
Additive only:
rolekeeps its two-value domain,textkeeps its meaning, and the responsewithout
?context=fullstays byte-identical (0 of 57 transcripts differ) because agentpollers hash
.data.text.Branch is ready with tests and the full gate green — happy to open the PR.