Compare commits
2 commits
7e12da83e2
...
918dccfedd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
918dccfedd | ||
|
|
06c23e0bdc |
4 changed files with 471 additions and 422 deletions
|
|
@ -1,15 +1,21 @@
|
||||||
// esbuild build for @hive/dashboard. Output layout (`dist/`):
|
// esbuild build for @hive/dashboard. Output layout (`dist/`):
|
||||||
//
|
//
|
||||||
// dist/index.html served by the Rust router at GET /
|
// dist/index.html served by the Rust router at GET /
|
||||||
// dist/static/app.js served at /static/app.js (ESM bundle,
|
// dist/flow.html served at GET /flow.html
|
||||||
// pulls in @hive/shared + marked)
|
// dist/static/app.js /index.html entry — tab renderers +
|
||||||
// dist/static/app.js.map source map sibling
|
// tab routing + refreshState
|
||||||
|
// dist/static/flow.js /flow.html entry — broker terminal +
|
||||||
|
// operator inbox + @-mention composer
|
||||||
|
// dist/static/{app,flow}.js.map source map siblings
|
||||||
// dist/static/dashboard.css served at /static/dashboard.css
|
// dist/static/dashboard.css served at /static/dashboard.css
|
||||||
// (@import resolved from @hive/shared)
|
// (@import resolved from @hive/shared)
|
||||||
//
|
//
|
||||||
// The Rust binary mounts `dist/` as a `tower_http::ServeDir` fallback;
|
// Both JS entries inline `./common.js` (DOM helpers, Panel singleton,
|
||||||
// the layout above keeps every URL the index.html references reachable
|
// NOTIF, path linkification) — esbuild dedupes the shared module
|
||||||
// without rewriting paths in the HTML.
|
// between bundles. The Rust binary mounts `dist/` as a
|
||||||
|
// `tower_http::ServeDir` fallback; the layout above keeps every URL
|
||||||
|
// the HTML files reference reachable without rewriting paths in the
|
||||||
|
// HTML.
|
||||||
|
|
||||||
import { build } from 'esbuild';
|
import { build } from 'esbuild';
|
||||||
import { mkdirSync, copyFileSync, rmSync } from 'node:fs';
|
import { mkdirSync, copyFileSync, rmSync } from 'node:fs';
|
||||||
|
|
@ -24,12 +30,13 @@ const staticDir = (p) => resolve(here, 'dist', 'static', p);
|
||||||
rmSync(dist(''), { recursive: true, force: true });
|
rmSync(dist(''), { recursive: true, force: true });
|
||||||
mkdirSync(staticDir(''), { recursive: true });
|
mkdirSync(staticDir(''), { recursive: true });
|
||||||
|
|
||||||
// Bundle the JS entry. ES-module output, browser target, no minify
|
// Bundle both JS entries. ES-module output, browser target, no minify
|
||||||
// (line-aligned source aids debugging; minification belongs in a later
|
// (line-aligned source aids debugging; minification belongs in a later
|
||||||
// follow-up once asset sizes warrant it).
|
// follow-up once asset sizes warrant it). esbuild writes each entry
|
||||||
|
// to `static/<name>.js` based on the entryPoint basename.
|
||||||
await build({
|
await build({
|
||||||
entryPoints: [src('app.js')],
|
entryPoints: [src('app.js'), src('flow.js')],
|
||||||
outfile: staticDir('app.js'),
|
outdir: staticDir(''),
|
||||||
bundle: true,
|
bundle: true,
|
||||||
format: 'esm',
|
format: 'esm',
|
||||||
platform: 'browser',
|
platform: 'browser',
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,17 @@
|
||||||
// Dashboard SPA. Renders containers + approvals from `/api/state`, wires
|
// /index.html entry point: tab renderers + tab routing + refreshState
|
||||||
// up async-form submission (URL-encoded POST + spinner + state refresh),
|
// + notification deltas. Reads /api/state on cold load and after every
|
||||||
// and tails the unified dashboard event channel over `/dashboard/stream`.
|
// async-form submit; live updates run through `applyXxx` mutation
|
||||||
|
// handlers triggered by the dashboard event stream. (Live SSE
|
||||||
|
// subscription on /index.html is still missing — see #406 step 3 / #408.)
|
||||||
//
|
//
|
||||||
// #406 step 1: pure helpers (DOM, formatters), the side-panel singleton,
|
// #406 step 1: pure helpers + side panel + OS notifications + path
|
||||||
// the OS-notification module, and the path-link / file-preview
|
// linkification moved to `./common.js`.
|
||||||
// infrastructure all moved into `./common.js`. The follow-up step splits
|
// #406 step 2: the flow-only IIFEs (operator inbox, inbox-pill, broker
|
||||||
// the broker-terminal / inbox / compose bits into a separate `flow.js`
|
// terminal, @-mention composer) moved to `./flow.js`. /flow.html now
|
||||||
// entry so /flow.html stops loading the tab renderers it doesn't use.
|
// loads `flow.js` as its own bundle entry; this file (still named
|
||||||
|
// `app.js` for the moment; rename to `tabs.js` is the last step
|
||||||
|
// of #406) is loaded only by /index.html.
|
||||||
|
|
||||||
import { create as termCreate, linkify as termLinkify } from '@hive/shared/terminal.js';
|
|
||||||
import { marked } from 'marked';
|
import { marked } from 'marked';
|
||||||
import {
|
import {
|
||||||
$, el, esc, form,
|
$, el, esc, form,
|
||||||
|
|
@ -17,13 +20,9 @@ import {
|
||||||
makePathLink, appendText, appendLinkified,
|
makePathLink, appendText, appendLinkified,
|
||||||
} from './common.js';
|
} from './common.js';
|
||||||
|
|
||||||
// Expose the previously-script-tag-provided globals so the IIFE below
|
// mdNode (in common.js) reads `window.marked` for the markdown side
|
||||||
// keeps working unchanged. Pre-split these were attached by
|
// panel preview path. Set it here on the dashboard entry so file
|
||||||
// `/static/hive-fr0nt.js` (HiveTerminal) and `/static/marked.js`
|
// previews work; flow.js does the same for the flow-page entry.
|
||||||
// (marked) loading before app.js. The bundle now pulls them in via ES
|
|
||||||
// imports; once the IIFE is opened up these aliases can be dropped in
|
|
||||||
// favour of direct named imports.
|
|
||||||
window.HiveTerminal = { create: termCreate, linkify: termLinkify };
|
|
||||||
window.marked = marked;
|
window.marked = marked;
|
||||||
|
|
||||||
(() => {
|
(() => {
|
||||||
|
|
@ -1086,63 +1085,7 @@ window.marked = marked;
|
||||||
});
|
});
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
||||||
// ─── operator inbox (derived from the broker message stream) ───────────
|
// Operator-inbox derived store moved to ./flow.js (#406 step 2).
|
||||||
// No longer shipped on `/api/state.operator_inbox`. The dashboard
|
|
||||||
// terminal's HiveTerminal feeds this via `onAnyEvent` — backfill from
|
|
||||||
// `/dashboard/history` populates on load, live SSE keeps it current.
|
|
||||||
// Newest-first to match the previous behaviour.
|
|
||||||
const INBOX_LIMIT = 50;
|
|
||||||
const operatorInbox = [];
|
|
||||||
function inboxAppendFromEvent(ev) {
|
|
||||||
if (ev.kind !== 'sent' || ev.to !== 'operator') return false;
|
|
||||||
operatorInbox.unshift({
|
|
||||||
from: ev.from,
|
|
||||||
body: ev.body,
|
|
||||||
at: ev.at,
|
|
||||||
file_refs: ev.file_refs || [],
|
|
||||||
});
|
|
||||||
if (operatorInbox.length > INBOX_LIMIT) operatorInbox.length = INBOX_LIMIT;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
function buildInboxListNode() {
|
|
||||||
if (!operatorInbox.length) return el('p', { class: 'empty' }, 'no messages');
|
|
||||||
const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(0, 19);
|
|
||||||
const ul = el('ul', { class: 'inbox' });
|
|
||||||
for (const m of operatorInbox) {
|
|
||||||
const li = el('li');
|
|
||||||
const body = el('span', { class: 'msg-body' });
|
|
||||||
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,
|
|
||||||
);
|
|
||||||
ul.append(li);
|
|
||||||
}
|
|
||||||
return ul;
|
|
||||||
}
|
|
||||||
function renderInbox() {
|
|
||||||
// Inline section on the dashboard (#inbox-section). Hidden /
|
|
||||||
// headless on the flow page; the flow page surfaces inbox via
|
|
||||||
// the pill + side-panel flyout instead.
|
|
||||||
const root = $('inbox-section');
|
|
||||||
if (root && !root.hidden) {
|
|
||||||
root.innerHTML = '';
|
|
||||||
root.append(buildInboxListNode());
|
|
||||||
}
|
|
||||||
// Flow-page pill: visible when there's at least one message,
|
|
||||||
// count tracks operatorInbox length, click opens the side
|
|
||||||
// panel. The element only exists on flow.html; on the
|
|
||||||
// dashboard this no-ops.
|
|
||||||
const pill = $('inbox-pill');
|
|
||||||
const pillCount = $('inbox-pill-count');
|
|
||||||
if (pillCount) pillCount.textContent = String(operatorInbox.length);
|
|
||||||
if (pill) pill.hidden = operatorInbox.length === 0;
|
|
||||||
// If the side panel is currently showing the inbox view, refresh
|
|
||||||
// its body in place so live messages land without a re-open.
|
|
||||||
Panel.refresh('inbox', 'inbox · ' + operatorInbox.length, buildInboxListNode());
|
|
||||||
}
|
|
||||||
|
|
||||||
const APPROVAL_TAB_KEY = 'hyperhive:approvals:tab';
|
const APPROVAL_TAB_KEY = 'hyperhive:approvals:tab';
|
||||||
// Derived approval state — cold-loaded from /api/state, then mutated
|
// Derived approval state — cold-loaded from /api/state, then mutated
|
||||||
|
|
@ -1855,7 +1798,8 @@ window.marked = marked;
|
||||||
// refetch.
|
// refetch.
|
||||||
syncQuestionsFromSnapshot(s);
|
syncQuestionsFromSnapshot(s);
|
||||||
renderQuestions();
|
renderQuestions();
|
||||||
renderInbox();
|
// (renderInbox now lives in ./flow.js — dashboard has no
|
||||||
|
// #inbox-section element to render into.)
|
||||||
syncApprovalsFromSnapshot(s);
|
syncApprovalsFromSnapshot(s);
|
||||||
renderApprovals();
|
renderApprovals();
|
||||||
renderMetaInputs(s);
|
renderMetaInputs(s);
|
||||||
|
|
@ -1943,8 +1887,11 @@ window.marked = marked;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setTabCount('system', sysCount);
|
setTabCount('system', sysCount);
|
||||||
// FL0W — operator inbox count.
|
// FL0W pill count: lives in ./flow.js now (it has the inbox
|
||||||
setTabCount('flow', operatorInbox.length);
|
// derived store). Dashboard tab strip's `#tab-count-flow` slot
|
||||||
|
// stays hidden by default; future plumbing could broadcast the
|
||||||
|
// count via localStorage / BroadcastChannel if both pages are
|
||||||
|
// open.
|
||||||
}
|
}
|
||||||
// Poll the state stores on a 1s tick to keep the pill counts in
|
// Poll the state stores on a 1s tick to keep the pill counts in
|
||||||
// sync. The state stores are mutated synchronously by every SSE
|
// sync. The state stores are mutated synchronously by every SSE
|
||||||
|
|
@ -1953,335 +1900,7 @@ window.marked = marked;
|
||||||
refreshTabCounts();
|
refreshTabCounts();
|
||||||
setInterval(refreshTabCounts, 1000);
|
setInterval(refreshTabCounts, 1000);
|
||||||
|
|
||||||
// Flow page: wire the inbox pill to open the side-panel flyout
|
// Flow-specific IIFEs moved to ./flow.js (#406 step 2): inbox-pill
|
||||||
// with the operator inbox. Only triggers when the pill exists
|
// wiring, the broker terminal init, and the @-mention composer.
|
||||||
// (i.e. we're on flow.html); on the dashboard this no-ops.
|
// /index.html no longer loads them — only /flow.html does.
|
||||||
(function bindFlowInboxPill() {
|
|
||||||
const pill = $('inbox-pill');
|
|
||||||
if (!pill) return;
|
|
||||||
pill.addEventListener('click', () => {
|
|
||||||
Panel.openNamed('inbox', 'inbox · ' + operatorInbox.length,
|
|
||||||
buildInboxListNode());
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
|
|
||||||
// ─── message flow: shared terminal pane ────────────────────────────────
|
|
||||||
// Scroll, pill, backfill + SSE plumbing live in hive-fr0nt::TERMINAL_JS
|
|
||||||
// (window.HiveTerminal). What stays here is the broker-message
|
|
||||||
// renderer + the page-local side effects (banner pulse, inbox refresh
|
|
||||||
// on operator-bound traffic, OS notifications).
|
|
||||||
(() => {
|
|
||||||
const flow = $('msgflow');
|
|
||||||
if (!flow || !window.HiveTerminal) return;
|
|
||||||
flow.innerHTML = '';
|
|
||||||
const tsFmt = (n) => new Date(n * 1000).toISOString().slice(11, 19);
|
|
||||||
// Pulse the page banner whenever a broker event lands. Each event
|
|
||||||
// nudges the shimmer window; if traffic stops, the class falls off
|
|
||||||
// after the grace timer.
|
|
||||||
const banner = document.querySelector('.banner');
|
|
||||||
let bannerOffTimer = null;
|
|
||||||
function pulseBanner() {
|
|
||||||
if (!banner) return;
|
|
||||||
banner.classList.add('active');
|
|
||||||
if (bannerOffTimer) clearTimeout(bannerOffTimer);
|
|
||||||
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.
|
|
||||||
// Bounded by the history window (~200 msgs from /dashboard/history),
|
|
||||||
// well within normal memory.
|
|
||||||
const msgRowMap = new Map();
|
|
||||||
|
|
||||||
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, '');
|
|
||||||
// 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';
|
|
||||||
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 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) => {
|
|
||||||
e.preventDefault();
|
|
||||||
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';
|
|
||||||
}
|
|
||||||
row.prepend(replyTag);
|
|
||||||
row.append(ts, ' ', arrow, ' ', from, ' ', sep, ' ', to, ' ', body);
|
|
||||||
} else {
|
|
||||||
row.append(ts, ' ', arrow, ' ', from, ' ', sep, ' ', to, ' ', body);
|
|
||||||
}
|
|
||||||
// Register this row so future replies can reference it.
|
|
||||||
if (ev.id != null && ev.id > 0) msgRowMap.set(ev.id, row);
|
|
||||||
}
|
|
||||||
// Anchor the `↓ N new` pill in `.flow-main` (NOT the default
|
|
||||||
// `log.parentElement` = `.terminal-wrap`). `.terminal-wrap`
|
|
||||||
// applies `backdrop-filter`, which creates a CSS stacking
|
|
||||||
// context — the pill's z-index would otherwise be trapped
|
|
||||||
// inside and clipped under the fixed composer (issue #375).
|
|
||||||
// `.flow-main` has no backdrop-filter / stacking-context
|
|
||||||
// creators, so the pill's z-index reaches the root and floats
|
|
||||||
// above the composer.
|
|
||||||
const flowMain = document.querySelector('.flow-main');
|
|
||||||
HiveTerminal.create({
|
|
||||||
logEl: flow,
|
|
||||||
pillAnchor: flowMain,
|
|
||||||
historyUrl: '/dashboard/history',
|
|
||||||
streamUrl: '/dashboard/stream',
|
|
||||||
renderers: {
|
|
||||||
sent: (ev, api) => renderMsg(ev, api, '→'),
|
|
||||||
delivered: (ev, api) => renderMsg(ev, api, '✓'),
|
|
||||||
// Mutation events update derived state and trigger a
|
|
||||||
// section re-render — no terminal log row (the terminal is
|
|
||||||
// for broker traffic, not state-change chatter).
|
|
||||||
approval_added: (ev) => { applyApprovalAdded(ev); },
|
|
||||||
approval_resolved: (ev) => { applyApprovalResolved(ev); },
|
|
||||||
question_added: (ev) => { applyQuestionAdded(ev); },
|
|
||||||
question_resolved: (ev) => { applyQuestionResolved(ev); },
|
|
||||||
transient_set: (ev) => { applyTransientSet(ev); },
|
|
||||||
transient_cleared: (ev) => { applyTransientCleared(ev); },
|
|
||||||
container_state_changed: (ev) => { applyContainerStateChanged(ev); },
|
|
||||||
container_removed: (ev) => { applyContainerRemoved(ev); },
|
|
||||||
tombstones_changed: (ev) => { applyTombstonesChanged(ev); },
|
|
||||||
meta_inputs_changed: (ev) => { applyMetaInputsChanged(ev); },
|
|
||||||
meta_update_running: (ev) => { applyMetaUpdateRunning(ev); },
|
|
||||||
rebuild_queue_changed: (ev) => { applyRebuildQueueChanged(ev); },
|
|
||||||
},
|
|
||||||
// Both history backfill and live frames flow through here, so the
|
|
||||||
// inbox section ends up populated correctly on first paint and
|
|
||||||
// updated thereafter — no /api/state refetch needed for inbox
|
|
||||||
// freshness (which used to be the workaround for the
|
|
||||||
// double-render bug).
|
|
||||||
onAnyEvent: (ev /* , { fromHistory } */) => {
|
|
||||||
if (inboxAppendFromEvent(ev)) renderInbox();
|
|
||||||
},
|
|
||||||
// Re-sync the full /api/state snapshot on every SSE (re)connect.
|
|
||||||
// Live mutation events that fired during a disconnect window are
|
|
||||||
// never replayed, so without this the derived stores (approvals,
|
|
||||||
// questions, containers, …) would drift stale until a manual
|
|
||||||
// reload (issue #163). refreshState() replaces every store from
|
|
||||||
// the snapshot, so a missed event self-heals on reconnect.
|
|
||||||
onStreamOpen: () => { refreshState(); },
|
|
||||||
onLiveEvent: (ev) => {
|
|
||||||
pulseBanner();
|
|
||||||
if (ev.kind === 'sent' && ev.to === 'operator') {
|
|
||||||
NOTIF.show(
|
|
||||||
'◆ ' + 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),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
|
|
||||||
// ─── 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() {
|
|
||||||
// Read live from the derived containers map so newly-spawned
|
|
||||||
// agents become addressable without an /api/state refetch.
|
|
||||||
// Broker uses the literal recipient `manager` for the manager's
|
|
||||||
// inbox, not the container name `hm1nd`.
|
|
||||||
const names = Array.from(containersState.values())
|
|
||||||
.map((c) => (c.is_manager ? 'manager' : c.name));
|
|
||||||
// `*` fans out to every registered agent (server-side
|
|
||||||
// broadcast_send).
|
|
||||||
names.unshift('*');
|
|
||||||
return names;
|
|
||||||
}
|
|
||||||
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 {
|
|
||||||
// /op-send now returns 200 (no more 303-to-/). The SSE channel
|
|
||||||
// carries the resulting MessageEvent → the terminal renders the
|
|
||||||
// sent row + the inbox updates on its own; no /api/state
|
|
||||||
// refetch needed.
|
|
||||||
const resp = await fetch('/op-send', {
|
|
||||||
method: 'POST',
|
|
||||||
body: new URLSearchParams(fd),
|
|
||||||
});
|
|
||||||
if (!resp.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();
|
|
||||||
})();
|
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -106,10 +106,10 @@
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Same bundled entry as the dashboard. Renderers whose target
|
<!-- Flow-specific bundle (#406 step 2). Contains the broker
|
||||||
DOM doesn't exist on this page no-op silently. SSE +
|
terminal init, the operator-inbox derived store, the inbox
|
||||||
/api/state fetching still run; only the chat-relevant chunks
|
pill flyout, and the @-mention composer. Tab renderers etc.
|
||||||
have anywhere to render to. -->
|
live in `/static/app.js` which /flow.html doesn't need. -->
|
||||||
<script type="module" src="/static/app.js" defer></script>
|
<script type="module" src="/static/flow.js" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
423
frontend/packages/dashboard/src/flow.js
Normal file
423
frontend/packages/dashboard/src/flow.js
Normal file
|
|
@ -0,0 +1,423 @@
|
||||||
|
// /flow.html entry point (#406 step 2 — flow-specific split from app.js).
|
||||||
|
//
|
||||||
|
// Owns the full-page broker terminal, the operator-inbox derived store
|
||||||
|
// (populated from the broker stream), the inbox pill flyout, and the
|
||||||
|
// @-mention compose box. Pulls shared infrastructure (DOM helpers, side
|
||||||
|
// panel, OS notifications, path linkification) from `./common.js`.
|
||||||
|
//
|
||||||
|
// Does NOT contain the dashboard's tab renderers, mutation-event
|
||||||
|
// dispatchers, or refreshState — that's `./app.js` (the legacy entry
|
||||||
|
// kept until step 3 renames it to `tabs.js`). For now the flow page
|
||||||
|
// runs purely on the broker stream + an initial /api/state fetch
|
||||||
|
// (compose autocomplete needs the live container list).
|
||||||
|
|
||||||
|
import { create as termCreate } from '@hive/shared/terminal.js';
|
||||||
|
import {
|
||||||
|
$, el,
|
||||||
|
Panel, NOTIF,
|
||||||
|
appendLinkified,
|
||||||
|
} from './common.js';
|
||||||
|
|
||||||
|
(() => {
|
||||||
|
Panel.bind();
|
||||||
|
NOTIF.bind();
|
||||||
|
|
||||||
|
// ─── operator inbox (derived from the broker message stream) ───────────
|
||||||
|
// No longer shipped on `/api/state.operator_inbox`. The broker
|
||||||
|
// terminal feeds this via `onAnyEvent` — backfill from
|
||||||
|
// `/dashboard/history` populates on load, live SSE keeps it current.
|
||||||
|
// Newest-first to match the previous behaviour.
|
||||||
|
const INBOX_LIMIT = 50;
|
||||||
|
const operatorInbox = [];
|
||||||
|
function inboxAppendFromEvent(ev) {
|
||||||
|
if (ev.kind !== 'sent' || ev.to !== 'operator') return false;
|
||||||
|
operatorInbox.unshift({
|
||||||
|
from: ev.from,
|
||||||
|
body: ev.body,
|
||||||
|
at: ev.at,
|
||||||
|
file_refs: ev.file_refs || [],
|
||||||
|
});
|
||||||
|
if (operatorInbox.length > INBOX_LIMIT) operatorInbox.length = INBOX_LIMIT;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
function buildInboxListNode() {
|
||||||
|
if (!operatorInbox.length) return el('p', { class: 'empty' }, 'no messages');
|
||||||
|
const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(0, 19);
|
||||||
|
const ul = el('ul', { class: 'inbox' });
|
||||||
|
for (const m of operatorInbox) {
|
||||||
|
const li = el('li');
|
||||||
|
const body = el('span', { class: 'msg-body' });
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
ul.append(li);
|
||||||
|
}
|
||||||
|
return ul;
|
||||||
|
}
|
||||||
|
function renderInbox() {
|
||||||
|
// Flow page surfaces inbox as a pill that opens the side-panel
|
||||||
|
// flyout. Pill is hidden when empty; click handler below opens
|
||||||
|
// the panel with the freshest list. If the panel is already
|
||||||
|
// showing the inbox view, refresh its body in place so live
|
||||||
|
// messages land without a re-open.
|
||||||
|
const pill = $('inbox-pill');
|
||||||
|
const pillCount = $('inbox-pill-count');
|
||||||
|
if (pillCount) pillCount.textContent = String(operatorInbox.length);
|
||||||
|
if (pill) pill.hidden = operatorInbox.length === 0;
|
||||||
|
Panel.refresh('inbox', 'inbox · ' + operatorInbox.length, buildInboxListNode());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wire the inbox pill to open the side-panel flyout with the
|
||||||
|
// operator inbox.
|
||||||
|
const inboxPill = $('inbox-pill');
|
||||||
|
if (inboxPill) {
|
||||||
|
inboxPill.addEventListener('click', () => {
|
||||||
|
Panel.openNamed('inbox', 'inbox · ' + operatorInbox.length,
|
||||||
|
buildInboxListNode());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── local containers cache (for compose autocomplete) ──────────────────
|
||||||
|
// The compose box's @-mention completion suggests known agent names.
|
||||||
|
// /index.html (tabs.js) maintains the canonical `containersState`
|
||||||
|
// from /api/state + SSE; here we keep a small local mirror updated
|
||||||
|
// 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);
|
||||||
|
}).catch(() => { /* graceful: compose just shows `*` and nothing else */ });
|
||||||
|
|
||||||
|
// ─── message flow: shared terminal pane ────────────────────────────────
|
||||||
|
// Scroll, pill, backfill + SSE plumbing live in @hive/shared/terminal.
|
||||||
|
// What stays here is the broker-message renderer + the page-local
|
||||||
|
// side effects (banner pulse, inbox refresh on operator-bound
|
||||||
|
// traffic, OS notifications).
|
||||||
|
(() => {
|
||||||
|
const flow = $('msgflow');
|
||||||
|
if (!flow) return;
|
||||||
|
flow.innerHTML = '';
|
||||||
|
const tsFmt = (n) => new Date(n * 1000).toISOString().slice(11, 19);
|
||||||
|
// Pulse the page banner whenever a broker event lands. (Note:
|
||||||
|
// post-#389 the `.banner` lives in the dashboard's <footer>, not
|
||||||
|
// in the flow page 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');
|
||||||
|
let bannerOffTimer = null;
|
||||||
|
function pulseBanner() {
|
||||||
|
if (!banner) return;
|
||||||
|
banner.classList.add('active');
|
||||||
|
if (bannerOffTimer) clearTimeout(bannerOffTimer);
|
||||||
|
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.
|
||||||
|
// Bounded by the history window (~200 msgs from /dashboard/history),
|
||||||
|
// well within normal memory.
|
||||||
|
const msgRowMap = new Map();
|
||||||
|
|
||||||
|
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, '');
|
||||||
|
// 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';
|
||||||
|
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 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) => {
|
||||||
|
e.preventDefault();
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
row.prepend(replyTag);
|
||||||
|
row.append(ts, ' ', arrow, ' ', from, ' ', sep, ' ', to, ' ', body);
|
||||||
|
} else {
|
||||||
|
row.append(ts, ' ', arrow, ' ', from, ' ', sep, ' ', to, ' ', body);
|
||||||
|
}
|
||||||
|
// Register this row so future replies can reference it.
|
||||||
|
if (ev.id != null && ev.id > 0) msgRowMap.set(ev.id, row);
|
||||||
|
}
|
||||||
|
// Anchor the `↓ N new` pill in `.flow-main` (NOT the default
|
||||||
|
// `log.parentElement` = `.terminal-wrap`). `.terminal-wrap`
|
||||||
|
// applies `backdrop-filter`, which creates a CSS stacking
|
||||||
|
// context — the pill's z-index would otherwise be trapped
|
||||||
|
// inside and clipped under the fixed composer (issue #375).
|
||||||
|
// `.flow-main` has no backdrop-filter / stacking-context
|
||||||
|
// creators, so the pill's z-index reaches the root and floats
|
||||||
|
// above the composer.
|
||||||
|
const flowMain = document.querySelector('.flow-main');
|
||||||
|
termCreate({
|
||||||
|
logEl: flow,
|
||||||
|
pillAnchor: flowMain,
|
||||||
|
historyUrl: '/dashboard/history',
|
||||||
|
streamUrl: '/dashboard/stream',
|
||||||
|
renderers: {
|
||||||
|
sent: (ev, api) => renderMsg(ev, api, '→'),
|
||||||
|
delivered: (ev, api) => renderMsg(ev, api, '✓'),
|
||||||
|
// Maintain the local containers cache from the same stream
|
||||||
|
// (compose autocomplete reads from `flowContainers`). The
|
||||||
|
// dashboard's tab renderers aren't on this page, so we don't
|
||||||
|
// need to dispatch to applyContainerStateChanged etc. — just
|
||||||
|
// keep the autocomplete list current.
|
||||||
|
container_state_changed: (ev) => {
|
||||||
|
if (ev.container && ev.container.name) {
|
||||||
|
flowContainers.set(ev.container.name, ev.container);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
container_removed: (ev) => { flowContainers.delete(ev.name); },
|
||||||
|
// Drop every other mutation kind silently — without this
|
||||||
|
// they'd fall through to the terminal module's default
|
||||||
|
// renderer and clutter the log with JSON dumps. The dashboard
|
||||||
|
// tabs handle these on /index.html via tabs.js.
|
||||||
|
_default: () => {},
|
||||||
|
},
|
||||||
|
// Both history backfill and live frames flow through here, so the
|
||||||
|
// inbox section ends up populated correctly on first paint and
|
||||||
|
// updated thereafter — no /api/state refetch needed for inbox
|
||||||
|
// freshness.
|
||||||
|
onAnyEvent: (ev /* , { fromHistory } */) => {
|
||||||
|
if (inboxAppendFromEvent(ev)) renderInbox();
|
||||||
|
},
|
||||||
|
// Re-sync the local containers cache on every SSE (re)connect.
|
||||||
|
// Live mutation events that fired during a disconnect window
|
||||||
|
// are never replayed, so without this the compose autocomplete
|
||||||
|
// could drift stale (issue #163). We don't try to recover
|
||||||
|
// missed broker rows here — operator inbox briefly stales on
|
||||||
|
// reconnect; HiveTerminal's history-replay covers the next
|
||||||
|
// page load.
|
||||||
|
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(() => {});
|
||||||
|
},
|
||||||
|
onLiveEvent: (ev) => {
|
||||||
|
pulseBanner();
|
||||||
|
if (ev.kind === 'sent' && ev.to === 'operator') {
|
||||||
|
NOTIF.show(
|
||||||
|
'◆ ' + 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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ─── 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() {
|
||||||
|
// Read live from the flow-local containers cache so newly-spawned
|
||||||
|
// agents become addressable without a manual reload.
|
||||||
|
// Broker uses the literal recipient `manager` for the manager's
|
||||||
|
// inbox, not the container name `hm1nd`.
|
||||||
|
const names = Array.from(flowContainers.values())
|
||||||
|
.map((c) => (c.is_manager ? 'manager' : c.name));
|
||||||
|
// `*` fans out to every registered agent (server-side
|
||||||
|
// broadcast_send).
|
||||||
|
names.unshift('*');
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
// /op-send returns 200. The SSE channel carries the resulting
|
||||||
|
// MessageEvent → the terminal renders the sent row + the
|
||||||
|
// inbox updates on its own; no /api/state refetch needed.
|
||||||
|
const resp = await fetch('/op-send', {
|
||||||
|
method: 'POST',
|
||||||
|
body: new URLSearchParams(fd),
|
||||||
|
});
|
||||||
|
if (!resp.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();
|
||||||
|
})();
|
||||||
|
})();
|
||||||
Loading…
Add table
Add a link
Reference in a new issue