dashboard: open long content in a slide-in side panel

file previews, approval diffs, journald logs and applied config no
longer expand inline — they open in a drawer that swipes in from the
right, with a title naming what's open and a close button (esc /
backdrop also close). path references in messages become plain inline
links that open the file in the panel; the sibling-<details> dance in
appendLinkified is gone.

also: the question-answer free-text field is now a textarea — enter
submits, shift+enter inserts a newline.
This commit is contained in:
müde 2026-05-20 10:43:23 +02:00
parent 5aad2d67e1
commit 7ce3da1e21
3 changed files with 308 additions and 228 deletions

View file

@ -38,6 +38,36 @@
return f;
};
// ─── side panel ─────────────────────────────────────────────────────────
// Singleton drawer that swipes in from the right. Long content
// (file previews, approval diffs, journald logs, applied config)
// opens here via `Panel.open(title, node)` instead of expanding
// inline. Body is swapped on each open; closing just slides out so
// the content stays visible through the transition.
const Panel = (() => {
const root = $('side-panel');
const titleEl = $('side-panel-title');
const bodyEl = $('side-panel-body');
function open(title, content) {
titleEl.textContent = title;
bodyEl.replaceChildren(...(content ? [content] : []));
root.classList.add('open');
root.setAttribute('aria-hidden', 'false');
}
function close() {
root.classList.remove('open');
root.setAttribute('aria-hidden', 'true');
}
function bind() {
$('side-panel-close').addEventListener('click', close);
$('side-panel-backdrop').addEventListener('click', close);
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && root.classList.contains('open')) close();
});
}
return { open, close, bind };
})();
// ─── path linkification ─────────────────────────────────────────────────
// Agents constantly drop pointer strings into messages + question
// bodies (it's the 1 KiB-cap escape hatch). Anything matching the
@ -54,53 +84,40 @@
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);
// Lazy-load `path` from /api/state-file into the side panel.
async function openFilePanel(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
}
Panel.open('↳ ' + path, pre);
try {
pre.textContent = await fetchStateFile(path);
} catch (e) {
pre.textContent = 'error: ' + (e.message || e);
}
details.addEventListener('toggle', () => { if (details.open) doFetch(); });
}
function makePathLink(path) {
const anchor = el('a', {
href: '#', class: 'path-link', title: 'open ' + path + ' in panel',
}, path);
anchor.addEventListener('click', (e) => {
e.preventDefault();
details.open = !details.open;
openFilePanel(path);
});
return { anchor, details };
return anchor;
}
// Append `text` to `parent` as a mix of text nodes + path
// anchors. `refs` is the server-attached `file_refs` array
// (verified-file tokens that appear in `text`); each occurrence
// of a ref in `text` becomes a clickable anchor + a sibling
// <details> preview that lazy-fetches from /api/state-file.
// Anything not in `refs` stays plain text. No client-side
// regex, no probe endpoint — the server saw the body first
// and made the call. When `refs` is empty/missing we just
// emit plain text.
// Append `text` to `parent` as a mix of text nodes + path anchors.
// `refs` is the server-attached `file_refs` array (verified-file
// tokens that appear in `text`); each occurrence of a ref becomes a
// clickable anchor that opens the file in the side panel. Anything
// not in `refs` stays plain text. No client-side regex, no probe
// endpoint — the server saw the body first and made the call. When
// `refs` is empty/missing we just emit plain text.
function appendLinkified(parent, text, refs) {
const previews = [];
if (text == null) return previews;
if (text == null) return;
const str = String(text);
const tokens = (refs || []).slice();
if (!tokens.length) {
if (str) parent.appendChild(document.createTextNode(str));
return previews;
return;
}
// Walk the string left-to-right, at each step looking for the
// next occurrence of any token. Longest-first tie-break so a
@ -128,12 +145,9 @@
if (bestStart > i) {
parent.appendChild(document.createTextNode(str.slice(i, bestStart)));
}
const { anchor, details } = makePathPreview(bestToken);
parent.appendChild(anchor);
previews.push(details);
parent.appendChild(makePathLink(bestToken));
i = bestStart + bestToken.length;
}
return previews;
}
// ─── browser notifications ──────────────────────────────────────────────
@ -567,112 +581,111 @@
}
li.append(actions);
// Per-container journald viewer. Expand to fetch + render the
// last N lines; refresh button re-fetches; unit selector
// Per-container journald viewer. Opens the side panel and
// fetches the last N lines; refresh re-fetches; unit selector
// narrows to the harness service (or empty = full machine).
const journalUnit = c.is_manager ? 'hive-m1nd.service' : 'hive-ag3nt.service';
li.append(buildJournalDetails(c.container, journalUnit));
li.append(buildJournalTrigger(c.container, journalUnit));
// Per-container applied config viewer. Shows the agent.nix
// the container is actually built against.
li.append(buildConfigDetails(c.name));
li.append(buildConfigTrigger(c.name));
ul.append(li);
}
root.append(ul);
}
// Build the per-container journald <details>. Lazy-fetches when the
// operator expands; refresh re-fetches; unit toggle switches
// between the harness service and the full machine journal.
function buildJournalDetails(containerName, defaultUnit) {
const details = el('details', {
class: 'journal',
'data-restore-key': 'journal:' + containerName,
});
const summary = el('summary', {}, '↳ logs · ' + containerName);
const body = el('div', { class: 'journal-body' });
const controls = el('div', { class: 'journal-controls' });
const unitSelect = el('select', { class: 'journal-unit' });
unitSelect.append(
el('option', { value: defaultUnit }, defaultUnit),
el('option', { value: '' }, '(full machine journal)'),
);
const refresh = el('button', { type: 'button', class: 'btn btn-restart journal-refresh' },
'↻ refresh');
const pre = el('pre', { class: 'journal-output' }, 'fetching…');
let fetching = false;
async function fetchLogs() {
if (fetching) return;
fetching = true;
pre.textContent = 'fetching…';
const unit = unitSelect.value;
const params = new URLSearchParams({ lines: '500' });
if (unit) params.set('unit', unit);
try {
const resp = await fetch('/api/journal/' + containerName + '?' + params);
const text = await resp.text();
if (!resp.ok) {
pre.textContent = 'error: ' + resp.status + '\n' + text;
} else {
pre.textContent = text || '(empty)';
// Auto-scroll to bottom on fresh fetch.
pre.scrollTop = pre.scrollHeight;
// Per-container journald viewer. Returns an inline trigger; the
// click opens the side panel and fetches the last N lines. Refresh
// re-fetches; the unit toggle switches between the harness service
// and the full machine journal.
function buildJournalTrigger(containerName, defaultUnit) {
const trigger = el('button', { type: 'button', class: 'panel-trigger' },
'↳ logs · ' + containerName);
trigger.addEventListener('click', () => {
const body = el('div', { class: 'journal-body' });
const controls = el('div', { class: 'journal-controls' });
const unitSelect = el('select', { class: 'journal-unit' });
unitSelect.append(
el('option', { value: defaultUnit }, defaultUnit),
el('option', { value: '' }, '(full machine journal)'),
);
const refresh = el('button', { type: 'button', class: 'btn btn-restart journal-refresh' },
'↻ refresh');
const pre = el('pre', { class: 'journal-output' }, 'fetching…');
let fetching = false;
async function fetchLogs() {
if (fetching) return;
fetching = true;
pre.textContent = 'fetching…';
const unit = unitSelect.value;
const params = new URLSearchParams({ lines: '500' });
if (unit) params.set('unit', unit);
try {
const resp = await fetch('/api/journal/' + containerName + '?' + params);
const text = await resp.text();
if (!resp.ok) {
pre.textContent = 'error: ' + resp.status + '\n' + text;
} else {
pre.textContent = text || '(empty)';
// Auto-scroll the panel to the newest lines on fresh fetch.
const sb = $('side-panel-body');
if (sb) sb.scrollTop = sb.scrollHeight;
}
} catch (err) {
pre.textContent = 'fetch failed: ' + err;
} finally {
fetching = false;
}
} catch (err) {
pre.textContent = 'fetch failed: ' + err;
} finally {
fetching = false;
}
}
details.addEventListener('toggle', () => { if (details.open) fetchLogs(); });
refresh.addEventListener('click', (e) => { e.preventDefault(); fetchLogs(); });
unitSelect.addEventListener('change', fetchLogs);
controls.append(unitSelect, refresh);
body.append(controls, pre);
details.append(summary, body);
return details;
refresh.addEventListener('click', (e) => { e.preventDefault(); fetchLogs(); });
unitSelect.addEventListener('change', fetchLogs);
controls.append(unitSelect, refresh);
body.append(controls, pre);
Panel.open('logs · ' + containerName, body);
fetchLogs();
});
return trigger;
}
// Per-container applied-config viewer. Lazy-fetches on expand;
// refresh button re-fetches. Read-only — the file is hive-c0re's
// applied repo, mutated only via the approval flow.
function buildConfigDetails(agentName) {
const details = el('details', {
class: 'journal',
'data-restore-key': 'agent-config:' + agentName,
});
const summary = el('summary', {}, '↳ agent.nix · ' + agentName);
const body = el('div', { class: 'journal-body' });
const controls = el('div', { class: 'journal-controls' });
const refresh = el('button', { type: 'button', class: 'btn btn-restart journal-refresh' },
'↻ refresh');
const pre = el('pre', { class: 'journal-output' }, 'fetching…');
let fetching = false;
async function fetchConfig() {
if (fetching) return;
fetching = true;
pre.textContent = 'fetching…';
try {
const resp = await fetch('/api/agent-config/' + agentName);
const text = await resp.text();
if (!resp.ok) {
pre.textContent = 'error: ' + resp.status + '\n' + text;
} else {
pre.textContent = text || '(empty)';
pre.scrollTop = 0;
// Per-container applied-config viewer. Returns an inline trigger;
// the click opens the side panel and fetches agent.nix. Read-only —
// the file is hive-c0re's applied repo, mutated only via approvals.
function buildConfigTrigger(agentName) {
const trigger = el('button', { type: 'button', class: 'panel-trigger' },
'↳ agent.nix · ' + agentName);
trigger.addEventListener('click', () => {
const body = el('div', { class: 'journal-body' });
const controls = el('div', { class: 'journal-controls' });
const refresh = el('button', { type: 'button', class: 'btn btn-restart journal-refresh' },
'↻ refresh');
const pre = el('pre', { class: 'journal-output' }, 'fetching…');
let fetching = false;
async function fetchConfig() {
if (fetching) return;
fetching = true;
pre.textContent = 'fetching…';
try {
const resp = await fetch('/api/agent-config/' + agentName);
const text = await resp.text();
if (!resp.ok) {
pre.textContent = 'error: ' + resp.status + '\n' + text;
} else {
pre.textContent = text || '(empty)';
}
} catch (err) {
pre.textContent = 'fetch failed: ' + err;
} finally {
fetching = false;
}
} catch (err) {
pre.textContent = 'fetch failed: ' + err;
} finally {
fetching = false;
}
}
details.addEventListener('toggle', () => { if (details.open) fetchConfig(); });
refresh.addEventListener('click', (e) => { e.preventDefault(); fetchConfig(); });
controls.append(refresh);
body.append(controls, pre);
details.append(summary, body);
return details;
refresh.addEventListener('click', (e) => { e.preventDefault(); fetchConfig(); });
controls.append(refresh);
body.append(controls, pre);
Panel.open('agent.nix · ' + agentName, body);
fetchConfig();
});
return trigger;
}
function renderTombstones(s) {
@ -867,19 +880,25 @@
head.append(' ', el('span', { class: 'q-ttl' }, txt));
}
const qBody = el('div', { class: 'q-body' });
const qPreviews = appendLinkified(qBody, q.question, q.question_refs);
appendLinkified(qBody, q.question, q.question_refs);
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': '',
});
const hasOptions = q.options && q.options.length;
const isMulti = !!q.multi && hasOptions;
const freeText = el('input', {
type: 'text', name: 'answer-free',
placeholder: hasOptions ? 'or type your own…' : 'your answer',
autocomplete: 'off',
const freeText = el('textarea', {
name: 'answer-free', rows: '2', autocomplete: 'off',
placeholder: (hasOptions ? 'or type your own…' : 'your answer')
+ ' (shift+enter for newline)',
});
// Enter submits; shift+enter inserts a newline (textarea default).
freeText.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
f.requestSubmit();
}
});
const optionGroup = el('div', { class: 'q-options' });
if (hasOptions) {
@ -963,17 +982,14 @@
el('span', { class: 'msg-sep' }, 'asked:'),
);
const histBody = el('div', { class: 'q-body' });
const histBodyPreviews = appendLinkified(histBody, q.question, q.question_refs);
appendLinkified(histBody, q.question, q.question_refs);
const ansText = el('span', { class: 'q-answer-text' });
const histAnsPreviews = appendLinkified(ansText, q.answer || '(none)', q.answer_refs);
appendLinkified(ansText, q.answer || '(none)', q.answer_refs);
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);
li.append(head, histBody, ansLine);
hul.append(li);
}
details.append(hul);
@ -1012,14 +1028,13 @@
for (const m of operatorInbox) {
const li = el('li');
const body = el('span', { class: 'msg-body' });
const previews = appendLinkified(body, m.body, m.file_refs);
appendLinkified(body, m.body, m.file_refs);
li.append(
el('span', { class: 'msg-ts' }, fmt(m.at)), ' ',
el('span', { class: 'msg-from' }, m.from), ' ',
el('span', { class: 'msg-sep' }, '→ '),
body,
);
for (const d of previews) li.appendChild(d);
ul.append(li);
}
root.append(ul);
@ -1174,30 +1189,30 @@
);
li.append(row);
if (a.diff) {
const details = el('details', {
'data-restore-key': 'approval-diff:' + a.id,
const trigger = el('button', { type: 'button', class: 'panel-trigger' },
'diff vs applied');
trigger.addEventListener('click', () => {
// Server ships the raw unified diff; classify each line by
// its leading char so `.diff-add` / `.diff-del` /
// `.diff-hunk` / `.diff-file` / `.diff-ctx` colour the
// output. Building spans here (instead of innerHTML-ing
// pre-rendered markup) keeps the snapshot wire format
// text-only and one less HTML-escape surface server-side.
const pre = el('pre', { class: 'diff' });
for (const raw of a.diff.split('\n')) {
let cls = 'diff-ctx';
if (raw.startsWith('--- ') || raw.startsWith('+++ ')) cls = 'diff-file';
else if (raw.startsWith('@')) cls = 'diff-hunk';
else if (raw.startsWith('+')) cls = 'diff-add';
else if (raw.startsWith('-')) cls = 'diff-del';
const span = document.createElement('span');
span.className = cls;
span.textContent = raw + '\n';
pre.appendChild(span);
}
Panel.open('diff · ' + a.agent + ' #' + a.id, pre);
});
details.append(el('summary', {}, 'diff vs applied'));
// Server ships the raw unified diff; classify each line by its
// leading char so `.diff-add` / `.diff-del` / `.diff-hunk` /
// `.diff-file` / `.diff-ctx` colour the output. Building spans
// here (instead of innerHTML-ing pre-rendered markup) keeps
// the snapshot wire format text-only and one less HTML-escape
// surface server-side.
const pre = el('pre', { class: 'diff' });
for (const raw of a.diff.split('\n')) {
let cls = 'diff-ctx';
if (raw.startsWith('--- ') || raw.startsWith('+++ ')) cls = 'diff-file';
else if (raw.startsWith('@')) cls = 'diff-hunk';
else if (raw.startsWith('+')) cls = 'diff-add';
else if (raw.startsWith('-')) cls = 'diff-del';
const span = document.createElement('span');
span.className = cls;
span.textContent = raw + '\n';
pre.appendChild(span);
}
details.append(pre);
li.append(details);
li.append(trigger);
}
ul.append(li);
}
@ -1381,9 +1396,8 @@
`${r.attempt_count} failed`));
}
const body = el('div', { class: 'reminder-body' });
const previews = appendLinkified(body, r.message);
appendLinkified(body, r.message);
li.append(head, body);
for (const d of previews) li.appendChild(d);
if (r.last_error) {
li.append(el('div', { class: 'reminder-error' },
el('span', { class: 'msg-sep' }, 'error: '),
@ -1439,9 +1453,10 @@
// <details> sections that should survive a refresh need a stable
// `data-restore-key` attribute. snapshotOpenDetails walks managed
// sections and records which keys are currently open; restoreOpenDetails
// re-applies after the render. The `toggle` event fires on
// programmatic open changes too, so any onload-fetch wired up via
// a toggle listener (e.g. journald) re-fires cleanly.
// re-applies after the render. (Long-content drill-ins — file
// previews, diffs, logs, config — open in the side panel instead,
// which lives outside the managed sections and survives re-render
// on its own.)
function snapshotOpenDetails() {
const open = new Set();
for (const id of MANAGED_SECTION_IDS) {
@ -1533,6 +1548,7 @@
}
refreshState();
NOTIF.bind();
Panel.bind();
// ─── message flow: shared terminal pane ────────────────────────────────
// Scroll, pill, backfill + SSE plumbing live in hive-fr0nt::TERMINAL_JS
@ -1571,9 +1587,8 @@
to.className = 'msg-to'; to.textContent = ev.to;
const body = document.createElement('span');
body.className = 'msg-body';
const previews = appendLinkified(body, ev.body, ev.file_refs);
appendLinkified(body, ev.body, ev.file_refs);
row.append(ts, ' ', arrow, ' ', from, ' ', sep, ' ', to, ' ', body);
for (const d of previews) row.appendChild(d);
}
HiveTerminal.create({
logEl: flow,