Merge pull request #369 from shenlvkang-collab/pr/claude-response-viewer-per-message

fix(web): render one Claude response-viewer message per model message
This commit is contained in:
Ark0N
2026-09-06 23:02:13 +02:00
committed by GitHub
11 changed files with 547 additions and 48 deletions
+75 -32
View File
@@ -1906,9 +1906,13 @@ export function registerSessionRoutes(
}
interface ClaudeResponseMessage {
kind: 'prompt' | 'response';
label: 'Prompt' | 'Response';
role: 'user' | 'assistant';
text: string;
timestamp?: string;
turn: number;
queued?: boolean;
}
interface ClaudeTranscriptEntry {
@@ -1918,6 +1922,19 @@ export function registerSessionRoutes(
isSidechain?: boolean;
isCompactSummary?: boolean;
message?: { content?: unknown };
// A prompt typed while Claude is working is absorbed mid-turn and recorded
// ONLY here — the CLI never re-emits it as a `user` row. Every field stays
// optional and unvalidated: `queued_command` is not a documented CLI
// contract, so a missing/renamed field must mean "skip", which is also what
// the CLI's own non-human queue entries (commandMode 'task-notification',
// no `origin` key) require. Shape observed on Claude Code 2.1.220-2.1.251.
attachment?: {
type?: string;
prompt?: string;
commandMode?: string;
timestamp?: string;
origin?: { kind?: string };
};
}
function extractClaudeText(content: unknown, separator: string): string {
@@ -1943,10 +1960,17 @@ export function registerSessionRoutes(
}
/**
* Claude writes one logical turn as many JSONL rows: text, thinking and tool
* blocks share message ids, while tool results are represented as user rows.
* Build viewer cards from real user boundaries instead of treating every row
* as a separate chat message.
* Claude writes an append-only event log: tool results arrive as user rows,
* thinking/tool_use rows carry no text, and a prompt typed while the agent is
* working is only ever recorded as an `attachment/queued_command` row. But one
* assistant row IS one whole model message: measured across ~/.claude/projects
* (CLI 2.1.220-2.1.251) no assistant row carries more than one content block
* and no message id carries more than one text block, so there is nothing to
* reassemble. Emit one card per row and group them with `turn` instead of
* concatenating a human turn's replies into a single card (#169), which fused
* up to 74 distinct model messages into one card. Splitting is safe for
* markdown: no adjacent pair of assistant text rows in the corpus continues a
* table, a list, or an open code fence.
*/
function parseClaudeResponseTranscript(
content: string,
@@ -1955,8 +1979,23 @@ export function registerSessionRoutes(
let lastText = '';
let lastTimestamp = '';
const messages: ClaudeResponseMessage[] = [];
let currentUserFragments = new Set<string>();
let currentAssistantFragments = new Set<string>();
// #169's replay guards, kept: they now SKIP a duplicated row instead of
// concatenating it into the previous card.
const currentUserFragments = new Set<string>();
const currentAssistantFragments = new Set<string>();
// Turn 0 is reserved for anything emitted before the first human prompt.
let turn = 0;
const pushUserMessage = (text: string, timestamp: string | undefined, queued: boolean): void => {
// A run of consecutive human inputs (a mid-turn queued burst) is ONE turn,
// so the viewer renders it under one badge instead of one badge per line.
if (messages.at(-1)?.role !== 'user') turn += 1;
const message: ClaudeResponseMessage = { kind: 'prompt', label: 'Prompt', role: 'user', text, timestamp, turn };
if (queued) message.queued = true;
messages.push(message);
currentUserFragments.add(text);
currentAssistantFragments.clear();
};
for (const line of content.split('\n')) {
if (!line) continue;
@@ -1970,25 +2009,37 @@ export function registerSessionRoutes(
// rows include repeated image dimensions and other UI-generated context.
if (entry.isSidechain) continue;
// A prompt typed while Claude is working is absorbed mid-turn and lives
// ONLY in an attachment row, so it was lost outright. `origin.kind` and
// `commandMode` separate the human's queue entries from the CLI's own:
// measured over 57 real transcripts on 2026-09-01, 322 queued_command rows
// split 163 `prompt`/`human` and 159 `task-notification`, and not one of
// those 159 carries an `origin` key. The 163 human rows become 162 user
// cards here — one is a verbatim repeat inside a still-unanswered user run
// and is collapsed by the dedup guard below — out of 353 user cards total.
if (entry.type === 'attachment') {
if (!full) continue;
const queued = entry.attachment;
if (!queued || queued.type !== 'queued_command') continue;
if (queued.origin?.kind !== 'human' || queued.commandMode !== 'prompt') continue;
const text = typeof queued.prompt === 'string' ? queued.prompt.trim() : '';
if (!text || isClaudeSyntheticUserMessage(entry, text)) continue;
if (currentUserFragments.has(text)) continue;
pushUserMessage(text, queued.timestamp || entry.timestamp, true);
continue;
}
if (entry.type === 'user') {
const text = extractClaudeText(entry.message?.content, '\n').trim();
// A tool_result block has no text block and naturally drops out here.
if (!text || isClaudeSyntheticUserMessage(entry, text)) continue;
if (!full) continue;
const previous = messages.at(-1);
if (previous?.role === 'user') {
// Claude can replay the initial user row while restoring a transcript.
// Only collapse duplicates within the same unanswered user turn; the
// same prompt after an assistant response remains a legitimate turn.
if (currentUserFragments.has(text)) continue;
previous.text += `\n\n${text}`;
currentUserFragments.add(text);
} else {
messages.push({ role: 'user', text, timestamp: entry.timestamp });
currentUserFragments = new Set([text]);
}
currentAssistantFragments.clear();
// Claude replays the initial user row while restoring a transcript, and
// a CLI that also wrote an absorbed prompt as a user row would double it.
// Both collapse here. The same prompt sent again AFTER a reply is a
// legitimate second turn, because that reply cleared the set.
if (currentUserFragments.has(text)) continue;
pushUserMessage(text, entry.timestamp, false);
continue;
}
@@ -1999,18 +2050,10 @@ export function registerSessionRoutes(
lastTimestamp = entry.timestamp || '';
if (!full) continue;
const previous = messages.at(-1);
if (previous?.role === 'assistant') {
// Replayed snapshots sometimes repeat an identical text block. Distinct
// progress/final blocks are kept, but remain inside one Claude card.
if (currentAssistantFragments.has(text)) continue;
previous.text += `\n\n${text}`;
previous.timestamp = entry.timestamp || previous.timestamp;
currentAssistantFragments.add(text);
} else {
messages.push({ role: 'assistant', text, timestamp: entry.timestamp });
currentAssistantFragments = new Set([text]);
}
// Replayed snapshots repeat an identical text block inside one turn.
if (currentAssistantFragments.has(text)) continue;
messages.push({ kind: 'response', label: 'Response', role: 'assistant', text, timestamp: entry.timestamp, turn });
currentAssistantFragments.add(text);
currentUserFragments.clear();
}