mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 23:19:43 +02:00
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:
+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}版本',
|
||||
|
||||
@@ -12607,6 +12607,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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user