fix(webui): pending reply notice until the seat answers, relative Age (#1507)

Dewey's return-flow candidate on the #1512 R1 baseline. The inspector used to
show the previous answer while a seat worked on a reply, which looked like the
reply; it now shows a pending notice that clears on the new final answer.
Age shows a relative time beside the ISO time. Filbert approved R2, source
only; the patch reproduces the pinned hashes (app.js d1a51646,
return-flow.test.mjs a598c0d4). webui tests 9/9, all eight suites green.
Known limits are in agents/dewey/work/return-flow-age/NOTES.md. Not pushed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-09-26 15:00:59 -05:00
co-authored by Claude Opus 5.5
parent 0f5b7cb9be
commit 42c08d5285
14 changed files with 460 additions and 5 deletions
+26 -5
View File
@@ -4,6 +4,8 @@
const esc = v => String(v ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const key = r => `${r.project}/${r.agent}`;
const drafts = new Map(), receipts = new Map(), sending = new Set();
// Delivered replies with no newer answer yet, by row: the board's sentAt. Page memory only.
const awaiting = new Map();
let data = null, project = null, selected = null, returnFocus = null, paused = false, busy = false, timer;
let board = 'the configured board', palette = 'harbor', mode = 'light';
const labels = { registration: 'registered', 'first-user-message': 'first message', 'tmux-pane': 'tmux pane', 'session-cwd': 'session cwd', 'workspace-git-root': 'git root' };
@@ -13,6 +15,22 @@
const badge = state => `<span class="badge ${ { working: 'badge-ok', waiting: 'badge-attn', error: 'badge-danger' }[state] || 'badge-muted'}">${esc(state || 'unknown')}</span>`;
const connectorStatus = r => r.connector ? `<span class="source">${r.connector.braked === true ? 'braked (STOP)' : r.connector.braked === false ? 'not braked' : 'brake unknown'} · owner ${esc(r.connector.ownerState)}</span>` : '';
const relaunchNotice = r => r.relaunchedAt ? `relaunched at ${r.relaunchedAt}, no messages since` : '';
// Relative age is the board's ageSeconds: time since last activity as of the scan, not session lifetime (#1507).
function age(s) {
if (typeof s !== 'number' || !(s >= 0)) return null;
const m = Math.floor(s / 60), h = Math.floor(m / 60);
return s < 60 ? `${s}s` : m < 60 ? `${m}m` : h < 24 ? `${h}h` : `${Math.floor(h / 24)}d`;
}
const activity = r => r.lastActivity ? (age(r.ageSeconds) ? `${age(r.ageSeconds)} ago` : r.lastActivity) : null;
// A reply is pending until the seat is no longer working and has written after the send.
// If either time is unparsable, the page falls back to the plain label rather than guessing.
function pending(r) {
const sent = awaiting.get(key(r));
if (sent === undefined) return false;
if (r.state === 'working') return true;
const s = Date.parse(sent), a = Date.parse(r.lastActivity);
return Number.isFinite(s) && Number.isFinite(a) && a < s;
}
const canReply = r => !r.connector && r.registered && r.registered.alive !== false && r.registered.tmux?.session;
const row = id => data?.sessions.find(r => key(r) === id);
const announce = text => { $('announce').textContent = text; };
@@ -37,11 +55,11 @@
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) || r.lastActivity || '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>${seenButton(r)}</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>${esc(relaunchNotice(r) || r.lastActivity || '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></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;
@@ -55,11 +73,11 @@
if (!selected) return;
if (!r) { $('inspection').innerHTML = '<p>This session is no longer in the board scan. Its draft is kept until this page closes.</p>'; return; }
$('inspector-title').textContent = r.agent;
const fields = [ ['State', r.state], ['Project', r.project], ['Task', r.task || 'unknown'], ['Task source', labels[r.taskSource] || r.taskSource || 'unknown'], ['Task set by', r.taskSetBy ? `${r.taskSetBy} (as claimed by the caller, not verified)` : 'not applicable (task is not from a registration)'], ['Active project', r.activeProject || 'unknown'], ['Workspace', r.workspace || 'unknown'], ['Model', [r.provider, r.model].filter(Boolean).join('/') || 'unknown'], ['Registered', registered(r)], [r.relaunchedAt ? 'Historical last activity' : 'Last activity', r.lastActivity || 'unknown'] ];
const fields = [ ['State', r.state], ['Project', r.project], ['Task', r.task || 'unknown'], ['Task source', labels[r.taskSource] || r.taskSource || 'unknown'], ['Task set by', r.taskSetBy ? `${r.taskSetBy} (as claimed by the caller, not verified)` : 'not applicable (task is not from a registration)'], ['Active project', r.activeProject || 'unknown'], ['Workspace', r.workspace || 'unknown'], ['Model', [r.provider, r.model].filter(Boolean).join('/') || 'unknown'], ['Registered', registered(r)], [r.relaunchedAt ? 'Historical last activity' : 'Last activity', !r.lastActivity ? 'unknown' : activity(r) === r.lastActivity ? r.lastActivity : `${activity(r)} (${r.lastActivity})`] ];
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('')}<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>${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>` : ''}`;
}
// Restore by data attribute equality, never interpolate API ids into selectors.
function focusSnapshot() {
@@ -99,7 +117,9 @@
}
function accept(value) {
if (!value || !Array.isArray(value.sessions) || value.sessions.some(r => !r || typeof r.project !== 'string' || typeof r.agent !== 'string')) throw new Error('Invalid board session data');
data = value; $('error').hidden = true; render();
data = value; $('error').hidden = true;
for (const id of awaiting.keys()) { const r = row(id); if (r && !pending(r)) awaiting.delete(id); }
render();
}
function error(err) { $('error').hidden = false; $('error').textContent = `${err.message} Board: ${board}. ${data ? 'Showing last known data.' : 'No board data loaded.'} Use Refresh to try again.`; }
function schedule() { clearTimeout(timer); if (!paused) timer = setTimeout(refresh, 10000); }
@@ -136,6 +156,7 @@
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 && !paused) await refresh();