feat(webui): CHAT-02 Console, read-only conversation view (#1507)

History opens a seat's conversation from the Waiting card, table row
and inspector. It pages the whole branch through the CHAT-02 board
routes, renders untrusted text inert, polls with the follow cursor, and
marks every switch (branch, newer, reconcile, gone). The WebUI proxy
passes only the two conversation routes' queries upstream.

Dewey authored it. Filbert asked for changes on r1 (24b046af) and
approved r2 (d06de6a7) in review 160dd68d. A relaunch shows 'newer',
not 'reconcile', a deviation from brief 2.3 item 6 that Filbert
accepted.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-09-26 18:05:11 -05:00
co-authored by Claude Opus 5.5
parent 3a209eeafe
commit c9e771cf59
34 changed files with 1669 additions and 26 deletions
+28 -5
View File
@@ -38,6 +38,19 @@ bytes. Do not expose either unauthenticated server through a public proxy.
with a warning. There are no automatic action retries.
- Palette and appearance use the existing brand tokens and persist in this
browser when local storage is available. No settings screen is added.
- History opens a read-only conversation view for a seat, from its Waiting
card, its table row or its inspector (#1507, CHAT-02). It shows the whole
branch with nothing clipped. Tool calls, tool results and thinking start
collapsed. Session text is always shown as text: Markdown stays as source,
and terminal controls, bidi controls and marks show as visible symbols. Reply in the view
uses the same board reply path as the inspector.
- The view checks for new entries on the same ten-second refresh, and Pause
stops it. It never switches on its own. A fork, a newer session for the seat
or a rewritten file each shows a marker with a button to open the other
history. Reloading after a rewrite keeps the branch the view was on; if that
branch is gone, the view opens the latest one and says so. Session lists
older sessions for the seat. Only repository Pi seats
have history; other harnesses say so.
Drafts and receipts stay in page memory, including across refresh, inspector
changes and a stale registration. Reloading or closing the page loses them.
@@ -47,11 +60,13 @@ delivery may be unknown: inspect the seat before sending again.
## Data and boundaries
GET `/api/board` and POST `/api/seen` and `/api/reply` proxy only the existing
board paths. POST bytes and upstream status/JSON are preserved. GET `/api/config`
returns the configured board URL for the page's error message. No scanner,
registration, session reader or transport is implemented here. No control-board,
seat, fleet, comms or root package files are changed.
GET `/api/board`, `/api/conversations` and `/api/conversation`, and POST
`/api/seen` and `/api/reply` proxy only those board paths. Only the two
conversation routes carry their query string; the board validates it. POST
bytes and upstream status/JSON are preserved. GET `/api/config` returns the
configured board URL for the page's error message. No scanner, registration,
session reader or transport is implemented here. The session reader is
`packages/conversation`, served by the board.
Console's shared CSS, Console CSS, brand.js and local Manrope fonts were copied
unchanged from `agents/dewey/work/wui/`. Font license and source URLs accompany
@@ -67,6 +82,7 @@ or mockup session hierarchy is presented as live data.
node --test packages/webui/tests/
node --test packages/control-board/tests/ packages/seat/tests/ packages/ledger/tests/ packages/mosaic/tests/
WEBUI_EVIDENCE=/tmp/webui-evidence node --test packages/webui/tests/browser.test.mjs
WEBUI_EVIDENCE=/tmp/webui-evidence node --test packages/webui/tests/conversation.test.mjs
```
Node's test runner and installed `/usr/bin/chromium` are required. Set `CHROMIUM`
@@ -83,6 +99,13 @@ on 330 rendered samples across ten palettes and three modes. Layouts are checked
at 320, 390, 768, 1440 and 2560px. Horizontal scrolling is intentional within the
dense table; the page itself must not overflow.
Conversation tests run the real board routes over temporary repository-layout
session files. They cover full-length answers, collapsed tools and thinking,
hostile content rendered inert, malformed-line and reconcile markers, forks, and
the return flow: a send from the view, a tool call and a delayed result while a
draft is typed, a peer message, a 4.5-million-character answer split into
continuation parts, and a relaunch mid-turn.
No root CI workflow is configured for this package. These local tests are not a
claim of CI, deployment, live-seat delivery or user acceptance.
+188 -13
View File
@@ -52,14 +52,15 @@
if (!['waiting', 'error'].includes(r.state) || !r.lastActivity) return '';
return `<button type="button" class="btn" data-seen="${esc(key(r))}" aria-label="${r.seen ? 'Unsee' : 'Mark seen'} ${esc(r.agent)}">${r.seen ? 'Unsee' : 'Seen'}</button>`;
}
const historyButton = r => `<button type="button" class="btn conv-open" data-history="${esc(key(r))}" aria-label="History for ${esc(r.agent)}">History</button>`;
const openButton = r => `<button type="button" class="session-open" data-open="${esc(key(r))}" aria-controls="inspector" aria-expanded="${selected === key(r)}">${esc(r.agent)}</button>`;
function cards(rows) {
if (!rows.length) return '<p class="muted">Nothing here.</p>';
return `<ul class="waiting">${rows.map(r => `<li class="wait-item"><div class="wait-head">${openButton(r)} ${badge(r.state)}${connectorStatus(r)}</div><p class="small muted">${esc(r.project)} · ${esc(r.activeProject || 'unknown')}</p><p>${esc(r.task || 'unknown')}${source(r.taskSource)}${setBy(r)}</p><p class="preview">${esc(relaunchNotice(r) || r.lastError || r.lastAssistantText || 'No assistant text yet.')}</p><div class="actions"><span class="small muted">${esc(relaunchNotice(r) || (activity(r) ? `Last activity ${activity(r)}` : 'No activity'))}</span>${seenButton(r)}</div></li>`).join('')}</ul>`;
return `<ul class="waiting">${rows.map(r => `<li class="wait-item"><div class="wait-head">${openButton(r)} ${badge(r.state)}${connectorStatus(r)}</div><p class="small muted">${esc(r.project)} · ${esc(r.activeProject || 'unknown')}</p><p>${esc(r.task || 'unknown')}${source(r.taskSource)}${setBy(r)}</p><p class="preview">${esc(relaunchNotice(r) || r.lastError || r.lastAssistantText || 'No assistant text yet.')}</p><div class="actions"><span class="small muted">${esc(relaunchNotice(r) || (activity(r) ? `Last activity ${activity(r)}` : 'No activity'))}</span><span>${historyButton(r)}${seenButton(r)}</span></div></li>`).join('')}</ul>`;
}
function table(rows) {
if (!rows.length) return '<p class="state">No sessions match these filters.</p>';
return `<div class="table-wrap" tabindex="0" role="region" aria-label="Session table, scroll horizontally for all columns"><table class="sessions"><thead><tr>${['Agent', 'State', 'Task', 'Active project', 'Workspace', 'Model', 'Registered', 'Last activity'].map(h => `<th scope="col">${h}</th>`).join('')}</tr></thead><tbody>${rows.map(r => `<tr data-row="${esc(key(r))}"><td>${openButton(r)}<span class="source">${esc(r.project)}</span></td><td>${badge(r.state)}${connectorStatus(r)}${r.seen ? '<span class="source">seen</span>' : ''}</td><td class="task">${esc(r.task || 'unknown')}${source(r.taskSource)}${setBy(r)}</td><td>${esc(r.activeProject || 'unknown')}${source(r.activeProjectSource)}</td><td>${esc(r.workspace || 'unknown')}${source(r.workspaceSource)}</td><td>${esc(r.model || 'unknown')}<span class="source">${esc(r.provider)}</span></td><td>${r.registered ? r.registered.alive === false ? 'stale' : 'registered' : 'no'}</td><td>${relaunchNotice(r) ? esc(relaunchNotice(r)) : activity(r) ? `${esc(activity(r))}${activity(r) === r.lastActivity ? '' : `<span class="source">${esc(r.lastActivity)}</span>`}` : 'unknown'}</td></tr>`).join('')}</tbody></table></div>`;
return `<div class="table-wrap" tabindex="0" role="region" aria-label="Session table, scroll horizontally for all columns"><table class="sessions"><thead><tr>${['Agent', 'State', 'Task', 'Active project', 'Workspace', 'Model', 'Registered', 'Last activity'].map(h => `<th scope="col">${h}</th>`).join('')}</tr></thead><tbody>${rows.map(r => `<tr data-row="${esc(key(r))}"><td>${openButton(r)}<span class="source">${esc(r.project)}</span>${historyButton(r)}</td><td>${badge(r.state)}${connectorStatus(r)}${r.seen ? '<span class="source">seen</span>' : ''}</td><td class="task">${esc(r.task || 'unknown')}${source(r.taskSource)}${setBy(r)}</td><td>${esc(r.activeProject || 'unknown')}${source(r.activeProjectSource)}</td><td>${esc(r.workspace || 'unknown')}${source(r.workspaceSource)}</td><td>${esc(r.model || 'unknown')}<span class="source">${esc(r.provider)}</span></td><td>${r.registered ? r.registered.alive === false ? 'stale' : 'registered' : 'no'}</td><td>${relaunchNotice(r) ? esc(relaunchNotice(r)) : activity(r) ? `${esc(activity(r))}${activity(r) === r.lastActivity ? '' : `<span class="source">${esc(r.lastActivity)}</span>`}` : 'unknown'}</td></tr>`).join('')}</tbody></table></div>`;
}
function registered(r) {
const reg = r.registered;
@@ -77,16 +78,183 @@
if (r.relaunchedAt) fields.unshift(['Current activity', relaunchNotice(r)]);
if (r.connector) fields.push(['Connector', `${r.connector.braked === true ? 'braked (STOP)' : r.connector.braked === false ? 'not braked' : 'brake unknown'}; owner ${r.connector.ownerState}`]);
const receipt = receipts.get(selected);
$('inspection').innerHTML = `<dl class="kv">${fields.map(([k, v]) => `<dt>${k}</dt><dd>${esc(v)}</dd>`).join('')}${pending(r) ? `<dt>Reply</dt><dd>Waiting for a reply to your message sent ${esc(awaiting.get(selected))}. The page checks every 10 seconds${paused ? ' once you resume' : ''}.</dd><dt>${r.relaunchedAt ? 'Historical assistant text' : 'Previous assistant text'}, before your message</dt>` : `<dt>${r.relaunchedAt ? 'Historical last assistant text' : 'Last assistant text'}</dt>`}<dd><pre class="last-text">${esc(r.lastAssistantText || 'No assistant text yet.')}</pre></dd>${r.lastError ? `<dt>${r.relaunchedAt ? 'Historical last error' : 'Last error'}</dt><dd>${esc(r.lastError)}</dd>` : ''}</dl>${seenButton(r)}${canReply(r) ? `<form class="reply-form"><label for="reply">Reply to ${esc(r.agent)}</label><textarea id="reply" maxlength="2000" rows="3" placeholder="Message this seat">${esc(drafts.get(selected) || '')}</textarea><button class="btn primary" type="submit" ${sending.has(selected) ? 'disabled' : ''}>${sending.has(selected) ? 'Sending…' : 'Send'}</button></form>` : `<p class="muted">${r.connector ? 'Board replies disabled for Discord connectors' : 'reply needs a registered seat'}</p>`}${receipt ? `<p class="receipt ${receipt.delivered ? '' : 'failed'}" role="status">${esc(receipt.delivered ? `delivered ${receipt.sentAt} to tmux ${receipt.session}` : `failed${receipt.exitCode == null ? '' : ' (exit ' + receipt.exitCode + ')'}: ${receipt.stderr || receipt.error || 'no output'}`)}</p>` : ''}`;
$('inspection').innerHTML = `<dl class="kv">${fields.map(([k, v]) => `<dt>${k}</dt><dd>${esc(v)}</dd>`).join('')}${pending(r) ? `<dt>Reply</dt><dd>Waiting for a reply to your message sent ${esc(awaiting.get(selected))}. The page checks every 10 seconds${paused ? ' once you resume' : ''}.</dd><dt>${r.relaunchedAt ? 'Historical assistant text' : 'Previous assistant text'}, before your message</dt>` : `<dt>${r.relaunchedAt ? 'Historical last assistant text' : 'Last assistant text'}</dt>`}<dd><pre class="last-text">${esc(r.lastAssistantText || 'No assistant text yet.')}</pre></dd>${r.lastError ? `<dt>${r.relaunchedAt ? 'Historical last error' : 'Last error'}</dt><dd>${esc(r.lastError)}</dd>` : ''}</dl>${historyButton(r)}${seenButton(r)}${canReply(r) ? `<form class="reply-form"><label for="reply">Reply to ${esc(r.agent)}</label><textarea id="reply" maxlength="2000" rows="3" placeholder="Message this seat">${esc(drafts.get(selected) || '')}</textarea><button class="btn primary" type="submit" ${sending.has(selected) ? 'disabled' : ''}>${sending.has(selected) ? 'Sending…' : 'Send'}</button></form>` : `<p class="muted">${r.connector ? 'Board replies disabled for Discord connectors' : 'reply needs a registered seat'}</p>`}${receipt ? `<p class="receipt ${receipt.delivered ? '' : 'failed'}" role="status">${esc(receipt.delivered ? `delivered ${receipt.sentAt} to tmux ${receipt.session}` : `failed${receipt.exitCode == null ? '' : ' (exit ' + receipt.exitCode + ')'}: ${receipt.stderr || receipt.error || 'no output'}`)}</p>` : ''}`;
}
// Conversation view (#1507, CHAT-02): the full read-only history from the board's
// /api/conversations and /api/conversation routes. Session content is set with
// textContent only, so it never creates an element, handler or navigation.
// Markdown shows as its source text. New entries arrive by polling a follow cursor.
let conv = null, convGen = 0;
const CONTROL = /[\u0000-\u0008\u000b-\u001f\u007f]/g, UNSEEN = /[\u0080-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g;
// C0 controls and DEL show as control pictures. C1 controls and bidi controls and marks have no picture
// and show as [U+XXXX]. Newlines and tabs stay.
const inert = v => String(v).replace(CONTROL, c => c === '\u007f' ? '␡' : String.fromCharCode(0x2400 + c.charCodeAt(0))).replace(UNSEEN, c => `[U+${c.charCodeAt(0).toString(16).toUpperCase().padStart(4, '0')}]`);
const node = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text !== undefined) e.textContent = inert(text); return e; };
const ROLES = { user: 'User', assistant: 'Assistant', tool: 'Tool', notice: 'Notice', compaction: 'Compaction' };
// The CHAT-01 field that holds each block type's string. Fragments of one block concatenate in order.
const FIELDS = { text: 'text', thinking: 'text', 'tool-call': 'argumentsText', 'tool-result': 'text', compaction: 'summary' };
const convURL = params => '/api/conversation?' + new URLSearchParams(params);
async function getJSON(path) {
const res = await fetch(path, { cache: 'no-store' });
let body; try { body = await res.json(); } catch { body = { error: `HTTP ${res.status}` }; }
return { ok: res.ok, body };
}
function blockNode(b) {
if (b.type === 'text') return node('div', 'conv-text', b.value);
if (b.type === 'attachment') return node('p', 'small muted', 'Attachment not shown.');
if (!FIELDS[b.type]) return node('p', 'small muted', `A ${b.type} block is not shown.`);
const summary = b.type === 'thinking' ? (b.visibility === 'unavailable' ? 'Thinking (not available)' : 'Thinking')
: b.type === 'tool-call' ? `Tool call: ${b.name}` : b.type === 'tool-result' ? (b.isError ? 'Tool result (error)' : 'Tool result') : 'Earlier context was compacted. Summary';
const d = node('details', `conv-${b.type}`); d.append(node('summary', '', summary), node('pre', 'conv-pre', b.value));
return d;
}
function drawMessage(m) {
const li = node('li', `turn conv-turn conv-${m.role}`), time = node('time', '', m.createdAt);
time.dateTime = m.createdAt; li.append(node('span', 'who', ROLES[m.role] || m.role), time);
const open = m.el ? [...m.el.querySelectorAll('details')].map(d => d.open) : [];
let i = 0;
for (const b of m.blocks) if (b) { const n = blockNode(b); if (n.tagName === 'DETAILS') n.open = open[i++] ?? false; li.append(n); }
if (m.el) m.el.replaceWith(li); else $('conv-log').append(li);
m.el = li;
}
function addEntries(entries) {
const touched = new Set();
for (const e of entries) {
let m = conv.messages.get(e.message);
if (!m) { m = { role: e.role, createdAt: e.createdAt, blocks: [], el: null }; conv.messages.set(e.message, m); }
for (const c of e.content) { const b = m.blocks[c.block] ??= { ...c, value: '' }; if (FIELDS[c.type]) b.value += c[FIELDS[c.type]]; }
touched.add(m);
}
for (const m of touched) drawMessage(m);
return touched.size;
}
function drawMarkers() {
const box = $('conv-markers'); box.replaceChildren();
for (const [kind, m] of conv.markers) {
const d = node('div', 'notice notice-attn conv-marker'); d.dataset.marker = kind; d.append(node('p', '', m.text));
if (m.action) { const b = node('button', 'btn', m.action.label); b.type = 'button'; b.dataset.convAction = m.action.id; d.append(b); }
box.append(d);
}
}
function marker(kind, text, action) { if (conv.markers.get(kind)?.text === text) return; conv.markers.set(kind, { text, action }); drawMarkers(); announce(text); }
function unmark(kind) { if (conv.markers.delete(kind)) drawMarkers(); }
function convStatus(text) {
$('conv-status').textContent = text ?? `${conv.messages.size} messages · ${!conv.follow ? 'not checking for new entries' : paused ? 'checking for new entries paused' : 'checks for new entries every 10 seconds'}`;
}
function apply(body) {
conv.branch = body.page.branch;
const added = addEntries(body.page.entries);
const v = body.view;
if (v.incomplete) marker('incomplete', 'The last line of this session is still being written. It shows here once it is complete.'); else unmark('incomplete');
if (v.defaultBranch !== v.branch) marker('branch', 'This conversation continued on another branch. This view stays on the branch it opened.', { id: 'latest', label: 'Open the latest branch' });
return added;
}
// A refusal with reconcile keeps everything already shown and stops polling. Nothing switches silently.
function refused(res) {
const code = res.body.refusal?.code;
conv.follow = null;
if (res.body.refusal?.reconcile) marker('reconcile', `This view is out of date (${code}): ${res.body.error}. The history below is what was loaded before.`, { id: 'reload', label: 'Reload conversation' }), convStatus();
else convStatus(`History unavailable: ${res.body.error || 'unknown error'}${code ? ` (${code})` : ''}.`);
}
const nearEnd = () => $('conv-log').getBoundingClientRect().bottom <= innerHeight + 300;
// Reads pages from `res` until the snapshot ends. Returns false when the view changed or a page was refused.
async function readPages(c, gen, res, onPage) {
for (;;) {
if (gen !== convGen) return false;
if (!res.ok) { refused(res); return false; }
onPage(apply(res.body));
if (!res.body.cursor) { c.follow = res.body.follow?.id ?? null; return true; }
res = await getJSON(convURL({ id: c.id, branch: c.branch, cursor: res.body.cursor.id }));
}
}
function showConversation(on) {
$('conversation').hidden = !on; $('board-view').hidden = on;
document.body.classList.toggle('has-conversation', on);
}
function convForm() {
if (!conv) return;
const r = row(conv.row), can = !!r && !!canReply(r), receipt = receipts.get(conv.row);
$('conv-form').hidden = !can; $('conv-no-reply').hidden = can;
$('conv-no-reply').textContent = !r ? 'This session is no longer in the board scan.' : r.connector ? 'Board replies disabled for Discord connectors' : 'reply needs a registered seat';
if (r) $('conv-reply-label').textContent = `Reply to ${r.agent}`;
$('conv-send').disabled = sending.has(conv.row); $('conv-send').textContent = sending.has(conv.row) ? 'Sending…' : 'Send';
$('conv-receipt').hidden = !receipt;
if (receipt) { $('conv-receipt').className = `conv-receipt ${receipt.delivered ? '' : 'failed'}`; $('conv-receipt').textContent = receipt.delivered ? `delivered ${receipt.sentAt} to tmux ${receipt.session}` : `failed${receipt.exitCode == null ? '' : ' (exit ' + receipt.exitCode + ')'}: ${receipt.stderr || receipt.error || 'no output'}`; }
// The board's newest file for this seat changed after the view opened: a relaunch. The view keeps its file.
if (conv.sessionId && r?.sessionId && r.sessionId !== conv.sessionId) marker('newer', conv.newerUnread ? 'A newer session started for this seat, but its history is not readable yet. This view stays on the session you opened.' : 'A newer session started for this seat. This view stays on the session you opened.', { id: 'newest', label: 'Open the newest session' });
}
// opts.branch reopens that branch (Reload). opts.from and opts.sessionId come from "Open the newest session".
async function openConversation(rowKey, pick = null, returnTo = conv?.returnFocus, opts = {}) {
const gen = ++convGen, r = row(rowKey), sameRow = conv?.row === rowKey;
conv = { row: rowKey, id: null, branch: null, follow: null, messages: new Map(), markers: new Map(), sessionId: null, loading: true, polling: false, returnFocus: returnTo };
selected = null; render(); showConversation(true);
$('conv-title').textContent = r ? `${r.agent} conversation` : 'Conversation';
$('conv-meta').textContent = ''; $('conv-log').replaceChildren(); drawMarkers(); convForm();
if (!sameRow) $('conv-reply').value = drafts.get(rowKey) || '';
convStatus('Loading history…');
const slash = rowKey.lastIndexOf('/'), project = r?.project ?? rowKey.slice(0, slash), agent = r?.agent ?? rowKey.slice(slash + 1);
const c = conv;
try {
const cat = await getJSON('/api/conversations');
if (gen !== convGen) return;
if (!cat.ok) { convStatus(`History unavailable: ${cat.body.error || 'the board refused the catalogue'}.`); return; }
const mine = cat.body.conversations.filter(x => x.project === project && x.seat === agent);
const readable = mine.filter(x => x.availability === 'available');
const choice = mine.find(x => x.conversation === pick) ?? readable[0] ?? mine[0];
$('conv-pick').replaceChildren(...mine.map(x => { const o = node('option', '', `${x.title || 'Untitled'} · ${x.lastActivityAt || x.conversationCreatedAt || 'no activity'}${x.availability === 'available' ? '' : ` (${x.unsupportedReason || x.refusal?.code || x.availability})`}`); o.value = x.conversation; return o; }));
$('conv-pick').disabled = mine.length < 2;
if (!choice) { convStatus('No readable history for this seat. Only repository Pi seats have history here.'); return; }
$('conv-pick').value = c.id = choice.conversation;
$('conv-meta').textContent = `${project} · ${choice.title || 'Untitled'} · started ${choice.conversationCreatedAt || 'unknown'}`;
if (choice.availability !== 'available') { convStatus(choice.unsupportedReason === 'unsupported-harness' ? `History for the ${choice.harness} harness is not available yet.` : `History unavailable (${choice.refusal?.code || choice.availability}).`); return; }
// Relaunch detection only makes sense for the newest session, the one the board row shows. If "newest"
// lands on the file already open, the board's newer file is not readable yet: keep the marker.
c.newerUnread = !!opts.from && opts.from === choice.conversation;
if (choice === readable[0]) c.sessionId = c.newerUnread ? opts.sessionId : r?.sessionId ?? null;
let first = await getJSON(convURL(opts.branch ? { id: c.id, branch: opts.branch } : { id: c.id }));
// The branch this view was on is gone: open the default branch and say so.
if (!first.ok && opts.branch && first.body.refusal?.code === 'unknown-branch') {
first = await getJSON(convURL({ id: c.id }));
if (gen !== convGen) return;
if (first.ok) marker('gone', 'The branch this view was on is no longer in the session. This view shows the latest branch.');
}
const done = await readPages(c, gen, first, () => convStatus(`Loading history… ${c.messages.size} messages`));
if (gen !== convGen) return;
if (done) convStatus();
$('conv-log').lastElementChild?.scrollIntoView({ block: 'end' });
convForm();
} catch (err) { if (gen === convGen) convStatus(`History unavailable: ${err.message}.`); }
finally { c.loading = false; }
}
async function pollConversation() {
const c = conv, gen = convGen;
if (!c || c.loading || c.polling || !c.follow) return;
c.polling = true;
try {
const atEnd = nearEnd();
let added = 0;
const done = await readPages(c, gen, await getJSON(convURL({ id: c.id, branch: c.branch, cursor: c.follow })), n => { added += n; });
if (gen !== convGen) return;
if (done) convStatus();
if (added && atEnd) $('conv-log').lastElementChild?.scrollIntoView({ block: 'end' });
} catch (err) { if (gen === convGen) convStatus(`Could not check for new entries: ${err.message}. The view keeps what it has; the next check tries again.`); }
finally { c.polling = false; }
}
function closeConversation() {
const back = conv?.returnFocus; convGen++; conv = null; showConversation(false); render();
if (back) restore(back);
if (document.activeElement === document.body) $('main').focus();
}
$('conv-back').onclick = closeConversation;
$('conv-pick').onchange = e => { if (conv) openConversation(conv.row, e.target.value); };
// Restore by data attribute equality, never interpolate API ids into selectors.
function focusSnapshot() {
const a = document.activeElement;
return { id: a?.id, open: a?.dataset.open, seen: a?.dataset.seen, project: a?.dataset.project, container: a?.closest('[id]')?.id, start: a?.selectionStart, end: a?.selectionEnd };
return { id: a?.id, open: a?.dataset.open, seen: a?.dataset.seen, history: a?.dataset.history, project: a?.dataset.project, container: a?.closest('[id]')?.id, start: a?.selectionStart, end: a?.selectionEnd };
}
function restore(f) {
let el = f.id ? $(f.id) : null;
for (const attr of ['open', 'seen', 'project']) if (f[attr] !== undefined) {
for (const attr of ['open', 'seen', 'history', 'project']) if (f[attr] !== undefined) {
el = [...document.querySelectorAll(`[data-${attr}]`)].find(e => e.dataset[attr] === f[attr] && e.closest('[id]')?.id === f.container);
}
if (el && !el.closest('[hidden]')) { el.focus({ preventScroll: true }); if (f.start != null) el.setSelectionRange(f.start, f.end); }
@@ -107,7 +275,7 @@
$('sessions').innerHTML = table(visible);
$('footer').textContent = Object.entries(data.counts || {}).map(([s, n]) => `${s} ${n}`).join(' · ') || 'No sessions';
$('status').textContent = `Scanned ${data.generatedAt || 'unknown'} · ${paused ? 'auto-refresh paused' : 'refresh every 10s'}`;
inspect(); restore(focus);
inspect(); convForm(); if (conv) convStatus(); restore(focus);
}
async function api(path, value) {
const res = await fetch(path, value === undefined ? { cache: 'no-store' } : { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(value) });
@@ -126,7 +294,7 @@
async function refresh() {
if (busy) return;
busy = true; $('refresh').disabled = true;
try { accept(await api('/api/board')); } catch (err) { error(err); }
try { try { accept(await api('/api/board')); } catch (err) { error(err); } await pollConversation(); }
finally { busy = false; $('refresh').disabled = false; schedule(); }
}
$('refresh').onclick = refresh;
@@ -135,10 +303,15 @@
function close() { selected = null; render(); if (returnFocus) restore(returnFocus); }
$('close').onclick = close;
document.addEventListener('click', async e => {
const history = e.target.closest('[data-history]');
if (history) { openConversation(history.dataset.history, null, { history: history.dataset.history, container: history.closest('[id]')?.id }); $('conv-title').focus(); return; }
const action = e.target.closest('[data-conv-action]');
// Reload keeps the open branch; "Open the latest branch" takes the default; "Open the newest session" takes the newest file.
if (action && conv) { const c = conv, a = action.dataset.convAction; openConversation(c.row, a === 'newest' ? null : c.id, undefined, a === 'reload' ? { branch: c.branch } : a === 'newest' ? { from: c.id, sessionId: c.sessionId } : {}); $('conv-title').focus(); return; }
const open = e.target.closest('[data-open]');
if (open) { returnFocus = focusSnapshot(); selected = open.dataset.open; render(); $('inspector-title').focus(); announce(`Inspecting ${row(selected)?.agent}`); return; }
if (open) { if (conv) closeConversation(); returnFocus = focusSnapshot(); selected = open.dataset.open; render(); $('inspector-title').focus(); announce(`Inspecting ${row(selected)?.agent}`); return; }
const projectButton = e.target.closest('[data-project]');
if (projectButton) { project = projectButton.dataset.project || null; render(); return; }
if (projectButton) { if (conv) closeConversation(); project = projectButton.dataset.project || null; render(); return; }
const seen = e.target.closest('[data-seen]');
if (!seen || busy) return;
const r = row(seen.dataset.seen); if (!r) return;
@@ -147,18 +320,20 @@
catch (err) { error(err); seen.disabled = false; }
finally { busy = false; schedule(); }
});
document.addEventListener('input', e => { if (e.target.id === 'reply') drafts.set(selected, e.target.value); });
document.addEventListener('input', e => { if (e.target.id === 'reply') drafts.set(selected, e.target.value); if (e.target.id === 'conv-reply' && conv) drafts.set(conv.row, e.target.value); });
document.addEventListener('submit', async e => {
if (!e.target.matches('.reply-form')) return;
if (!e.target.matches('.reply-form, #conv-form')) return;
e.preventDefault();
const id = selected, text = $('reply').value;
// The inspector form is rebuilt from drafts on render; the conversation form is not rebuilt, so its box is cleared here.
const inView = e.target.id === 'conv-form', id = inView ? conv?.row : selected, box = inView ? $('conv-reply') : $('reply'), text = box.value;
if (!id) return;
if (!text.trim() || sending.has(id)) return;
drafts.set(id, text); sending.add(id); render();
try {
const result = await api('/api/reply', { agent: id, text }); receipts.set(id, result);
if (result.delivered) awaiting.set(id, result.sentAt);
// Do not discard text typed while the request was in flight.
if (result.delivered && drafts.get(id) === text) drafts.delete(id);
if (result.delivered && drafts.get(id) === text) { drafts.delete(id); if (inView && box.value === text) box.value = ''; }
if (result.delivered && !paused) await refresh();
} catch (err) { receipts.set(id, { error: err.message + ' Delivery may be unknown; check the seat before sending again.' }); }
finally { sending.delete(id); render(); announce(receipts.get(id)?.delivered ? 'Reply delivered' : 'Reply failed; draft kept'); }
+5 -2
View File
@@ -11,9 +11,12 @@
<nav class="tree" aria-label="Projects"><h2 class="tree-title">Projects</h2><div id="projects"><p class="muted">Loading projects…</p></div><p class="small muted">Select a project to filter sessions. Waiting on you always shows all projects.</p></nav>
<main id="main" class="content" tabindex="-1"><div class="page-head"><div><h1>Control board</h1><p id="status" class="small muted" role="status">Loading board…</p></div></div>
<div id="error" class="state state-error" role="alert" hidden></div>
<section class="console-waiting" aria-labelledby="waiting-title"><h2 id="waiting-title">Waiting on you <span id="waiting-count" class="count">0</span></h2><div id="waiting"><p class="muted">Loading sessions…</p></div></section>
<section id="conversation" class="conversation" aria-labelledby="conv-title" hidden><div class="page-head"><div><h2 id="conv-title" tabindex="-1">Conversation</h2><p id="conv-meta" class="small muted"></p></div><div class="conv-actions"><label>Session<select id="conv-pick"></select></label><button class="btn" id="conv-back" type="button">Back to board</button></div></div>
<div id="conv-markers"></div><p id="conv-status" class="small muted" role="status"></p><ol id="conv-log" class="turns conv-log" aria-label="Conversation history"></ol>
<form id="conv-form" class="conv-form" hidden><label for="conv-reply" id="conv-reply-label">Reply</label><textarea id="conv-reply" maxlength="2000" rows="3" placeholder="Message this seat"></textarea><button class="btn primary" type="submit" id="conv-send">Send</button><p id="conv-receipt" class="conv-receipt" role="status" hidden></p></form><p id="conv-no-reply" class="muted" hidden></p></section>
<div id="board-view"><section class="console-waiting" aria-labelledby="waiting-title"><h2 id="waiting-title">Waiting on you <span id="waiting-count" class="count">0</span></h2><div id="waiting"><p class="muted">Loading sessions…</p></div></section>
<details id="seen-section"><summary>Seen <span id="seen-count" class="count">0</span></summary><div id="seen"></div></details>
<section aria-labelledby="sessions-title"><div class="page-head"><h2 id="sessions-title">All sessions <span id="session-count" class="count">0</span></h2><div class="filters"><label><input id="hide-offline" type="checkbox" checked>Hide offline</label><label><input id="hide-seen" type="checkbox" checked>Hide seen</label></div></div><p class="small muted">Select an agent to inspect. Arrow keys move between agents; Enter opens the inspector. Scroll the table for all columns.</p><div id="sessions"></div></section>
<section aria-labelledby="sessions-title"><div class="page-head"><h2 id="sessions-title">All sessions <span id="session-count" class="count">0</span></h2><div class="filters"><label><input id="hide-offline" type="checkbox" checked>Hide offline</label><label><input id="hide-seen" type="checkbox" checked>Hide seen</label></div></div><p class="small muted">Select an agent to inspect. Arrow keys move between agents; Enter opens the inspector. Scroll the table for all columns.</p><div id="sessions"></div></section></div>
</main>
<aside class="inspector" id="inspector" aria-labelledby="inspector-title" hidden><div class="inspector-head"><h2 id="inspector-title" tabindex="-1">Inspector</h2><button type="button" class="btn" id="close">Close</button></div><div id="inspection"></div></aside>
</div><footer class="foot"><span id="footer">Mosaic Stack</span><span id="board-url"></span></footer><div id="announce" class="sr-only" aria-live="polite"></div>
+6 -1
View File
@@ -6,6 +6,11 @@
section{margin-bottom:20px}#seen-section{margin-bottom:20px}summary{cursor:pointer}#seen{margin-top:10px}
.table-wrap{background:var(--surface);border:1px solid var(--line);border-radius:var(--r)}table.sessions{min-width:1050px}table.sessions td{max-width:270px;overflow-wrap:anywhere}table.sessions .task{min-width:190px}.source{display:block;color:var(--muted);font-size:.75rem}.session-open{font:inherit;font-weight:600;background:none;border:0;color:var(--action);padding:3px;cursor:pointer;text-align:left;overflow-wrap:anywhere}
.waiting{grid-template-columns:repeat(auto-fit,minmax(min(100%,260px),1fr))}.wait-item .session-open{font-size:1rem}.wait-item p{margin-top:6px}.wait-item .actions{justify-content:space-between}.wait-item .preview{white-space:pre-wrap;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}
.inspector .kv{grid-template-columns:1fr}.inspector .kv dd{margin-bottom:10px}.last-text,.receipt{white-space:pre-wrap;overflow-wrap:anywhere}.last-text{font:inherit;font-size:.9rem;margin:0}.receipt{font-size:.85rem;margin-top:8px}.receipt.failed{color:var(--danger)}.reply-form{display:grid;gap:8px;margin-top:14px}.reply-form .btn{justify-self:start}.inspector-head{flex-wrap:wrap}#inspection{min-width:0}.badge{white-space:nowrap}.foot>*{min-width:0;overflow-wrap:anywhere}
.inspector .kv{grid-template-columns:1fr}.inspector .kv dd{margin-bottom:10px}.last-text,.receipt,.conv-receipt{white-space:pre-wrap;overflow-wrap:anywhere}.last-text{font:inherit;font-size:.9rem;margin:0}.receipt,.conv-receipt{font-size:.85rem;margin-top:8px}.receipt.failed,.conv-receipt.failed{color:var(--danger)}.reply-form,.conv-form{display:grid;gap:8px;margin-top:14px}.reply-form .btn,.conv-form .btn{justify-self:start}.inspector-head{flex-wrap:wrap}#inspection{min-width:0}.badge{white-space:nowrap}.foot>*{min-width:0;overflow-wrap:anywhere}
@media(max-width:959px){.tree #projects{flex-basis:100%;width:100%}.tree-list{display:flex;flex-wrap:wrap}.tree-list li{max-width:100%}.tree .small{flex-basis:100%;margin:0}.inspector{max-height:75vh}.cmdbar{position:static}}
@media(max-width:479px){.cmd-right{margin-left:0;gap:6px}.cmd-right label{flex:1 1 110px}.cmd-right select{max-width:100%;width:100%}.kv{grid-template-columns:1fr}.content{padding:12px}.tree{padding:12px}.page-head>h2{flex-basis:100%}}
/* Conversation view (#1507, CHAT-02). Text wraps; nothing in it scrolls the page sideways. */
.conversation{min-width:0}.conversation .page-head{flex-wrap:wrap;gap:10px}.conversation .page-head>div:first-child{min-width:0;overflow-wrap:anywhere}.conv-actions{display:flex;gap:8px;align-items:end;flex-wrap:wrap;max-width:100%}.conv-actions label{display:grid;gap:2px;font-size:.75rem;min-width:0;max-width:100%}.conv-actions select{max-width:min(100%,360px);min-height:36px}
.conv-log .turn{max-width:100%;min-width:0}.conv-turn time{display:block;font-size:.72rem;color:var(--muted);margin-bottom:4px}.conv-user{border-left:3px solid var(--action)}.conv-notice,.conv-compaction{background:none;border:1px dashed var(--border)}
.conv-text,.conv-pre{white-space:pre-wrap;overflow-wrap:anywhere;margin:0}.conv-pre{font-family:var(--mono);font-size:.82rem;max-height:60vh;overflow:auto;margin-top:6px}.conv-turn details{margin-top:6px}.conv-turn summary{font-size:.85rem;color:var(--muted);cursor:pointer}
.conv-marker{display:flex;gap:10px;align-items:center;flex-wrap:wrap}.conv-marker p{margin:0;flex:1 1 220px;overflow-wrap:anywhere}.conv-open{margin-left:6px;padding:2px 8px;min-height:0;font-size:.8rem}#conv-form{max-width:72ch}
+6 -4
View File
@@ -49,15 +49,17 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
res.setHeader('x-content-type-options', 'nosniff');
res.setHeader('referrer-policy', 'no-referrer');
res.setHeader('content-security-policy', "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; font-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'");
let path;
let path, search;
try {
const authority = new URL(`http://${req.headers.host}`);
if (!isLoopback(authority.hostname.replace(/^\[|\]$/g, '')) || Number(authority.port || 80) !== server.address().port) return json(res, 403, { error: 'non-local Host refused' });
path = new URL(req.url, 'http://localhost').pathname;
({ pathname: path, search } = new URL(req.url, 'http://localhost'));
} catch { return json(res, 400, { error: 'invalid URL' }); }
// No CORS. JSON content type and same-origin checks keep browser forms out.
if (req.headers.origin && req.headers.origin !== `http://${req.headers.host}`) return json(res, 403, { error: 'cross-origin request refused' });
const allowed = path === '/api/board' ? 'GET' : ['/api/seen', '/api/reply'].includes(path) ? 'POST' : null;
// The conversation routes (#1507, CHAT-02) keep their query; the board validates it.
const conversation = path === '/api/conversations' || path === '/api/conversation';
const allowed = path === '/api/board' || conversation ? 'GET' : ['/api/seen', '/api/reply'].includes(path) ? 'POST' : null;
if (allowed) {
if (req.method !== allowed) return json(res, 405, { error: 'method not allowed' });
let bytes;
@@ -65,7 +67,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
try { bytes = await body(req); } catch (err) { return json(res, 400, { error: err.message }); }
}
try {
const response = await fetch(upstream + path, {
const response = await fetch(upstream + path + (conversation ? search : ''), {
method: allowed, headers: bytes ? { 'content-type': 'application/json' } : {}, body: bytes,
redirect: 'error', signal: AbortSignal.timeout(timeout),
});
+195
View File
@@ -0,0 +1,195 @@
// Conversation view (#1507, CHAT-02 brief §2.2): full branch history through
// the real board routes and the real WebUI. The hostile-render fixture (R1)
// must stay inert text: no element, handler or navigation from session content.
// WEBUI_EVIDENCE=<dir> also saves full-page screenshots at 320 and 1440 in the
// light and dark modes.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { browser } from './browser.mjs';
import { historyFixture, session, user, assistant, toolResult, at, waiter, turns } from './history-fixture.mjs';
const HOSTILE = '<script>window.injected=1</script><img src=x onerror="window.injected=2"> [click](javascript:window.injected=3) <a href="javascript:window.injected=4">link</a> \u001b[31mRED\u001b[0m \u001b]8;;http://example.invalid\u0007osc\u001b]8;;\u0007 \u009b31mCSI \u200emark \u202eevil';
const LONG = 'This answer is longer than the board summary. '.repeat(40) + 'LONG_END';
test('conversation view: full history, collapsed tools, hidden thinking, inert hostile content, malformed and reconcile markers', { timeout: 120000 }, async () => {
const f = await historyFixture();
let b;
try {
const file = join(f.sessionsDir, '2026-09-26T10-00-00_s1.jsonl');
const log = session(file, f.projectRoot);
log.add(user('Show me the file'), at(-300));
log.add(assistant([{ type: 'thinking', thinking: 'SECRET_THOUGHT' }, { type: 'text', text: 'Reading it.' }, { type: 'toolCall', id: 'call_1|fc_2', name: 'read', arguments: { path: 'README.md' } }], 'toolUse'), at(-290));
log.add(toolResult('call_1|fc_2', HOSTILE), at(-280));
log.raw('{"type":"message","id":');
log.add(assistant('Here: ' + HOSTILE), at(-270));
log.add(user('And the summary?'), at(-260));
log.add(assistant(LONG), at(-250));
b = await browser(); await b.viewport(1440, 1000);
const wait = waiter(b);
await b.navigate(f.base); await wait('document.querySelector("table.sessions [data-history]")');
const href = await b.evaluate('location.href');
await b.evaluate('document.querySelector("table.sessions [data-history]").click()');
await wait('document.querySelector("#conv-status").textContent.startsWith("7 messages")');
assert.equal(await b.evaluate('document.querySelector("#board-view").hidden'), true);
assert.equal(await b.evaluate('document.activeElement.id'), 'conv-title');
assert.match(await b.evaluate('document.querySelector("#conv-meta").textContent'), /^repo · Show me the file · started /);
assert.deepEqual((await turns(b)).map(t => t[0]), ['User', 'Assistant', 'Tool', 'Notice', 'Assistant', 'User', 'Assistant']);
assert.match(await b.evaluate('document.querySelectorAll("#conv-log > li")[3].textContent'), /Line 5 could not be read/);
// The long answer in full: the board's 240-character summary is not used.
assert.equal(await b.evaluate('[...document.querySelectorAll("#conv-log .conv-text")].at(-1).textContent'), LONG);
// Thinking and tools collapsed; their contents are not rendered visible.
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#conv-log details")].map(d => [d.className, d.querySelector("summary").textContent, d.open, d.querySelector("pre").checkVisibility()])'), [
['conv-thinking', 'Thinking', false, false],
['conv-tool-call', 'Tool call: read', false, false],
['conv-tool-result', 'Tool result', false, false],
]);
assert.equal(await b.evaluate('document.querySelector(".conv-tool-call pre").textContent'), '{"path":"README.md"}');
// Hostile content: shown as text, controls made visible, nothing active created.
await b.evaluate('document.querySelectorAll("#conv-log details").forEach(d => d.open = true)');
const shown = await b.evaluate('[...document.querySelectorAll("#conv-log .conv-text")].find(e => e.textContent.startsWith("Here: ")).textContent');
assert.ok(shown.startsWith('Here: <script>window.injected=1</script><img src=x onerror="window.injected=2"> [click](javascript:window.injected=3) <a href="javascript:window.injected=4">link</a> ␛[31mRED'), shown);
assert.ok(shown.includes('␛]8;;http://example.invalid␇osc') && shown.endsWith('[U+009B]31mCSI [U+200E]mark [U+202E]evil'), shown);
assert.equal(await b.evaluate('document.querySelector(".conv-tool-result pre").textContent'), shown.slice(6));
assert.equal(await b.evaluate('document.querySelectorAll("#conversation script, #conversation img, #conversation a, #conversation iframe, #conversation object, #conversation embed, #conversation svg, #conversation style, #conversation link").length'), 0);
assert.equal(await b.evaluate('[...document.querySelectorAll("*")].some(e => [...e.attributes].some(a => a.name.startsWith("on")))'), false);
assert.equal(await b.evaluate('/[\\u001b\\u009b\\u200e\\u202e]/.test(document.body.textContent)'), false);
await b.evaluate('[...document.querySelectorAll("#conv-log .conv-text")].find(e => e.textContent.startsWith("Here: ")).click()');
assert.equal(await b.evaluate('typeof window.injected'), 'undefined');
assert.equal(await b.evaluate('location.href'), href);
// A same-inode rewrite of the history: the next check refuses, the view keeps what it showed.
writeFileSync(file, readFileSync(file, 'utf8').replace('Show me the file', 'Show me the FILE'));
await b.evaluate('document.querySelector("#refresh").click()');
await wait('document.querySelector("[data-marker=reconcile]")');
assert.match(await b.evaluate('document.querySelector("[data-marker=reconcile]").textContent'), /out of date \(source-replaced\).*loaded before\.Reload conversation$/);
assert.equal(await b.evaluate('document.querySelectorAll("#conv-log > li").length'), 7);
assert.match(await b.evaluate('document.querySelector("#conv-log").textContent'), /Show me the file/);
assert.match(await b.evaluate('document.querySelector("#conv-status").textContent'), /not checking for new entries/);
// Evidence shows thinking as a reader first sees it (closed), from the top of the page.
await b.evaluate('document.querySelector(".conv-thinking").open = false; scrollTo(0, 0)');
for (const width of [320, 1440]) for (const mode of ['light', 'dark']) {
await b.viewport(width, 1000);
await b.evaluate(`(() => { const m = document.querySelector("#mode"); m.value = ${JSON.stringify(mode)}; m.dispatchEvent(new Event("change")); })()`);
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= innerWidth'), true, `no horizontal overflow at ${width} ${mode}`);
if (process.env.WEBUI_EVIDENCE) {
const { data } = await b.call('Page.captureScreenshot', { format: 'png', captureBeyondViewport: true });
writeFileSync(join(process.env.WEBUI_EVIDENCE, `conversation-${width}-${mode}.png`), Buffer.from(data, 'base64'));
}
}
// Reload takes a fresh snapshot of the rewritten file; Back returns focus to the History button.
await b.evaluate('document.querySelector("[data-conv-action=reload]").click()');
await wait('document.querySelector("#conv-log").textContent.includes("Show me the FILE")');
assert.equal(await b.evaluate('document.querySelector("[data-marker=reconcile]")'), null);
await b.evaluate('document.querySelector("#conv-back").click()');
assert.equal(await b.evaluate('document.querySelector("#conversation").hidden'), true);
assert.equal(await b.evaluate('document.activeElement.dataset.history'), 'repo/fixture');
// The inspector opens the same view.
await b.evaluate('document.querySelector("table.sessions [data-open]").click()');
await wait('document.querySelector("#inspection [data-history]")');
await b.evaluate('document.querySelector("#inspection [data-history]").click()');
await wait('!document.querySelector("#conversation").hidden && document.querySelector("#conv-status").textContent.startsWith("7 messages")');
assert.match(await b.evaluate('document.querySelector("#conv-log").textContent'), /Show me the FILE/);
} finally { if (b) await b.close(); await f.close(); }
});
test('conversation view: a fork keeps the open branch, says so, and opens the new one on request', { timeout: 60000 }, async () => {
const f = await historyFixture();
let b;
try {
const file = join(f.sessionsDir, '2026-09-26T10-00-00_s1.jsonl');
const log = session(file, f.projectRoot);
const first = log.add(user('Question'), at(-120));
log.add(assistant('MAIN_ANSWER'), at(-110));
b = await browser(); await b.viewport(1440, 1000);
const wait = waiter(b);
await b.navigate(f.base); await wait('document.querySelector("table.sessions [data-history]")');
await b.evaluate('document.querySelector("table.sessions [data-history]").click()');
await wait('document.querySelector("#conv-status").textContent.startsWith("2 messages")');
// Pi forks from the first entry; the fork is now its default leaf, on branch b.fork-1.
log.raw(JSON.stringify({ type: 'message', id: 'fork-1', parentId: first, timestamp: at(0), message: assistant('FORK_ANSWER') }));
await b.evaluate('document.querySelector("#refresh").click()');
await wait('document.querySelector("[data-marker=branch]")');
assert.match(await b.evaluate('document.querySelector("[data-marker=branch]").textContent'), /continued on another branch\. This view stays on the branch it opened\.Open the latest branch$/);
const text = await b.evaluate('document.querySelector("#conv-log").textContent');
assert.ok(text.includes('MAIN_ANSWER') && !text.includes('FORK_ANSWER'), 'no silent switch');
// The open branch grows; the next poll still follows it, not the default branch.
log.add(user('MAIN_MORE'), at(0));
await b.evaluate('document.querySelector("#refresh").click()');
await wait('document.querySelector("#conv-log").textContent.includes("MAIN_MORE") || !!document.querySelector("[data-marker=reconcile]") || document.querySelector("#conv-status").textContent.includes("unavailable")');
assert.deepEqual(await turns(b), [['User', 'Question'], ['Assistant', 'MAIN_ANSWER'], ['User', 'MAIN_MORE']]);
assert.equal(await b.evaluate('document.querySelector("[data-marker=reconcile]")'), null);
// The fork becomes the default leaf again. A same-inode rewrite then refuses the next check.
log.raw(JSON.stringify({ type: 'message', id: 'fork-2', parentId: 'fork-1', timestamp: at(0), message: user('FORK_MORE') }));
writeFileSync(file, readFileSync(file, 'utf8').replace('"Question"', '"QUESTION"'));
await b.evaluate('document.querySelector("#refresh").click()');
await wait('document.querySelector("[data-marker=reconcile]")');
// Reload stays on the branch the view was on, and still says the conversation went elsewhere.
await b.evaluate('document.querySelector("[data-conv-action=reload]").click()');
await wait('document.querySelector("#conv-log").textContent.includes("QUESTION") && !document.querySelector("[data-marker=reconcile]")');
await wait('document.querySelector("[data-marker=branch]")');
assert.deepEqual(await turns(b), [['User', 'QUESTION'], ['Assistant', 'MAIN_ANSWER'], ['User', 'MAIN_MORE']]);
// "Open the latest branch" takes the default.
await b.evaluate('document.querySelector("[data-conv-action=latest]").click()');
await wait('document.querySelector("#conv-log").textContent.includes("FORK_MORE")');
assert.deepEqual(await turns(b), [['User', 'QUESTION'], ['Assistant', 'FORK_ANSWER'], ['User', 'FORK_MORE']]);
assert.equal(await b.evaluate('document.querySelector("[data-marker=branch]")'), null);
// The fork is rewritten away. Reload cannot keep a branch that is gone, so it opens the default and says so.
writeFileSync(file, readFileSync(file, 'utf8').split('\n').filter(l => !l.includes('"fork-')).join('\n'));
await b.evaluate('document.querySelector("#refresh").click()');
await wait('document.querySelector("[data-marker=reconcile]")');
await b.evaluate('document.querySelector("[data-conv-action=reload]").click()');
await wait('document.querySelector("[data-marker=gone]")');
assert.match(await b.evaluate('document.querySelector("[data-marker=gone]").textContent'), /^The branch this view was on is no longer in the session\. This view shows the latest branch\.$/);
assert.deepEqual(await turns(b), [['User', 'QUESTION'], ['Assistant', 'MAIN_ANSWER'], ['User', 'MAIN_MORE']]);
assert.equal(await b.evaluate('document.querySelector("[data-marker=reconcile]")'), null);
} finally { if (b) await b.close(); await f.close(); }
});
test('conversation view: a newer session with no readable history keeps the marker', { timeout: 60000 }, async () => {
const f = await historyFixture();
let b;
try {
const log = session(join(f.sessionsDir, '2026-09-26T10-00-00_s1.jsonl'), f.projectRoot);
log.add(user('Question'), at(-120));
log.add(assistant('OLD_ANSWER'), at(-110));
b = await browser(); await b.viewport(1440, 1000);
const wait = waiter(b);
await b.navigate(f.base); await wait('document.querySelector("table.sessions [data-history]")');
await b.evaluate('document.querySelector("table.sessions [data-history]").click()');
await wait('document.querySelector("#conv-status").textContent.startsWith("2 messages")');
// The board takes the newest file by mtime; the catalogue orders by last entry, so a header-only file sorts last.
const log2 = session(join(f.sessionsDir, '2026-09-26T11-00-00_s2.jsonl'), f.projectRoot, { id: 'sess-2', timestamp: at(0) });
await b.evaluate('document.querySelector("#refresh").click()');
await wait('document.querySelector("[data-marker=newer]")');
await b.evaluate('document.querySelector("[data-conv-action=newest]").click()');
await wait('/not readable yet/.test(document.querySelector("[data-marker=newer]")?.textContent)');
assert.match(await b.evaluate('document.querySelector("#conv-log").textContent'), /OLD_ANSWER/);
// Once the new file has entries, the same button opens it.
log2.add(user('NEW_QUESTION'), at(1));
await b.evaluate('document.querySelector("[data-conv-action=newest]").click()');
await wait('document.querySelector("#conv-log").textContent.includes("NEW_QUESTION")');
assert.equal(await b.evaluate('document.querySelector("[data-marker=newer]")'), null);
} finally { if (b) await b.close(); await f.close(); }
});
test('conversation view: seats without history say so and offer no reply',{ timeout: 60000 }, async () => {
const f = await historyFixture();
let b;
try {
// No session file yet: the board still lists the registered seat.
b = await browser(); await b.viewport(390, 900);
const wait = waiter(b);
await b.navigate(f.base); await wait('document.querySelector("table.sessions [data-history]")');
await b.evaluate('document.querySelector("table.sessions [data-history]").click()');
await wait('document.querySelector("#conv-status").textContent.includes("No readable history")');
assert.equal(await b.evaluate('document.querySelectorAll("#conv-log > li").length'), 0);
assert.equal(await b.evaluate('document.querySelector("#conv-pick").disabled'), true);
} finally { if (b) await b.close(); await f.close(); }
});
+51
View File
@@ -0,0 +1,51 @@
// Repository-layout fixture for the conversation view (#1507, CHAT-02). The
// board's history reader reads only <projectRoot>/.pi/state/<seat>/sessions,
// with the project named after the root directory and a Pi header whose cwd is
// inside the project. Real board, real routes, real WebUI; temporary data only.
import { mkdtempSync, mkdirSync, writeFileSync, appendFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { startServer as startBoard } from '../../control-board/src/serve.mjs';
import { makeRegistration, writeRegistration } from '../../seat/src/seat.mjs';
import { startServer } from '../src/serve.mjs';
import { close } from './fixture.mjs';
export const at = s => new Date(Date.now() + s * 1000).toISOString();
// Appends Pi entries to one session file, each the child of the one before.
export function session(file, cwd, { id = 'sess-1', timestamp = at(-600) } = {}) {
let n = 0, parent = null;
writeFileSync(file, JSON.stringify({ type: 'session', version: 3, id, timestamp, cwd }) + '\n');
return {
add(message, timestamp = at(0)) {
const entry = `${id}-e${++n}`;
appendFileSync(file, JSON.stringify({ type: 'message', id: entry, parentId: parent, timestamp, message }) + '\n');
parent = entry;
return entry;
},
raw(text) { appendFileSync(file, text + '\n'); },
};
}
export const user = text => ({ role: 'user', content: [{ type: 'text', text }] });
export const assistant = (content, stopReason = 'stop') => ({ role: 'assistant', stopReason, content: typeof content === 'string' ? [{ type: 'text', text: content }] : content });
export const toolResult = (toolCallId, text, isError = false) => ({ role: 'toolResult', toolCallId, toolName: 'read', content: [{ type: 'text', text }], isError });
export async function historyFixture({ seat = 'fixture', project = 'repo' } = {}) {
const root = mkdtempSync(join(tmpdir(), 'webui-history-'));
const projectRoot = join(root, project), sessionsDir = join(projectRoot, '.pi', 'state', seat, 'sessions');
mkdirSync(sessionsDir, { recursive: true });
const seatsDir = join(root, 'seats'), captures = [];
writeRegistration(seatsDir, makeRegistration({ resolved: { seat, project, sessionsDir, seatDir: root, launchScript: join(root, 'unused.sh'), layout: 'repo', defaultWorkspace: projectRoot }, task: 'Fixed task', tmux: { session: seat, socket: null }, pid: process.pid, now: () => new Date(Date.now() - 3600000) }));
const board = await startBoard({ port: 0, specs: [{ agent: seat, project, sessionsDir, tmux: { session: seat } }], boardDir: join(root, 'board'), seatsDir, isAlive: () => true, isPidAlive: () => true,
exec: (f, args) => { captures.push(args); return { status: 0, stdout: '', stderr: '' }; } });
const web = await startServer({ port: 0, board: `http://127.0.0.1:${board.address().port}` });
return { root, projectRoot, sessionsDir, board, web, base: `http://127.0.0.1:${web.address().port}`, captures,
async close() { await close(web); await close(board); rmSync(root, { recursive: true, force: true }); },
};
}
// Browser helpers shared by the history tests.
export function waiter(b) {
return (expression, ms = 25000) => b.evaluate(`(async()=>{for(const end=Date.now()+${ms};Date.now()<end;){if(${expression})return true;await new Promise(r=>setTimeout(r,100))}throw Error('timeout: '+${JSON.stringify(expression)})})()`);
}
export const turns = b => b.evaluate('[...document.querySelectorAll("#conv-log > li")].map(li => [li.querySelector(".who").textContent, li.querySelector(".conv-text")?.textContent ?? [...li.querySelectorAll("summary")].map(s => s.textContent).join(" | ")])');
@@ -0,0 +1,99 @@
// CHAT-02 return-flow regression (#1507, brief §2.3). Real board, real
// conversation routes, real WebUI; nothing is injected into the page and the
// Refresh button is never used, so every new entry arrives by polling.
// 1 path, 2 exactness, 3 continuation, 4 thread order, 5 interleaving,
// 6 relaunch mid-turn, 7 a delayed tool result with a draft being typed.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { join } from 'node:path';
import { browser } from './browser.mjs';
import { historyFixture, session, user, assistant, toolResult, at, waiter, turns } from './history-fixture.mjs';
const PEER = '[from: darkwing (7525c8e4) -> to: fixture (00000000)] peer note that lands mid-turn';
const ANSWER = 'A'.repeat(250) + 'MID_SENTINEL' + ' and the rest of the answer'.repeat(20) + ' END_SENTINEL';
const BIG = 'BIG_START ' + Array.from({ length: 900000 }, (_, i) => (i % 1679616).toString(36).padStart(4, '0')).join('-') + ' BIG_END';
test('return flow through the conversation view: send, tool call, delayed result, peer message, exact long answers, relaunch', { timeout: 240000 }, async () => {
const f = await historyFixture();
let b;
try {
const file = join(f.sessionsDir, '2026-09-26T10-00-00_s1.jsonl');
const log = session(file, f.projectRoot);
log.add(user('Start'), at(-120));
log.add(assistant('Input needed: OLD_ANSWER'), at(-90));
b = await browser(); await b.viewport(1440, 1000);
const wait = waiter(b);
const draft = () => b.evaluate('(() => { const t = document.querySelector("#conv-reply"); return [t.value, t.selectionStart, document.activeElement === t]; })()');
// The seat is waiting on Jason, so the view opens from its card.
await b.navigate(f.base); await wait('document.querySelector("#waiting [data-history]")');
await b.evaluate('document.querySelector("#waiting [data-history]").click()');
await wait('document.querySelector("#conv-status").textContent.startsWith("2 messages")');
const opened = await b.evaluate('document.querySelector("#conv-pick").value');
// 1. Send from the conversation view, through the board's reply path.
await b.evaluate('(() => { const t = document.querySelector("#conv-reply"); t.value = "REPLY_FROM_JASON"; t.dispatchEvent(new Event("input", { bubbles: true })); document.querySelector("#conv-form").requestSubmit(); })()');
await wait('document.querySelector("#conv-receipt").textContent.includes("delivered")');
const sent = f.captures.at(-1).at(-1);
assert.match(sent, /^REPLY_FROM_JASON\n/);
assert.equal(await b.evaluate('document.querySelector("#conv-reply").value'), '', 'delivered draft cleared');
// The seat takes the message and calls a tool. No answer yet.
log.add(user(sent), at(0));
log.add(assistant([{ type: 'toolCall', id: 'call-1', name: 'read', arguments: { path: 'x' } }], 'toolUse'), at(1));
await wait('document.querySelector("#conv-log .conv-tool-call")');
// 7. A draft typed while the seat works. The tool result lands only after that poll.
await b.evaluate('(() => { const t = document.querySelector("#conv-reply"); t.focus(); t.value = "NEXT_DRAFT"; t.setSelectionRange(4, 4); t.dispatchEvent(new Event("input", { bubbles: true })); })()');
log.add(toolResult('call-1', 'file body'), at(2));
await wait('document.querySelector("#conv-log .conv-tool-result")');
assert.deepEqual(await draft(), ['NEXT_DRAFT', 4, true], 'draft and caret survive the poll');
// 5. A peer agent-send lands before the final answer. 2. The answer has sentinels past 240 and at the end.
log.add(user(PEER), at(3));
log.add(assistant(ANSWER), at(4));
await wait('document.querySelector("#conv-log").textContent.includes("END_SENTINEL")');
// 4. Thread order: sent message, collapsed tool call and result, peer message, answer.
assert.deepEqual(await turns(b), [
['User', 'Start'], ['Assistant', 'Input needed: OLD_ANSWER'], ['User', sent], ['Assistant', 'Tool call: read'],
['Tool', 'Tool result'], ['User', PEER], ['Assistant', ANSWER],
]);
const text = await b.evaluate('document.querySelector("#conv-log").textContent');
assert.equal(text.split('MID_SENTINEL').length, 2, 'answer shown once');
assert.equal(text.includes('…'), false, 'nothing clipped');
assert.equal(await b.evaluate('document.querySelectorAll("#conv-log details[open]").length'), 0, 'tools stay collapsed');
assert.deepEqual(await draft(), ['NEXT_DRAFT', 4, true]);
// 3. An answer long enough to split into continuation parts reassembles exactly and in order.
const bigId = log.add(assistant(BIG), at(5));
await wait('document.querySelector("#conv-log").textContent.endsWith("BIG_END")', 45000);
const hash = await b.evaluate('crypto.subtle.digest("SHA-256", new TextEncoder().encode([...document.querySelectorAll("#conv-log .conv-text")].at(-1).textContent)).then(d => [...new Uint8Array(d)].map(x => x.toString(16).padStart(2, "0")).join(""))');
assert.equal(hash, createHash('sha256').update(BIG).digest('hex'));
const parts = [];
let res = await (await fetch(`${f.base}/api/conversation?id=${opened}`)).json();
for (;;) {
parts.push(...res.page.entries.filter(e => e.nativeEntry === bigId));
if (!res.cursor) break;
res = await (await fetch(`${f.base}/api/conversation?id=${opened}&branch=${res.page.branch}&cursor=${res.cursor.id}`)).json();
}
assert.ok(parts.length >= 2, `continuation parts: ${parts.length}`);
assert.deepEqual(parts.map(p => [p.part, p.lastPart]), parts.map((_, i) => [i, i === parts.length - 1]));
assert.deepEqual(await draft(), ['NEXT_DRAFT', 4, true]);
// 6. Relaunch mid-turn: a new session file appears. The view keeps its file and says so.
const log2 = session(join(f.sessionsDir, '2026-09-26T11-00-00_s2.jsonl'), f.projectRoot, { id: 'sess-2', timestamp: at(6) });
log2.add(user('NEW_SESSION_MESSAGE'), at(7));
await wait('document.querySelector("[data-marker=newer]")');
assert.match(await b.evaluate('document.querySelector("[data-marker=newer]").textContent'), /newer session started.*stays on the session you opened/);
assert.equal(await b.evaluate('document.querySelector("#conv-pick").value'), opened);
const after = await b.evaluate('document.querySelector("#conv-log").textContent');
assert.ok(after.includes('END_SENTINEL') && !after.includes('NEW_SESSION_MESSAGE'), 'no silent switch');
assert.deepEqual(await draft(), ['NEXT_DRAFT', 4, true]);
// Opening the newest session is Jason's choice; the draft for this seat stays.
await b.evaluate('document.querySelector("[data-conv-action=newest]").click()');
await wait('document.querySelector("#conv-log").textContent.includes("NEW_SESSION_MESSAGE")');
assert.notEqual(await b.evaluate('document.querySelector("#conv-pick").value'), opened);
assert.equal(await b.evaluate('document.querySelector("[data-marker=newer]")'), null);
assert.equal(await b.evaluate('document.querySelector("#conv-reply").value'), 'NEXT_DRAFT');
} finally { if (b) await b.close(); await f.close(); }
});
+16
View File
@@ -54,6 +54,7 @@ test('proxy preserves exact request bytes, status and receipt, rejects forms and
let body = ''; for await (const c of req) body += c;
requests.push({ url: req.url, method: req.method, body });
if (req.url === '/api/board') { res.writeHead(302, { location: 'http://192.0.2.1/' }); return res.end('{}'); }
if (req.url.startsWith('/api/conversation')) { res.writeHead(404, { 'content-type': 'application/json' }); return res.end('{"error":"fixture unknown <x>","refusal":{"code":"unknown-branch","reconcile":true}}'); }
res.writeHead(409, { 'content-type': 'application/json' }); res.end('{"error":"fixture refusal <unsafe>"}');
});
await new Promise(r => upstream.listen(0, '127.0.0.1', r));
@@ -73,6 +74,21 @@ test('proxy preserves exact request bytes, status and receipt, rejects forms and
assert.equal(requests.length, 2);
assert.equal((await fetch(base + '/api/board')).status, 502);
assert.equal(requests.length, 3);
// CHAT-02 conversation routes: GET only, the query passes unchanged, the board's status and body come back as sent.
for (const path of ['/api/conversations', '/api/conversation?id=pi-0a&branch=b.e5&cursor=c-1', '/api/conversation?id=x&id=y&unknown=%3C']) {
const res = await fetch(base + path);
assert.equal(res.status, 404, path);
assert.equal(await res.text(), '{"error":"fixture unknown <x>","refusal":{"code":"unknown-branch","reconcile":true}}');
assert.deepEqual(requests.at(-1), { url: path, method: 'GET', body: '' });
}
const before = requests.length;
assert.equal((await post(base, '/api/conversation?id=x', '{}')).status, 405);
assert.equal((await post(base, '/api/conversations', '{}')).status, 405);
assert.equal((await fetch(base + '/api/conversation?id=x', { headers: { origin: 'https://evil.example' } })).status, 403);
assert.equal(requests.length, before, 'refused before the board');
// Only the conversation routes carry a query upstream.
await fetch(base + '/api/board?x=1');
assert.equal(requests.at(-1).url, '/api/board');
} finally { await close(web); await close(upstream); }
});