dashboard: clickable file-path previews
agents constantly emit pointer strings to /agents/<n>/state/foo.md since broker bodies cap at 1 KiB. now those tokens linkify in the message flow, question bodies, answer text, and operator inbox; clicking expands an inline <details> that lazy-fetches via the new /api/state-file?path=... endpoint. endpoint allow-list: per-agent state dirs + shared docs, both in their container-mount form (/agents/<n>/state, /shared) and host form (/var/lib/hyperhive/...). 1 MiB read cap; canonicalises before the prefix check so `..` / symlinks can't escape. legacy bare `/state/...` is deliberately not matched — ambiguous from the host's perspective (we'd need to know which agent the message references to translate). agents should use the qualified form going forward.
This commit is contained in:
parent
a15fafb5de
commit
cb71a07300
4 changed files with 249 additions and 18 deletions
|
|
@ -38,6 +38,78 @@
|
|||
return f;
|
||||
};
|
||||
|
||||
// ─── path linkification ─────────────────────────────────────────────────
|
||||
// Agents constantly drop pointer strings into messages + question
|
||||
// bodies (it's the 1 KiB-cap escape hatch). Anything matching the
|
||||
// PATH_RE patterns becomes a clickable anchor; clicking expands an
|
||||
// inline <details> with the file's contents, fetched lazily from
|
||||
// /api/state-file. The legacy in-container `/state/...` prefix is
|
||||
// deliberately not matched — it's ambiguous from the host's
|
||||
// perspective (we'd need to know which agent the message is about
|
||||
// to translate it). Prefer `/agents/<name>/state/...` in agent
|
||||
// outputs and the link will resolve.
|
||||
const PATH_RE = /(\/var\/lib\/hyperhive\/agents\/[\w.-]+\/state\/[\w./-]+|\/var\/lib\/hyperhive\/shared\/[\w./-]+|\/agents\/[\w.-]+\/state\/[\w./-]+|\/shared\/[\w./-]+)/g;
|
||||
async function fetchStateFile(path) {
|
||||
const resp = await fetch('/api/state-file?path=' + encodeURIComponent(path));
|
||||
const text = await resp.text();
|
||||
if (!resp.ok) throw new Error(text || ('HTTP ' + resp.status));
|
||||
return text;
|
||||
}
|
||||
function makePathPreview(path) {
|
||||
// Inline anchor + a sibling <details> that lazy-loads the file
|
||||
// on first open. Caller appends both: the anchor inline with the
|
||||
// surrounding text, the details as a block sibling after the
|
||||
// line so the layout doesn't get awkward.
|
||||
const anchor = el('a', {
|
||||
href: '#', class: 'path-link', title: 'click to preview ' + path,
|
||||
}, path);
|
||||
const details = el('details', { class: 'path-preview' });
|
||||
const summary = el('summary', {}, '↳ ' + path);
|
||||
const pre = el('pre', { class: 'path-preview-body' }, '(fetching…)');
|
||||
details.append(summary, pre);
|
||||
let fetched = false;
|
||||
async function doFetch() {
|
||||
if (fetched) return;
|
||||
fetched = true;
|
||||
try {
|
||||
pre.textContent = await fetchStateFile(path);
|
||||
} catch (e) {
|
||||
pre.textContent = 'error: ' + (e.message || e);
|
||||
fetched = false; // allow retry on next open
|
||||
}
|
||||
}
|
||||
details.addEventListener('toggle', () => { if (details.open) doFetch(); });
|
||||
anchor.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
details.open = !details.open;
|
||||
});
|
||||
return { anchor, details };
|
||||
}
|
||||
// Append `text` to `parent` as a mix of text nodes + path anchors.
|
||||
// Returns the array of generated `<details>` previews so the
|
||||
// caller can append them as block siblings under the row.
|
||||
function appendLinkified(parent, text) {
|
||||
const previews = [];
|
||||
if (text == null) return previews;
|
||||
const str = String(text);
|
||||
let lastIdx = 0;
|
||||
PATH_RE.lastIndex = 0;
|
||||
let m;
|
||||
while ((m = PATH_RE.exec(str)) !== null) {
|
||||
if (m.index > lastIdx) {
|
||||
parent.appendChild(document.createTextNode(str.slice(lastIdx, m.index)));
|
||||
}
|
||||
const { anchor, details } = makePathPreview(m[0]);
|
||||
parent.appendChild(anchor);
|
||||
previews.push(details);
|
||||
lastIdx = m.index + m[0].length;
|
||||
}
|
||||
if (lastIdx < str.length) {
|
||||
parent.appendChild(document.createTextNode(str.slice(lastIdx)));
|
||||
}
|
||||
return previews;
|
||||
}
|
||||
|
||||
// ─── browser notifications ──────────────────────────────────────────────
|
||||
// Fires OS notifications on three operator-bound signals:
|
||||
// - new approval landed in the queue
|
||||
|
|
@ -721,7 +793,10 @@
|
|||
+ Math.floor((remaining % 3600) / 60) + 'm';
|
||||
head.append(' ', el('span', { class: 'q-ttl' }, txt));
|
||||
}
|
||||
li.append(head, el('div', { class: 'q-body' }, q.question));
|
||||
const qBody = el('div', { class: 'q-body' });
|
||||
const qPreviews = appendLinkified(qBody, q.question);
|
||||
li.append(head, qBody);
|
||||
for (const d of qPreviews) li.appendChild(d);
|
||||
const f = el('form', {
|
||||
method: 'POST', action: '/answer-question/' + q.id,
|
||||
class: 'qform', 'data-async': '', 'data-no-refresh': '',
|
||||
|
|
@ -814,14 +889,18 @@
|
|||
el('span', { class: q.target ? 'msg-to msg-to-peer' : 'msg-to' }, targetLabel), ' ',
|
||||
el('span', { class: 'msg-sep' }, 'asked:'),
|
||||
);
|
||||
li.append(
|
||||
head,
|
||||
el('div', { class: 'q-body' }, q.question),
|
||||
el('div', { class: 'q-answer' },
|
||||
el('span', { class: 'msg-sep' }, `${q.answerer || '?'}: `),
|
||||
el('span', { class: 'q-answer-text' }, q.answer || '(none)'),
|
||||
),
|
||||
const histBody = el('div', { class: 'q-body' });
|
||||
const histBodyPreviews = appendLinkified(histBody, q.question);
|
||||
const ansText = el('span', { class: 'q-answer-text' });
|
||||
const histAnsPreviews = appendLinkified(ansText, q.answer || '(none)');
|
||||
const ansLine = el('div', { class: 'q-answer' },
|
||||
el('span', { class: 'msg-sep' }, `${q.answerer || '?'}: `),
|
||||
ansText,
|
||||
);
|
||||
li.append(head, histBody);
|
||||
for (const d of histBodyPreviews) li.appendChild(d);
|
||||
li.append(ansLine);
|
||||
for (const d of histAnsPreviews) li.appendChild(d);
|
||||
hul.append(li);
|
||||
}
|
||||
details.append(hul);
|
||||
|
|
@ -854,12 +933,15 @@
|
|||
const ul = el('ul', { class: 'inbox' });
|
||||
for (const m of operatorInbox) {
|
||||
const li = el('li');
|
||||
const body = el('span', { class: 'msg-body' });
|
||||
const previews = appendLinkified(body, m.body);
|
||||
li.append(
|
||||
el('span', { class: 'msg-ts' }, fmt(m.at)), ' ',
|
||||
el('span', { class: 'msg-from' }, m.from), ' ',
|
||||
el('span', { class: 'msg-sep' }, '→ '),
|
||||
el('span', { class: 'msg-body' }, m.body),
|
||||
body,
|
||||
);
|
||||
for (const d of previews) li.appendChild(d);
|
||||
ul.append(li);
|
||||
}
|
||||
root.append(ul);
|
||||
|
|
@ -1288,14 +1370,24 @@
|
|||
bannerOffTimer = setTimeout(() => banner.classList.remove('active'), 4000);
|
||||
}
|
||||
function renderMsg(ev, api, glyph) {
|
||||
const el = api.row('msgrow ' + ev.kind, '');
|
||||
el.innerHTML =
|
||||
'<span class="msg-ts">' + tsFmt(ev.at) + '</span>' +
|
||||
'<span class="msg-arrow">' + glyph + '</span>' +
|
||||
'<span class="msg-from">' + esc(ev.from) + '</span>' +
|
||||
'<span class="msg-sep">→</span>' +
|
||||
'<span class="msg-to">' + esc(ev.to) + '</span>' +
|
||||
'<span class="msg-body">' + esc(ev.body) + '</span>';
|
||||
const row = api.row('msgrow ' + ev.kind, '');
|
||||
// Build via DOM so path anchors stay live + escape rules are
|
||||
// automatic (text nodes don't need esc()).
|
||||
const ts = document.createElement('span');
|
||||
ts.className = 'msg-ts'; ts.textContent = tsFmt(ev.at);
|
||||
const arrow = document.createElement('span');
|
||||
arrow.className = 'msg-arrow'; arrow.textContent = glyph;
|
||||
const from = document.createElement('span');
|
||||
from.className = 'msg-from'; from.textContent = ev.from;
|
||||
const sep = document.createElement('span');
|
||||
sep.className = 'msg-sep'; sep.textContent = '→';
|
||||
const to = document.createElement('span');
|
||||
to.className = 'msg-to'; to.textContent = ev.to;
|
||||
const body = document.createElement('span');
|
||||
body.className = 'msg-body';
|
||||
const previews = appendLinkified(body, ev.body);
|
||||
row.append(ts, ' ', arrow, ' ', from, ' ', sep, ' ', to, ' ', body);
|
||||
for (const d of previews) row.appendChild(d);
|
||||
}
|
||||
HiveTerminal.create({
|
||||
logEl: flow,
|
||||
|
|
|
|||
|
|
@ -450,6 +450,41 @@ summary:hover { color: var(--purple); }
|
|||
0%, 100% { box-shadow: 0 0 12px -4px rgba(250, 179, 135, 0.55); }
|
||||
50% { box-shadow: 0 0 22px -2px rgba(250, 179, 135, 0.95); }
|
||||
}
|
||||
/* Path linkification — agents drop pointer strings into messages
|
||||
constantly; clicking the anchor expands a sibling <details> that
|
||||
lazy-loads from /api/state-file. */
|
||||
.path-link {
|
||||
color: var(--blue, #89b4fa);
|
||||
text-decoration: underline dotted;
|
||||
cursor: pointer;
|
||||
}
|
||||
.path-link:hover { color: var(--amber); }
|
||||
.path-preview {
|
||||
margin: 0.2em 0 0.4em 1.5em;
|
||||
border-left: 2px solid var(--border);
|
||||
padding-left: 0.6em;
|
||||
}
|
||||
.path-preview > summary {
|
||||
cursor: pointer;
|
||||
color: var(--muted);
|
||||
font-size: 0.85em;
|
||||
list-style: none;
|
||||
user-select: none;
|
||||
}
|
||||
.path-preview > summary::marker { content: ''; }
|
||||
.path-preview-body {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.5em 0.7em;
|
||||
margin: 0.3em 0 0;
|
||||
max-height: 30em;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-size: 0.85em;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
/* Filter chip row above the questions list. The active chip lights
|
||||
up amber to match the rest of the dashboard's selection accents. */
|
||||
.questions-filters {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue