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:
@@ -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'); }
|
||||
|
||||
@@ -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,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}
|
||||
|
||||
Reference in New Issue
Block a user