dashboard: extract flow.js as separate /flow.html entry (#406 step 2)
Splits the single bundle into two: `app.js` (entry for /index.html —
tab renderers + tab routing + refreshState) and a new `flow.js`
(entry for /flow.html — operator inbox derived store + inbox pill
flyout + broker terminal + @-mention composer). Both bundles inline
`./common.js` (DOM helpers, Panel, NOTIF, path linkification).
## What `flow.js` owns
- Operator inbox derived store (`operatorInbox`, `INBOX_LIMIT`,
`inboxAppendFromEvent`, `buildInboxListNode`, `renderInbox`) +
inbox-pill click wiring
- Broker terminal init (`HiveTerminal.create({ logEl: msgflow, ... })`)
with `renderMsg`, `pulseBanner`, `msgRowMap` reply-thread indicator,
and the renderers map for `sent` / `delivered` broker rows
- @-mention composer (`#op-compose-input` IIFE — sticky recipient,
autocomplete, parseAddressed, /op-send POST)
- A small local `flowContainers` cache for the composer's
autocomplete, refreshed on cold load + on every SSE reconnect via
`onStreamOpen`, and live-updated by `container_state_changed` /
`container_removed` SSE events (the dashboard's `containersState`
lives in `app.js` and isn't available here)
## What `app.js` no longer does
- Drops the inbox derived store, the bindFlowInboxPill IIFE, the
broker-terminal IIFE, and the composer IIFE — all moved
- Drops the `renderInbox()` call in `refreshState` (dashboard has
no #inbox-section element)
- Drops `setTabCount('flow', operatorInbox.length)` — the FL0W tab
count lives in flow.js now (cross-page count broadcasting is a
future follow-up; the slot currently stays hidden on /index.html)
- Drops the `window.HiveTerminal` global — the bare-import pattern
in common.js / flow.js made it unused on the dashboard
## What changes for /flow.html
- `<script src>` switches from `/static/app.js` → `/static/flow.js`
- Mutation events on the dashboard stream (`approval_added`,
`container_state_changed`, etc.) are silently ignored on /flow.html
via a `_default: () => {}` renderer (the dashboard tabs aren't on
this page; firing the legacy applyXxx handlers from here just
mutated dead stores). #408 follow-up filters this at the SSE level
## Validation
- `npm run build` clean.
- Bundle deltas:
- `app.js`: 154kb → 135kb (dropped ~19kb of flow code)
- `flow.js`: NEW 29kb (was bundled into the old 154kb app.js)
- `flow.html` page total: 154kb → 29kb (flow.js + inlined common,
no tab renderers shipped)
- Source: `app.js` 2287 → 1907 lines (-380); `flow.js` 423 lines (new)
- No HTML / CSS changes besides the `<script src>` swap on /flow.html.
## Known limitations (out of scope; tracked separately)
- /index.html still has no live SSE subscription — the dashboard
updates only on cold load + after async-form submits. Pre-existing
behaviour; the SSE wiring also lived in the flow IIFE before.
Step 3 of #406 (or its own bug fix) re-wires it.
- /flow.html's `_default: noop` drops mutation events; #408 fixes
the duplicate-traffic by splitting the SSE endpoint server-side.
- The FL0W tab-strip count pill on /index.html stays hidden — the
count source is now in flow.js. Broadcast via localStorage /
BroadcastChannel is a small follow-up if both pages are open.
Browser smoke test isn't possible from inside iris's container.
Worth eyeballing post-deploy:
- /flow.html: terminal renders broker rows; inbox pill shows count
+ opens flyout; composer autocomplete suggests known agents
+ sends successfully
- /index.html: tab renderers all work; notification toggle still
binds; side panel still opens for diffs / file previews / logs
This commit is contained in:
parent
7e12da83e2
commit
06c23e0bdc
4 changed files with 476 additions and 422 deletions
|
|
@ -1,15 +1,21 @@
|
|||
// esbuild build for @hive/dashboard. Output layout (`dist/`):
|
||||
//
|
||||
// dist/index.html served by the Rust router at GET /
|
||||
// dist/static/app.js served at /static/app.js (ESM bundle,
|
||||
// pulls in @hive/shared + marked)
|
||||
// dist/static/app.js.map source map sibling
|
||||
// dist/flow.html served at GET /flow.html
|
||||
// dist/static/app.js /index.html entry — tab renderers +
|
||||
// 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
|
||||
// (@import resolved from @hive/shared)
|
||||
//
|
||||
// The Rust binary mounts `dist/` as a `tower_http::ServeDir` fallback;
|
||||
// the layout above keeps every URL the index.html references reachable
|
||||
// without rewriting paths in the HTML.
|
||||
// Both JS entries inline `./common.js` (DOM helpers, Panel singleton,
|
||||
// NOTIF, path linkification) — esbuild dedupes the shared module
|
||||
// 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 { mkdirSync, copyFileSync, rmSync } from 'node:fs';
|
||||
|
|
@ -24,12 +30,13 @@ const staticDir = (p) => resolve(here, 'dist', 'static', p);
|
|||
rmSync(dist(''), { recursive: true, force: 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
|
||||
// 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({
|
||||
entryPoints: [src('app.js')],
|
||||
outfile: staticDir('app.js'),
|
||||
entryPoints: [src('app.js'), src('flow.js')],
|
||||
outdir: staticDir(''),
|
||||
bundle: true,
|
||||
format: 'esm',
|
||||
platform: 'browser',
|
||||
|
|
|
|||
|
|
@ -1,14 +1,17 @@
|
|||
// Dashboard SPA. Renders containers + approvals from `/api/state`, wires
|
||||
// up async-form submission (URL-encoded POST + spinner + state refresh),
|
||||
// and tails the unified dashboard event channel over `/dashboard/stream`.
|
||||
// /index.html entry point: tab renderers + tab routing + refreshState
|
||||
// + notification deltas. Reads /api/state on cold load and after every
|
||||
// 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,
|
||||
// the OS-notification module, and the path-link / file-preview
|
||||
// infrastructure all moved into `./common.js`. The follow-up step splits
|
||||
// the broker-terminal / inbox / compose bits into a separate `flow.js`
|
||||
// entry so /flow.html stops loading the tab renderers it doesn't use.
|
||||
// #406 step 1: pure helpers + side panel + OS notifications + path
|
||||
// linkification moved to `./common.js`.
|
||||
// #406 step 2: the flow-only IIFEs (operator inbox, inbox-pill, broker
|
||||
// terminal, @-mention composer) moved to `./flow.js`. /flow.html now
|
||||
// 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 {
|
||||
$, el, esc, form,
|
||||
|
|
@ -17,13 +20,9 @@ import {
|
|||
makePathLink, appendText, appendLinkified,
|
||||
} from './common.js';
|
||||
|
||||
// Expose the previously-script-tag-provided globals so the IIFE below
|
||||
// keeps working unchanged. Pre-split these were attached by
|
||||
// `/static/hive-fr0nt.js` (HiveTerminal) and `/static/marked.js`
|
||||
// (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 };
|
||||
// mdNode (in common.js) reads `window.marked` for the markdown side
|
||||
// panel preview path. Set it here on the dashboard entry so file
|
||||
// previews work; flow.js does the same for the flow-page entry.
|
||||
window.marked = marked;
|
||||
|
||||
(() => {
|
||||
|
|
@ -1086,63 +1085,7 @@ window.marked = marked;
|
|||
});
|
||||
}, 1000);
|
||||
|
||||
// ─── operator inbox (derived from the broker message stream) ───────────
|
||||
// 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());
|
||||
}
|
||||
// Operator-inbox derived store moved to ./flow.js (#406 step 2).
|
||||
|
||||
const APPROVAL_TAB_KEY = 'hyperhive:approvals:tab';
|
||||
// Derived approval state — cold-loaded from /api/state, then mutated
|
||||
|
|
@ -1855,7 +1798,8 @@ window.marked = marked;
|
|||
// refetch.
|
||||
syncQuestionsFromSnapshot(s);
|
||||
renderQuestions();
|
||||
renderInbox();
|
||||
// (renderInbox now lives in ./flow.js — dashboard has no
|
||||
// #inbox-section element to render into.)
|
||||
syncApprovalsFromSnapshot(s);
|
||||
renderApprovals();
|
||||
renderMetaInputs(s);
|
||||
|
|
@ -1943,8 +1887,11 @@ window.marked = marked;
|
|||
}
|
||||
}
|
||||
setTabCount('system', sysCount);
|
||||
// FL0W — operator inbox count.
|
||||
setTabCount('flow', operatorInbox.length);
|
||||
// FL0W pill count: lives in ./flow.js now (it has the inbox
|
||||
// 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
|
||||
// sync. The state stores are mutated synchronously by every SSE
|
||||
|
|
@ -1953,335 +1900,7 @@ window.marked = marked;
|
|||
refreshTabCounts();
|
||||
setInterval(refreshTabCounts, 1000);
|
||||
|
||||
// Flow page: wire the inbox pill to open the side-panel flyout
|
||||
// with the operator inbox. Only triggers when the pill exists
|
||||
// (i.e. we're on flow.html); on the dashboard this no-ops.
|
||||
(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();
|
||||
})();
|
||||
// Flow-specific IIFEs moved to ./flow.js (#406 step 2): inbox-pill
|
||||
// wiring, the broker terminal init, and the @-mention composer.
|
||||
// /index.html no longer loads them — only /flow.html does.
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -106,10 +106,10 @@
|
|||
</aside>
|
||||
</div>
|
||||
|
||||
<!-- Same bundled entry as the dashboard. Renderers whose target
|
||||
DOM doesn't exist on this page no-op silently. SSE +
|
||||
/api/state fetching still run; only the chat-relevant chunks
|
||||
have anywhere to render to. -->
|
||||
<script type="module" src="/static/app.js" defer></script>
|
||||
<!-- Flow-specific bundle (#406 step 2). Contains the broker
|
||||
terminal init, the operator-inbox derived store, the inbox
|
||||
pill flyout, and the @-mention composer. Tab renderers etc.
|
||||
live in `/static/app.js` which /flow.html doesn't need. -->
|
||||
<script type="module" src="/static/flow.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
428
frontend/packages/dashboard/src/flow.js
Normal file
428
frontend/packages/dashboard/src/flow.js
Normal file
|
|
@ -0,0 +1,428 @@
|
|||
// /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, linkify as termLinkify } from '@hive/shared/terminal.js';
|
||||
import {
|
||||
$, el,
|
||||
Panel, NOTIF,
|
||||
appendLinkified,
|
||||
} from './common.js';
|
||||
|
||||
// Common helpers (mdNode in common.js) probe `window.marked` at use
|
||||
// time. Flow page doesn't actually call mdNode (it has no file-preview
|
||||
// path); we set it anyway so any future preview-on-flow lands hot.
|
||||
window.HiveTerminal = { create: termCreate, linkify: termLinkify };
|
||||
|
||||
(() => {
|
||||
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