mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 16:39:42 +02:00
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
178 lines
6.9 KiB
TypeScript
178 lines
6.9 KiB
TypeScript
/**
|
|
* @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');
|
|
});
|
|
});
|