Compare commits
2 changed files with 79 additions and 78 deletions
|
|
@ -823,13 +823,7 @@ through. Three flex columns (#394 redesign):
|
||||||
agent has a forge account; any `hyperhive.dashboardLinks` extras
|
agent has a forge account; any `hyperhive.dashboardLinks` extras
|
||||||
(`kind = External`). A `↑ dashboard` link is prepended by the JS
|
(`kind = External`). A `↑ dashboard` link is prepended by the JS
|
||||||
so the host dashboard is one click away. `GET /api/agent/{name}/links`
|
so the host dashboard is one click away. `GET /api/agent/{name}/links`
|
||||||
is the single source of truth. Each `NavLink.kind` resolves
|
is the single source of truth.
|
||||||
differently in the frontend: `Container` → same-origin path
|
|
||||||
(the agent page is itself container-local); `Forge` →
|
|
||||||
`http://<host>:3000<url>`; `External` → already absolute.
|
|
||||||
All anchors are built via `el()` — agent-declared icon /
|
|
||||||
label / url strings never reach `innerHTML` (XSS-safe by
|
|
||||||
construction).
|
|
||||||
- Row 2 (`.agent-state-row`): alive badge + state badge + model chip
|
- Row 2 (`.agent-state-row`): alive badge + state badge + model chip
|
||||||
+ ctx badge + cost badge + last-turn chip + cancel button.
|
+ ctx badge + cost badge + last-turn chip + cancel button.
|
||||||
- Alive badge: `● alive` (green) / `⊘ rate limited` (red) /
|
- Alive badge: `● alive` (green) / `⊘ rate limited` (red) /
|
||||||
|
|
@ -877,26 +871,11 @@ header `<h2 id="title">` stays short (#589 phase A).
|
||||||
and scrolls behind the fixed header + footer.
|
and scrolls behind the fixed header + footer.
|
||||||
- `#status` overlay: empty when online; shows the login form / OAuth
|
- `#status` overlay: empty when online; shows the login form / OAuth
|
||||||
URL when `status` is `needs_login_*`. The OAuth code input is
|
URL when `status` is `needs_login_*`. The OAuth code input is
|
||||||
`type="password"` with a `👁 reveal` toggle that flips it back to
|
`type="password"` with a `👁 reveal` toggle (#568 — avoids
|
||||||
`text` on press so the operator can sanity-check the paste before
|
accidental on-screen token exposure; `autocomplete="one-time-code"`
|
||||||
submit — avoids accidental on-screen token exposure to
|
for password-manager suppression).
|
||||||
shoulder-surfers or screenshots. `autocomplete="one-time-code"`
|
|
||||||
is the semantic value for OAuth codes (per WHATWG): browsers may
|
|
||||||
silently ignore `autocomplete="off"` on `type="password"`, but
|
|
||||||
`one-time-code` is honoured and suppresses the "save password
|
|
||||||
for this site?" prompt that would otherwise fire on submit.
|
|
||||||
- Terminal-wrap: live event tail (sticky-bottom auto-scroll +
|
- Terminal-wrap: live event tail (sticky-bottom auto-scroll +
|
||||||
`↓ N new` pill when not at bottom). The pill is **anchored in
|
`↓ N new` pill when not at bottom).
|
||||||
`.agent-main`**, not in `log.parentElement = .terminal-wrap`:
|
|
||||||
`.terminal-wrap` applies `backdrop-filter: blur` for the frost
|
|
||||||
effect, which creates a CSS stacking context — anchoring the
|
|
||||||
pill inside that context would trap its `z-index` below the
|
|
||||||
fixed composer in the root stacking context, and it'd never
|
|
||||||
float. `.agent-main` has no backdrop-filter (no stacking-context
|
|
||||||
creators), so the pill's `z-index` reaches the root and properly
|
|
||||||
composites above the composer. Geometry is unchanged —
|
|
||||||
`.agent-main` and `.terminal-wrap` both `inset: 0` fill the same
|
|
||||||
area.
|
|
||||||
|
|
||||||
**Fixed-overlay footer** (`<footer class="agent-composer">`): frosted
|
**Fixed-overlay footer** (`<footer class="agent-composer">`): frosted
|
||||||
glass, symmetric with the header. Contains the operator-input
|
glass, symmetric with the header. Contains the operator-input
|
||||||
|
|
@ -910,7 +889,7 @@ dashboard's side panel shape. Carries inbox and loose-ends flyouts
|
||||||
diffs, journald logs). Inbox flyout: last 30 messages addressed to
|
diffs, journald logs). Inbox flyout: last 30 messages addressed to
|
||||||
this agent (`AgentRequest::Recent { limit: 30 }`); reply messages
|
this agent (`AgentRequest::Recent { limit: 30 }`); reply messages
|
||||||
indented with `↳ reply ·` in amber. A `✓ mark all read` button
|
indented with `↳ reply ·` in amber. A `✓ mark all read` button
|
||||||
appears in the flyout header when the inbox is non-empty;
|
appears in the flyout header when the inbox is non-empty (#559);
|
||||||
clicking it confirms then POSTs cross-origin to the core
|
clicking it confirms then POSTs cross-origin to the core
|
||||||
dashboard's `POST /api/agent/{name}/mark-all-read` — all pending
|
dashboard's `POST /api/agent/{name}/mark-all-read` — all pending
|
||||||
messages for this agent are acked, the harness won't receive
|
messages for this agent are acked, the harness won't receive
|
||||||
|
|
@ -923,31 +902,6 @@ question rows carry an inline answer form that POSTs cross-origin to
|
||||||
the core dashboard's `/answer-question/{id}` so the operator answers
|
the core dashboard's `/answer-question/{id}` so the operator answers
|
||||||
*as operator* (see `docs/boundary.md`).
|
*as operator* (see `docs/boundary.md`).
|
||||||
|
|
||||||
**Ask → operator inline-answer binding.** When the agent emits
|
|
||||||
`mcp__hyperhive__ask(to: "operator", ...)`, the tool_use renderer
|
|
||||||
mounts an empty slot (`<div class="ask-answer-inline-slot">`)
|
|
||||||
right under the `↳ ask → operator` row in the terminal scrollback
|
|
||||||
and pushes a reference into `pendingAskBinds`. The broker assigns
|
|
||||||
the question id asynchronously, so the slot waits — and the next
|
|
||||||
`/api/loose-ends` refresh runs `reconcileAskBinds()`, which walks
|
|
||||||
the slot list and pairs each unbound slot with the first unclaimed
|
|
||||||
pending operator-bound question whose `question` text matches the
|
|
||||||
slot's stashed `_askQuestion`. On match the slot mounts the
|
|
||||||
`buildAnswerForm` (same form shape as the loose-ends flyout —
|
|
||||||
POSTs to the core's `/answer-question/{id}` cross-origin). Slots
|
|
||||||
stay in the array after binding so the reconciler can flip them
|
|
||||||
to a neutral `[resolved]` tag when the question later disappears
|
|
||||||
from the pending list. Disappearance can mean answered, cancelled
|
|
||||||
by the asker, or TTL-expired — the neutral label avoids
|
|
||||||
mis-asserting "✓" on the cancel / expire paths; full resolution
|
|
||||||
state is visible via the side-panel history. A defensive prune
|
|
||||||
walks the slot list each tick and drops any whose DOM node has
|
|
||||||
been removed (e.g. via a future "clear single row" affordance),
|
|
||||||
so stale references don't accumulate. Slots whose question never
|
|
||||||
arrives (e.g. the agent cancelled the ask, or the question is
|
|
||||||
older than the loose-ends retention window) stay empty — the
|
|
||||||
operator can still answer via the side panel, no regression.
|
|
||||||
|
|
||||||
### Live view
|
### Live view
|
||||||
|
|
||||||
Each agent runs an `events::Bus`: a `tokio::sync::broadcast<LiveEvent>`
|
Each agent runs an `events::Bus`: a `tokio::sync::broadcast<LiveEvent>`
|
||||||
|
|
|
||||||
|
|
@ -360,15 +360,20 @@ window.marked = marked;
|
||||||
const code = el('form', {
|
const code = el('form', {
|
||||||
action: 'login/code', method: 'POST', class: 'loginform', 'data-async': '',
|
action: 'login/code', method: 'POST', class: 'loginform', 'data-async': '',
|
||||||
});
|
});
|
||||||
// OAuth code input — masked password + reveal toggle, semantic
|
// #568: OAuth code is a sensitive secret — mask the input with
|
||||||
// autocomplete. See docs/web-ui.md::Per-agent page (#status
|
// type="password" so a shoulder-surfer / screenshot doesn't
|
||||||
// overlay) for the shoulder-surfer + WHATWG one-time-code
|
// capture it. The reveal button flips it back to text on press
|
||||||
// rationale.
|
// so the operator can sanity-check the paste before submit.
|
||||||
const codeInput = el('input', {
|
const codeInput = el('input', {
|
||||||
name: 'code',
|
name: 'code',
|
||||||
type: 'password',
|
type: 'password',
|
||||||
placeholder: 'paste OAuth code here (hidden)',
|
placeholder: 'paste OAuth code here (hidden)',
|
||||||
required: '',
|
required: '',
|
||||||
|
// `one-time-code` is the semantic value for OAuth codes (per
|
||||||
|
// WHATWG / argus #592 review): browsers may silently ignore
|
||||||
|
// `autocomplete="off"` on `type="password"` inputs, but
|
||||||
|
// `one-time-code` is honoured + suppresses the "save password
|
||||||
|
// for this site?" prompt that would otherwise fire on submit.
|
||||||
autocomplete: 'one-time-code',
|
autocomplete: 'one-time-code',
|
||||||
spellcheck: 'false',
|
spellcheck: 'false',
|
||||||
});
|
});
|
||||||
|
|
@ -682,9 +687,17 @@ window.marked = marked;
|
||||||
let lastLooseEnds = [];
|
let lastLooseEnds = [];
|
||||||
let lastInbox = [];
|
let lastInbox = [];
|
||||||
|
|
||||||
/** ask → operator inline-answer slot registry. See
|
/** #666: ask-operator inline-answer slots awaiting a question id.
|
||||||
* docs/web-ui.md::Per-agent page (Ask → operator inline-answer
|
* Each entry is the empty `<div class="ask-answer-inline-slot">`
|
||||||
* binding) for the matching algorithm + resolution semantics. */
|
* element appended to an `ask → operator` row by
|
||||||
|
* `renderRichToolUse`. `reconcileAskBinds()` walks the list on
|
||||||
|
* every loose-ends refresh, looking up the matching pending
|
||||||
|
* question by `kind=question` + `target == 'operator'` (or
|
||||||
|
* missing — broker omits target for operator-bound asks) +
|
||||||
|
* question text match against the slot's stashed `_askQuestion`.
|
||||||
|
* Slots stay in the array even after binding so the reconciler
|
||||||
|
* can flip them back to "answered" if the question later
|
||||||
|
* resolves. */
|
||||||
const pendingAskBinds = [];
|
const pendingAskBinds = [];
|
||||||
|
|
||||||
function buildLooseEndsList(threads) {
|
function buildLooseEndsList(threads) {
|
||||||
|
|
@ -756,32 +769,53 @@ window.marked = marked;
|
||||||
if (pill) pill.hidden = threads.length === 0;
|
if (pill) pill.hidden = threads.length === 0;
|
||||||
Panel.refresh('loose-ends', 'loose ends · ' + threads.length,
|
Panel.refresh('loose-ends', 'loose ends · ' + threads.length,
|
||||||
buildLooseEndsList(threads));
|
buildLooseEndsList(threads));
|
||||||
// Wire inline answer forms into any `ask → operator` rows
|
// #666: wire inline answer forms into any `ask → operator` rows
|
||||||
// waiting on a broker-assigned question id.
|
// that have been waiting for their broker-assigned question id.
|
||||||
reconcileAskBinds();
|
reconcileAskBinds();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Walk `pendingAskBinds` against the latest `lastLooseEnds`
|
/** #666: for each `ask → operator` row mounted by renderRichToolUse,
|
||||||
* snapshot, pair unbound slots with the first unclaimed pending
|
* look up the matching pending question in `lastLooseEnds` (by
|
||||||
* operator-bound question whose text matches, and flip already-
|
* target=operator + question text) and inject an inline answer
|
||||||
* bound slots to `[resolved]` when their question disappears.
|
* form. If a previously-bound slot's question has been resolved
|
||||||
* See docs/web-ui.md::Per-agent page (Ask → operator inline-answer
|
* (no longer in the pending list), replace the form with a small
|
||||||
* binding) for the full design + edge cases. */
|
* `[answered ✓]` tag so the scrollback reflects the closed state.
|
||||||
|
* Slots whose question never appears (e.g. agent cancelled the
|
||||||
|
* ask, or the question is older than the loose-ends fetch
|
||||||
|
* retention) stay empty — the operator can still answer via the
|
||||||
|
* side panel, no regression. */
|
||||||
function reconcileAskBinds() {
|
function reconcileAskBinds() {
|
||||||
if (!pendingAskBinds.length) return;
|
if (!pendingAskBinds.length) return;
|
||||||
|
// Build an index of pending operator-bound questions by text.
|
||||||
|
// Note: an agent could in theory ask the same question text
|
||||||
|
// twice; the loose-ends list is small and the broker assigns
|
||||||
|
// distinct ids — we match the first unbound slot to the first
|
||||||
|
// unmatched pending question to keep the pairing stable.
|
||||||
const pending = lastLooseEnds.filter(
|
const pending = lastLooseEnds.filter(
|
||||||
(t) => t.kind === 'question' && (!t.target || t.target === 'operator'),
|
(t) => t.kind === 'question' && (!t.target || t.target === 'operator'),
|
||||||
);
|
);
|
||||||
// Defensive prune: drop slots whose DOM node has been removed.
|
// Defensive prune: drop slots whose DOM node has been removed
|
||||||
|
// (e.g. via a future "clear single row" affordance) so we don't
|
||||||
|
// process detached references on every refresh.
|
||||||
for (let i = pendingAskBinds.length - 1; i >= 0; i--) {
|
for (let i = pendingAskBinds.length - 1; i >= 0; i--) {
|
||||||
if (!pendingAskBinds[i].isConnected) pendingAskBinds.splice(i, 1);
|
if (!pendingAskBinds[i].isConnected) pendingAskBinds.splice(i, 1);
|
||||||
}
|
}
|
||||||
|
// Track which pending question ids have already been claimed by
|
||||||
|
// an existing bound slot so we don't double-bind on refresh.
|
||||||
const claimed = new Set();
|
const claimed = new Set();
|
||||||
for (const slot of pendingAskBinds) {
|
for (const slot of pendingAskBinds) {
|
||||||
if (slot._boundId) claimed.add(slot._boundId);
|
if (slot._boundId) claimed.add(slot._boundId);
|
||||||
}
|
}
|
||||||
for (const slot of pendingAskBinds) {
|
for (const slot of pendingAskBinds) {
|
||||||
if (slot._boundId) {
|
if (slot._boundId) {
|
||||||
|
// Already bound — check if the question got resolved. The
|
||||||
|
// loose-ends endpoint only returns pending questions, so
|
||||||
|
// disappearance can mean "answered", "cancelled by asker",
|
||||||
|
// or "TTL expired" (argus #668 review). Use the neutral
|
||||||
|
// `[resolved]` label rather than a check-mark that would
|
||||||
|
// misrepresent the cancel / expire paths; full resolution
|
||||||
|
// state is visible via the question's history in the
|
||||||
|
// side panel.
|
||||||
const stillPending = pending.some((q) => q.id === slot._boundId);
|
const stillPending = pending.some((q) => q.id === slot._boundId);
|
||||||
if (!stillPending && !slot._resolvedShown) {
|
if (!stillPending && !slot._resolvedShown) {
|
||||||
slot.innerHTML = '';
|
slot.innerHTML = '';
|
||||||
|
|
@ -790,6 +824,7 @@ window.marked = marked;
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// Find the first matching pending question we haven't claimed yet.
|
||||||
const q = pending.find((p) => p.question === slot._askQuestion && !claimed.has(p.id));
|
const q = pending.find((p) => p.question === slot._askQuestion && !claimed.has(p.id));
|
||||||
if (!q) continue;
|
if (!q) continue;
|
||||||
claimed.add(q.id);
|
claimed.add(q.id);
|
||||||
|
|
@ -803,7 +838,7 @@ window.marked = marked;
|
||||||
// the host dashboard (core backend), never this agent's socket — the
|
// the host dashboard (core backend), never this agent's socket — the
|
||||||
// core is the only place that can stamp `operator` as the answerer.
|
// core is the only place that can stamp `operator` as the answerer.
|
||||||
// Used by both the loose-ends side panel (`buildLooseEndsList`) and
|
// Used by both the loose-ends side panel (`buildLooseEndsList`) and
|
||||||
// the in-stream ask-row binder (`reconcileAskBinds`).
|
// the in-stream ask-row binder (`reconcileAskBinds`, #666).
|
||||||
function buildAnswerForm(id) {
|
function buildAnswerForm(id) {
|
||||||
const wrap = el('div', { class: 'answer-form' });
|
const wrap = el('div', { class: 'answer-form' });
|
||||||
const ta = el('textarea', { rows: '2', placeholder: 'answer as operator…' });
|
const ta = el('textarea', { rows: '2', placeholder: 'answer as operator…' });
|
||||||
|
|
@ -1056,9 +1091,14 @@ window.marked = marked;
|
||||||
const s = await resp.json();
|
const s = await resp.json();
|
||||||
if (!headerSet) { setHeader(s.label, s.qualified_label, s.dashboard_port); headerSet = true; }
|
if (!headerSet) { setHeader(s.label, s.qualified_label, s.dashboard_port); headerSet = true; }
|
||||||
currentLabel = s.label;
|
currentLabel = s.label;
|
||||||
// Render server-supplied navigation links — see
|
// Render server-supplied navigation links — stats, screen, the
|
||||||
// docs/web-ui.md::Per-agent page (meta-nav) for the NavLink
|
// forge profile, the agent-configs mirror, plus any
|
||||||
// kind → URL resolution table + XSS-safe DOM-build rationale.
|
// agent-declared `dashboardLinks` extras (issue #262). Each
|
||||||
|
// NavLink's `kind` says how to resolve `url`: Container →
|
||||||
|
// same-origin path (the agent page is itself container-local);
|
||||||
|
// Forge → `http://<host>:3000<url>`; External → already
|
||||||
|
// absolute. DOM-built via el() — agent-declared icon / label /
|
||||||
|
// url strings must NEVER reach innerHTML.
|
||||||
const metaLinks = $('meta-links');
|
const metaLinks = $('meta-links');
|
||||||
if (metaLinks && Array.isArray(s.links)) {
|
if (metaLinks && Array.isArray(s.links)) {
|
||||||
metaLinks.replaceChildren();
|
metaLinks.replaceChildren();
|
||||||
|
|
@ -1466,13 +1506,20 @@ window.marked = marked;
|
||||||
|
|
||||||
const term = HiveTerminal.create({
|
const term = HiveTerminal.create({
|
||||||
logEl: log,
|
logEl: log,
|
||||||
// Anchor the `↓ N new` pill in `.agent-main` rather than the
|
// Anchor the `↓ N new` pill in `.agent-main` (NOT the default
|
||||||
// default `.terminal-wrap` parent — see docs/web-ui.md::Per-agent
|
// `log.parentElement` = `.terminal-wrap`). `.terminal-wrap`
|
||||||
// page (Terminal-wrap) for the backdrop-filter stacking-context
|
// applies `backdrop-filter` for the frost effect, which
|
||||||
// gotcha.
|
// creates a CSS stacking context — the pill's z-index 35
|
||||||
|
// (agent.css #375) then gets TRAPPED inside that context and
|
||||||
|
// can't compete with the fixed composer's z-index 30 in the
|
||||||
|
// root stacking context. Anchoring in `.agent-main` (no
|
||||||
|
// backdrop-filter, no other stacking-context creators) lets
|
||||||
|
// the pill's z-index reach the root and properly float above
|
||||||
|
// the composer. Geometry unchanged — `.agent-main` and
|
||||||
|
// `.terminal-wrap` both `inset: 0` fill the same area.
|
||||||
pillAnchor: $('agent-main'),
|
pillAnchor: $('agent-main'),
|
||||||
// Path-relative URLs so the page mounted under a nginx prefix
|
// Path-relative so the page mounted under a nginx prefix
|
||||||
// (e.g. /agent/<name>/) still hits the right SSE upstream.
|
// (e.g. /agent/<name>/) still hits the right SSE upstream (#14).
|
||||||
historyUrl: 'events/history',
|
historyUrl: 'events/history',
|
||||||
streamUrl: 'events/stream',
|
streamUrl: 'events/stream',
|
||||||
renderers: {
|
renderers: {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue