agent page: dashboard back-link + last-turn timing chip

title bar grows a '↑ DASHB04RD' link next to the rebuild button —
opens the host dashboard in a new tab so the operator can pivot
between agents without losing the live tail. uses the dashboardPort
already plumbed via /api/state.

state row picks up a 'last turn 12.3s' chip that fills in when
state transitions away from thinking. format: ms / s.s / m s.
hidden until the first turn completes.
This commit is contained in:
müde 2026-05-15 20:27:09 +02:00
parent ee5b85716d
commit bd7d2d4860
4 changed files with 55 additions and 4 deletions

View file

@ -27,8 +27,12 @@ Pick anything from here when relevant. Cross-cutting design notes live in
## UI / UX ## UI / UX
- **Per-agent UI substance.** Show last N inbox messages, last turn timing, - **Per-agent inbox view.** Show the last N messages addressed to
link back to dashboard. this agent on its page (the per-agent equivalent of the
dashboard's operator inbox). Needs a new wire request from agent
→ host (host has the broker; agent doesn't); reuse the broker's
`recent_for` query. Last-turn timing + dashboard back-link
already shipped.
- **State badge: compacting + napping states.** Idle/thinking already - **State badge: compacting + napping states.** Idle/thinking already
ship (driven from SSE turn_start/turn_end). Add `compacting 📦` and ship (driven from SSE turn_start/turn_end). Add `compacting 📦` and
`napping 😴` once the `/compact` trigger and `nap` tool exist — `napping 😴` once the `/compact` trigger and `nap` tool exist —

View file

@ -130,6 +130,26 @@ pre.diff {
align-items: center; align-items: center;
gap: 0.6em; gap: 0.6em;
} }
.last-turn {
color: var(--muted);
font-size: 0.8em;
letter-spacing: 0.05em;
}
.btn-dashlink {
color: var(--cyan);
border: 1px solid var(--cyan);
padding: 0.15em 0.6em;
font-size: 0.55em;
font-family: inherit;
text-decoration: none;
letter-spacing: 0.1em;
margin-left: 0.6em;
vertical-align: middle;
}
.btn-dashlink:hover {
background: rgba(137, 220, 235, 0.1);
box-shadow: 0 0 10px -2px currentColor;
}
.btn-cancel-turn { .btn-cancel-turn {
font-family: inherit; font-family: inherit;
font-size: 0.8em; font-size: 0.8em;

View file

@ -65,16 +65,25 @@
`░▒▓█▓▒░ ${label} ░▒▓█▓▒░ hyperhive ag3nt ░▒▓█▓▒░`; `░▒▓█▓▒░ ${label} ░▒▓█▓▒░ hyperhive ag3nt ░▒▓█▓▒░`;
const title = $('title'); const title = $('title');
title.textContent = `${label}`; title.textContent = `${label}`;
// ↑ DASHB04RD — back-link to the host dashboard. Opens in a new
// tab to keep the agent page anchored where the operator is.
const dashUrl = `${location.protocol}//${location.hostname}:${dashboardPort}/`;
title.append(
el('a', {
href: dashUrl, target: '_blank', rel: 'noopener',
class: 'btn-dashlink', title: 'host dashboard',
}, '↑ DASHB04RD'),
' ',
);
const btn = el('a', { const btn = el('a', {
href: '#', class: 'btn-rebuild', id: 'rebuild-btn', href: '#', class: 'btn-rebuild', id: 'rebuild-btn',
}, '↻ R3BU1LD'); }, '↻ R3BU1LD');
btn.addEventListener('click', (e) => { btn.addEventListener('click', (e) => {
e.preventDefault(); e.preventDefault();
if (!confirm(`rebuild ${label}? container will hot-reload.`)) return; if (!confirm(`rebuild ${label}? container will hot-reload.`)) return;
const url = `${location.protocol}//${location.hostname}:${dashboardPort}/rebuild/${label}`;
const f = document.createElement('form'); const f = document.createElement('form');
f.method = 'POST'; f.method = 'POST';
f.action = url; f.action = `${dashUrl}rebuild/${label}`;
document.body.appendChild(f); document.body.appendChild(f);
f.submit(); f.submit();
}); });
@ -310,6 +319,13 @@
} }
function setState(next) { function setState(next) {
if (next === stateName) return; if (next === stateName) return;
// Capture the just-ending state's duration when leaving 'thinking'
// so the operator can eyeball turn length without scrolling the
// terminal back.
if (stateName === 'thinking' && next !== 'thinking') {
const elapsedMs = Date.now() - stateSince;
renderLastTurn(elapsedMs);
}
stateName = next; stateName = next;
stateSince = Date.now(); stateSince = Date.now();
const badge = $('state-badge'); const badge = $('state-badge');
@ -321,6 +337,16 @@
} }
renderStateBadge(); renderStateBadge();
} }
function renderLastTurn(ms) {
const el_ = $('last-turn');
if (!el_) return;
let s = '';
if (ms < 1000) s = ms + 'ms';
else if (ms < 60_000) s = (ms / 1000).toFixed(1) + 's';
else s = Math.floor(ms / 60_000) + 'm ' + Math.floor((ms / 1000) % 60) + 's';
el_.textContent = '· last turn ' + s;
el_.hidden = false;
}
function startStateTicker() { function startStateTicker() {
if (stateTickTimer) return; if (stateTickTimer) return;
stateTickTimer = setInterval(renderStateBadge, 1000); stateTickTimer = setInterval(renderStateBadge, 1000);

View file

@ -15,6 +15,7 @@
<div id="state-row"> <div id="state-row">
<span id="state-badge" class="state-badge state-loading">… booting</span> <span id="state-badge" class="state-badge state-loading">… booting</span>
<span id="last-turn" class="last-turn" hidden></span>
<button type="button" id="cancel-btn" class="btn-cancel-turn" hidden>■ cancel turn</button> <button type="button" id="cancel-btn" class="btn-cancel-turn" hidden>■ cancel turn</button>
</div> </div>