treefmt: apply prettier

Pure `nix fmt` output from the commit before this one — no hand edits.
203 files: 52 md, 42 tsx, 32 js, 32 css, 21 ts, 13 html, 8 json, 3 mjs.

Reproduce with `nix develop -c nix fmt` on the parent commit; the result
should be byte-identical to this tree.

None of the 13 `.prettierignore` entries appears here — verified by
intersecting the changed-file list against the ignore file, with a
control proving the intersection finds a match when one exists.
This commit is contained in:
atlas 2026-09-02 14:29:33 +02:00
commit 39b95c2ede
203 changed files with 10090 additions and 6085 deletions

View file

@ -11,15 +11,16 @@
// initial /api/state fetch (compose autocomplete needs the live
// container list).
import { create as termCreate } from '@hive/shared/terminal.js';
import { create as termCreate } from "@hive/shared/terminal.js";
import {
$,
NOTIF,
appendLinkified,
openStream, initServerWarnings,
} from './common.js';
import { el } from '@hive/shared/dom.js';
import { epochSec } from './util.js';
openStream,
initServerWarnings,
} from "./common.js";
import { el } from "@hive/shared/dom.js";
import { epochSec } from "./util.js";
(() => {
NOTIF.bind();
@ -32,11 +33,16 @@ import { epochSec } from './util.js';
// by the same `container_state_changed` / `container_removed` events
// the dashboard would handle.
const flowContainers = new Map();
fetch('/api/state').then((r) => r.ok ? r.json() : null).then((s) => {
if (!s || !Array.isArray(s.containers)) return;
for (const c of s.containers) flowContainers.set(c.name, c);
populateAgentFilter();
}).catch(() => { /* graceful: compose just shows `*` and nothing else */ });
fetch("/api/state")
.then((r) => (r.ok ? r.json() : null))
.then((s) => {
if (!s || !Array.isArray(s.containers)) return;
for (const c of s.containers) flowContainers.set(c.name, c);
populateAgentFilter();
})
.catch(() => {
/* graceful: compose just shows `*` and nothing else */
});
// ─── agent filter ───────────────────────────────────────────────
// A select in the FL0W header narrows the timeline to messages involving
@ -46,40 +52,40 @@ import { epochSec } from './util.js';
// filter re-scans existing rows. Selection persists in localStorage so a
// reload / tab-switch keeps the view. Uses `$('msgflow')` for row access
// so it works regardless of the message-flow IIFE's local scope.
let agentFilter = localStorage.getItem('flow-agent-filter') || '';
let agentFilter = localStorage.getItem("flow-agent-filter") || "";
function rowMatchesFilter(from, to) {
return !agentFilter || from === agentFilter || to === agentFilter;
}
function applyAgentFilter() {
const flow = $('msgflow');
const flow = $("msgflow");
if (!flow) return;
for (const row of flow.children) {
const { from, to } = row.dataset;
if (from === undefined && to === undefined) continue; // non-message row
row.classList.toggle('flow-hidden', !rowMatchesFilter(from, to));
row.classList.toggle("flow-hidden", !rowMatchesFilter(from, to));
}
}
function populateAgentFilter() {
const sel = $('flow-agent-filter');
const sel = $("flow-agent-filter");
if (!sel) return;
const names = [...flowContainers.keys()].sort();
sel.replaceChildren();
sel.append(el('option', { value: '' }, 'all agents'));
for (const n of names) sel.append(el('option', { value: n }, n));
sel.append(el("option", { value: "" }, "all agents"));
for (const n of names) sel.append(el("option", { value: n }, n));
// Preserve a saved selection even if that agent isn't in the live
// container list (yet / anymore) so the filter doesn't silently reset.
if (agentFilter && !names.includes(agentFilter)) {
sel.append(el('option', { value: agentFilter }, agentFilter));
sel.append(el("option", { value: agentFilter }, agentFilter));
}
sel.value = agentFilter;
}
{
const sel = $('flow-agent-filter');
const sel = $("flow-agent-filter");
if (sel) {
sel.addEventListener('change', () => {
sel.addEventListener("change", () => {
agentFilter = sel.value;
if (agentFilter) localStorage.setItem('flow-agent-filter', agentFilter);
else localStorage.removeItem('flow-agent-filter');
if (agentFilter) localStorage.setItem("flow-agent-filter", agentFilter);
else localStorage.removeItem("flow-agent-filter");
applyAgentFilter();
});
}
@ -91,7 +97,7 @@ import { epochSec } from './util.js';
// side effects (banner pulse, OS notifications on operator-bound
// traffic).
(() => {
const flow = $('msgflow');
const flow = $("msgflow");
if (!flow) return;
flow.replaceChildren();
const tsFmt = (ts) => new Date(ts).toISOString().slice(11, 19);
@ -100,13 +106,16 @@ import { epochSec } from './util.js';
// in the flow chrome — `pulseBanner` no-ops on /flow.html since
// there's no element to find. Kept for parity if a future chrome
// change reintroduces a banner.
const banner = document.querySelector('.banner');
const banner = document.querySelector(".banner");
let bannerOffTimer = null;
function pulseBanner() {
if (!banner) return;
banner.classList.add('active');
banner.classList.add("active");
if (bannerOffTimer) clearTimeout(bannerOffTimer);
bannerOffTimer = setTimeout(() => banner.classList.remove('active'), 4000);
bannerOffTimer = setTimeout(
() => banner.classList.remove("active"),
4000,
);
}
// Map of broker row id → rendered row element. Lets reply rows add
// a visual "↳ in reply to" indicator that links back to the parent.
@ -116,55 +125,60 @@ import { epochSec } from './util.js';
function renderMsg(ev, api, glyph) {
const isReply = ev.in_reply_to != null;
const cls = 'msgrow ' + ev.kind + (isReply ? ' msg-reply' : '');
const row = api.row(cls, '');
const cls = "msgrow " + ev.kind + (isReply ? " msg-reply" : "");
const row = api.row(cls, "");
// 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 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";
appendLinkified(body, ev.body, ev.file_refs);
// Reply thread indicator: a small "↳ reply to <from>" hint that
// shows which message this is responding to. If we have the parent
// in our row map, clicking scrolls it into view.
if (isReply) {
const replyTag = document.createElement('span');
replyTag.className = 'msg-reply-tag';
const replyTag = document.createElement("span");
replyTag.className = "msg-reply-tag";
const parentRow = msgRowMap.get(ev.in_reply_to);
if (parentRow) {
const link = document.createElement('a');
link.href = '#';
link.textContent = '↳ reply';
link.title = 'scroll to parent message';
link.addEventListener('click', (e) => {
const link = document.createElement("a");
link.href = "#";
link.textContent = "↳ reply";
link.title = "scroll to parent message";
link.addEventListener("click", (e) => {
e.preventDefault();
parentRow.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
parentRow.classList.add('msg-highlight');
setTimeout(() => parentRow.classList.remove('msg-highlight'), 1500);
parentRow.scrollIntoView({ behavior: "smooth", block: "nearest" });
parentRow.classList.add("msg-highlight");
setTimeout(() => parentRow.classList.remove("msg-highlight"), 1500);
});
replyTag.append(link);
} else {
replyTag.textContent = '↳ reply';
replyTag.textContent = "↳ reply";
}
row.prepend(replyTag);
row.append(ts, ' ', arrow, ' ', from, ' ', sep, ' ', to, ' ', body);
row.append(ts, " ", arrow, " ", from, " ", sep, " ", to, " ", body);
} else {
row.append(ts, ' ', arrow, ' ', from, ' ', sep, ' ', to, ' ', body);
row.append(ts, " ", arrow, " ", from, " ", sep, " ", to, " ", body);
}
// Tag with the participants so the agent filter can match
// on `from`/`to`, and hide the row up-front if a filter is active.
row.dataset.from = ev.from;
row.dataset.to = ev.to;
if (!rowMatchesFilter(ev.from, ev.to)) row.classList.add('flow-hidden');
if (!rowMatchesFilter(ev.from, ev.to)) row.classList.add("flow-hidden");
// Register this row so future replies can reference it.
if (ev.id != null && ev.id > 0) msgRowMap.set(ev.id, row);
return row;
@ -193,13 +207,13 @@ import { epochSec } from './util.js';
if (ev.id == null || ev.id <= 0) return false;
const s = recentSent.get(ev.id);
if (!s || epochSec(ev.at) - s.at > COLLAPSE_SECS) return false;
const arrow = s.row.querySelector('.msg-arrow');
if (arrow) arrow.textContent = '✓';
const arrow = s.row.querySelector(".msg-arrow");
if (arrow) arrow.textContent = "✓";
// Re-style the row as delivered (green ✓) — the collapsed line now
// represents the delivered state; it was sent + delivered as one.
s.row.classList.remove('sent');
s.row.classList.add('delivered');
s.row.title = 'sent + delivered';
s.row.classList.remove("sent");
s.row.classList.add("delivered");
s.row.title = "sent + delivered";
recentSent.delete(ev.id);
return true;
}
@ -207,11 +221,11 @@ import { epochSec } from './util.js';
// default `.terminal-wrap` parent — see docs/web-ui.md::Per-agent
// page (Terminal-wrap) for the backdrop-filter stacking-context
// gotcha (same shape on the flow page).
const flowMain = document.querySelector('.flow-main');
const flowMain = document.querySelector(".flow-main");
termCreate({
logEl: flow,
pillAnchor: flowMain,
historyUrl: '/api/dashboard/history',
historyUrl: "/api/dashboard/history",
// Server-side filter — only the kinds this page actually renders
// or routes (sent/delivered → broker terminal,
// container_state_changed/_removed → local autocomplete cache).
@ -220,18 +234,19 @@ import { epochSec } from './util.js';
// JSON-serialise is skipped entirely on irrelevant kinds. The
// dashboard tabs page (tabs.js) keeps the unfiltered subscribe
// since it routes every mutation kind into its derived stores.
streamUrl: '/api/dashboard/stream?kinds=sent,delivered,container_state_changed,container_removed',
streamUrl:
"/api/dashboard/stream?kinds=sent,delivered,container_state_changed,container_removed",
// Route through the SharedWorker — see docs/web-ui.md (SSE
// multiplexing paragraph). Worker keys on the full URL incl.
// query string, so this filtered subscribe is its own upstream
// and won't accidentally share with tabs.js's wider subscribe.
streamFactory: openStream,
renderers: {
sent: (ev, api) => rememberSent(ev, renderMsg(ev, api, '→')),
sent: (ev, api) => rememberSent(ev, renderMsg(ev, api, "→")),
delivered: (ev, api) => {
// Fold into the matching sent row when it just happened;
// otherwise render the delivery as its own line.
if (!collapseDelivered(ev)) renderMsg(ev, api, '✓');
if (!collapseDelivered(ev)) renderMsg(ev, api, "✓");
},
// Maintain the local containers cache from the same stream
// (compose autocomplete reads from `flowContainers`). The
@ -259,21 +274,27 @@ import { epochSec } from './util.js';
// are never replayed, so without this the compose autocomplete
// could drift stale.
onStreamOpen: () => {
fetch('/api/state').then((r) => r.ok ? r.json() : null).then((s) => {
if (!s || !Array.isArray(s.containers)) return;
flowContainers.clear();
for (const c of s.containers) flowContainers.set(c.name, c);
}).catch(() => {});
fetch("/api/state")
.then((r) => (r.ok ? r.json() : null))
.then((s) => {
if (!s || !Array.isArray(s.containers)) return;
flowContainers.clear();
for (const c of s.containers) flowContainers.set(c.name, c);
})
.catch(() => {});
},
onLiveEvent: (ev) => {
pulseBanner();
if (ev.kind === 'sent' && ev.to === 'operator') {
if (ev.kind === "sent" && ev.to === "operator") {
NOTIF.show(
'◆ ' + ev.from + ' → operator',
String(ev.body || '').slice(0, 200),
"◆ " + ev.from + " → operator",
String(ev.body || "").slice(0, 200),
// Unique-per-arrival tag so a burst stacks instead of
// overwriting itself in the OS notification center.
'hyperhive:msg:' + ev.at + ':' + Math.random().toString(36).slice(2, 6),
"hyperhive:msg:" +
ev.at +
":" +
Math.random().toString(36).slice(2, 6),
);
}
},
@ -282,28 +303,27 @@ import { epochSec } from './util.js';
// ─── compose: @-mention with sticky recipient ───────────────────────────
(() => {
const input = $('op-compose-input');
const prompt = $('op-compose-prompt');
const suggest = $('op-compose-suggest');
const input = $("op-compose-input");
const prompt = $("op-compose-prompt");
const suggest = $("op-compose-suggest");
if (!input || !prompt || !suggest) return;
const STORAGE_KEY = 'hyperhive:op-compose:to';
let stickyTo = localStorage.getItem(STORAGE_KEY) || '';
const STORAGE_KEY = "hyperhive:op-compose:to";
let stickyTo = localStorage.getItem(STORAGE_KEY) || "";
let suggestActive = -1;
function renderPrompt() {
prompt.textContent = stickyTo ? `@${stickyTo}>` : '@—>';
prompt.textContent = stickyTo ? `@${stickyTo}>` : "@—>";
}
function knownAgents() {
// Read live from the flow-local containers cache so newly-spawned
// agents become addressable without a manual reload.
const names = Array.from(flowContainers.values())
.map((c) => c.name);
const names = Array.from(flowContainers.values()).map((c) => c.name);
// `*` fans out to every registered agent (server-side
// broadcast_send).
names.unshift('*');
names.unshift("*");
return names;
}
function autosize() {
input.style.height = 'auto';
input.style.height = "auto";
input.style.height = `${input.scrollHeight}px`;
}
/// Parse "@name body…" — return {to, body} when the input opens
@ -320,12 +340,15 @@ import { epochSec } from './util.js';
}
function renderSuggest(matches) {
suggest.replaceChildren();
if (!matches.length) { hideSuggest(); return; }
if (!matches.length) {
hideSuggest();
return;
}
for (let i = 0; i < matches.length; i += 1) {
const item = document.createElement('div');
item.className = 'item' + (i === suggestActive ? ' active' : '');
item.textContent = '@' + matches[i];
item.addEventListener('mousedown', (e) => {
const item = document.createElement("div");
item.className = "item" + (i === suggestActive ? " active" : "");
item.textContent = "@" + matches[i];
item.addEventListener("mousedown", (e) => {
e.preventDefault();
applySuggestion(matches[i]);
});
@ -338,7 +361,7 @@ import { epochSec } from './util.js';
const v = input.value;
const m = v.match(/^@(\S*)/);
if (m) {
input.value = `@${name} ` + v.slice(m[0].length).replace(/^\s+/, '');
input.value = `@${name} ` + v.slice(m[0].length).replace(/^\s+/, "");
} else {
input.value = `@${name} ` + v;
}
@ -353,11 +376,20 @@ import { epochSec } from './util.js';
// input — switching recipient is always "redirect this whole
// line." Mid-message @-mentions stay literal.
const m = v.match(/^@(\S*)/);
if (!m) { hideSuggest(); return; }
if (!m) {
hideSuggest();
return;
}
const partial = m[1].toLowerCase();
const matches = knownAgents().filter((n) => n.toLowerCase().startsWith(partial));
if (!matches.length) { hideSuggest(); return; }
if (suggestActive < 0 || suggestActive >= matches.length) suggestActive = 0;
const matches = knownAgents().filter((n) =>
n.toLowerCase().startsWith(partial),
);
if (!matches.length) {
hideSuggest();
return;
}
if (suggestActive < 0 || suggestActive >= matches.length)
suggestActive = 0;
renderSuggest(matches);
}
async function submit() {
@ -373,20 +405,20 @@ import { epochSec } from './util.js';
to = stickyTo;
body = raw;
} else {
flashError('no recipient — start with @name to address a message');
flashError("no recipient — start with @name to address a message");
return;
}
if (!body) return;
const fd = new FormData();
fd.append('to', to);
fd.append('body', body);
fd.append("to", to);
fd.append("body", body);
input.disabled = true;
try {
// /op-send returns 200. The SSE channel carries the resulting
// MessageEvent → the terminal renders the sent row on its own;
// no /api/state refetch needed.
const resp = await fetch('/api/op-send', {
method: 'POST',
const resp = await fetch("/api/op-send", {
method: "POST",
body: new URLSearchParams(fd),
});
if (!resp.ok) {
@ -401,17 +433,17 @@ import { epochSec } from './util.js';
}
stickyTo = to;
localStorage.setItem(STORAGE_KEY, to);
input.value = '';
input.value = "";
autosize();
renderPrompt();
input.focus();
}
function flashError(msg) {
const flow = $('msgflow');
const flow = $("msgflow");
if (!flow) return;
const row = document.createElement('div');
row.className = 'msgrow meta';
row.textContent = '✗ ' + msg;
const row = document.createElement("div");
row.className = "msgrow meta";
row.textContent = "✗ " + msg;
// Append at the bottom so the error is visible — the terminal
// renders newest-last, so inserting before firstChild would place
// the error at the top (oldest end) and hide it from view.
@ -420,43 +452,46 @@ import { epochSec } from './util.js';
const wrap = flow.parentElement;
if (wrap) wrap.scrollTop = wrap.scrollHeight;
}
input.addEventListener('input', () => { autosize(); updateSuggest(); });
input.addEventListener('keydown', (e) => {
input.addEventListener("input", () => {
autosize();
updateSuggest();
});
input.addEventListener("keydown", (e) => {
if (!suggest.hidden) {
if (e.key === 'ArrowDown') {
const items = suggest.querySelectorAll('.item');
if (e.key === "ArrowDown") {
const items = suggest.querySelectorAll(".item");
suggestActive = (suggestActive + 1) % items.length;
renderSuggest(Array.from(items).map((i) => i.textContent.slice(1)));
e.preventDefault();
return;
}
if (e.key === 'ArrowUp') {
const items = suggest.querySelectorAll('.item');
if (e.key === "ArrowUp") {
const items = suggest.querySelectorAll(".item");
suggestActive = (suggestActive - 1 + items.length) % items.length;
renderSuggest(Array.from(items).map((i) => i.textContent.slice(1)));
e.preventDefault();
return;
}
if (e.key === 'Tab' || (e.key === 'Enter' && !e.shiftKey)) {
const active = suggest.querySelector('.item.active');
if (e.key === "Tab" || (e.key === "Enter" && !e.shiftKey)) {
const active = suggest.querySelector(".item.active");
if (active) {
applySuggestion(active.textContent.slice(1));
e.preventDefault();
return;
}
}
if (e.key === 'Escape') {
if (e.key === "Escape") {
hideSuggest();
e.preventDefault();
return;
}
}
if (e.key === 'Enter' && !e.shiftKey) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
submit();
}
});
input.addEventListener('blur', () => {
input.addEventListener("blur", () => {
// Defer so a click on a suggestion item (mousedown) lands first.
setTimeout(hideSuggest, 100);
});