mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 14:09:42 +02:00
fix(web): render one Claude response-viewer message per model message
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 #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 commit is contained in:
+49
-8
@@ -2183,15 +2183,26 @@ class CodemanApp {
|
||||
}
|
||||
|
||||
/** Build one response-viewer message so the brief and full views share markup and CSS. */
|
||||
_buildResponseViewerMessage(text, role, agentLabel) {
|
||||
_buildResponseViewerMessage(text, role, agentLabel, meta) {
|
||||
const div = document.createElement('div');
|
||||
const isUser = role === 'user';
|
||||
div.className = 'rv-message ' + (isUser ? 'rv-msg-user' : 'rv-msg-assistant');
|
||||
// Consecutive messages from one speaker inside one turn are segments of a
|
||||
// single utterance: one badge, a hairline seam. Claude emits a median of 3
|
||||
// messages per turn (p90 11, max 51), so a badge per message would be the
|
||||
// card spam the old concatenation was introduced to avoid. `meta` is
|
||||
// optional so the brief view's 3-argument call keeps its exact shape.
|
||||
const continuation = !!(meta && meta.continuation);
|
||||
if (continuation) div.classList.add('rv-msg-cont');
|
||||
if (meta && meta.kind) div.dataset.kind = meta.kind;
|
||||
if (meta && meta.queued) div.dataset.queued = '1';
|
||||
|
||||
const roleBadge = document.createElement('div');
|
||||
roleBadge.className = 'rv-role ' + (isUser ? 'rv-role-user' : 'rv-role-assistant');
|
||||
roleBadge.textContent = isUser ? 'You' : agentLabel;
|
||||
div.appendChild(roleBadge);
|
||||
if (!continuation) {
|
||||
const roleBadge = document.createElement('div');
|
||||
roleBadge.className = 'rv-role ' + (isUser ? 'rv-role-user' : 'rv-role-assistant');
|
||||
roleBadge.textContent = isUser ? 'You' : agentLabel;
|
||||
div.appendChild(roleBadge);
|
||||
}
|
||||
|
||||
const renderedText = document.createElement('div');
|
||||
renderedText.className = 'rv-text';
|
||||
@@ -2351,19 +2362,49 @@ class CodemanApp {
|
||||
if (!body) return;
|
||||
|
||||
if (messages.length === 0) {
|
||||
body.textContent = 'No conversation history available';
|
||||
// Never destroy what the eye button already rendered: the brief view has
|
||||
// a terminal-buffer fallback (see toggleResponseViewer) that this
|
||||
// endpoint does not, so an empty full-context result must not wipe a
|
||||
// real answer the user is reading.
|
||||
// ⚠️ Idempotent, because More deliberately stays live here: the branch
|
||||
// returns before the button is hidden so a transcript that appears a
|
||||
// moment later can still be loaded, and appending would then stack a
|
||||
// second identical notice on every retry.
|
||||
// `:scope >` keeps the lookup off model-rendered markdown inside .rv-text.
|
||||
let notice = body.querySelector(':scope > .rv-notice');
|
||||
if (!notice) {
|
||||
notice = document.createElement('div');
|
||||
notice.className = 'rv-notice';
|
||||
body.appendChild(notice);
|
||||
}
|
||||
const emptyText = 'No full conversation history available for this session';
|
||||
notice.textContent = window.codemanT?.(emptyText) || emptyText;
|
||||
return;
|
||||
}
|
||||
|
||||
// Render conversation thread
|
||||
const agentLabel = this._getResponseViewerAgentLabel();
|
||||
body.innerHTML = '';
|
||||
let previous = null;
|
||||
for (const msg of messages) {
|
||||
body.appendChild(this._buildResponseViewerMessage(msg.text, msg.role, agentLabel));
|
||||
// ⚠️ A numeric `turn` is REQUIRED, never same-role adjacency alone.
|
||||
// Only the Claude reader emits turns; Codex and the external-CLI pane
|
||||
// parser emit adjacent assistant/response blocks with no turn at all, and
|
||||
// an older server emits none either — all three must keep rendering one
|
||||
// badged card per message exactly as they do today.
|
||||
const continuation =
|
||||
!!previous && previous.role === msg.role && typeof msg.turn === 'number' && previous.turn === msg.turn;
|
||||
body.appendChild(this._buildResponseViewerMessage(msg.text, msg.role, agentLabel, { ...msg, continuation }));
|
||||
previous = msg;
|
||||
}
|
||||
this._bindResponseViewerInteractions(body);
|
||||
|
||||
if (title) title.textContent = `Conversation (${messages.length} messages)`;
|
||||
const turns = new Set(messages.filter((msg) => typeof msg.turn === 'number').map((msg) => msg.turn)).size;
|
||||
if (title) {
|
||||
title.textContent = turns
|
||||
? `Conversation (${messages.length} messages, ${turns} turns)`
|
||||
: `Conversation (${messages.length} messages)`;
|
||||
}
|
||||
if (moreBtn) moreBtn.style.display = 'none';
|
||||
// Scroll to bottom (latest message)
|
||||
body.scrollTop = body.scrollHeight;
|
||||
|
||||
@@ -86,6 +86,7 @@
|
||||
'Instance count': '实例数量',
|
||||
'No response yet': '暂无回复',
|
||||
'No response yet — send a message in this session first.': '暂无回复,请先在此会话中发送一条消息。',
|
||||
'No full conversation history available for this session': '此会话没有可显示的完整对话历史',
|
||||
'Last Response': '最近一次回复',
|
||||
More: '更多',
|
||||
'Codeman version': '{name}版本',
|
||||
|
||||
@@ -12600,6 +12600,43 @@ kbd {
|
||||
background: color-mix(in srgb, var(--green) 12%, transparent);
|
||||
}
|
||||
|
||||
/* Consecutive messages from one speaker inside one turn are segments of a
|
||||
single utterance, not separate cards: no repeated badge, a hairline seam.
|
||||
The role accent survives because the colour rules above match on BOTH
|
||||
:has(.rv-role-*) and .rv-msg-* — a badge-less continuation still hits the
|
||||
class arm. Do not drop either arm. */
|
||||
.rv-message.rv-msg-cont {
|
||||
margin-top: -18px;
|
||||
border-top: 0;
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.rv-message.rv-msg-cont > .rv-text {
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.rv-message:has(+ .rv-msg-cont) {
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
/* A prompt the user typed while the agent was working (absorbed mid-turn).
|
||||
A pseudo-element, not a text node, so the i18n MutationObserver cannot
|
||||
rewrite it. */
|
||||
.rv-message[data-queued='1'] .rv-role::after {
|
||||
content: ' ⏱';
|
||||
}
|
||||
|
||||
.rv-notice {
|
||||
opacity: 0.7;
|
||||
font-style: italic;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
/* Markdown rendered content inside response viewer.
|
||||
Prose uses a proportional font for readability; code keeps monospace. */
|
||||
.rv-text,
|
||||
|
||||
@@ -1926,9 +1926,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 {
|
||||
@@ -1938,6 +1942,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 {
|
||||
@@ -1963,10 +1980,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,
|
||||
@@ -1975,8 +1999,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;
|
||||
@@ -1990,25 +2029,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;
|
||||
}
|
||||
|
||||
@@ -2019,18 +2070,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();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user