Skip to content

fix(web): render one Claude response-viewer message per model message - #369

Open
shenlvkang-collab wants to merge 1 commit into
Ark0N:masterfrom
shenlvkang-collab:pr/claude-response-viewer-per-message
Open

fix(web): render one Claude response-viewer message per model message#369
shenlvkang-collab wants to merge 1 commit into
Ark0N:masterfrom
shenlvkang-collab:pr/claude-response-viewer-per-message

Conversation

@shenlvkang-collab

Copy link
Copy Markdown
Contributor

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:

  1. parseClaudeResponseTranscript() concatenates. previous.text += '\n\n' + text for
    every 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.
  2. A prompt typed while Claude is working is never read. The CLI absorbs it mid-turn and
    records it ONLY as an attachment/queued_command row; it never re-emits it as a user
    row. The parser dispatches on 'user'/'assistant' only, so those prompts vanish — and
    with them the turn boundary that would have stopped the fusion above.

#367 is independent of this one: different functions, and both cherry-pick onto master
cleanly on their own. They overlap only in
test/routes/session-routes-claude-last-response.test.ts and
docs/architecture-invariants.md, so I will rebase whichever lands second — merge them in
either order.

Codex avoids (2) because readCodexLastResponse() reads real user turns from a dedicated
event_msg/user_message channel that records mid-turn submissions, and avoids (1) because it
pushes one message per response_item unconditionally. attachment/queued_command is
Claude'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 does
not imply the cards must be joined. Measured across 57 real transcripts (CLI 2.1.220–2.1.251,
snapshot 2026-09-01):

  • 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.
  • extractClaudeText() already returns '' for thinking/tool_use rows, so the merge only
    ever fused prose the model emitted separately.
  • markdown cannot be cut: across every adjacent pair of assistant text rows there are
    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 my
transcripts.

Measurements

⚠️ The corpus is append-live (one of the transcripts is the session that did this work), so
totals drift by a few dozen between runs. The invariants below the table do not.

metric master this branch
viewer messages 356 1,806
user cards 178 353
assistant messages delivered 178 cards / 1,453 messages 1,453
worst fusion 74 model messages in one card 1
assistant text sequence identical row for row (0 missing, 0 extra)
brief data.{text,timestamp} differing 0 of 57
queued_command census 322 rows = 163 human + 159 task-notification (0 of the 159 carry origin) 163 human rows → 162 cards

Stable regardless of drift: the assistant text sequence is unchanged row for row; the only
character delta is the removed \n\n joiners; the brief response is byte-identical on every
file; 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 turn index and the viewer renders a
same-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.

⚠️ Continuation requires a numeric turn, never same-role adjacency alone: Codex, the
external-CLI pane parser and an older server all emit adjacent same-role messages with no
turn and must keep one badge per card. Pinned in test/frontend-public-tooling.test.ts.

API — additive only

GET /api/sessions/:id/last-response?context=full.data.messages[] gains
kind: 'prompt'|'response', label: 'Prompt'|'Response', turn: number, and queued: true
on a prompt recovered from an attachment row. role keeps its two-value domain, text keeps
its meaning, timestamp is now the row's own instead of the last fused fragment's. The route
declares no Fastify schema:, so new fields are not stripped; per docs/versioning-policy.md
additive fields are non-breaking.

⚠️ data.text (no ?context=full) is frozen and stays derived from the last assistant
row, never messages.at(-1)
— agent pollers hash it (skills/codeman/preamble.sh
last_text()), and the last message can now be the user's own queued prompt. Pinned by a
dedicated test. label also makes the shipped skills/codeman/reference/recipes.md jq recipe
stop 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/label are absent for codex, timestamp for
deepseek and the pane parser, and turn/queued are claude-only.

What this deliberately does NOT fix

  • The conversation chain. The viewer opens exactly one <uuid>.jsonl; /clear, respawn
    and 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).
    ⚠️ Worth saying plainly: on my machine most of the "missing history" is not recoverable by
    any code change — Claude Code prunes ~/.claude/projects itself, and only 21 of the 96
    conversations that folder's history.jsonl names still have a file on disk.
  • Tool activity as a third block kind, and tool output, which would be megabytes and
    can carry credential material.
  • Compact-boundary dividers, image placeholders, streaming reads — 3 rows, 2 rows, and a
    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 kept
byte-for-byte (they are the only coverage of isMeta / <command-name> / teammate-message /
<task-notification> / isCompactSummary / isSidechain filtering in one assertion); only
the expected messages array narrows. Four new cases, including the must-fail-on-master one:

  • surfaces a prompt the user queued while Claude was working — on origin/master it
    fails on both counts at once, i.e. both user symptoms in one assertion:
    - ["user","start the job",1]        + ["user","start the job",undefined]
    - ["assistant","Working on it.",1]  + ["assistant","Working on it.\n\nSwitched to PowerShell.",undefined]
    - ["user","actually use PowerShell",2]
    - ["assistant","Switched to PowerShell.",2]
    
  • renders an absorbed prompt once when the CLI also writes it as a user row
  • keeps the brief response on the last assistant row while a turn is in flight — freezes the
    poller contract
  • groups a burst of queued prompts into one turn

New test/response-viewer-turn-segments.test.ts (jsdom + vm, same harness as
response-viewer-file-links.test.ts) pins badge suppression, the DOM-only queued marker, that
the 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 check:lockfile         Lockfile in sync with package.json (1.24.1).
npm run typecheck              clean
npm run lint                   clean
npm run format:check           All matched files use Prettier code style!
npm run check:frontend-syntax  ✓ 34 frontend JS files parse cleanly
npm run check:public-assets    Public asset checks passed (42 files).
npm test                       Test Files 326 passed | 1 skipped (327)
                               Tests 6348 passed | 12 skipped (6360)

npm run test:mobile is environment-dependent as CONTRIBUTING says, so I diffed its FAIL list
against origin/master instead of reading it as pass/fail: 41 distinct failures on master, 40
on 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.text unchanged.

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.
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.

Response viewer fuses a Claude turn's replies into one card, and never shows a prompt typed mid-turn

1 participant