dashboard: terminal compose with @-mention sticky recipient

new section under MESS4GE FL0W. msgflow already tails only
broker traffic (sent + delivered), which is exactly the
'messages through core' view the operator wants; no
per-agent thinking leaks through. compose box below:

- a prompt span renders the sticky recipient ('@coder>'),
  rendered outside the textarea so it can't be edited
  inadvertently. on submit the recipient gets persisted to
  localStorage so it survives reload.
- start the input with '@name body' to redirect — the parser
  splits at the first whitespace and the new recipient
  becomes sticky.
- typing '@' at the start opens a completion dropdown over
  the textarea pulled from window.__hyperhive_state.containers;
  arrow keys cycle, tab/enter selects, escape closes. clicking
  works too.
- manager swap: agents flagged is_manager are surfaced as
  '@manager' (the broker's recipient string) instead of
  '@hm1nd' (the container name), so the message actually
  routes to the manager's inbox.

backend: new POST /op-send accepts {to, body} and drops a
broker.send({from:'operator', to, body}) — same shape as the
per-agent web UI's OperatorMsg, but lets the operator choose
the recipient explicitly from the main dashboard.
This commit is contained in:
müde 2026-05-16 01:55:00 +02:00
parent 2a6d084718
commit 5208b0112a
4 changed files with 278 additions and 1 deletions

View file

@ -744,6 +744,10 @@
const resp = await fetch('/api/state');
if (!resp.ok) throw new Error('http ' + resp.status);
const s = await resp.json();
// Stash the latest snapshot for any sub-widget that wants a
// synchronous read (e.g. the compose autocomplete pulls agent
// names from here instead of refetching on every keystroke).
window.__hyperhive_state = s;
const openDetails = snapshotOpenDetails();
renderContainers(s);
renderTombstones(s);
@ -824,4 +828,180 @@
}), flow.firstChild);
};
})();
// ─── compose: @-mention with sticky recipient ───────────────────────────
(() => {
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) || '';
let suggestActive = -1;
function renderPrompt() {
prompt.textContent = stickyTo ? `@${stickyTo}>` : '@—>';
}
function knownAgents() {
const s = window.__hyperhive_state;
if (!s || !Array.isArray(s.containers)) return [];
// The broker uses the literal recipient `manager` for the
// manager's inbox, not the container name `hm1nd`. Swap on
// suggestion so `@manager` Just Works.
return s.containers.map((c) => (c.is_manager ? 'manager' : c.name));
}
function autosize() {
input.style.height = 'auto';
input.style.height = `${input.scrollHeight}px`;
}
/// Parse "@name body…" — return {to, body} when the input opens
/// with a known @-mention, otherwise null.
function parseAddressed(raw) {
const m = raw.match(/^@([\w-]+)\s+([\s\S]+)$/);
if (!m) return null;
return { to: m[1], body: m[2] };
}
function hideSuggest() {
suggest.hidden = true;
suggest.innerHTML = '';
suggestActive = -1;
}
function renderSuggest(matches) {
suggest.innerHTML = '';
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) => {
e.preventDefault();
applySuggestion(matches[i]);
});
suggest.append(item);
}
suggest.hidden = false;
}
function applySuggestion(name) {
// Replace the partial @-token at the start with the full name.
const v = input.value;
const m = v.match(/^@(\S*)/);
if (m) {
input.value = `@${name} ` + v.slice(m[0].length).replace(/^\s+/, '');
} else {
input.value = `@${name} ` + v;
}
hideSuggest();
input.focus();
input.setSelectionRange(input.value.length, input.value.length);
autosize();
}
function updateSuggest() {
const v = input.value;
// Only suggest when an @-token sits at the very start of the
// input — switching recipient is always "redirect this whole
// line." Mid-message @-mentions stay literal.
const m = v.match(/^@(\S*)/);
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;
renderSuggest(matches);
}
async function submit() {
const raw = input.value.trim();
if (!raw) return;
let to;
let body;
const addressed = parseAddressed(raw);
if (addressed) {
to = addressed.to;
body = addressed.body.trim();
} else if (stickyTo) {
to = stickyTo;
body = raw;
} else {
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);
input.disabled = true;
try {
const resp = await fetch('/op-send', {
method: 'POST',
body: new URLSearchParams(fd),
redirect: 'manual',
});
const ok = resp.ok || resp.type === 'opaqueredirect'
|| (resp.status >= 200 && resp.status < 400);
if (!ok) {
flashError(`send failed: http ${resp.status}`);
return;
}
} catch (err) {
flashError(`send failed: ${err}`);
return;
} finally {
input.disabled = false;
}
stickyTo = to;
localStorage.setItem(STORAGE_KEY, to);
input.value = '';
autosize();
renderPrompt();
input.focus();
}
function flashError(msg) {
const flow = $('msgflow');
if (!flow) return;
const row = document.createElement('div');
row.className = 'msgrow meta';
row.textContent = msg;
flow.insertBefore(row, flow.firstChild);
}
input.addEventListener('input', () => { autosize(); updateSuggest(); });
input.addEventListener('keydown', (e) => {
if (!suggest.hidden) {
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');
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 (active) {
applySuggestion(active.textContent.slice(1));
e.preventDefault();
return;
}
}
if (e.key === 'Escape') {
hideSuggest();
e.preventDefault();
return;
}
}
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
submit();
}
});
input.addEventListener('blur', () => {
// Defer so a click on a suggestion item (mousedown) lands first.
setTimeout(hideSuggest, 100);
});
renderPrompt();
autosize();
})();
})();