--- 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 {