frontend: vibec0re terminal overhaul (#360)
Per operator spec at #360#issuecomment-3333:
- full-screen terminal
- frosted-glass header overlaid on top
- inbox + loose-ends → flyout
- no a/b flag, just ship it
## Layout
`frontend/packages/agent/src/index.html` restructured to a three-zone
fixed-overlay shape:
- `<header.agent-header>` — fixed top, frosted glass via
`backdrop-filter: blur(12px) saturate(140%)`. Holds icon + title +
nav links + state-row (badges/buttons) + two new pill buttons that
surface inbox / loose-ends counts (and open the side panel on click).
- `<main.agent-main>` — fills the viewport. Terminal positioned absolute
inset:0 with padding-top/-bottom + scroll-padding equal to the
floating header/composer heights so first/last rows stay reachable
and `↓ N new` pill anchors land in the visible scroll zone.
- `<footer.agent-composer>` — fixed bottom, mirror-frosted. Owns
`#term-input`; dropped the in-frame dashed separator (border-top
+ box-shadow on the bar already separate it from the terminal).
- `<div.side-panel>` — singleton drawer (copy of the dashboard
pattern, candidate for extraction into @hive/shared). Inbox +
loose-ends details render here instead of expanding inline.
Dropped from the page: the pre-banner ASCII shimmer (`<pre.banner>`)
and the in-page `<details>` collapsibles for inbox + loose-ends. The
banner JS path (`setBannerActive`) is now a no-op (early-returns on
missing element); kept as dead code rather than ripped out to keep
the diff focused.
## JS
`frontend/packages/agent/src/app.js`:
- New `Panel` singleton with `open(name, title, content)` +
`close()` + `refresh(name, title, content)` (no-op if a different
view owns the panel — lets live updates re-render an open view
without grabbing focus from a closed one). Mirror of the
dashboard's Panel module; the duplication is intentional for now.
- `renderInbox` + `renderLooseEnds` refactored: update the header
pill counts, hide/show the pills, and `Panel.refresh` if the
matching view is open. The list-building DOM logic moved into
`buildInboxList` + `buildLooseEndsList` so the pill click handler
can call them on the latest snapshot kept in `lastInbox` /
`lastLooseEnds` module state.
- Pill click handlers `Panel.open(...)` with the freshly built list.
- Auto-expand behavior on first appearance dropped (the pill +
count badge is the discoverable signal; auto-popping the flyout
would interrupt whatever the operator is doing).
- `setHeader` no longer touches `#banner` (element removed); title +
dashboard back-link + rebuild button still get appended to `#title`.
## CSS
`frontend/packages/agent/src/agent.css` major additions, scoped
`body.agent-shell` so the sibling `stats.html` (which doesn't apply
the shell class) keeps its normal-document scroll + `.banner` ASCII
header via a `body:not(.agent-shell)` block.
New CSS custom properties on :root: `--agent-header-h`,
`--agent-composer-h`, `--agent-frost-bg`, `--agent-frost-blur`. The
terminal's padding + scroll-padding derive from these so a single
height tweak ripples consistently.
Added `.header-pill` (inbox/loose-ends triggers) +
`.agent-status-overlay` (centred login card when status != online).
Side-panel rules copied from `dashboard.css` with one delta: width
caps at 640px (vs dashboard's 760px) since per-agent inbox / loose-
ends rows are narrower than approval diffs / file previews.
## Validation
- `npm run build` — succeeds both workspaces.
- agent: `dist/static/{app.js (115kb), stats.js (435kb), agent.css (21kb)}`
- dashboard unchanged (no shared sources touched).
- Browser smoke test isn't possible from inside iris's container
(no JS engine) — op-side check on next deploy.
Closes #360.
This commit is contained in:
parent
4d7c767eb0
commit
e931c08739
3 changed files with 496 additions and 79 deletions
|
|
@ -77,10 +77,69 @@ window.marked = marked;
|
|||
}
|
||||
});
|
||||
|
||||
// ─── side panel (singleton drawer for inbox + loose-ends flyouts) ──────
|
||||
// Shared shape with the dashboard's panel. Candidate for extraction
|
||||
// into @hive/shared in a follow-up — keeping the duplication for
|
||||
// now to land #360 without simultaneously refactoring the dashboard.
|
||||
const Panel = (() => {
|
||||
const root = $('side-panel');
|
||||
const titleEl = $('side-panel-title');
|
||||
const bodyEl = $('side-panel-body');
|
||||
/** Owner key (e.g. 'inbox', 'loose-ends'). Refresh hooks check
|
||||
* against this so a live event only re-renders the panel when
|
||||
* the matching view is actually visible. null when closed. */
|
||||
let owner = null;
|
||||
function open(name, title, content) {
|
||||
owner = name;
|
||||
titleEl.textContent = title;
|
||||
bodyEl.replaceChildren(...(content ? [content] : []));
|
||||
root.classList.add('open');
|
||||
root.setAttribute('aria-hidden', 'false');
|
||||
}
|
||||
function close() {
|
||||
owner = null;
|
||||
root.classList.remove('open');
|
||||
root.setAttribute('aria-hidden', 'true');
|
||||
}
|
||||
function refresh(name, title, content) {
|
||||
if (owner !== name) return;
|
||||
titleEl.textContent = title;
|
||||
bodyEl.replaceChildren(...(content ? [content] : []));
|
||||
}
|
||||
function bind() {
|
||||
$('side-panel-close').addEventListener('click', close);
|
||||
$('side-panel-backdrop').addEventListener('click', close);
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && root.classList.contains('open')) close();
|
||||
});
|
||||
}
|
||||
return { open, close, refresh, bind, currentOwner: () => owner };
|
||||
})();
|
||||
Panel.bind();
|
||||
|
||||
// Wire the header pills to open the side panel. Pre-built (vs
|
||||
// re-building per-click) so the freshest snapshot already lives
|
||||
// in `lastInbox` / `lastLooseEnds` when the pill is clicked — even
|
||||
// if it fires during a turn the render is the same.
|
||||
(function bindHeaderPills() {
|
||||
const inboxPill = $('inbox-pill');
|
||||
if (inboxPill) {
|
||||
inboxPill.addEventListener('click', () => {
|
||||
Panel.open('inbox', 'inbox · ' + lastInbox.length,
|
||||
buildInboxList(lastInbox));
|
||||
});
|
||||
}
|
||||
const loosePill = $('loose-ends-pill');
|
||||
if (loosePill) {
|
||||
loosePill.addEventListener('click', () => {
|
||||
Panel.open('loose-ends', 'loose ends · ' + lastLooseEnds.length,
|
||||
buildLooseEndsList(lastLooseEnds));
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
// ─── state rendering ────────────────────────────────────────────────────
|
||||
function setHeader(label, dashboardPort) {
|
||||
$('banner').textContent =
|
||||
`░▒▓█▓▒░ ${label} ░▒▓█▓▒░ hyperhive ag3nt ░▒▓█▓▒░`;
|
||||
const title = $('title');
|
||||
title.textContent = `◆ ${label} ◆ `;
|
||||
// ↑ DASHB04RD — back-link to the host dashboard. Opens in a new
|
||||
|
|
@ -415,8 +474,7 @@ window.marked = marked;
|
|||
// Loose-ends section: same data the get_loose_ends MCP tool
|
||||
// returns. Best-effort fetch on cold load + after every turn_end
|
||||
// (a turn likely answered or asked something). Silent failure
|
||||
// keeps the section hidden rather than surfacing an empty banner.
|
||||
let lastLooseEndsCount = 0;
|
||||
// keeps the pill count at zero rather than surfacing a stale chrome.
|
||||
async function refreshLooseEnds() {
|
||||
try {
|
||||
const resp = await fetch('/api/loose-ends');
|
||||
|
|
@ -431,24 +489,22 @@ window.marked = marked;
|
|||
renderLooseEnds([]);
|
||||
}
|
||||
}
|
||||
function renderLooseEnds(threads) {
|
||||
const root = $('loose-ends-section');
|
||||
const list = $('loose-ends-list');
|
||||
const summary = $('loose-ends-summary');
|
||||
if (!root || !list || !summary) return;
|
||||
/** Latest snapshot kept in module state so the pill click handler
|
||||
* has fresh data to render into the panel without re-fetching. */
|
||||
let lastLooseEnds = [];
|
||||
let lastInbox = [];
|
||||
|
||||
function buildLooseEndsList(threads) {
|
||||
// Returns the <div> the side panel renders. The structural shape
|
||||
// mirrors the legacy <details>-collapsible block — same CSS rules
|
||||
// apply via `.side-panel-body .agent-inbox`.
|
||||
const wrap = el('div', { class: 'agent-inbox' });
|
||||
if (!threads.length) {
|
||||
root.hidden = true;
|
||||
lastLooseEndsCount = 0;
|
||||
return;
|
||||
wrap.append(el('p', { class: 'side-panel-empty' },
|
||||
'no loose ends — every question, approval and reminder is resolved.'));
|
||||
return wrap;
|
||||
}
|
||||
root.hidden = false;
|
||||
summary.textContent = 'loose ends · ' + threads.length;
|
||||
list.innerHTML = '';
|
||||
// Auto-expand on first appearance of any open thread so the
|
||||
// operator notices new loose ends; collapse only on operator
|
||||
// click (sticky after that).
|
||||
if (lastLooseEndsCount === 0) root.open = true;
|
||||
lastLooseEndsCount = threads.length;
|
||||
const list = el('ul');
|
||||
const fmtAge = (s) => {
|
||||
if (s < 60) return s + 's';
|
||||
if (s < 3600) return Math.floor(s / 60) + 'm';
|
||||
|
|
@ -492,6 +548,21 @@ window.marked = marked;
|
|||
}
|
||||
list.append(li);
|
||||
}
|
||||
wrap.append(list);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
/** Pill-count + open-panel-refresh wiring for loose-ends. The legacy
|
||||
* in-page `<details>` block is gone — operator clicks the header
|
||||
* pill to surface the list in the side panel. */
|
||||
function renderLooseEnds(threads) {
|
||||
lastLooseEnds = threads;
|
||||
const pill = $('loose-ends-pill');
|
||||
const count = $('loose-ends-count');
|
||||
if (count) count.textContent = threads.length;
|
||||
if (pill) pill.hidden = threads.length === 0;
|
||||
Panel.refresh('loose-ends', 'loose ends · ' + threads.length,
|
||||
buildLooseEndsList(threads));
|
||||
}
|
||||
|
||||
// Inline "answer as operator" form for a question loose-end. POSTs to
|
||||
|
|
@ -530,18 +601,14 @@ window.marked = marked;
|
|||
return wrap;
|
||||
}
|
||||
|
||||
function renderInbox(rows) {
|
||||
const root = $('inbox-section');
|
||||
const list = $('inbox-list');
|
||||
const summary = $('inbox-summary');
|
||||
if (!root || !list || !summary) return;
|
||||
function buildInboxList(rows) {
|
||||
const wrap = el('div', { class: 'agent-inbox' });
|
||||
if (!rows.length) {
|
||||
root.hidden = true;
|
||||
return;
|
||||
wrap.append(el('p', { class: 'side-panel-empty' },
|
||||
'inbox empty.'));
|
||||
return wrap;
|
||||
}
|
||||
root.hidden = false;
|
||||
summary.textContent = 'inbox · ' + rows.length;
|
||||
list.innerHTML = '';
|
||||
const list = el('ul');
|
||||
const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(5, 19);
|
||||
for (const m of rows) {
|
||||
const li = el('li', m.in_reply_to != null ? { class: 'inbox-reply' } : {});
|
||||
|
|
@ -556,6 +623,18 @@ window.marked = marked;
|
|||
);
|
||||
list.append(li);
|
||||
}
|
||||
wrap.append(list);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
/** Pill-count + open-panel-refresh wiring for inbox. */
|
||||
function renderInbox(rows) {
|
||||
lastInbox = rows;
|
||||
const pill = $('inbox-pill');
|
||||
const count = $('inbox-count');
|
||||
if (count) count.textContent = rows.length;
|
||||
if (pill) pill.hidden = rows.length === 0;
|
||||
Panel.refresh('inbox', 'inbox · ' + rows.length, buildInboxList(rows));
|
||||
}
|
||||
// Harness reachability badge: derived from the same `s.status` the
|
||||
// status block reads. Each status maps to a glyph + label + colour
|
||||
|
|
|
|||
Loading…
Reference in a new issue