agent/app.js + docs: migrate ask→operator inline-answer binding prose (#713 batch 1 cont.)
Moves the #666 ask→operator inline-answer wiring rationale from app.js into a new docs/web-ui.md::Per-agent page sub-paragraph **Ask → operator inline-answer binding**. Substantial block — the slot-registry / reconcileAskBinds / buildAnswerForm trio has real design rationale (async question id, text-match pairing, resolved-vs-cancelled-vs-expired neutrality, defensive prune, no-regression-fallback to side-panel answer) that belongs in docs, not three JSDoc blocks scattered through app.js. Moved to docs (~30 lines of substantive prose): - pendingAskBinds slot-registry rationale - reconcileAskBinds matching algorithm (text-match, first-unbound to first-unclaimed pairing stability) - defensive prune for disconnected slots - [resolved] vs ✓ rationale (answered / cancelled / TTL-expired ambiguity — neutral label) - fallback to side-panel answer when slot stays unbound Collapsed in app.js: 3 JSDoc blocks + inline comments → ~10-line total pointer. Drops #666 ×3, #559, #668 cookies en passant (substance now lives in docs). app.js: 24 → 19 refs in this commit; 27 → 19 across batch 1 (30% reduction so far). Net ~57 lines of substantive prose migrated from app.js to docs/web-ui.md across both commits in this PR.
This commit is contained in:
parent
8366c3739b
commit
3f84b6492a
2 changed files with 39 additions and 44 deletions
|
|
@ -910,7 +910,7 @@ dashboard's side panel shape. Carries inbox and loose-ends flyouts
|
|||
diffs, journald logs). Inbox flyout: last 30 messages addressed to
|
||||
this agent (`AgentRequest::Recent { limit: 30 }`); reply messages
|
||||
indented with `↳ reply ·` in amber. A `✓ mark all read` button
|
||||
appears in the flyout header when the inbox is non-empty (#559);
|
||||
appears in the flyout header when the inbox is non-empty;
|
||||
clicking it confirms then POSTs cross-origin to the core
|
||||
dashboard's `POST /api/agent/{name}/mark-all-read` — all pending
|
||||
messages for this agent are acked, the harness won't receive
|
||||
|
|
@ -923,6 +923,31 @@ question rows carry an inline answer form that POSTs cross-origin to
|
|||
the core dashboard's `/answer-question/{id}` so the operator answers
|
||||
*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
|
||||
|
||||
Each agent runs an `events::Bus`: a `tokio::sync::broadcast<LiveEvent>`
|
||||
|
|
|
|||
|
|
@ -682,17 +682,9 @@ window.marked = marked;
|
|||
let lastLooseEnds = [];
|
||||
let lastInbox = [];
|
||||
|
||||
/** #666: ask-operator inline-answer slots awaiting a question id.
|
||||
* Each entry is the empty `<div class="ask-answer-inline-slot">`
|
||||
* 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. */
|
||||
/** ask → operator inline-answer slot registry. See
|
||||
* docs/web-ui.md::Per-agent page (Ask → operator inline-answer
|
||||
* binding) for the matching algorithm + resolution semantics. */
|
||||
const pendingAskBinds = [];
|
||||
|
||||
function buildLooseEndsList(threads) {
|
||||
|
|
@ -764,53 +756,32 @@ window.marked = marked;
|
|||
if (pill) pill.hidden = threads.length === 0;
|
||||
Panel.refresh('loose-ends', 'loose ends · ' + threads.length,
|
||||
buildLooseEndsList(threads));
|
||||
// #666: wire inline answer forms into any `ask → operator` rows
|
||||
// that have been waiting for their broker-assigned question id.
|
||||
// Wire inline answer forms into any `ask → operator` rows
|
||||
// waiting on a broker-assigned question id.
|
||||
reconcileAskBinds();
|
||||
}
|
||||
|
||||
/** #666: for each `ask → operator` row mounted by renderRichToolUse,
|
||||
* look up the matching pending question in `lastLooseEnds` (by
|
||||
* target=operator + question text) and inject an inline answer
|
||||
* form. If a previously-bound slot's question has been resolved
|
||||
* (no longer in the pending list), replace the form with a small
|
||||
* `[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. */
|
||||
/** Walk `pendingAskBinds` against the latest `lastLooseEnds`
|
||||
* snapshot, pair unbound slots with the first unclaimed pending
|
||||
* operator-bound question whose text matches, and flip already-
|
||||
* bound slots to `[resolved]` when their question disappears.
|
||||
* See docs/web-ui.md::Per-agent page (Ask → operator inline-answer
|
||||
* binding) for the full design + edge cases. */
|
||||
function reconcileAskBinds() {
|
||||
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(
|
||||
(t) => t.kind === 'question' && (!t.target || t.target === 'operator'),
|
||||
);
|
||||
// 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.
|
||||
// Defensive prune: drop slots whose DOM node has been removed.
|
||||
for (let i = pendingAskBinds.length - 1; i >= 0; i--) {
|
||||
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();
|
||||
for (const slot of pendingAskBinds) {
|
||||
if (slot._boundId) claimed.add(slot._boundId);
|
||||
}
|
||||
for (const slot of pendingAskBinds) {
|
||||
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);
|
||||
if (!stillPending && !slot._resolvedShown) {
|
||||
slot.innerHTML = '';
|
||||
|
|
@ -819,7 +790,6 @@ window.marked = marked;
|
|||
}
|
||||
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));
|
||||
if (!q) continue;
|
||||
claimed.add(q.id);
|
||||
|
|
@ -833,7 +803,7 @@ window.marked = marked;
|
|||
// the host dashboard (core backend), never this agent's socket — the
|
||||
// core is the only place that can stamp `operator` as the answerer.
|
||||
// Used by both the loose-ends side panel (`buildLooseEndsList`) and
|
||||
// the in-stream ask-row binder (`reconcileAskBinds`, #666).
|
||||
// the in-stream ask-row binder (`reconcileAskBinds`).
|
||||
function buildAnswerForm(id) {
|
||||
const wrap = el('div', { class: 'answer-form' });
|
||||
const ta = el('textarea', { rows: '2', placeholder: 'answer as operator…' });
|
||||
|
|
|
|||
Loading…
Reference in a new issue