fix(web): show the whole last turn in the Claude response viewer's brief view

The eye button rendered `data.text`, which is one row: the last assistant
row of the transcript. A Claude answer is a median of 3 model messages
(p90 11) split around tool calls, so the brief view usually showed the tail
of an answer ("Done.", "Let me look.") and the substance appeared only after
More. The full view was fine, which is why the brief one read as broken by
comparison.

The brief view now asks `?context=turn`. The reader answers with the
assistant messages of the last ANSWERED turn (`selectLastAnsweredTurn`: the
highest `turn` that has an assistant row, so a prompt queued after the
answer does not blank the view) and the frontend renders them exactly as
the full view renders that turn: one badge, then continuation segments,
gated on the numeric `turn` as before.

`data.text` is unchanged in every context — still the last assistant row,
never `messages.at(-1)` — because agent pollers hash it. Readers that emit
no turns (Codex, the pane parser, DeepSeek, an older server) return `text`
only for `context=turn`, and the brief view keeps its single card for them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01McLWqCWBuQYGuPMScb4Aou
This commit is contained in:
shenlvkang-collab
2026-09-10 12:45:16 +08:00
co-authored by Claude Fable 5.1
parent 5b667264b4
commit bd61735393
8 changed files with 325 additions and 7 deletions
+177
View File
@@ -0,0 +1,177 @@
/**
* @fileoverview The brief ("Last Response") view renders the last ANSWERED turn.
*
* `data.text` is one row — the last assistant row — and a Claude turn is a
* median of 3 rows, so the eye button used to show the tail of an answer
* ("Done.") while the More view showed the whole thing. The brief view now asks
* for `context=turn` and renders those rows the way the full view does: one
* badge, then continuation segments. Pinned here:
*
* 1. `selectLastAnsweredTurn` picks the highest turn that HAS an assistant
* message, so a prompt queued after the answer (a new, unanswered turn)
* does not blank the view; and it yields nothing without numeric turns.
* 2. The brief view falls back to `text` when the server sends no messages
* (Codex, the pane parser, an older server), so those keep their one card.
* 3. The continuation gate is the numeric `turn`, as in loadFullContext.
*
* app.js is loaded via `vm` with a jsdom document, as in
* response-viewer-turn-segments.test.ts.
* Port: N/A
*/
import { readFileSync } from 'node:fs';
import { performance } from 'node:perf_hooks';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { JSDOM } from 'jsdom';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { selectLastAnsweredTurn } from '../src/web/response-viewer-transcript.js';
describe('selectLastAnsweredTurn', () => {
const user = (text: string, turn: number) => ({ role: 'user', text, turn });
const assistant = (text: string, turn: number) => ({ role: 'assistant', text, turn });
it('returns every assistant message of the highest answered turn', () => {
const messages = [
user('a', 1),
assistant('a1', 1),
user('b', 2),
assistant('b1', 2),
assistant('b2', 2),
user('c', 3),
];
expect(selectLastAnsweredTurn(messages).map((m) => m.text)).toEqual(['b1', 'b2']);
});
it('yields nothing for messages without numeric turns, so callers fall back to text', () => {
const messages = [
{ role: 'user', text: 'a' },
{ role: 'assistant', text: 'a1' },
{ role: 'assistant', text: 'a2' },
];
expect(selectLastAnsweredTurn(messages)).toEqual([]);
expect(selectLastAnsweredTurn([])).toEqual([]);
});
it('keeps turn-0 output emitted before the first prompt', () => {
expect(selectLastAnsweredTurn([assistant('hello', 0)]).map((m) => m.text)).toEqual(['hello']);
});
});
describe('response viewer brief view (last answered turn)', () => {
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>');
const { document, NodeFilter } = dom.window;
function loadCodemanAppClass() {
const constants = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
const context = vm.createContext({
console,
performance,
setInterval: vi.fn(),
clearInterval: vi.fn(),
setTimeout,
clearTimeout,
requestAnimationFrame: vi.fn(),
HTMLCanvasElement: class HTMLCanvasElement {},
fetch: vi.fn(),
document,
NodeFilter,
localStorage: { length: 0, key: vi.fn(), getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() },
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
MobileDetection: {},
});
vm.runInContext(`${constants}\n${source}\nglobalThis.__CodemanApp = CodemanApp;`, context);
return { CodemanApp: (context as { __CodemanApp: { prototype: object } }).__CodemanApp, context };
}
const { CodemanApp, context: appContext } = loadCodemanAppClass();
interface ViewerApp {
toggleResponseViewer(): Promise<void>;
activeSessionId?: string;
sessions?: Map<string, { mode: string }>;
}
function mountViewer() {
const viewer = document.createElement('div');
viewer.id = 'responseViewer';
const backdrop = document.createElement('div');
backdrop.id = 'responseViewerBackdrop';
const body = document.createElement('div');
body.id = 'responseViewerBody';
const title = document.createElement('div');
title.id = 'responseViewerTitle';
const more = document.createElement('button');
more.id = 'responseViewerMore';
document.body.append(viewer, backdrop, body, title, more);
return { viewer, body, title, more };
}
function makeApp(payload: unknown): { app: ViewerApp; fetchMock: ReturnType<typeof vi.fn> } {
const app = Object.create(CodemanApp.prototype) as ViewerApp;
app.activeSessionId = 's1';
app.sessions = new Map([['s1', { mode: 'claude' }]]);
const fetchMock = vi.fn(async () => ({ json: async () => ({ data: payload }) }));
(appContext as { fetch: unknown }).fetch = fetchMock;
return { app, fetchMock };
}
afterEach(() => {
document.body.innerHTML = '';
});
it('asks for context=turn and renders the whole turn as one badged card with segments', async () => {
const { body, viewer } = mountViewer();
const { app, fetchMock } = makeApp({
text: 'Done.',
timestamp: 't',
messages: [
{ role: 'assistant', text: 'Looking at the file.', turn: 2 },
{ role: 'assistant', text: 'The bug is on line 3.', turn: 2 },
{ role: 'assistant', text: 'Done.', turn: 2 },
],
});
await app.toggleResponseViewer();
expect(String(fetchMock.mock.calls[0][0])).toBe('/api/sessions/s1/last-response?context=turn');
const cards = body.querySelectorAll('.rv-message');
expect(cards).toHaveLength(3);
expect(body.querySelectorAll('.rv-role')).toHaveLength(1);
expect(cards[0].classList.contains('rv-msg-cont')).toBe(false);
expect(cards[1].classList.contains('rv-msg-cont')).toBe(true);
expect(cards[2].classList.contains('rv-msg-cont')).toBe(true);
expect(body.textContent).toContain('The bug is on line 3.');
expect(viewer.classList.contains('visible')).toBe(true);
});
it('falls back to text when the server sends no messages, keeping one badged card', async () => {
const { body } = mountViewer();
const { app } = makeApp({ text: 'Only the last row.', timestamp: 't' });
await app.toggleResponseViewer();
expect(body.querySelectorAll('.rv-message')).toHaveLength(1);
expect(body.querySelectorAll('.rv-role')).toHaveLength(1);
expect(body.textContent).toContain('Only the last row.');
});
it('ignores user rows and blank rows in a turn payload', async () => {
const { body } = mountViewer();
const { app } = makeApp({
text: 'answer',
timestamp: 't',
messages: [
{ role: 'user', text: 'prompt', turn: 1 },
{ role: 'assistant', text: ' ', turn: 1 },
{ role: 'assistant', text: 'answer', turn: 1 },
],
});
await app.toggleResponseViewer();
expect(body.querySelectorAll('.rv-message')).toHaveLength(1);
expect(body.textContent).not.toContain('prompt');
expect(body.textContent).toContain('answer');
});
});
@@ -336,6 +336,69 @@ describe('GET /api/sessions/:id/last-response (claude)', () => {
expect(body.data.text).toBe('Let me look.');
});
it('answers context=turn with the last answered turn only, text still on the last row', async () => {
const sessionId = harness.ctx._session.id;
const session = harness.ctx._session as typeof harness.ctx._session & {
claudeSessionId: string;
adoptClaudeSessionId: ReturnType<typeof vi.fn>;
};
session.claudeSessionId = sessionId;
session.adoptClaudeSessionId = vi.fn();
writeTranscript(sessionId, [
userEntry('first'),
assistantEntry('Old answer.', '2026-07-21T00:00:01Z'),
userEntry('second'),
assistantEntry('Looking at the file.', '2026-07-21T00:00:02Z'),
{ type: 'assistant', message: { content: [{ type: 'tool_use', id: 'x' }] } },
{ type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: 'x' }] } },
assistantEntry('The bug is on line 3.', '2026-07-21T00:00:03Z'),
assistantEntry('Done.', '2026-07-21T00:00:04Z'),
// A prompt queued after the answer opens a new, unanswered turn.
queuedEntry('third', '2026-07-21T00:00:05Z'),
]);
const response = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${sessionId}/last-response?context=turn`,
});
expect(response.statusCode).toBe(200);
const data = JSON.parse(response.body).data as {
text: string;
timestamp: string;
messages: Array<{ role: string; text: string; turn: number }>;
};
// The frozen brief contract holds: still the last assistant row.
expect(data.text).toBe('Done.');
expect(data.timestamp).toBe('2026-07-21T00:00:04Z');
// The turn view is that row's whole turn, assistant rows only, and not the
// queued prompt that has no answer yet.
expect(data.messages.map((m) => [m.role, m.text, m.turn])).toEqual([
['assistant', 'Looking at the file.', 2],
['assistant', 'The bug is on line 3.', 2],
['assistant', 'Done.', 2],
]);
const brief = await getLastResponse(sessionId);
expect(brief.body.data).toEqual({ text: 'Done.', timestamp: '2026-07-21T00:00:04Z' });
});
it('answers context=turn with an empty list when nothing has been answered yet', async () => {
const sessionId = harness.ctx._session.id;
const session = harness.ctx._session as typeof harness.ctx._session & {
claudeSessionId: string;
adoptClaudeSessionId: ReturnType<typeof vi.fn>;
};
session.claudeSessionId = sessionId;
session.adoptClaudeSessionId = vi.fn();
writeTranscript(sessionId, [userEntry('go')]);
const response = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${sessionId}/last-response?context=turn`,
});
expect(JSON.parse(response.body).data).toEqual({ text: '', timestamp: '', messages: [] });
});
/**
* A multi-line paste absorbed mid-turn arrives as N queued rows within a few
* hundred milliseconds (observed: 5 rows inside ~360ms). They are one turn, so