--- 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 = '
[click](javascript:window.injected=3) link \u001b[31mRED\u001b[0m \u001b]8;;http://example.invalid\u0007osc\u001b]8;;\u0007 evil';
+const HOSTILE = '
[click](javascript:window.injected=3) link \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:
[click](javascript:window.injected=3) link ␛[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 {