diff --git a/frontend/packages/agent/src/agent.css b/frontend/packages/agent/src/agent.css index 6810320..6222bcb 100644 --- a/frontend/packages/agent/src/agent.css +++ b/frontend/packages/agent/src/agent.css @@ -3,10 +3,31 @@ @import "@hive/shared/base.css"; @import "@hive/shared/terminal.css"; -body { +/* ─── full-screen vibec0re overhaul (issue #360) ────────────────── + Layout shape: fixed-position frosted-glass header at top, fixed- + position composer at bottom, full-viewport terminal in between. + The terminal scrolls — its text passes BENEATH the floating + header/composer with backdrop-filter blur for the frosted look. + Inbox + loose-ends move into the side-panel flyout; header pills + surface their counts as the only chrome they get. */ + +:root { + --agent-header-h: 4.6em; + --agent-composer-h: 3.6em; + --agent-frost-bg: rgba(30, 30, 46, 0.72); + --agent-frost-blur: blur(12px) saturate(140%); +} + +html, body { height: 100%; margin: 0; } + +/* Legacy in-page layout retained for the sibling stats page + (`stats.html`) which doesn't apply `body.agent-shell` and stays + on a normal-document scroll. */ +body:not(.agent-shell) { max-width: 110em; margin: 1.5em auto; padding: 0 1.5em; + height: auto; } .banner { text-align: center; @@ -26,30 +47,177 @@ body { color: transparent; filter: drop-shadow(0 0 6px rgba(203, 166, 247, 0.45)); } -.banner.active { - animation: banner-shimmer 1.8s linear infinite; + +body.agent-shell { + background: var(--bg); + color: var(--fg); + /* Body itself doesn't scroll; the terminal does inside .agent-main. */ + overflow: hidden; + /* Subtle radial accent to give the otherwise-flat full-screen + surface some depth and reinforce the vibec0re mood. */ + background: + radial-gradient(ellipse 80% 60% at 50% 0%, + rgba(203, 166, 247, 0.06) 0%, + transparent 60%), + var(--bg); } -@keyframes banner-shimmer { - from { background-position: 200% 0; } - to { background-position: -100% 0; } + +.agent-header { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 30; + min-height: var(--agent-header-h); + display: flex; + align-items: center; + gap: 1em; + padding: 0.55em 1em; + background: var(--agent-frost-bg); + -webkit-backdrop-filter: var(--agent-frost-blur); + backdrop-filter: var(--agent-frost-blur); + border-bottom: 1px solid var(--purple-dim); + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.35); + flex-wrap: wrap; } + +.agent-header-title { + display: flex; + flex-direction: column; + gap: 0.1em; + min-width: 0; + flex: 1 1 auto; +} +.agent-header-title h2 { + margin: 0; + line-height: 1; +} +.agent-nav { + /* Slim nav row directly under the title — keeps the operator's + fingers near stats/screen/forge without dominating the header. */ + display: flex; + flex-wrap: wrap; + gap: 0.4em 0.8em; + margin: 0; +} + +.agent-state-row { + margin: 0; + display: flex; + align-items: center; + gap: 0.5em; + flex-wrap: wrap; +} + h2, h3 { color: var(--purple); text-transform: uppercase; letter-spacing: 0.15em; text-shadow: 0 0 8px rgba(203, 166, 247, 0.4); } -.title-row { - display: flex; - align-items: center; - gap: 0.6rem; -} -.title-row h2 { margin: 0; } .agent-icon { - width: 40px; - height: 40px; + width: 44px; + height: 44px; border-radius: 6px; flex-shrink: 0; + box-shadow: 0 0 14px -2px rgba(203, 166, 247, 0.35); +} + +/* Header pill — inbox / loose-ends triggers. Compact, count-prominent. */ +.header-pill { + background: transparent; + border: 1px solid var(--purple-dim); + color: var(--fg); + font-family: inherit; + font-size: 0.85em; + letter-spacing: 0.04em; + border-radius: 999px; + padding: 0.25em 0.7em; + display: inline-flex; + align-items: center; + gap: 0.4em; + cursor: pointer; + transition: border-color 0.15s ease, box-shadow 0.15s ease, color 0.15s ease; +} +.header-pill:hover { + border-color: var(--purple); + color: var(--purple); + box-shadow: 0 0 10px -2px var(--purple); +} +.header-pill-icon { font-size: 1.05em; line-height: 1; } +.header-pill-label { color: var(--muted); } +.header-pill-count { + background: var(--purple-dim); + color: var(--purple); + border-radius: 999px; + padding: 0 0.5em; + min-width: 1.6em; + text-align: center; + font-weight: bold; + font-variant-numeric: tabular-nums; +} +.header-pill-inbox .header-pill-count { + background: rgba(250, 179, 135, 0.18); + color: var(--amber); +} +.header-pill-loose .header-pill-count { + background: rgba(243, 139, 168, 0.18); + color: var(--red); +} + +.agent-main { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + overflow: hidden; +} + +/* Login flow overlay: only rendered when status != online. Sits + centred over the (likely-empty) terminal area; doesn't take chrome + space in the normal online flow. */ +.agent-status-overlay { + position: absolute; + top: calc(var(--agent-header-h) + 1.5em); + left: 50%; + transform: translateX(-50%); + max-width: 44em; + width: calc(100% - 3em); + z-index: 10; +} +.agent-status-overlay:empty { display: none; } +.agent-status-overlay > * { + background: var(--bg-elev); + border: 1px solid var(--purple-dim); + border-radius: 6px; + padding: 1em 1.2em; + box-shadow: 0 8px 30px rgba(0, 0, 0, 0.4); +} + +.agent-composer { + position: fixed; + bottom: 0; + left: 0; + right: 0; + z-index: 30; + min-height: var(--agent-composer-h); + background: var(--agent-frost-bg); + -webkit-backdrop-filter: var(--agent-frost-blur); + backdrop-filter: var(--agent-frost-blur); + border-top: 1px solid var(--purple-dim); + box-shadow: 0 -6px 18px rgba(0, 0, 0, 0.35); +} +.agent-composer .term-input { + /* The composer is its own chrome now — drop the in-terminal-wrap + padding the legacy layout assumed. */ + padding: 0.45em 1em; +} +.agent-composer .term-input .sendform-term { + /* No dashed top-border in the floating composer — the box-shadow + and frosted border already separate it from the terminal. */ + border-top: 0; + padding-top: 0; } .meta { color: var(--muted); font-size: 0.85em; } .status-online { color: var(--green); text-shadow: 0 0 6px rgba(166, 227, 161, 0.55); } @@ -347,14 +515,54 @@ pre.diff { 60% { box-shadow: 0 0 18px -4px currentColor, 0 0 4px 0 currentColor; } 100% { box-shadow: 0 0 0 0 currentColor, 0 0 0 0 currentColor; } } -/* `.terminal-wrap`, `.live`, `.live.terminal`, row + pill + details - styling all live in hive-fr0nt::TERMINAL_CSS (prepended by serve_css). - What stays here is the composer chrome that sits inside the wrap. */ -.term-input { padding: 0.4em 1em 0.8em; } +/* Full-screen overrides for the shared terminal rules. The base + `.terminal-wrap` (in shared/src/terminal.css) ships a crust-on- + black frame for the in-page case; the agent page now owns the + whole viewport so the frame chrome would be redundant noise. + `.live.terminal` similarly drops the in-page max-height cap so + it can fill the main area top-to-bottom; the floating header + + composer overlay it via fixed positioning. */ +.agent-main .terminal-wrap { + position: absolute; + inset: 0; + border: 0; + background: transparent; + box-shadow: none; + border-radius: 0; + margin: 0; + padding: 0; +} +.agent-main .live.terminal { + position: absolute; + inset: 0; + height: auto; + max-height: none; + /* Scroll behind the floating header/composer, but keep the first + and last rows reachable with extra padding inside the scroll + area. scroll-padding-* keeps anchor-jumps (the `↓ N new` pill, + focus restore) clear of the floats too. */ + padding-top: calc(var(--agent-header-h) + 0.8em); + padding-bottom: calc(var(--agent-composer-h) + 0.8em); + scroll-padding-top: calc(var(--agent-header-h) + 0.8em); + scroll-padding-bottom: calc(var(--agent-composer-h) + 0.8em); + overflow: auto; +} +/* Tail pill (↓ N new): nudged up so it floats clear of the composer + rather than colliding with the frosted bar. */ +.agent-main .tail-pill { + bottom: calc(var(--agent-composer-h) + 0.6em); +} + +/* Composer chrome — used to live inside `.terminal-wrap`; now lives + inside the fixed `.agent-composer` defined further up. The base + rules below stay scoped to whichever ancestor owns it. */ .term-input .sendform-term { display: flex; align-items: flex-start; gap: 0.5em; + /* The dashed in-frame separator is dropped — see the + .agent-composer .term-input override above for the floating-bar + variant. */ border-top: 1px dashed var(--purple-dim); padding-top: 0.5em; } @@ -387,3 +595,97 @@ pre.diff { .term-input.disabled .prompt { color: var(--muted); text-shadow: none; } .term-input.disabled textarea { color: var(--muted); } /* Row + pill + details styling moved to hive-fr0nt::TERMINAL_CSS. */ + +/* ─── side panel (singleton drawer) ──────────────────────────────── + Inbox + loose-ends details open here instead of expanding inline + (issue #360). Copy of the dashboard's side-panel pattern — + candidate for extraction into @hive/shared once both surfaces + stabilize. */ +.side-panel { + position: fixed; + inset: 0; + z-index: 50; + /* Closed: ignore pointer events so the agent page underneath stays + interactive; `.open` flips it back on. */ + pointer-events: none; +} +.side-panel-backdrop { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.55); + opacity: 0; + transition: opacity 0.2s ease; +} +.side-panel-drawer { + position: absolute; + top: 0; + right: 0; + bottom: 0; + width: min(640px, 92vw); + display: flex; + flex-direction: column; + background: var(--bg-elev); + border-left: 2px solid var(--purple); + box-shadow: -10px 0 30px rgba(0, 0, 0, 0.45); + transform: translateX(100%); + transition: transform 0.25s ease; +} +.side-panel.open { pointer-events: auto; } +.side-panel.open .side-panel-backdrop { opacity: 1; } +.side-panel.open .side-panel-drawer { transform: translateX(0); } +.side-panel-head { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1em; + padding: 0.7em 1em; + border-bottom: 1px solid var(--border); +} +.side-panel-title { + color: var(--purple); + font-weight: bold; + letter-spacing: 0.05em; + word-break: break-all; +} +.side-panel-close { + flex: 0 0 auto; + background: var(--bg); + color: var(--fg); + border: 1px solid var(--border); + font-family: inherit; + font-size: 1em; + line-height: 1; + padding: 0.25em 0.55em; + cursor: pointer; +} +.side-panel-close:hover { border-color: var(--red); color: var(--red); } +.side-panel-body { + flex: 1 1 auto; + overflow: auto; + padding: 0.8em 1em; +} +/* Inbox / loose-ends lists rendered into the side-panel body. The + legacy
-collapsible variant of .agent-inbox is gone, so + here we strip the inbox-only chrome (background, border-left) and + let the panel body's own padding own the framing. */ +.side-panel-body .agent-inbox { + margin: 0; + font-size: inherit; + color: var(--fg); +} +.side-panel-body .agent-inbox ul { + background: transparent; + border-left: 0; + padding: 0; + max-height: none; + overflow: visible; +} + +/* Empty-state placeholders for the side panel (when count drops to 0 + between the click and the render — rare, but possible). */ +.side-panel-empty { + color: var(--muted); + font-style: italic; + padding: 1em 0; +} diff --git a/frontend/packages/agent/src/app.js b/frontend/packages/agent/src/app.js index c2a56c5..605078c 100644 --- a/frontend/packages/agent/src/app.js +++ b/frontend/packages/agent/src/app.js @@ -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
the side panel renders. The structural shape + // mirrors the legacy
-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 `
` 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 diff --git a/frontend/packages/agent/src/index.html b/frontend/packages/agent/src/index.html index dcc0d2b..5874e19 100644 --- a/frontend/packages/agent/src/index.html +++ b/frontend/packages/agent/src/index.html @@ -6,43 +6,79 @@ - - -
+ + + +
-

◆ … ◆

-
- +
+

◆ … ◆

+ +
-
-

loading…

-
+
+ + … booting + + + + + + +
-
- - … booting - - - - - - -
+ + + + - + +
+
+
+
connecting…
+
+
- - -
-
connecting…
+ +
+
+ + +