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
+49 -8
View File
@@ -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;
+1
View File
@@ -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}版本',
+37
View File
@@ -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,