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:
parent
2a6d084718
commit
5208b0112a
4 changed files with 278 additions and 1 deletions
|
|
@ -744,6 +744,10 @@
|
||||||
const resp = await fetch('/api/state');
|
const resp = await fetch('/api/state');
|
||||||
if (!resp.ok) throw new Error('http ' + resp.status);
|
if (!resp.ok) throw new Error('http ' + resp.status);
|
||||||
const s = await resp.json();
|
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();
|
const openDetails = snapshotOpenDetails();
|
||||||
renderContainers(s);
|
renderContainers(s);
|
||||||
renderTombstones(s);
|
renderTombstones(s);
|
||||||
|
|
@ -824,4 +828,180 @@
|
||||||
}), flow.firstChild);
|
}), 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();
|
||||||
|
})();
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -475,6 +475,63 @@ summary:hover { color: var(--purple); }
|
||||||
.msg-sep { color: var(--muted); }
|
.msg-sep { color: var(--muted); }
|
||||||
.msg-to { color: var(--pink); }
|
.msg-to { color: var(--pink); }
|
||||||
.msg-body { color: var(--fg); white-space: pre-wrap; word-break: break-word; }
|
.msg-body { color: var(--fg); white-space: pre-wrap; word-break: break-word; }
|
||||||
|
.op-compose {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.6em;
|
||||||
|
margin-top: 0.4em;
|
||||||
|
padding: 0.55em 0.8em;
|
||||||
|
background: rgba(24, 24, 37, 0.85);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
.op-compose-prompt {
|
||||||
|
color: var(--purple);
|
||||||
|
text-shadow: 0 0 4px currentColor;
|
||||||
|
font-weight: bold;
|
||||||
|
white-space: nowrap;
|
||||||
|
user-select: none;
|
||||||
|
padding-top: 0.15em;
|
||||||
|
}
|
||||||
|
.op-compose-input {
|
||||||
|
flex: 1;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
color: var(--fg);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.85em;
|
||||||
|
line-height: 1.5;
|
||||||
|
resize: none;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 1.5em;
|
||||||
|
caret-color: var(--purple);
|
||||||
|
}
|
||||||
|
.op-compose-input::placeholder { color: var(--muted); }
|
||||||
|
.op-compose-suggest {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 100%;
|
||||||
|
left: 0.8em;
|
||||||
|
margin-bottom: 0.2em;
|
||||||
|
background: rgba(24, 24, 37, 0.95);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
font-size: 0.85em;
|
||||||
|
min-width: 12em;
|
||||||
|
max-height: 12em;
|
||||||
|
overflow-y: auto;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
.op-compose-suggest .item {
|
||||||
|
padding: 0.2em 0.8em;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
.op-compose-suggest .item.active,
|
||||||
|
.op-compose-suggest .item:hover {
|
||||||
|
background: rgba(203, 166, 247, 0.18);
|
||||||
|
color: var(--purple);
|
||||||
|
}
|
||||||
footer {
|
footer {
|
||||||
margin-top: 4em;
|
margin-top: 4em;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
|
||||||
|
|
@ -49,8 +49,15 @@
|
||||||
|
|
||||||
<h2>◆ MESS4GE FL0W ◆</h2>
|
<h2>◆ MESS4GE FL0W ◆</h2>
|
||||||
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
||||||
<p class="meta">live tail — newest at the top. tap on every <code>send</code> / <code>recv</code> through the broker.</p>
|
<p class="meta">live tail — newest at the top. tap on every <code>send</code> / <code>recv</code> through the broker. compose below: <code>@name</code> picks the recipient (sticky until you @ someone else); <code>tab</code> completes.</p>
|
||||||
<div id="msgflow" class="msgflow"><span class="meta">connecting…</span></div>
|
<div id="msgflow" class="msgflow"><span class="meta">connecting…</span></div>
|
||||||
|
<div id="op-compose" class="op-compose">
|
||||||
|
<span id="op-compose-prompt" class="op-compose-prompt">@—></span>
|
||||||
|
<textarea id="op-compose-input" class="op-compose-input"
|
||||||
|
placeholder="@agent message… (enter sends, shift+enter newline, tab completes @-mention)"
|
||||||
|
rows="1" autocomplete="off"></textarea>
|
||||||
|
<div id="op-compose-suggest" class="op-compose-suggest" hidden></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
|
||||||
.route("/api/journal/{name}", get(get_journal))
|
.route("/api/journal/{name}", get(get_journal))
|
||||||
.route("/api/agent-config/{name}", get(get_agent_config))
|
.route("/api/agent-config/{name}", get(get_agent_config))
|
||||||
.route("/request-spawn", post(post_request_spawn))
|
.route("/request-spawn", post(post_request_spawn))
|
||||||
|
.route("/op-send", post(post_op_send))
|
||||||
.route("/messages/stream", get(messages_stream))
|
.route("/messages/stream", get(messages_stream))
|
||||||
.with_state(AppState { coord });
|
.with_state(AppState { coord });
|
||||||
let addr = SocketAddr::from(([0, 0, 0, 0], port));
|
let addr = SocketAddr::from(([0, 0, 0, 0], port));
|
||||||
|
|
@ -708,6 +709,38 @@ async fn post_purge_tombstone(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Operator-side compose form on the dashboard terminal. Drops a
|
||||||
|
/// message into the broker as `{from: "operator", to, body}`. Same
|
||||||
|
/// shape that per-agent web UIs use via `OperatorMsg`, but here the
|
||||||
|
/// operator picks the recipient explicitly with `@name`. No
|
||||||
|
/// validation that `to` resolves to a known agent — broker accepts
|
||||||
|
/// arbitrary recipients (and the agent's inbox grows whether or not
|
||||||
|
/// they exist, which is fine for spawn-then-greet flows).
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct OpSendForm {
|
||||||
|
to: String,
|
||||||
|
body: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn post_op_send(State(state): State<AppState>, Form(form): Form<OpSendForm>) -> Response {
|
||||||
|
let to = form.to.trim().to_owned();
|
||||||
|
let body = form.body.trim().to_owned();
|
||||||
|
if to.is_empty() {
|
||||||
|
return error_response("op-send: `to` required");
|
||||||
|
}
|
||||||
|
if body.is_empty() {
|
||||||
|
return error_response("op-send: `body` required");
|
||||||
|
}
|
||||||
|
if let Err(e) = state.coord.broker.send(&hive_sh4re::Message {
|
||||||
|
from: hive_sh4re::OPERATOR_RECIPIENT.to_owned(),
|
||||||
|
to: to.clone(),
|
||||||
|
body,
|
||||||
|
}) {
|
||||||
|
return error_response(&format!("op-send to {to} failed: {e:#}"));
|
||||||
|
}
|
||||||
|
Redirect::to("/").into_response()
|
||||||
|
}
|
||||||
|
|
||||||
async fn post_request_spawn(
|
async fn post_request_spawn(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Form(form): Form<RequestSpawnForm>,
|
Form(form): Form<RequestSpawnForm>,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue