|
|
|
@@ -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'); }
|
|
|
|
|