Files
stack/agents/dewey/work/chat-02/evidence/console/r2-delta.patch
T
jason.woltjeandClaude Opus 5.5 c9e771cf59 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]>
2026-09-26 18:05:11 -05:00

198 lines
18 KiB
Diff
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
--- a/packages/webui/README.md
+++ b/packages/webui/README.md
@@ -42,12 +42,14 @@
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 and bidi controls show as visible symbols. Reply in the view
+ 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. Session lists older sessions for the seat. Only repository Pi seats
+ 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
--- a/packages/webui/src/public/app.js
+++ b/packages/webui/src/public/app.js
@@ -85,9 +85,10 @@
// 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, BIDI = /[‪-‮⁦-⁩]/g;
- // Terminal and bidi controls show as visible symbols. Newlines and tabs stay.
- const inert = v => String(v).replace(CONTROL, c => c === '\u007f' ? '␡' : String.fromCharCode(0x2400 + c.charCodeAt(0))).replace(BIDI, c => `[U+${c.charCodeAt(0).toString(16).toUpperCase()}]`);
+ 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.
@@ -145,14 +146,14 @@
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: 'reopen', label: 'Open the latest branch' });
+ 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: 'reopen', label: 'Reload conversation' }), convStatus();
+ 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;
@@ -180,9 +181,10 @@
$('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', 'A newer session started for this seat. This view stays on the session you opened.', { id: 'newest', label: 'Open the newest session' });
+ 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' });
}
- async function openConversation(rowKey, pick = null, returnTo = conv?.returnFocus) {
+ // 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);
@@ -205,9 +207,18 @@
$('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 (choice === readable[0]) c.sessionId = r?.sessionId ?? null;
- const done = await readPages(c, gen, await getJSON(convURL({ id: c.id })), () => convStatus(`Loading history… ${c.messages.size} messages`));
+ // 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' });
@@ -295,7 +306,8 @@
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]');
- if (action && conv) { openConversation(conv.row, action.dataset.convAction === 'reopen' ? conv.id : null); $('conv-title').focus(); return; }
+ // 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) { 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]');
--- a/packages/webui/tests/conversation.test.mjs
+++ b/packages/webui/tests/conversation.test.mjs
@@ -10,7 +10,7 @@
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 ‮evil';
+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 () => {
@@ -53,11 +53,11 @@
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+202E]evil'), 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('document.body.textContent.includes("\\u001b") || document.body.textContent.includes("\\u202e")'), 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);
@@ -84,7 +84,7 @@
}
// Reload takes a fresh snapshot of the rewritten file; Back returns focus to the History button.
- await b.evaluate('document.querySelector("[data-conv-action=reopen]").click()');
+ 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()');
@@ -125,16 +125,61 @@
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 before the view reopens.
+ // 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') }));
- await b.evaluate('document.querySelector("[data-conv-action=reopen]").click()');
+ 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.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 () => {
+test('conversation view: seats without history say so and offer no reply',{ timeout: 60000 }, async () => {
const f = await historyFixture();
let b;
try {