/**
* @fileoverview Response-viewer turn segmentation (`CodemanApp._buildResponseViewerMessage`).
*
* The server now emits one message per model message instead of concatenating a
* human turn's replies into one card, so a long autonomous run arrives as tens
* of messages rather than one 12,000-character block. Rendered naively that is
* card spam — the measured distribution is p50 3 messages per turn, p90 11,
* max 51, with 58% of messages under 80 characters. So consecutive messages
* from one speaker inside one `turn` render as SEGMENTS of one card: no
* repeated role badge, a hairline seam.
*
* Pinned here because the badge suppression is the only thing standing between
* the server change and a wall of 51 "Claude" badges:
*
* 1. A continuation carries `rv-msg-cont` and has NO `.rv-role` child, while
* keeping its role class so the CSS accent survives (the colour rules match
* on both `:has(.rv-role-*)` and `.rv-msg-*` — only the class arm hits here).
* 2. A queued prompt is marked in the DOM, not in text, so the i18n
* MutationObserver cannot rewrite the marker.
* 3. The 4th argument is genuinely optional: the brief view's 3-argument call
* still renders a badge.
*
* Loaded via `vm` with a jsdom document injected (same technique as
* response-viewer-file-links.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 { describe, expect, it, vi } from 'vitest';
const dom = new JSDOM('
');
const { document, NodeFilter } = dom.window;
interface MessageBuilder {
_buildResponseViewerMessage(text: string, role: string, agentLabel: string, meta?: unknown): HTMLElement;
loadFullContext(): Promise;
activeSessionId?: string;
}
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);
// The context is returned too: app.js closes over the context's own `fetch`, so a
// test that drives loadFullContext has to replace THAT binding, not globalThis'.
return { CodemanApp: (context as { __CodemanApp: { prototype: MessageBuilder } }).__CodemanApp, context };
}
const { CodemanApp, context: appContext } = loadCodemanAppClass();
function build(text: string, role: string, meta?: unknown): HTMLElement {
const app = Object.create(CodemanApp.prototype) as MessageBuilder;
return app._buildResponseViewerMessage(text, role, 'Claude', meta);
}
describe('response viewer turn segmentation', () => {
it('renders a continuation without a repeated role badge but keeps its role class', () => {
const div = build('second half of the same turn', 'assistant', { continuation: true, kind: 'response', turn: 3 });
expect(div.classList.contains('rv-msg-cont')).toBe(true);
expect(div.classList.contains('rv-msg-assistant')).toBe(true);
expect(div.querySelector('.rv-role')).toBeNull();
expect(div.querySelector('.rv-text')).not.toBeNull();
expect(div.dataset.kind).toBe('response');
});
it('marks a prompt the user queued mid-turn in the DOM, not in the text', () => {
const div = build('actually use PowerShell', 'user', {
continuation: false,
kind: 'prompt',
queued: true,
turn: 2,
});
expect(div.dataset.queued).toBe('1');
expect(div.dataset.kind).toBe('prompt');
const badge = div.querySelector('.rv-role');
expect(badge).not.toBeNull();
expect(badge!.classList.contains('rv-role-user')).toBe(true);
// The marker is a CSS pseudo-element, so the badge text stays translatable.
expect(badge!.textContent).toBe('You');
});
it('still renders a badge for the brief view, which passes no meta', () => {
const div = build('the last response', 'assistant');
expect(div.classList.contains('rv-msg-cont')).toBe(false);
expect(div.querySelector('.rv-role')!.textContent).toBe('Claude');
expect(div.dataset.kind).toBeUndefined();
expect(div.dataset.queued).toBeUndefined();
});
});
/**
* The empty-state branch deliberately does NOT wipe the body — the brief view has
* a terminal-buffer fallback this endpoint does not — and deliberately leaves the
* More button live so a transcript that appears a moment later can still be
* loaded. Both together mean the notice must be idempotent: without that, every
* retry stacks another identical line. Upstream got this for free because it
* assigned `body.textContent`.
*/
describe('response viewer empty full-context state', () => {
it('reuses one notice across repeated More clicks and keeps the brief card', async () => {
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(body, title, more);
body.textContent = 'No response yet — send a message in this session first.';
const app = Object.create(CodemanApp.prototype) as MessageBuilder;
app.activeSessionId = 's1';
(appContext as { fetch: unknown }).fetch = vi.fn(async () => ({
json: async () => ({ data: { messages: [] } }),
}));
await app.loadFullContext();
await app.loadFullContext();
await app.loadFullContext();
expect(body.querySelectorAll('.rv-notice')).toHaveLength(1);
expect(body.textContent).toContain('No response yet');
// More stays clickable: it is the only retry path once a transcript lands.
expect(more.style.display).toBe('');
document.body.innerHTML = '';
});
});