Per mara's steer on #3053 ("chip/pill/badge is the same if you squint ... theme unification is part of the goal" then "make it common css instead of component, thats fine. but make them look unified (not as much per usage css)"): shared CSS, not a JS component. New @hive/shared/pill.css defines two classes, `.hive-pill` (primary state chips) and `.hive-pill-sm` (secondary meta chips) — border/ border-radius/padding/font-size/letter-spacing (colour stays per-site, the meaningful semantic part). Every render call site across dashboard (swarm.js/core.js/builds.js) and agent (index.html/app.js) now carries one of the two shared classes directly, alongside its own existing semantic-colour modifier class. Second cut of this PR, per argus's approve + mara's follow-up review comment on the first cut: the first version instead enumerated every legacy classname (`.badge`, `.status-badge`, `.header-pill`, etc.) straight into pill.css's own selector groups so no call sites needed touching. Mara's correction: that just relocates the duplication rather than removing it, and the shared CSS shouldn't have to keep naming every consumer. This version does the real rename instead. Most visible consequence, unchanged from the first cut: dashboard's `.badge` family moves off its own shape (2px square corners, uppercase, tighter padding) onto the shared rounded-pill shape + agent's "sm" tier sizing. `npm run build` clean across all three packages; verified the compiled bundles carry the new classnames at every call site (dashboard JS, agent index.html + app.js), not just the source tree. Fixes #3053
1717 lines
74 KiB
JavaScript
1717 lines
74 KiB
JavaScript
// Per-agent web UI. Renders title + login/online view from `/api/state`,
|
|
// tails `/events/stream` for live claude events, drives async-form
|
|
// actions (send / login/* / dashboard rebuild).
|
|
|
|
import { create as termCreate, linkify as termLinkify } from '@hive/shared/terminal.js';
|
|
import { asyncBtn, bindAsyncForms } from '@hive/shared/forms.js';
|
|
import { themedConfirm } from '@hive/shared/modal.js';
|
|
import { el } from '@hive/shared/dom.js';
|
|
import { getExpandDetailsPref } from '@hive/shared/prefs.js';
|
|
import '@hive/shared/side-panel.js'; // registers <hive-side-panel> — side-effect import
|
|
import { marked } from 'marked';
|
|
import DOMPurify from 'dompurify';
|
|
|
|
// Expose the previously-script-tag-provided globals so the IIFE below
|
|
// keeps working unchanged. Pre-split these were attached by
|
|
// `/static/hive-fr0nt.js` (HiveTerminal) and `/static/marked.js`
|
|
// (marked) loading before app.js. The bundle now pulls them in via ES
|
|
// imports; once the IIFE is opened up these aliases can be dropped in
|
|
// favour of direct named imports.
|
|
window.HiveTerminal = { create: termCreate, linkify: termLinkify };
|
|
window.marked = marked;
|
|
|
|
(() => {
|
|
// ─── helpers ────────────────────────────────────────────────────────────
|
|
const $ = (id) => document.getElementById(id);
|
|
const escText = (s) => String(s).replace(/[&<>"]/g, (c) =>
|
|
({ '&':'&', '<':'<', '>':'>', '"':'"' }[c])
|
|
);
|
|
|
|
// Base URL of the host dashboard (core backend). Set once the first
|
|
// /api/state lands. Operator-authority actions (answering a question
|
|
// as the operator) POST here rather than to this agent's own socket —
|
|
// see docs/boundary.md for why the boundary lives on the core side.
|
|
let dashboardBase = '';
|
|
|
|
// ─── async-form submit (shared with dashboard) ──────────────────────────
|
|
// Themed confirm/prompt/toast dialogs + the asyncBtn spinner instead of
|
|
// native confirm()/alert() dialogs that broke out of the page theme.
|
|
bindAsyncForms(() => refreshState());
|
|
|
|
// ─── header icon fallback ───────────────────────────────────────────────
|
|
// `/icon` 404s when this agent has no `hyperhive.icon` override (see
|
|
// hive-agent::web_ui::screen::serve_icon — no bundled server-side
|
|
// default any more). Mirrors the dashboard's `swarm.js` `/favicon.svg`
|
|
// fallback: fire-and-forget load, swap on failure, guarded so a 404 on
|
|
// the fallback itself can't loop.
|
|
(function bindHeaderIconFallback() {
|
|
const iconImg = document.querySelector('.agent-icon');
|
|
if (!iconImg) return;
|
|
iconImg.addEventListener('error', () => {
|
|
if (iconImg.dataset.fallback) return;
|
|
iconImg.dataset.fallback = '1';
|
|
iconImg.src = '/favicon.svg';
|
|
});
|
|
})();
|
|
|
|
// ─── side panel (singleton drawer for inbox + todos flyouts) ───────────
|
|
// The shared `<hive-side-panel>` element (see @hive/shared/side-panel.js
|
|
// for the chrome/behavior it owns), created once, eagerly, when this
|
|
// module's IIFE runs (ES modules execute after the document is
|
|
// parsed, so `document.body` already exists). Used directly — this
|
|
// UI's opens always take an owner name, so call sites use the
|
|
// element's own `openNamed(name, title, content)` rather than the
|
|
// untyped `open`. `agent.css` reaches the slotted content via a plain
|
|
// `hive-side-panel .agent-inbox …` tag-name selector (no compat class
|
|
// needed — the element's own tag name already identifies it).
|
|
const sidePanel = document.createElement('hive-side-panel');
|
|
document.body.append(sidePanel);
|
|
|
|
// Wire the header pills to open the side panel. Pre-built (vs
|
|
// re-building per-click) so the freshest snapshot already lives
|
|
// in `lastInbox` / `lastTodos` when the pill is clicked — even
|
|
// if it fires during a turn the render is the same.
|
|
(function bindHeaderPills() {
|
|
const inboxPill = $('inbox-pill');
|
|
if (inboxPill) {
|
|
inboxPill.addEventListener('click', () => {
|
|
sidePanel.openNamed('inbox', 'inbox · ' + lastInbox.length,
|
|
buildInboxList(lastInbox));
|
|
});
|
|
}
|
|
const todosPill = $('todos-pill');
|
|
if (todosPill) {
|
|
todosPill.addEventListener('click', () => {
|
|
sidePanel.openNamed('todos', 'todos · ' + lastTodos.length,
|
|
buildTodosList(lastTodos));
|
|
});
|
|
}
|
|
})();
|
|
|
|
// ─── state rendering ────────────────────────────────────────────────────
|
|
function setHeader(label, qualifiedLabel, dashboardPort, hiveName, swarmName) {
|
|
const title = $('title');
|
|
// Title is just the glowing identity glyph — DASHB04RD, R3BU1LD,
|
|
// NEW SESSION live in the overflow `⋯` menu. Glow + uppercase
|
|
// styling from h2 / .agent-header-title-row. The glyphic title
|
|
// stays short (no @hive suffix) — the hive qualifier lives on
|
|
// the second row's `qualified` chip + the browser tab title so
|
|
// the cinematic header reads cleanly at a glance.
|
|
title.textContent = `◆ ${label} ◆`;
|
|
// Document title: prefer the human display names when available
|
|
// (e.g. "iris // pr1ma") so browser tabs read naturally. Fall back
|
|
// to the qualified domain label (e.g. "iris@pr1ma.darkest.space")
|
|
// for multi-hive disambiguation when display names are unset, then
|
|
// to plain label for single-hive deploys.
|
|
const hiveLabel = hiveName || null;
|
|
const tab = qualifiedLabel && qualifiedLabel !== label ? qualifiedLabel : label;
|
|
document.title = hiveLabel ? `${label} // ${hiveLabel}` : `${tab} // hyperhive`;
|
|
// When accessed via hive-gateway the page lives at
|
|
// `/agent/<name>/` on the same origin as the dashboard (`/`).
|
|
// Detect this by the path prefix and use `location.origin` instead
|
|
// of the direct TCP port — the direct port is unreachable or
|
|
// wrong scheme behind HTTPS TLS termination.
|
|
const dashUrl = location.pathname.startsWith('/agent/')
|
|
? location.origin + '/'
|
|
: `${location.protocol}//${location.hostname}:${dashboardPort}/`;
|
|
dashboardBase = dashUrl;
|
|
populateOverflowMenu(label, dashUrl);
|
|
// Swarm label in the chrome: show "swarm/hive" when both set, just
|
|
// "hive" when only hive is known. Targets the `.agent-hive-label`
|
|
// element in the header row if present.
|
|
const hiveEl = document.querySelector('.agent-hive-label');
|
|
if (hiveEl && (hiveName || swarmName)) {
|
|
hiveEl.textContent = swarmName && hiveName
|
|
? `${swarmName} / ${hiveName}` : (hiveName || swarmName);
|
|
hiveEl.hidden = false;
|
|
}
|
|
}
|
|
|
|
// Overflow popover: dashboard back-link + rebuild + new-session +
|
|
// logout. Rare + destructive actions live here behind one extra
|
|
// click (the operator rebuilds from the host dashboard normally).
|
|
// See docs/web-ui.md::Per-agent page (Overflow button).
|
|
let overflowMenuPopulated = false;
|
|
function populateOverflowMenu(label, dashUrl) {
|
|
const menu = $('overflow-menu');
|
|
if (!menu) return;
|
|
menu.replaceChildren();
|
|
|
|
// ↑ dashboard — host dashboard back-link. The dashboard SPA lives at
|
|
// `dashboard.html` (the `/` root now serves the H0M3 menu hub), so the
|
|
// link targets that file; `dashUrl` itself stays the API origin used
|
|
// for the rebuild / mark-all-read POSTs below.
|
|
menu.append(el('a', {
|
|
class: 'overflow-item overflow-item-dashboard',
|
|
href: dashUrl + 'dashboard.html',
|
|
target: '_blank',
|
|
rel: 'noopener',
|
|
role: 'menuitem',
|
|
},
|
|
el('span', { class: 'overflow-item-icon', 'aria-hidden': 'true' }, '↑'),
|
|
'dashboard',
|
|
));
|
|
|
|
// ↻ rebuild — POST `{dash}/rebuild/{label}` after a confirm.
|
|
// Same shape as the old `.btn-rebuild` handler.
|
|
const rebuildBtn = el('button', {
|
|
type: 'button',
|
|
class: 'overflow-item overflow-item-rebuild',
|
|
role: 'menuitem',
|
|
id: 'rebuild-btn',
|
|
},
|
|
el('span', { class: 'overflow-item-icon', 'aria-hidden': 'true' }, '↻'),
|
|
'rebuild container',
|
|
);
|
|
rebuildBtn.addEventListener('click', async () => {
|
|
if (!(await themedConfirm({ message: `rebuild ${label}? container will hot-reload.`, confirmLabel: '↻ rebuild' }))) return;
|
|
closeOverflowMenu();
|
|
const f = document.createElement('form');
|
|
f.method = 'POST';
|
|
f.action = `${dashUrl}api/rebuild/${label}`;
|
|
document.body.appendChild(f);
|
|
f.submit();
|
|
});
|
|
menu.append(rebuildBtn);
|
|
|
|
// ↻ new session — arms a one-shot for the next turn. Mildly
|
|
// destructive (drops --continue context) so we confirm.
|
|
const newSessBtn = el('button', {
|
|
type: 'button',
|
|
class: 'overflow-item overflow-item-new-session',
|
|
role: 'menuitem',
|
|
id: 'new-session-btn',
|
|
title: 'next turn runs without --continue, starting a fresh claude session',
|
|
},
|
|
el('span', { class: 'overflow-item-icon', 'aria-hidden': 'true' }, '↻'),
|
|
'new claude session',
|
|
);
|
|
newSessBtn.addEventListener('click', async () => {
|
|
if (!(await themedConfirm({
|
|
message: 'arm a fresh claude session for the next turn? all prior --continue context will be dropped.',
|
|
danger: true, confirmLabel: '↻ arm fresh session',
|
|
}))) return;
|
|
newSessBtn.disabled = true;
|
|
closeOverflowMenu();
|
|
postNewSession().finally(() => { newSessBtn.disabled = false; });
|
|
});
|
|
menu.append(newSessBtn);
|
|
|
|
// 🔓 logout — SIGINTs claude, wipes the credentials dir, flips
|
|
// the harness LoginState to NeedsLogin. The turn loop's next
|
|
// iteration parks in wait_for_login; a fresh `claude auth login`
|
|
// from the dashboard re-arms it. Operator has to re-paste OAuth
|
|
// creds on the login screen after, but the --continue session
|
|
// history is preserved (the backend wipe is narrowed to just
|
|
// .credentials.json + mcp-needs-auth-cache.json) — so the agent
|
|
// picks up where it left off on the next turn after re-login.
|
|
const logoutBtn = el('button', {
|
|
type: 'button',
|
|
class: 'overflow-item overflow-item-logout',
|
|
role: 'menuitem',
|
|
id: 'logout-btn',
|
|
title: 'rotate OAuth credentials in ~/.claude/ + park in needs-login until a fresh `claude auth login` runs (--continue session history preserved)',
|
|
},
|
|
el('span', { class: 'overflow-item-icon', 'aria-hidden': 'true' }, '🔓'),
|
|
'logout',
|
|
);
|
|
logoutBtn.addEventListener('click', async () => {
|
|
if (!(await themedConfirm({
|
|
message:
|
|
`log ${label} out? this SIGINTs any running claude turn, deletes only the OAuth ` +
|
|
`credential files (.credentials.json + mcp-needs-auth-cache.json) in ~/.claude/, ` +
|
|
`and parks the agent in 'needs login' until you paste a fresh OAuth code from the ` +
|
|
`login screen. prior --continue session history is preserved — the agent picks up ` +
|
|
`where it left off on the next turn after re-login.`,
|
|
danger: true, confirmLabel: '🔓 log out',
|
|
}))) return;
|
|
logoutBtn.disabled = true;
|
|
closeOverflowMenu();
|
|
postLogout().finally(() => { logoutBtn.disabled = false; });
|
|
});
|
|
menu.append(logoutBtn);
|
|
|
|
// ⏸ pause / ▶ resume — this page has no pause state of its own to
|
|
// hold or mutate; it POSTs to the same hive-c0re endpoints
|
|
// (`/api/pause/<name>` / `/api/resume/<name>`) the dashboard's own
|
|
// `<hive-agent-menu>` already uses, and only needs `state.paused`
|
|
// (this agent's own `/api/state`, added alongside this menu item)
|
|
// to know which of the two to show. See `renderPausedChip`, called
|
|
// from `refreshState` on every snapshot so the label tracks reality
|
|
// even when the pause/resume actually happened from the dashboard.
|
|
const pauseBtn = el('button', {
|
|
type: 'button',
|
|
class: 'overflow-item overflow-item-pause',
|
|
role: 'menuitem',
|
|
id: 'pause-btn',
|
|
},
|
|
el('span', { class: 'overflow-item-icon', 'aria-hidden': 'true' }, '⏸'),
|
|
el('span', { id: 'pause-btn-label' }, 'pause agent'),
|
|
);
|
|
pauseBtn.addEventListener('click', async () => {
|
|
const paused = pauseBtn.dataset.paused === 'true';
|
|
const verb = paused ? 'resume' : 'pause';
|
|
const message = paused
|
|
? `resume ${label}? the turn loop restarts and drains queued messages.`
|
|
: `pause ${label}? parks the turn loop — inbox messages queue unacked.`;
|
|
if (!(await themedConfirm({
|
|
message, danger: true, confirmLabel: paused ? '▶ resume' : '⏸ pause',
|
|
}))) return;
|
|
closeOverflowMenu();
|
|
const f = document.createElement('form');
|
|
f.method = 'POST';
|
|
f.action = `${dashUrl}api/${verb}/${label}`;
|
|
document.body.appendChild(f);
|
|
f.submit();
|
|
});
|
|
menu.append(pauseBtn);
|
|
|
|
// ─── model quick-picker ────────────────────────────────────────
|
|
// One-click shortcuts for each model in `availableModels` (seeded
|
|
// from `state.available_models` / `HIVE_AVAILABLE_MODELS` nix option).
|
|
// The active model is highlighted via the `active` class; see
|
|
// `renderModelChip` which updates `modelPickerBtns` live.
|
|
const modelSep = el('div', { class: 'overflow-sep', 'aria-hidden': 'true' });
|
|
const modelLabel = el('div', { class: 'overflow-section-label' }, 'model');
|
|
menu.append(modelSep, modelLabel);
|
|
// Well-known aliases get a parenthetical description; unknown aliases
|
|
// (operator-declared custom models) show just the name.
|
|
const MODEL_DESCRIPTIONS = {
|
|
haiku: 'haiku (fast)',
|
|
sonnet: 'sonnet (balanced)',
|
|
opus: 'opus (powerful)',
|
|
};
|
|
modelPickerBtns = [];
|
|
for (const name of availableModels) {
|
|
const label = MODEL_DESCRIPTIONS[name] ?? name;
|
|
const btn = el('button', {
|
|
type: 'button',
|
|
class: 'overflow-item overflow-item-model',
|
|
role: 'menuitem',
|
|
title: `/model ${name}`,
|
|
'data-model': name,
|
|
},
|
|
el('span', { class: 'overflow-item-icon', 'aria-hidden': 'true' }, '⊞'),
|
|
label,
|
|
);
|
|
btn.addEventListener('click', () => {
|
|
if (currentModel === name) { closeOverflowMenu(); return; }
|
|
closeOverflowMenu();
|
|
postModel(name);
|
|
});
|
|
modelPickerBtns.push(btn);
|
|
menu.append(btn);
|
|
}
|
|
|
|
// ─── effort quick-picker ───────────────────────────────────────
|
|
// One-click shortcuts for each reasoning-effort level the backend
|
|
// declares in `availableEfforts` (from `state.available_efforts`).
|
|
// The active level is highlighted via the `active` class; see
|
|
// `renderEffortChip` which updates `effortPickerBtns` live. Applies
|
|
// on the next claude session. Omitted entirely when the backend
|
|
// hasn't supplied any levels (older harness / cold-load not yet in).
|
|
effortPickerBtns = [];
|
|
if (availableEfforts.length) {
|
|
const effortSep = el('div', { class: 'overflow-sep', 'aria-hidden': 'true' });
|
|
const effortLabel = el('div', { class: 'overflow-section-label' }, 'effort');
|
|
menu.append(effortSep, effortLabel);
|
|
// Display-only labels for well-known levels; any level the backend
|
|
// sends that isn't here just shows its bare name.
|
|
const EFFORT_DESCRIPTIONS = {
|
|
low: 'low',
|
|
medium: 'medium (default)',
|
|
high: 'high',
|
|
xhigh: 'xhigh',
|
|
max: 'max',
|
|
};
|
|
for (const level of availableEfforts) {
|
|
const label = EFFORT_DESCRIPTIONS[level] ?? level;
|
|
const btn = el('button', {
|
|
type: 'button',
|
|
class: 'overflow-item overflow-item-effort',
|
|
role: 'menuitem',
|
|
title: `set reasoning effort to ${level} (applies next session)`,
|
|
'data-effort': level,
|
|
},
|
|
el('span', { class: 'overflow-item-icon', 'aria-hidden': 'true' }, '◇'),
|
|
label,
|
|
);
|
|
btn.addEventListener('click', () => {
|
|
if (currentEffort === level) { closeOverflowMenu(); return; }
|
|
closeOverflowMenu();
|
|
postEffort(level);
|
|
});
|
|
effortPickerBtns.push(btn);
|
|
menu.append(btn);
|
|
}
|
|
}
|
|
|
|
overflowMenuPopulated = true;
|
|
}
|
|
|
|
function openOverflowMenu() {
|
|
const btn = $('overflow-btn');
|
|
const menu = $('overflow-menu');
|
|
if (!btn || !menu || !overflowMenuPopulated) return;
|
|
// Position the menu so its top-right corner anchors just below
|
|
// the trigger button's bottom-right edge. Using fixed positioning
|
|
// + getBoundingClientRect so we don't get trapped in any of the
|
|
// header's stacking contexts.
|
|
const r = btn.getBoundingClientRect();
|
|
menu.style.top = `${Math.round(r.bottom + 6)}px`;
|
|
// Render off-screen first to measure, then anchor the right edge.
|
|
menu.hidden = false;
|
|
const mr = menu.getBoundingClientRect();
|
|
menu.style.left = `${Math.round(r.right - mr.width)}px`;
|
|
btn.setAttribute('aria-expanded', 'true');
|
|
}
|
|
function closeOverflowMenu() {
|
|
const btn = $('overflow-btn');
|
|
const menu = $('overflow-menu');
|
|
if (!btn || !menu) return;
|
|
menu.hidden = true;
|
|
btn.setAttribute('aria-expanded', 'false');
|
|
}
|
|
function toggleOverflowMenu() {
|
|
const menu = $('overflow-menu');
|
|
if (!menu) return;
|
|
if (menu.hidden) openOverflowMenu();
|
|
else closeOverflowMenu();
|
|
}
|
|
// Wire once on boot. The trigger itself + click-outside + Escape
|
|
// dismissal pattern matches the side-panel flyout (sidePanel).
|
|
(function bindOverflowMenu() {
|
|
const btn = $('overflow-btn');
|
|
if (!btn) return;
|
|
btn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
toggleOverflowMenu();
|
|
});
|
|
document.addEventListener('click', (e) => {
|
|
const menu = $('overflow-menu');
|
|
if (!menu || menu.hidden) return;
|
|
if (menu.contains(e.target) || btn.contains(e.target)) return;
|
|
closeOverflowMenu();
|
|
});
|
|
document.addEventListener('keydown', (e) => {
|
|
const menu = $('overflow-menu');
|
|
if (e.key === 'Escape' && menu && !menu.hidden) {
|
|
closeOverflowMenu();
|
|
btn.focus();
|
|
}
|
|
});
|
|
})();
|
|
|
|
function renderOnline(_label, _root) {
|
|
// Online state is conveyed by the `#alive-badge` chip in the
|
|
// state row — no longer a separate paragraph in the status
|
|
// block (keeps the terminal the star, status row stays compact).
|
|
}
|
|
|
|
function renderNeedsLoginIdle(root) {
|
|
root.append(
|
|
el('p', { class: 'status-needs-login' }, '◌ NEEDS L0G1N'),
|
|
el('p', { html:
|
|
'No Claude session in <code>~/.claude/</code>. The harness is up but the turn loop is paused until you log in.',
|
|
}),
|
|
);
|
|
const start = el('form', {
|
|
action: 'login/start', method: 'POST', 'data-async': '',
|
|
});
|
|
start.append(
|
|
el('button', { type: 'submit', class: 'btn btn-login' }, '◆ ST4RT L0G1N'),
|
|
);
|
|
root.append(start);
|
|
root.append(el('p', { class: 'meta', html:
|
|
'Spawns <code>claude auth login</code> over plain stdio pipes. The OAuth URL will appear here when claude emits it; paste the resulting code back into the form below.',
|
|
}));
|
|
}
|
|
|
|
function renderLoginInProgress(s, root) {
|
|
root.append(el('p', { class: 'status-needs-login' }, '◌ L0G1N 1N PR0GRESS'));
|
|
if (s.url) {
|
|
const link = el('a', {
|
|
href: s.url, target: '_blank', rel: 'noreferrer',
|
|
}, s.url);
|
|
root.append(el('p', {}, '▶ ', link));
|
|
root.append(el('p', { class: 'meta' },
|
|
'open this URL in a browser, complete the OAuth flow, paste the resulting code below.',
|
|
));
|
|
} else {
|
|
root.append(el('p', { class: 'meta' },
|
|
'waiting for claude to emit an OAuth URL on stdout… (output below)',
|
|
));
|
|
}
|
|
if (!s.finished) {
|
|
const code = el('form', {
|
|
action: 'login/code', method: 'POST', class: 'loginform', 'data-async': '',
|
|
});
|
|
// OAuth code input — masked password + reveal toggle, semantic
|
|
// autocomplete. See docs/web-ui.md::Per-agent page (#status
|
|
// overlay) for the shoulder-surfer + WHATWG one-time-code
|
|
// rationale.
|
|
const codeInput = el('input', {
|
|
name: 'code',
|
|
type: 'password',
|
|
placeholder: 'paste OAuth code here (hidden)',
|
|
required: '',
|
|
autocomplete: 'one-time-code',
|
|
spellcheck: 'false',
|
|
});
|
|
const reveal = el('button', {
|
|
type: 'button',
|
|
class: 'loginform-reveal',
|
|
title: 'show / hide pasted code',
|
|
'aria-label': 'show / hide pasted OAuth code',
|
|
'aria-pressed': 'false',
|
|
}, '👁');
|
|
reveal.addEventListener('click', () => {
|
|
const showing = codeInput.getAttribute('type') === 'text';
|
|
codeInput.setAttribute('type', showing ? 'password' : 'text');
|
|
reveal.setAttribute('aria-pressed', showing ? 'false' : 'true');
|
|
});
|
|
code.append(
|
|
codeInput,
|
|
reveal,
|
|
el('button', { type: 'submit', class: 'btn btn-login' }, '◆ S3ND C0DE'),
|
|
);
|
|
root.append(code);
|
|
}
|
|
const cancel = el('form', {
|
|
action: 'login/cancel', method: 'POST', 'data-async': '',
|
|
style: 'margin-top: 0.4em;',
|
|
});
|
|
cancel.append(el('button', { type: 'submit', class: 'btn btn-cancel' }, 'cancel + kill'));
|
|
root.append(cancel);
|
|
if (s.finished) {
|
|
root.append(el('p', { class: 'status-needs-login' },
|
|
`claude process exited: ${s.exit_note || 'exited'}. Start over if needed.`,
|
|
));
|
|
}
|
|
root.append(el('h3', {}, 'output'));
|
|
root.append(el('pre', { class: 'diff' }, s.output || ''));
|
|
}
|
|
|
|
let headerSet = false;
|
|
let lastStatus = null;
|
|
let lastOutputLen = -1;
|
|
let pollTimer = null;
|
|
let termInputRendered = false;
|
|
// Filled in by the live-event IIFE below. Used by the slash-command
|
|
// dispatcher to print local-only rows ('help', errors) and to clear
|
|
// the terminal on `/clear`.
|
|
let termAPI = null;
|
|
// Label captured from the first /api/state cold load — used by the
|
|
// bus-driven `status_changed` handler so it can re-enable the
|
|
// composer without waiting for the next snapshot fetch.
|
|
let currentLabel = '';
|
|
// Tracked so the overflow-menu model picker can highlight the active
|
|
// model and avoid a redundant `/api/model` POST on same-model click.
|
|
let currentModel = null;
|
|
// References to the model picker buttons populated in the overflow
|
|
// menu so renderModelChip can update their `active` state without
|
|
// rebuilding the whole menu.
|
|
let modelPickerBtns = [];
|
|
// Ordered list of model short-names available on this hive. Seeded from
|
|
// `state.available_models` (injected by the nix option); falls back to
|
|
// the built-in default until the first /api/state cold-load completes.
|
|
let availableModels = ['haiku', 'sonnet', 'opus'];
|
|
|
|
// Effort quick-picker — sibling of the model picker. The operator's
|
|
// selected reasoning-effort level applies on the next claude session.
|
|
// `currentEffort` highlights the active button + chip; `effortPickerBtns`
|
|
// are the menu buttons updated live by `renderEffortChip`. The level list
|
|
// is the backend's to declare — seeded from `state.available_efforts` on
|
|
// cold-load; empty until then (no hard-coded levels in the frontend), so
|
|
// the picker section is omitted until the backend supplies the set.
|
|
let currentEffort = null;
|
|
let effortPickerBtns = [];
|
|
let availableEfforts = [];
|
|
|
|
// Pause/resume toggle in the overflow menu — see `renderPausedChip`.
|
|
// Tracked so subsequent /api/state refreshes can flip the existing
|
|
// button's label without rebuilding the whole overflow menu (same
|
|
// reason `currentModel`/`currentEffort` are tracked above).
|
|
let currentPaused = false;
|
|
|
|
const SLASH_COMMANDS = [
|
|
{ name: '/help', desc: 'list slash commands' },
|
|
{ name: '/clear', desc: 'wipe the terminal panel (local-only)' },
|
|
{ name: '/cancel', desc: 'SIGINT the in-flight claude turn' },
|
|
{ name: '/compact', desc: 'compact the persistent claude session' },
|
|
{ name: '/model', desc: '/model <name> — switch claude model for future turns' },
|
|
{ name: '/effort', desc: '/effort <level> — set claude effort (medium/high/xhigh) for future turns' },
|
|
{ name: '/new-session', desc: 'next turn runs without --continue (fresh claude session)' },
|
|
{ name: '/logout', desc: 'rotate OAuth credentials + park in needs-login (--continue session preserved)' },
|
|
];
|
|
|
|
async function postModel(name) {
|
|
try {
|
|
const resp = await fetch('api/model', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body: new URLSearchParams({ model: name }),
|
|
redirect: 'manual',
|
|
});
|
|
const ok = resp.ok || resp.type === 'opaqueredirect'
|
|
|| (resp.status >= 200 && resp.status < 400);
|
|
if (!ok && termAPI) {
|
|
const text = await resp.text().catch(() => '');
|
|
termAPI.row('turn-end-fail', '✗ /model failed: ' + resp.status
|
|
+ (text ? ' — ' + text : ''));
|
|
}
|
|
// No refreshState — the harness emits `model_changed` on the
|
|
// SSE bus and the chip handler picks it up live.
|
|
} catch (err) {
|
|
if (termAPI) termAPI.row('turn-end-fail', '✗ /model failed: ' + err);
|
|
}
|
|
}
|
|
|
|
async function postEffort(level) {
|
|
try {
|
|
const resp = await fetch('api/effort', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body: new URLSearchParams({ effort: level }),
|
|
redirect: 'manual',
|
|
});
|
|
const ok = resp.ok || resp.type === 'opaqueredirect'
|
|
|| (resp.status >= 200 && resp.status < 400);
|
|
if (!ok && termAPI) {
|
|
const text = await resp.text().catch(() => '');
|
|
termAPI.row('turn-end-fail', '✗ effort change failed: ' + resp.status
|
|
+ (text ? ' — ' + text : ''));
|
|
}
|
|
// No refreshState — the harness emits `effort_changed` on the
|
|
// SSE bus and the chip handler picks it up live.
|
|
} catch (err) {
|
|
if (termAPI) termAPI.row('turn-end-fail', '✗ effort change failed: ' + err);
|
|
}
|
|
}
|
|
|
|
async function postSimple(url, label) {
|
|
try {
|
|
const resp = await fetch(url, { method: 'POST', redirect: 'manual' });
|
|
const ok = resp.ok || resp.type === 'opaqueredirect'
|
|
|| (resp.status >= 200 && resp.status < 400);
|
|
if (!ok && termAPI) {
|
|
termAPI.row('turn-end-fail', '✗ ' + label + ' failed: http ' + resp.status);
|
|
}
|
|
} catch (err) {
|
|
if (termAPI) termAPI.row('turn-end-fail', '✗ ' + label + ' failed: ' + err);
|
|
}
|
|
}
|
|
// First arg is the URL path (relative to document base — see
|
|
// docs/web-ui.md::Per-agent relative paths); second is the
|
|
// slash-command label rendered in the local note.
|
|
const postCancelTurn = () => postSimple('api/cancel', '/cancel');
|
|
const postCompact = () => postSimple('api/compact', '/compact');
|
|
const postNewSession = () => postSimple('api/new-session', '/new-session');
|
|
const postLogout = () => postSimple('api/logout', '/logout');
|
|
|
|
function handleSlashCommand(line) {
|
|
if (!termAPI) return false;
|
|
const trimmed = line.trim();
|
|
if (!trimmed.startsWith('/')) return false;
|
|
const [cmd] = trimmed.split(/\s+/);
|
|
switch (cmd) {
|
|
case '/help':
|
|
termAPI.row('note', '· /help');
|
|
for (const c of SLASH_COMMANDS) {
|
|
termAPI.row('note', ' ' + c.name.padEnd(10) + ' — ' + c.desc);
|
|
}
|
|
return true;
|
|
case '/clear':
|
|
termAPI.clear();
|
|
termAPI.row('note', '· terminal cleared (local view only — server history kept)');
|
|
return true;
|
|
case '/cancel':
|
|
postCancelTurn();
|
|
return true;
|
|
case '/compact':
|
|
postCompact();
|
|
return true;
|
|
case '/new-session':
|
|
// Fire the (async) themed confirm without blocking this function's
|
|
// synchronous `true` return — the caller only needs to know the
|
|
// line was a recognized slash command, not that the action fired.
|
|
(async () => {
|
|
if (await themedConfirm({
|
|
message: 'arm a fresh claude session for the next turn? all prior --continue context will be dropped.',
|
|
danger: true, confirmLabel: '↻ arm fresh session',
|
|
})) postNewSession();
|
|
})();
|
|
return true;
|
|
case '/logout':
|
|
(async () => {
|
|
if (await themedConfirm({
|
|
message:
|
|
`log out? this SIGINTs any running claude turn, deletes only the OAuth ` +
|
|
`credential files (.credentials.json + mcp-needs-auth-cache.json) in ~/.claude/, ` +
|
|
`and parks the agent in 'needs login' until you paste a fresh OAuth code from the ` +
|
|
`login screen. prior --continue session history is preserved — the agent picks up ` +
|
|
`where it left off on the next turn after re-login.`,
|
|
danger: true, confirmLabel: '🔓 log out',
|
|
})) postLogout();
|
|
})();
|
|
return true;
|
|
case '/model': {
|
|
const parts = trimmed.split(/\s+/);
|
|
if (parts.length < 2 || !parts[1]) {
|
|
termAPI.row('turn-end-fail',
|
|
'✗ /model needs a name (e.g. /model haiku, /model sonnet, /model opus)');
|
|
} else {
|
|
postModel(parts[1]);
|
|
}
|
|
return true;
|
|
}
|
|
case '/effort': {
|
|
const parts = trimmed.split(/\s+/);
|
|
if (parts.length < 2 || !parts[1]) {
|
|
termAPI.row('turn-end-fail',
|
|
'✗ /effort needs a level (e.g. /effort medium, /effort high, /effort xhigh)');
|
|
} else {
|
|
postEffort(parts[1]);
|
|
}
|
|
return true;
|
|
}
|
|
default:
|
|
termAPI.row('turn-end-fail', '✗ unknown slash command: ' + cmd + ' — try /help');
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// Cycle through commands when operator hits Tab on a `/…` prefix.
|
|
function completeSlash(prefix) {
|
|
const matches = SLASH_COMMANDS.filter((c) => c.name.startsWith(prefix));
|
|
if (!matches.length) return null;
|
|
// Cycle: when the current prefix already equals a command name,
|
|
// advance to the next match.
|
|
const idx = matches.findIndex((c) => c.name === prefix);
|
|
return matches[(idx + 1) % matches.length].name;
|
|
}
|
|
|
|
function renderTermInput(label, online) {
|
|
const slot = $('term-input');
|
|
if (!slot) return;
|
|
if (!termInputRendered) {
|
|
slot.replaceChildren();
|
|
const form = el('form', {
|
|
action: 'send', method: 'POST',
|
|
class: 'sendform-term', 'data-async': '',
|
|
});
|
|
const ta = el('textarea', {
|
|
name: 'body', placeholder: 'message ' + label + '…',
|
|
required: '', autocomplete: 'off', rows: '1',
|
|
});
|
|
// Enter submits, Shift+Enter inserts a newline. Auto-grow up to
|
|
// ~8 rows of content, then scroll inside the textarea.
|
|
const MAX_PX = 12 * 16; // ~8 lines @ 1.5 line-height, 1em base
|
|
const grow = () => {
|
|
ta.style.height = 'auto';
|
|
ta.style.height = Math.min(ta.scrollHeight, MAX_PX) + 'px';
|
|
};
|
|
ta.addEventListener('input', grow);
|
|
ta.addEventListener('keydown', (e) => {
|
|
// Tab-complete slash commands when the buffer starts with `/`.
|
|
if (e.key === 'Tab' && ta.value.startsWith('/') && !ta.value.includes(' ')) {
|
|
const next = completeSlash(ta.value);
|
|
if (next) { e.preventDefault(); ta.value = next; return; }
|
|
}
|
|
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
|
|
e.preventDefault();
|
|
const line = ta.value;
|
|
if (!line.trim()) return;
|
|
// Intercept slash commands locally; never send them to the agent.
|
|
if (line.trim().startsWith('/')) {
|
|
if (handleSlashCommand(line)) {
|
|
ta.value = '';
|
|
grow();
|
|
return;
|
|
}
|
|
}
|
|
form.requestSubmit();
|
|
}
|
|
});
|
|
// Reset height after async submit clears the value.
|
|
form.addEventListener('submit', () => setTimeout(grow, 0));
|
|
form.append(
|
|
el('span', { class: 'prompt' }, 'operator@' + label + ' ▸'),
|
|
ta,
|
|
el('span', { class: 'submit-hint' }, '↵ send · ⇧↵ newline · /help'),
|
|
);
|
|
slot.append(form);
|
|
termInputRendered = true;
|
|
}
|
|
slot.classList.toggle('disabled', !online);
|
|
const ta = slot.querySelector('textarea');
|
|
if (ta) ta.disabled = !online;
|
|
}
|
|
|
|
// Granular state badge: idle / thinking / offline. Driven from SSE
|
|
// turn_start/turn_end. Age timer ticks client-side; badge re-renders
|
|
// each second so the "· 12s" suffix stays current. State changes
|
|
// trigger a short flash animation via .state-just-changed.
|
|
const STATE_LABELS = {
|
|
loading: { glyph: '…', text: 'booting' },
|
|
offline: { glyph: '○', text: 'offline' },
|
|
idle: { glyph: '💤', text: 'idle' },
|
|
thinking: { glyph: '🧠', text: 'thinking' },
|
|
compacting: { glyph: '📦', text: 'compacting' },
|
|
};
|
|
let stateName = 'loading';
|
|
let stateSince = Date.now();
|
|
let stateTickTimer = null;
|
|
function fmtAge(ms) {
|
|
const s = Math.floor(ms / 1000);
|
|
if (s < 60) return s + 's';
|
|
const m = Math.floor(s / 60);
|
|
if (m < 60) return m + 'm ' + (s % 60) + 's';
|
|
const h = Math.floor(m / 60);
|
|
return h + 'h ' + (m % 60) + 'm';
|
|
}
|
|
// Wall-clock HH:MM:SS (UTC, matching the inbox timestamps on this page)
|
|
// from a unix-seconds value. Used to label turn-start / turn-end rows
|
|
// when the event carries a `ts` (see the turn renderers below).
|
|
function fmtClock(sec) {
|
|
return new Date(sec * 1000).toISOString().slice(11, 19);
|
|
}
|
|
// Unix-seconds stamp of the most recent open turn-start, so the
|
|
// matching turn-end can show a duration. Turns are sequential, so a
|
|
// single slot is enough (history replays chronologically too).
|
|
let pendingTurnStartTs = null;
|
|
const STATE_TOOLTIPS = {
|
|
loading: 'harness not yet contacted',
|
|
offline: 'harness unreachable or claude not logged in',
|
|
idle: 'turn loop running, no claude invocation in flight',
|
|
thinking: 'claude is executing the current turn',
|
|
compacting: 'operator-triggered /compact running on the persistent session',
|
|
};
|
|
function renderStateBadge() {
|
|
const badge = $('state-badge');
|
|
if (!badge) return;
|
|
const def = STATE_LABELS[stateName] || STATE_LABELS.loading;
|
|
const age = fmtAge(Date.now() - stateSince);
|
|
badge.textContent = def.glyph + ' ' + def.text + ' · ' + age;
|
|
badge.className = 'hive-pill state-badge state-' + stateName;
|
|
badge.title = (STATE_TOOLTIPS[stateName] || '') + '\nin this state for ' + age;
|
|
const cancelBtn = $('cancel-btn');
|
|
if (cancelBtn) cancelBtn.hidden = stateName !== 'thinking';
|
|
}
|
|
function setState(next) {
|
|
setStateAbs(next, Math.floor(Date.now() / 1000));
|
|
}
|
|
/// Set state with an authoritative since-unix from the server. Lets
|
|
/// `last turn` track the actual server-side duration rather than
|
|
/// whatever the client perceived between SSE events.
|
|
function setStateAbs(next, sinceUnix) {
|
|
if (next === stateName && sinceUnix * 1000 === stateSince) return;
|
|
if (stateName === 'thinking' && next !== 'thinking') {
|
|
const elapsedMs = Date.now() - stateSince;
|
|
renderLastTurn(elapsedMs);
|
|
}
|
|
const flashing = next !== stateName;
|
|
stateName = next;
|
|
stateSince = sinceUnix * 1000;
|
|
const badge = $('state-badge');
|
|
if (badge && flashing) {
|
|
badge.classList.remove('state-just-changed');
|
|
void badge.offsetWidth;
|
|
badge.classList.add('state-just-changed');
|
|
}
|
|
renderStateBadge();
|
|
}
|
|
// Todos section: in-agent todos (loose-ends v2) pushed by subsystems —
|
|
// matrix, forge, bash are the built-in ones, but any user-configured MCP
|
|
// server can push its own. Best-effort fetch on cold load + after every
|
|
// turn_end. Silent failure keeps the pill at zero.
|
|
async function refreshTodos() {
|
|
try {
|
|
const resp = await fetch('api/todos');
|
|
if (!resp.ok) {
|
|
renderTodos([]);
|
|
return;
|
|
}
|
|
const data = await resp.json();
|
|
renderTodos(data.todos || []);
|
|
} catch (err) {
|
|
console.warn('todos fetch failed', err);
|
|
renderTodos([]);
|
|
}
|
|
}
|
|
/** Latest snapshot kept in module state so the pill click handler
|
|
* has fresh data to render into the panel without re-fetching. */
|
|
let lastTodos = [];
|
|
let lastInbox = [];
|
|
|
|
/** Bulk "mark done" row for the todos flyout: select all / select none
|
|
* + a mark-done button, disabled until at least one row is checked.
|
|
* POSTs the checked ids (comma-joined into one field, same shape as
|
|
* `hive-c0re`'s meta-inputs bulk form — axum's `Form` extractor doesn't
|
|
* natively decode repeated same-name keys) to this agent's own
|
|
* `/api/todos/mark-done`, then calls `refreshTodos()` on success so the
|
|
* flyout reloads without the now-dismissed rows. `wrap` is the panel
|
|
* root — bulk buttons read/toggle the checkboxes it contains. */
|
|
function buildTodosMarkDoneRow(wrap) {
|
|
const status = el('span', { class: 'inbox-mark-status' });
|
|
const selAll = el('button', { type: 'button', class: 'inbox-mark-all-btn' }, 'select all');
|
|
const selNone = el('button', { type: 'button', class: 'inbox-mark-all-btn' }, 'select none');
|
|
const markBtn = el('button', {
|
|
type: 'button', class: 'inbox-mark-all-btn', disabled: '',
|
|
}, '✓ mark done');
|
|
const checkboxes = () => Array.from(wrap.querySelectorAll('input[data-todo-id]'));
|
|
const refreshDisabled = () => {
|
|
const any = checkboxes().some((cb) => cb.checked);
|
|
if (any) markBtn.removeAttribute('disabled');
|
|
else markBtn.setAttribute('disabled', '');
|
|
};
|
|
selAll.addEventListener('click', () => {
|
|
checkboxes().forEach((cb) => { cb.checked = true; });
|
|
refreshDisabled();
|
|
});
|
|
selNone.addEventListener('click', () => {
|
|
checkboxes().forEach((cb) => { cb.checked = false; });
|
|
refreshDisabled();
|
|
});
|
|
wrap.addEventListener('change', (e) => {
|
|
if (e.target.matches('input[data-todo-id]')) refreshDisabled();
|
|
});
|
|
markBtn.addEventListener('click', () => {
|
|
const ids = checkboxes().filter((cb) => cb.checked).map((cb) => cb.dataset.todoId);
|
|
if (!ids.length) return;
|
|
status.textContent = 'marking…';
|
|
asyncBtn(markBtn, async () => {
|
|
try {
|
|
const resp = await fetch('api/todos/mark-done', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body: 'ids=' + encodeURIComponent(ids.join(',')),
|
|
});
|
|
if (resp.ok) {
|
|
status.textContent = '✓ marked done';
|
|
refreshTodos();
|
|
} else {
|
|
status.textContent = 'failed: ' + (await resp.text());
|
|
}
|
|
} catch (err) {
|
|
status.textContent = 'failed: ' + err;
|
|
}
|
|
});
|
|
});
|
|
return el('div', { class: 'inbox-mark-all-row' }, selAll, selNone, markBtn, status);
|
|
}
|
|
|
|
/** Build the todos side-panel list. Each entry is a LooseEnd::Todo
|
|
* (id, subsystem, summary, source, age_seconds). A checkbox per row
|
|
* plus the bulk row above lets the operator dismiss several at once
|
|
* instead of one `cancel_loose_end` call at a time. */
|
|
function buildTodosList(todos) {
|
|
const wrap = el('div', { class: 'agent-inbox' });
|
|
if (!todos.length) {
|
|
wrap.append(el('p', { class: 'side-panel-empty' },
|
|
'no todos — all subsystem queues are clear.'));
|
|
return wrap;
|
|
}
|
|
wrap.append(buildTodosMarkDoneRow(wrap));
|
|
const list = el('ul');
|
|
const fmtAge = (s) => {
|
|
if (s < 60) return s + 's';
|
|
if (s < 3600) return Math.floor(s / 60) + 'm';
|
|
if (s < 86400) return Math.floor(s / 3600) + 'h';
|
|
return Math.floor(s / 86400) + 'd';
|
|
};
|
|
for (const t of todos) {
|
|
const li = el('li');
|
|
const label = t.source ? t.subsystem + ' · ' + t.source : t.subsystem;
|
|
const cbId = 'todo-cb-' + t.id;
|
|
const cb = el('input', {
|
|
type: 'checkbox', id: cbId, class: 'todo-cb', 'data-todo-id': String(t.id),
|
|
});
|
|
li.append(
|
|
cb, ' ',
|
|
el('label', { for: cbId, class: 'inbox-from' }, label), ' ',
|
|
el('span', { class: 'inbox-ts' }, fmtAge(t.age_seconds || 0) + ' ago'),
|
|
el('div', { class: 'inbox-body' }, t.summary || ''),
|
|
);
|
|
list.append(li);
|
|
}
|
|
wrap.append(list);
|
|
return wrap;
|
|
}
|
|
|
|
/** Pill-count + open-panel-refresh wiring for todos. */
|
|
function renderTodos(todos) {
|
|
lastTodos = todos;
|
|
const pill = $('todos-pill');
|
|
const count = $('todos-count');
|
|
if (count) count.textContent = todos.length;
|
|
if (pill) pill.hidden = todos.length === 0;
|
|
sidePanel.refresh('todos', 'todos · ' + todos.length,
|
|
buildTodosList(todos));
|
|
}
|
|
|
|
/** "mark all read" affordance for the agent's inbox flyout —
|
|
* see docs/web-ui.md::Per-agent page for the inbox flyout's
|
|
* cross-origin POST + count rendering. Returns a DOM row
|
|
* containing a button + an inline status pill; re-runs
|
|
* `onCleared` on success so the caller can refresh its own state. */
|
|
function buildInboxMarkAllRow(label, onCleared) {
|
|
const status = el('span', { class: 'inbox-mark-status' });
|
|
const btn = el('button', {
|
|
type: 'button',
|
|
class: 'inbox-mark-all-btn',
|
|
title: 'mark every queued message for this agent as read — '
|
|
+ 'drains the host broker\'s pending + delivered-unacked rows. '
|
|
+ 'history shown here is the most-recent-N regardless of state, '
|
|
+ 'so the list itself stays visible.',
|
|
}, '✓ mark all read');
|
|
btn.addEventListener('click', async () => {
|
|
if (!dashboardBase) { status.textContent = 'dashboard url unknown'; return; }
|
|
if (!label) { status.textContent = 'agent label unknown'; return; }
|
|
if (!(await themedConfirm({
|
|
message: `mark every queued message for ${label} as read? `
|
|
+ `the message history shown stays; only the unread queue is drained.`,
|
|
confirmLabel: '✓ mark all read',
|
|
}))) return;
|
|
status.textContent = 'clearing…';
|
|
asyncBtn(btn, async () => {
|
|
try {
|
|
const resp = await fetch(
|
|
dashboardBase + 'api/agent/' + encodeURIComponent(label) + '/mark-all-read',
|
|
{ method: 'POST' });
|
|
if (resp.ok) {
|
|
const data = await resp.json().catch(() => ({}));
|
|
const n = Number(data.marked) || 0;
|
|
status.textContent = '✓ marked ' + n + ' as read';
|
|
if (typeof onCleared === 'function') onCleared();
|
|
} else {
|
|
status.textContent = 'failed: http ' + resp.status;
|
|
}
|
|
} catch (err) {
|
|
status.textContent = 'failed: ' + err;
|
|
}
|
|
});
|
|
});
|
|
return el('div', { class: 'inbox-mark-all-row' }, btn, status);
|
|
}
|
|
|
|
function buildInboxList(rows) {
|
|
const wrap = el('div', { class: 'agent-inbox' });
|
|
if (!rows.length) {
|
|
wrap.append(el('p', { class: 'side-panel-empty' },
|
|
'inbox empty.'));
|
|
return wrap;
|
|
}
|
|
// "mark all read" header row drains the host broker's pending +
|
|
// delivered-unacked rows for this agent. The inbox shows only
|
|
// unread (acked_at IS NULL) rows, so after the drain + refreshState()
|
|
// the list empties (matching the operator's expectation).
|
|
wrap.append(buildInboxMarkAllRow(currentLabel, () => {
|
|
// Refresh state so any UI surface that DOES depend on
|
|
// delivery state (eg future per-status filters) picks up
|
|
// the new shape.
|
|
refreshState();
|
|
}));
|
|
const list = el('ul');
|
|
const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(5, 19);
|
|
for (const m of rows) {
|
|
const li = el('li', m.in_reply_to != null ? { class: 'inbox-reply' } : {});
|
|
if (m.in_reply_to != null) {
|
|
li.append(el('span', { class: 'inbox-reply-tag' }, '↳ reply · '));
|
|
}
|
|
li.append(
|
|
el('span', { class: 'inbox-ts' }, fmt(m.at)), ' ',
|
|
el('span', { class: 'inbox-from' }, m.from), ' ',
|
|
el('span', { class: 'inbox-sep' }, '→'), ' ',
|
|
el('span', { class: 'inbox-body' }, m.body),
|
|
);
|
|
list.append(li);
|
|
}
|
|
wrap.append(list);
|
|
return wrap;
|
|
}
|
|
|
|
/** Pill-count + open-panel-refresh wiring for inbox. */
|
|
function renderInbox(rows) {
|
|
lastInbox = rows;
|
|
const pill = $('inbox-pill');
|
|
const count = $('inbox-count');
|
|
if (count) count.textContent = rows.length;
|
|
if (pill) pill.hidden = rows.length === 0;
|
|
sidePanel.refresh('inbox', 'inbox · ' + rows.length, buildInboxList(rows));
|
|
}
|
|
// Harness reachability badge: derived from the same `s.status` the
|
|
// status block reads. Each status maps to a glyph + label + colour
|
|
// class. Lives in the state row so the operator sees boot/login/
|
|
// online without losing terminal real-estate to a paragraph.
|
|
const ALIVE_LABELS = {
|
|
loading: { glyph: '…', text: 'connecting', cls: 'status-loading' },
|
|
online: { glyph: '●', text: 'alive', cls: 'status-online' },
|
|
rate_limited: { glyph: '⊘', text: 'rate limited', cls: 'status-rate-limited' },
|
|
needs_login_idle: { glyph: '◌', text: 'needs login', cls: 'status-needs-login' },
|
|
needs_login_in_progress: { glyph: '◌', text: 'logging in', cls: 'status-needs-login' },
|
|
offline: { glyph: '○', text: 'offline', cls: 'status-offline' },
|
|
};
|
|
function renderAliveBadge(status) {
|
|
const el_ = $('alive-badge');
|
|
if (!el_) return;
|
|
const def = ALIVE_LABELS[status] || ALIVE_LABELS.loading;
|
|
el_.textContent = def.glyph + ' ' + def.text;
|
|
el_.className = 'hive-pill status-badge ' + def.cls;
|
|
}
|
|
|
|
function renderModelChip(model) {
|
|
currentModel = model || null;
|
|
const el_ = $('model-chip');
|
|
if (!el_) return;
|
|
if (!model) { el_.hidden = true; } else {
|
|
el_.hidden = false;
|
|
el_.textContent = 'model · ' + model;
|
|
el_.title = `claude --model ${model}\nset via the operator's /model command; persists across turns until changed`;
|
|
}
|
|
// Sync model picker buttons: highlight the active alias. A short alias
|
|
// (haiku/sonnet/opus) matches if `model` ends with that string; full
|
|
// API names like `claude-3-5-haiku-20241022` match on the suffix too.
|
|
for (const btn of modelPickerBtns) {
|
|
const alias = btn.dataset.model || '';
|
|
const isActive = !!model && (model === alias || model.endsWith(alias));
|
|
btn.classList.toggle('active', isActive);
|
|
btn.setAttribute('aria-pressed', String(isActive));
|
|
}
|
|
}
|
|
|
|
function renderEffortChip(effort) {
|
|
currentEffort = effort || null;
|
|
const el_ = $('effort-chip');
|
|
if (el_) {
|
|
if (!effort) { el_.hidden = true; } else {
|
|
el_.hidden = false;
|
|
el_.textContent = 'effort · ' + effort;
|
|
el_.title = `reasoning effort: ${effort}\nset via the operator's effort picker; applies on the next claude session`;
|
|
}
|
|
}
|
|
// Sync effort picker buttons: highlight the active level (exact match).
|
|
for (const btn of effortPickerBtns) {
|
|
const isActive = !!effort && btn.dataset.effort === effort;
|
|
btn.classList.toggle('active', isActive);
|
|
btn.setAttribute('aria-pressed', String(isActive));
|
|
}
|
|
}
|
|
|
|
// Flips the overflow menu's pause/resume item to match the backend's
|
|
// reported `state.paused` (harness-local marker stat, cheap to refresh
|
|
// on every /api/state poll) — without this, an operator who pauses from
|
|
// the *dashboard* while this page is open would still see a stale
|
|
// "pause agent" item here, offering the wrong action.
|
|
function renderPausedChip(paused) {
|
|
currentPaused = !!paused;
|
|
const btn = $('pause-btn');
|
|
if (!btn) return;
|
|
btn.dataset.paused = String(currentPaused);
|
|
const icon = btn.querySelector('.overflow-item-icon');
|
|
const label_ = $('pause-btn-label');
|
|
if (icon) icon.textContent = currentPaused ? '▶' : '⏸';
|
|
if (label_) label_.textContent = currentPaused ? 'resume agent' : 'pause agent';
|
|
btn.title = currentPaused
|
|
? 'resume this agent — the turn loop restarts and drains queued messages'
|
|
: 'pause this agent — parks the turn loop, inbox messages queue unacked';
|
|
}
|
|
// Token badges — two separate chips:
|
|
// ctx · N last inference's prompt size = current context window
|
|
// utilisation (what to watch for compaction decisions)
|
|
// cost · M cumulative billed tokens across the whole last turn
|
|
// (sum across every inference; tool-heavy turns rebill
|
|
// the cached prompt per call and blow past the model's
|
|
// context window — this is a cost signal, not a size
|
|
// signal)
|
|
// Both fed by the same `token_usage_changed` SSE event (`{ ctx, cost }`).
|
|
const fmtTokens = (n) => {
|
|
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
|
|
if (n >= 1_000) return Math.round(n / 1000) + 'k';
|
|
return String(n);
|
|
};
|
|
function renderOneUsage(elId, label, u, blurb) {
|
|
const el_ = $(elId);
|
|
if (!el_) return;
|
|
if (!u) { el_.hidden = true; return; }
|
|
const total = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens;
|
|
el_.hidden = false;
|
|
el_.title = [
|
|
blurb,
|
|
'input: ' + u.input_tokens,
|
|
'cache_read: ' + u.cache_read_input_tokens,
|
|
'cache_write: ' + u.cache_creation_input_tokens,
|
|
'output: ' + u.output_tokens,
|
|
].join('\n');
|
|
el_.textContent = label + ' · ' + fmtTokens(total);
|
|
}
|
|
function renderTokenUsage(ev) {
|
|
// `ev` is `{ ctx, cost }` either off /api/state cold-load (each may
|
|
// be null) or off a `token_usage_changed` SSE event (both present
|
|
// post-turn).
|
|
renderOneUsage('ctx-badge', 'ctx', ev && ev.ctx,
|
|
'last-inference prompt size — the actual context window in use right now');
|
|
renderOneUsage('cost-badge', 'cost', ev && ev.cost,
|
|
'cumulative tokens billed across the last turn (sum across every inference)');
|
|
}
|
|
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_.title = `wall-clock duration of the last completed claude turn (${ms} ms)`;
|
|
el_.hidden = false;
|
|
}
|
|
function startStateTicker() {
|
|
if (stateTickTimer) return;
|
|
stateTickTimer = setInterval(renderStateBadge, 1000);
|
|
}
|
|
startStateTicker();
|
|
|
|
// Wire the cancel-turn button (visible only while state === thinking).
|
|
(() => {
|
|
const btn = $('cancel-btn');
|
|
if (!btn) return;
|
|
btn.addEventListener('click', () => {
|
|
btn.disabled = true;
|
|
postCancelTurn().finally(() => { btn.disabled = false; });
|
|
});
|
|
})();
|
|
|
|
// Track banner activity by reference-counting in-flight turns. A turn
|
|
// can begin while the previous turn_end is still in the pipeline (rare
|
|
// but happens on tight wake cycles), so we count rather than toggle.
|
|
let activeTurns = 0;
|
|
function setBannerActive(on) {
|
|
const banner = $('banner');
|
|
if (!banner) return;
|
|
if (on) {
|
|
activeTurns += 1;
|
|
banner.classList.add('active');
|
|
} else {
|
|
activeTurns = Math.max(0, activeTurns - 1);
|
|
if (activeTurns === 0) banner.classList.remove('active');
|
|
}
|
|
}
|
|
|
|
async function refreshState() {
|
|
try {
|
|
const resp = await fetch('api/state');
|
|
if (!resp.ok) throw new Error('http ' + resp.status);
|
|
const s = await resp.json();
|
|
// Seed available_models before populateOverflowMenu (called from
|
|
// setHeader on the first load) so the picker uses the operator-declared
|
|
// list rather than the JS fallback default.
|
|
if (Array.isArray(s.available_models) && s.available_models.length > 0) {
|
|
availableModels = s.available_models;
|
|
}
|
|
if (Array.isArray(s.available_efforts) && s.available_efforts.length > 0) {
|
|
availableEfforts = s.available_efforts;
|
|
}
|
|
if (!headerSet) { setHeader(s.label, s.qualified_label, s.dashboard_port, s.hive_name, s.swarm_name); headerSet = true; }
|
|
currentLabel = s.label;
|
|
// Render server-supplied navigation links — see
|
|
// docs/web-ui.md::Per-agent page (meta-nav) for the NavLink
|
|
// kind → URL resolution table + XSS-safe DOM-build rationale.
|
|
const metaLinks = $('meta-links');
|
|
if (metaLinks && Array.isArray(s.links)) {
|
|
metaLinks.replaceChildren();
|
|
// s.forge_public_url (set from services.hyperhive.forge.publicUrl)
|
|
// or falsy — never guessed from "<hostname>:3000". A forge-kind
|
|
// link is skipped entirely below when there's no public URL to
|
|
// point it at.
|
|
const forgeBase = s.forge_public_url || null;
|
|
s.links.forEach((lnk, i) => {
|
|
if (lnk.kind === 'forge' && !forgeBase) return;
|
|
const href = lnk.kind === 'forge' ? forgeBase + (lnk.url || '')
|
|
: lnk.kind === 'external' ? (lnk.url || '')
|
|
: /* container */ (lnk.url || '');
|
|
const a = el('a', {
|
|
class: 'agent-nav-link',
|
|
href,
|
|
target: '_blank',
|
|
rel: 'noopener',
|
|
title: lnk.label || '',
|
|
});
|
|
// Layout gap comes from `.agent-nav { gap }`. The trailing
|
|
// `→` is the "leaves this page" affordance.
|
|
a.append(((lnk.icon || '') + ' ' + (lnk.label || '')).trim() + ' →');
|
|
metaLinks.append(a);
|
|
});
|
|
}
|
|
renderTermInput(s.label, s.status === 'online');
|
|
renderInbox(s.inbox || []);
|
|
// Authoritative state comes from the harness via /api/state.
|
|
// Login-not-yet → 'offline'; otherwise use the server-reported
|
|
// turn_state (idle / thinking / compacting). stateSince in
|
|
// unix-seconds is converted to a client-side Date.now() anchor.
|
|
if (s.status !== 'online') {
|
|
setState('offline');
|
|
} else if (s.turn_state) {
|
|
setStateAbs(s.turn_state, s.turn_state_since);
|
|
}
|
|
renderAliveBadge(s.status);
|
|
renderModelChip(s.model);
|
|
renderEffortChip(s.effort);
|
|
renderPausedChip(s.paused);
|
|
renderTokenUsage({ ctx: s.ctx_usage, cost: s.cost_usage });
|
|
// Todos pill: cold-load populate; turn_end refreshes via renderTodos.
|
|
refreshTodos();
|
|
// Skip the re-render if nothing structurally changed. The most
|
|
// common case is `online` polling itself — without this guard, the
|
|
// operator's <input value> gets clobbered every cycle.
|
|
const outLen = s.session?.output?.length ?? -1;
|
|
const dirty =
|
|
s.status !== lastStatus ||
|
|
(s.status === 'needs_login_in_progress' && outLen !== lastOutputLen);
|
|
if (dirty) {
|
|
const root = $('status');
|
|
root.replaceChildren();
|
|
if (s.status === 'online') renderOnline(s.label, root);
|
|
else if (s.status === 'needs_login_idle') renderNeedsLoginIdle(root);
|
|
else if (s.status === 'needs_login_in_progress') renderLoginInProgress(s.session || {}, root);
|
|
lastStatus = s.status;
|
|
lastOutputLen = outLen;
|
|
}
|
|
// Only poll while a login is in flight — otherwise SSE turn_end
|
|
// events trigger a refresh, and the operator can type into the
|
|
// send form without it getting cleared every few seconds.
|
|
if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }
|
|
if (s.status === 'needs_login_in_progress') {
|
|
pollTimer = setTimeout(refreshState, 1500);
|
|
}
|
|
} catch (err) {
|
|
console.error('refreshState failed', err);
|
|
pollTimer = setTimeout(refreshState, 5000);
|
|
}
|
|
}
|
|
refreshState();
|
|
// Keep the todos pill live. Todos change asynchronously (matrix syncs,
|
|
// bash task starts/completions) independent of turn_end SSE, so poll
|
|
// the snapshot endpoint on a light interval. Fails silently when offline.
|
|
setInterval(refreshTodos, 4000);
|
|
|
|
// ─── live event stream ──────────────────────────────────────────────────
|
|
// Scrolling, pill, backfill + SSE plumbing live in hive-fr0nt::TERMINAL_JS
|
|
// (window.HiveTerminal). What stays here is the per-kind rendering:
|
|
// turn framing, claude stream-json interpretation, tool_use prettyprint,
|
|
// tool_result collapse, +/- diff bodies for Write/Edit.
|
|
(function() {
|
|
const log = $('live');
|
|
if (!log || !window.HiveTerminal) return;
|
|
log.replaceChildren();
|
|
|
|
function trim(s, n) { return s.length > n ? s.slice(0, n) + '…' : s; }
|
|
// Render a message body as markdown into a new <div class="md">.
|
|
// Wraps `marked.parse` so the per-row body element carries the
|
|
// `.md` class (CSS in TERMINAL_CSS scopes paragraph/code/list
|
|
// styles to it). Falls back to a plain text node if marked isn't
|
|
// loaded (network glitch, asset 404) so the body still renders.
|
|
// `text` is untrusted (peer-agent / matrix-relayed message bodies,
|
|
// agent-authored files) — the parsed HTML is run through DOMPurify
|
|
// before it ever touches innerHTML, since markdown can carry raw
|
|
// HTML/script tags that `marked` itself no longer strips (v5+
|
|
// dropped the built-in sanitizer).
|
|
function mdNode(text) {
|
|
const div = document.createElement('div');
|
|
div.className = 'md';
|
|
const src = String(text || '');
|
|
if (window.marked && typeof window.marked.parse === 'function') {
|
|
try {
|
|
marked.setOptions({ breaks: true, gfm: true });
|
|
div.innerHTML = DOMPurify.sanitize(marked.parse(src));
|
|
// marked autolinks URLs but leaves them same-tab — open them
|
|
// externally so a click never unloads the terminal.
|
|
div.querySelectorAll('a[href]').forEach((a) => {
|
|
a.target = '_blank';
|
|
a.rel = 'noopener noreferrer';
|
|
});
|
|
} catch (err) {
|
|
console.warn('marked failed', err);
|
|
div.textContent = src;
|
|
}
|
|
} else {
|
|
div.textContent = src;
|
|
}
|
|
return div;
|
|
}
|
|
// Build a default-open details row whose body is markdown-rendered.
|
|
// Used by send / ask / answer tool_use renderers and by `recv`
|
|
// tool_result so message-bearing rows show their content inline
|
|
// without an extra click.
|
|
function detailsOpenMd(api, cls, summary, body, icon) {
|
|
const d = api.details(cls, summary, '', icon);
|
|
d.open = true;
|
|
const pre = d.querySelector('pre.tool-body');
|
|
if (pre) {
|
|
pre.replaceWith(mdNode(body));
|
|
} else {
|
|
d.appendChild(mdNode(body));
|
|
}
|
|
return d;
|
|
}
|
|
// Build a "rich" tool_use row for tools whose input has a body we
|
|
// want the operator to see in full. Returns null for any other tool
|
|
// so the caller falls back to the flat-row path.
|
|
// Write: every input.content line is "+".
|
|
// Edit: old_string lines as "-", new_string lines as "+".
|
|
// mcp__hyperhive__send: collapsed <details>, full body text inside.
|
|
function renderRichToolUse(c, api) {
|
|
const name = c.name || '';
|
|
const icon = c._icon || '🔧';
|
|
// Message-bearing tools render default-open with a markdown body so
|
|
// the operator sees the content without an extra click. send / ask
|
|
// address a target; answer attaches to an existing question id.
|
|
// Generic backend-computed body: the backend stamps `_body` + `_body_type`
|
|
// for tools whose expandable content is pre-computable. Dispatch on type —
|
|
// no tool-specific JS needed for most cases.
|
|
if (c._body != null) {
|
|
const summary = c._summary || name || '?';
|
|
if (c._body_type === 'diff') {
|
|
return api.detailsDiff('tool-use', summary, c._body, icon);
|
|
}
|
|
if (c._body_type === 'markdown') {
|
|
return detailsOpenMd(api, 'tool-use', summary, c._body, icon);
|
|
}
|
|
return api.details('tool-use', summary, c._body, icon);
|
|
}
|
|
return null;
|
|
}
|
|
// Track tool_use_id → tool name so we can decide on rendering when the
|
|
// matching tool_result lands later. Lets us default-open the body for
|
|
// message-bearing tools (`recv`) while keeping shell/file tool output
|
|
// collapsed unless the operator clicks. Cleared on /clear; otherwise
|
|
// grows with the session — entries are tiny strings.
|
|
const toolNameById = new Map();
|
|
function renderToolResult(c, api) {
|
|
const rawTxt = Array.isArray(c.content)
|
|
? c.content.map(p => p.text || '').join('')
|
|
: (c.content || '');
|
|
// Strip the <tool_use_error>…</tool_use_error> wrapper that claude
|
|
// emits when a tool call fails — the tags are an implementation detail
|
|
// and add noise to the terminal display.
|
|
const isError = !!c.is_error;
|
|
const txt = isError
|
|
? rawTxt.replace(/^<tool_use_error>([\s\S]*)<\/tool_use_error>$/, '$1').trim()
|
|
: rawTxt;
|
|
const sourceName = c.tool_use_id ? toolNameById.get(c.tool_use_id) : null;
|
|
const isMessageBearing = sourceName === 'mcp__hyperhive__recv';
|
|
// When an ask's tool_result lands the broker has just
|
|
const trimmed = txt.replace(/\s+/g, ' ').trim();
|
|
const summaryBody = (() => {
|
|
if (!trimmed) return '(empty)';
|
|
if (trimmed.length <= 120) return trimmed;
|
|
const lines = txt.split('\n').filter(l => l.length).length;
|
|
const headline = trimmed.slice(0, 90) + '…';
|
|
return `${lines}L · ${headline}`;
|
|
})();
|
|
if (isError) {
|
|
if (!txt.trim() || txt.length <= 120) {
|
|
api.row('tool-result error', '✗ ' + summaryBody);
|
|
} else {
|
|
api.details('tool-result-block error', summaryBody, txt);
|
|
}
|
|
return;
|
|
}
|
|
// Flat row: keep the `←` glyph in the prefix column. Details rows
|
|
// drop it — the `▸/▾` disclosure marker sits in that column via CSS.
|
|
if (isMessageBearing && txt.trim()) {
|
|
return detailsOpenMd(api, 'tool-result-block',
|
|
'recv ← ' + summaryBody, txt);
|
|
}
|
|
if (!txt.trim() || txt.length <= 120) {
|
|
api.row('tool-result', '← ' + summaryBody);
|
|
} else {
|
|
api.details('tool-result-block', summaryBody, txt);
|
|
}
|
|
}
|
|
// Pretty-render claude's background-task subagent events
|
|
// (`task_started`, `task_notification`). They share the same
|
|
// task_id so the operator can correlate start ↔ result; render
|
|
// each as a peer of tool_use / tool_result with a `⌁` glyph to
|
|
// mark "this happened in a subagent" rather than the main
|
|
// session.
|
|
function renderTaskEvent(v, api) {
|
|
const id = (v.task_id || '').slice(0, 8);
|
|
const kind = v.task_type ? ` [${v.task_type}]` : '';
|
|
const desc = v.description || v.summary || '(no description)';
|
|
if (v.subtype === 'task_started') {
|
|
api.row('tool-use', `⌁ task ${id} started · ${desc}${kind}`);
|
|
return true;
|
|
}
|
|
if (v.subtype === 'task_notification') {
|
|
const status = v.status || 'unknown';
|
|
const glyph = status === 'completed' ? '✓' : status === 'failed' ? '✗' : '◌';
|
|
const cls = status === 'completed' ? 'turn-end-ok'
|
|
: status === 'failed' ? 'turn-end-fail'
|
|
: 'tool-result';
|
|
const out = v.output_file ? ` · → ${v.output_file}` : '';
|
|
api.row(cls, `⌁ task ${id} ${glyph} ${status} · ${desc}${out}`);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
// Coalescing helper: collapses a burst of same-kind ticks into ONE
|
|
// row that updates in place instead of appending a fresh note per
|
|
// tick. Holds the row + its text node in a closure; reuses them only
|
|
// while the row is still the last one rendered (nextElementSibling
|
|
// === null) — any other event in between starts a fresh row. `cls`
|
|
// and `icon` are fixed per coalescer instance (mirrors `api.mutableRow`'s
|
|
// signature); `api` is passed per-call since `renderStream` receives a
|
|
// fresh one each invocation.
|
|
function makeCoalescer(cls, icon) {
|
|
let row = null;
|
|
let text = null;
|
|
return function update(api, newText) {
|
|
if (row && row.isConnected && row.nextElementSibling === null) {
|
|
text.nodeValue = newText;
|
|
} else {
|
|
[row, text] = api.mutableRow(cls, newText, icon);
|
|
}
|
|
};
|
|
}
|
|
// Live `thinking_tokens` counter — claude streams many of these per turn.
|
|
const updateThinkingTokens = makeCoalescer('note', '🧠');
|
|
// Bare `status` ticks (claude's generic "still working" signal) — the
|
|
// only signal during a compaction pass (no dedicated "compacting…"
|
|
// event), so without coalescing a compaction looked like a wall of
|
|
// identical `⚙ status` rows.
|
|
const updateStatus = makeCoalescer('note');
|
|
// `plugin_install`: claude emits a `started` tick then a `completed`
|
|
// tick per plugin — without coalescing that's two rows ("loading…"
|
|
// then "✓ done") for what's really one event.
|
|
const updatePluginInstall = makeCoalescer('note');
|
|
function renderStream(v, api) {
|
|
// Backend pre-computes `_category` on all known event types.
|
|
// "drop" covers: type=result, type=rate_limit_event, and system/init.
|
|
if (v._category === 'drop') return;
|
|
|
|
if (v.type === 'system') {
|
|
const cat = v._category;
|
|
const summary = v._summary;
|
|
// thinking_tok: collapse many ticks into one in-place counter row.
|
|
if (cat === 'thinking_tok') {
|
|
updateThinkingTokens(api, summary || 'thinking…');
|
|
return;
|
|
}
|
|
// plugin_install: coalesced in-place row while the plugin loads.
|
|
if (v.subtype === 'plugin_install') {
|
|
updatePluginInstall(api, summary || '⚙ plugin install');
|
|
return;
|
|
}
|
|
// status: backend provides the base label; when the harness state is
|
|
// `compacting` we override with elapsed time from `stateSince` — a
|
|
// client-side wall-clock value the backend can't know at emit time.
|
|
if (v.subtype === 'status') {
|
|
let label = summary || '⚙ status';
|
|
if (stateName === 'compacting') {
|
|
const elapsed = Math.round((Date.now() - stateSince) / 1000);
|
|
label = '⚙ compact · ' + elapsed + 's…';
|
|
}
|
|
updateStatus(api, label);
|
|
return;
|
|
}
|
|
// details: expandable row with _summary as header, _body as content.
|
|
if (cat === 'details') {
|
|
api.details('note', summary || '⚙ ' + (v.subtype || ''), v._body || '');
|
|
return;
|
|
}
|
|
// note (and any unknown category): single summary line.
|
|
api.row('note', summary || '⚙ ' + (v.subtype || 'system'));
|
|
return;
|
|
}
|
|
// Background-task subagent events (claude's `Task` tool spawns
|
|
// a separate session whose progress lands here as `task_*`
|
|
// subtypes). Match by subtype so we don't have to track which
|
|
// top-level `type` claude wraps them under across versions.
|
|
if (v.subtype === 'task_started' || v.subtype === 'task_notification') {
|
|
if (renderTaskEvent(v, api)) return;
|
|
}
|
|
if (v.type === 'assistant' && v.message && v.message.content) {
|
|
for (const c of v.message.content) {
|
|
if (c.type === 'text' && c.text && c.text.trim()) {
|
|
// Assistant prose renders with markdown — claude often
|
|
// emits bullets / fenced code / inline code; raw text
|
|
// loses the structure.
|
|
const row = api.row('text', '');
|
|
row.appendChild(mdNode(c.text));
|
|
}
|
|
else if (c.type === 'thinking') {
|
|
const txt = (c.thinking || c.text || '').trim();
|
|
api.row('thinking', txt || 'thinking …', '💭');
|
|
}
|
|
else if (c.type === 'tool_use') {
|
|
if (c.id && c.name) toolNameById.set(c.id, c.name);
|
|
// `_category: "rich"` is stamped by the backend on tools that
|
|
// have full-body renderers (Write/Edit diffs, send/ask/answer
|
|
// message bodies). Flat-row tools use backend _icon/_summary.
|
|
if (c._category === 'rich') {
|
|
if (!renderRichToolUse(c, api)) {
|
|
api.row('tool-use', c._summary || c.name || '?', c._icon || '🔧');
|
|
}
|
|
} else {
|
|
api.row('tool-use', c._summary || c.name || '?', c._icon || '🔧');
|
|
}
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
if (v.type === 'user' && v.message && v.message.content) {
|
|
for (const c of v.message.content) {
|
|
if (c.type === 'tool_result') renderToolResult(c, api);
|
|
}
|
|
return;
|
|
}
|
|
// Catch-all for unrecognised stream-json shapes. Loud (orange) so
|
|
// silently-dropped event types surface in the scrollback for
|
|
// follow-up classification.
|
|
api.row('sys', '! ' + trim(JSON.stringify(v), 200));
|
|
}
|
|
|
|
// Count open turns across the backfill replay so the live banner +
|
|
// state badge reflect whatever the history last left running. With
|
|
// shared HiveTerminal this is computed inside each renderer instead
|
|
// of in a second walk over the events list.
|
|
let openTurnsFromHistory = 0;
|
|
|
|
const term = HiveTerminal.create({
|
|
logEl: log,
|
|
// Anchor the `↓ N new` pill in `.agent-main` rather than the
|
|
// default `.terminal-wrap` parent — see docs/web-ui.md::Per-agent
|
|
// page (Terminal-wrap) for the backdrop-filter stacking-context
|
|
// gotcha.
|
|
pillAnchor: $('agent-main'),
|
|
// Path-relative URLs so the page mounted under a nginx prefix
|
|
// (e.g. /agent/<name>/) still hits the right SSE upstream.
|
|
historyUrl: 'events/history',
|
|
streamUrl: 'events/stream',
|
|
// Re-read live (not captured once) so flipping the overflow-menu
|
|
// toggle mid-session affects the next rendered row immediately.
|
|
expandDetails: () => getExpandDetailsPref(),
|
|
renderers: {
|
|
turn_start(ev, api) {
|
|
if (api.fromHistory) openTurnsFromHistory += 1;
|
|
else { setBannerActive(true); setState('thinking'); }
|
|
const block = api.row('turn-start', '◆ TURN ← ' + ev.from);
|
|
// Turn start time. Guarded on a numeric `ts` (unix seconds)
|
|
// so the row degrades to its old text-only form until the
|
|
// backend surfaces per-event timestamps.
|
|
if (typeof ev.ts === 'number') {
|
|
pendingTurnStartTs = ev.ts;
|
|
const t = document.createElement('span');
|
|
t.className = 'turn-time';
|
|
t.textContent = '· ' + fmtClock(ev.ts);
|
|
block.appendChild(t);
|
|
}
|
|
if (ev.unread > 0) {
|
|
const badge = document.createElement('span');
|
|
badge.className = 'unread-badge';
|
|
badge.textContent = '· ' + ev.unread + ' unread';
|
|
block.appendChild(badge);
|
|
}
|
|
const body = document.createElement('div');
|
|
body.className = 'turn-body';
|
|
body.textContent = ev.body;
|
|
block.appendChild(body);
|
|
},
|
|
turn_end(ev, api) {
|
|
if (api.fromHistory) {
|
|
openTurnsFromHistory = Math.max(0, openTurnsFromHistory - 1);
|
|
} else {
|
|
setBannerActive(false); setState('idle');
|
|
refreshTodos();
|
|
}
|
|
const cls = ev.ok ? 'turn-end-ok' : 'turn-end-fail';
|
|
const row = api.row(cls,
|
|
(ev.ok ? '✅' : '❌') + ' turn ' + (ev.ok ? 'ok' : 'fail')
|
|
+ (ev.note ? ' — ' + ev.note : ''));
|
|
// Turn end time + duration since the paired turn-start.
|
|
// Same `ts` guard as turn_start; duration only when we saw the
|
|
// matching start's stamp.
|
|
if (typeof ev.ts === 'number') {
|
|
const t = document.createElement('span');
|
|
t.className = 'turn-time';
|
|
let label = '· ' + fmtClock(ev.ts);
|
|
if (pendingTurnStartTs != null && ev.ts >= pendingTurnStartTs) {
|
|
label += ' · ' + fmtAge((ev.ts - pendingTurnStartTs) * 1000);
|
|
}
|
|
t.textContent = label;
|
|
row.appendChild(t);
|
|
}
|
|
pendingTurnStartTs = null;
|
|
},
|
|
note(ev, api) {
|
|
const t = String(ev.text || '');
|
|
// stderr lines coming off the claude pump get an orange `!`
|
|
// glyph so they're not visually fused with ambient harness
|
|
// chatter. Operator-initiated notes (/cancel, /compact,
|
|
// /model, new-session) get a mauve italic affordance so the
|
|
// scrollback distinguishes "the operator did this" from
|
|
// "the harness did this on its own."
|
|
if (t.startsWith('stderr:')) {
|
|
api.row('note stderr', '! ' + t);
|
|
} else if (t.startsWith('operator:')) {
|
|
api.row('note op', '· ' + t);
|
|
} else {
|
|
api.row('note', '· ' + t);
|
|
}
|
|
},
|
|
stream(ev, api) {
|
|
const v = Object.assign({}, ev); delete v.kind;
|
|
renderStream(v, api);
|
|
},
|
|
// Bus-driven state/badges. `status_changed` may also need a
|
|
// /api/state refresh to render the login `#status` block
|
|
// (which carries the OAuth URL + form), so we kick the
|
|
// existing refresh path on that transition. Online → only
|
|
// the badge updates; no /api/state fetch needed.
|
|
status_changed(ev, api) {
|
|
if (api.fromHistory) return;
|
|
renderAliveBadge(ev.status);
|
|
renderTermInput(currentLabel, ev.status === 'online');
|
|
// Login-flow transitions need the #status block rebuilt
|
|
// (it carries the OAuth URL + form). The existing
|
|
// refreshState path also re-arms the in-progress poll for
|
|
// session output streaming. Online → only the badge moves;
|
|
// no /api/state fetch is necessary.
|
|
if (ev.status !== 'online' && ev.status !== lastStatus) {
|
|
refreshState();
|
|
} else if (ev.status === 'online' && lastStatus !== 'online') {
|
|
// Status block stays as-is or shows the previous
|
|
// login UI; clear it so the operator sees a clean
|
|
// online state without a separate refetch.
|
|
const root = $('status');
|
|
if (root) root.replaceChildren();
|
|
lastStatus = 'online';
|
|
}
|
|
},
|
|
model_changed(ev, api) { if (!api.fromHistory) renderModelChip(ev.model); },
|
|
effort_changed(ev, api) { if (!api.fromHistory) renderEffortChip(ev.effort); },
|
|
token_usage_changed(ev, api) {
|
|
if (!api.fromHistory) renderTokenUsage({ ctx: ev.ctx, cost: ev.cost });
|
|
},
|
|
turn_state_changed(ev, api) {
|
|
if (!api.fromHistory) setStateAbs(ev.state, ev.since_unix);
|
|
},
|
|
},
|
|
onBackfillDone() {
|
|
// If the last replayed turn never closed, the banner shimmer +
|
|
// thinking badge should be on. Apply in one pass after replay.
|
|
for (let i = 0; i < openTurnsFromHistory; i++) setBannerActive(true);
|
|
if (openTurnsFromHistory > 0) setState('thinking');
|
|
},
|
|
});
|
|
|
|
// Expose the panel API for slash commands (`/help`, `/clear`).
|
|
termAPI = {
|
|
row: (cls, text) => term.row(cls, text),
|
|
clear: () => { log.replaceChildren(); },
|
|
};
|
|
})();
|
|
|
|
// Avoid unused-var lint while keeping `escText` available for future use.
|
|
void escText;
|
|
})();
|