/**
* @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('
');
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;
activeSessionId?: string;
sessions?: Map;
}
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 } {
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('opens a multi-row turn at its NEWEST text, and a single card at the top', async () => {
// `body.scrollTop = 0` was right when the brief view was one card holding
// the last row. With the whole turn rendered, the top of the scroller is
// the turn's FIRST narration line and the answer the eye button exists to
// show can be several screens below it; loadFullContext already scrolls to
// the bottom for the same turn, so the two views disagreed.
// jsdom does no layout, so scrollHeight is stubbed and the write recorded.
const spyScroll = (body: HTMLElement) => {
const writes: number[] = [];
Object.defineProperty(body, 'scrollHeight', { configurable: true, get: () => 4200 });
Object.defineProperty(body, 'scrollTop', {
configurable: true,
get: () => writes[writes.length - 1] ?? 0,
set: (v: number) => void writes.push(v),
});
return writes;
};
const many = mountViewer();
const manyWrites = spyScroll(many.body);
await makeApp({
text: 'Done.',
timestamp: 't',
messages: [
{ role: 'assistant', text: 'Looking at the file.', turn: 2 },
{ role: 'assistant', text: 'Done.', turn: 2 },
],
}).app.toggleResponseViewer();
expect(manyWrites.at(-1)).toBe(4200);
document.body.innerHTML = '';
const one = mountViewer();
const oneWrites = spyScroll(one.body);
await makeApp({
text: 'Done.',
timestamp: 't',
messages: [{ role: 'assistant', text: 'Done.', turn: 2 }],
}).app.toggleResponseViewer();
expect(oneWrites.at(-1)).toBe(0);
});
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');
});
});