diff --git a/frontend/packages/agent/src/agent.css b/frontend/packages/agent/src/agent.css index 72318bb..4328ac1 100644 --- a/frontend/packages/agent/src/agent.css +++ b/frontend/packages/agent/src/agent.css @@ -12,7 +12,10 @@ surface their counts as the only chrome they get. */ :root { - --agent-header-h: 4.6em; + /* Bumped to 6em (#394) so the agent icon can be a full-height + square identity anchor without crowding the two-row main column + (title + nav-links on top, state strip below). */ + --agent-header-h: 6em; --agent-composer-h: 3.6em; --agent-frost-bg: rgba(30, 30, 46, 0.72); --agent-frost-blur: blur(12px) saturate(140%); @@ -70,31 +73,41 @@ body.agent-shell { z-index: 30; min-height: var(--agent-header-h); display: flex; - align-items: center; - gap: 1em; - padding: 0.55em 1em; + /* align-items: stretch lets the icon take the full header height + (it sizes itself via aspect-ratio off the stretched height). The + main column + pills column self-centre via inner layout. */ + align-items: stretch; + gap: 0.9em; + padding: 0.5em 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 { +/* Main column: title row on top, state strip below (#394). Centred + vertically against the full-height icon on the left. */ +.agent-header-main { display: flex; flex-direction: column; - gap: 0.1em; + justify-content: center; + gap: 0.45em; min-width: 0; flex: 1 1 auto; } -.agent-header-title h2 { +.agent-header-row { + display: flex; + align-items: center; + gap: 0.8em; + flex-wrap: wrap; + min-width: 0; +} +.agent-header-title-row 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; @@ -103,10 +116,21 @@ body.agent-shell { .agent-state-row { margin: 0; + gap: 0.5em; +} + +/* Right cluster — flyout pills stacked / inline with the overflow + trigger. Vertically centred against the full-height icon, no + wrap; pills can drop to a row of their own under crowding via + the flex-wrap of `.agent-header-pills` itself. */ +.agent-header-pills { display: flex; align-items: center; gap: 0.5em; + flex-shrink: 0; flex-wrap: wrap; + justify-content: flex-end; + align-self: center; } h2, h3 { @@ -116,11 +140,126 @@ h2, h3 { text-shadow: 0 0 8px rgba(203, 166, 247, 0.4); } .agent-icon { - width: 44px; - height: 44px; - border-radius: 6px; + /* Full-height square identity anchor (#394 — mara's spec). The + `align-items: stretch` on .agent-header stretches the icon's + `` box; `aspect-ratio: 1` keeps it square; `height: 100%` + makes it follow the header's actual height through resizes / + wrap. width:auto + aspect-ratio derives the width from height. */ + height: 100%; + width: auto; + aspect-ratio: 1; flex-shrink: 0; - box-shadow: 0 0 14px -2px rgba(203, 166, 247, 0.35); + border-radius: 8px; + box-shadow: 0 0 18px -2px rgba(203, 166, 247, 0.4); + object-fit: cover; +} + +/* Meta-nav links (stats / screen / forge / dashboard / extras) — + no underline (#394 mara's spec); hover lights with cyan glow + + subtle background tint. Reads as a row of soft tabs rather than + default-styled inline anchors. */ +.agent-nav-link { + color: var(--cyan); + text-decoration: none; + font-size: 0.85em; + letter-spacing: 0.04em; + padding: 0.1em 0.35em; + border-radius: 3px; + text-shadow: 0 0 4px rgba(137, 220, 235, 0.4); + transition: color 0.15s ease, text-shadow 0.15s ease, background 0.15s ease; +} +.agent-nav-link:hover { + color: var(--fg); + background: rgba(137, 220, 235, 0.08); + text-shadow: 0 0 10px rgba(137, 220, 235, 0.85); +} + +/* Overflow menu trigger — `⋯` round button on the right of the + pills row. Quiet by default, lights on hover / open (#394). */ +.overflow-btn { + background: transparent; + border: 1px solid var(--purple-dim); + color: var(--muted); + border-radius: 999px; + width: 2em; + height: 1.8em; + font-size: 1em; + line-height: 1; + cursor: pointer; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + transition: color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease; +} +.overflow-btn:hover, +.overflow-btn[aria-expanded="true"] { + color: var(--purple); + border-color: var(--purple); + box-shadow: 0 0 10px -2px var(--purple); +} + +/* Overflow popover — rebuild + new-session (and the dashboard + back-link, prepended in app.js setHeader). Positioned in JS so + the menu's top-right corner anchors under the trigger button. */ +.overflow-menu { + position: fixed; + background: var(--agent-frost-bg); + -webkit-backdrop-filter: var(--agent-frost-blur); + backdrop-filter: var(--agent-frost-blur); + border: 1px solid var(--purple-dim); + border-radius: 6px; + padding: 0.35em; + display: flex; + flex-direction: column; + gap: 0.15em; + z-index: 40; + box-shadow: 0 10px 26px rgba(0, 0, 0, 0.45); + min-width: 14em; +} +.overflow-item { + background: transparent; + border: 1px solid transparent; + color: var(--fg); + font-family: inherit; + font-size: 0.9em; + text-align: left; + padding: 0.4em 0.7em; + border-radius: 4px; + cursor: pointer; + display: flex; + align-items: center; + gap: 0.6em; + letter-spacing: 0.06em; + text-decoration: none; + text-shadow: 0 0 4px currentColor; + transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease; +} +.overflow-item:hover { + background: rgba(203, 166, 247, 0.08); + border-color: var(--purple-dim); +} +.overflow-item-icon { + font-size: 1.05em; + width: 1.4em; + text-align: center; + flex-shrink: 0; +} +.overflow-item-rebuild { color: var(--amber); } +.overflow-item-new-session { color: var(--amber); } +.overflow-item-rebuild:hover, +.overflow-item-new-session:hover { + background: rgba(250, 179, 135, 0.1); + border-color: var(--amber); +} +.overflow-item-dashboard { color: var(--cyan); } +.overflow-item-dashboard:hover { + background: rgba(137, 220, 235, 0.1); + border-color: var(--cyan); +} +.overflow-item:disabled { + opacity: 0.4; + cursor: progress; } /* Header pill — inbox / loose-ends triggers. Compact, count-prominent. */ @@ -249,20 +388,14 @@ a:hover { color: var(--fg); text-shadow: 0 0 12px rgba(137, 220, 235, 0.9); } } .btn-login { color: var(--amber); border-color: var(--amber); } .btn-cancel { color: var(--red); border-color: var(--red); font-size: 0.85em; padding: 0.15em 0.6em; } -.btn-rebuild { - color: var(--amber); - border: 1px solid var(--amber); - padding: 0.15em 0.6em; - font-size: 0.55em; - font-family: inherit; - text-decoration: none; - letter-spacing: 0.1em; - margin-left: 0.6em; - vertical-align: middle; - cursor: pointer; -} -.btn-rebuild:hover { background: rgba(250, 179, 135, 0.1); } -.btn-send { color: var(--green); border-color: var(--green); } +/* `.btn-rebuild` was the per-agent header chip — moved into the + overflow menu in #394 (`.overflow-item-rebuild` covers it now). + The dashboard has its own `.btn-rebuild` rule for the per-row + R3BU1LD form on the SW4RM tab; this one was specific to the + per-agent header. + `.btn-send` was a green send-button variant — orphaned since + the dashboard's compose form was retired; no live consumer left + in either the agent or dashboard tree. */ .sendform { display: flex; gap: 0.6em; margin-top: 0.5em; } .sendform input { font-family: inherit; font-size: 1em; @@ -292,12 +425,6 @@ pre.diff { word-break: break-all; max-height: 30em; } -#state-row { - margin: 0.4em 0 0.2em; - display: flex; - align-items: center; - gap: 0.6em; -} /* Per-agent inbox section — collapsible, dim, lives between the state row and the terminal so the operator can peek at what landed without scrolling through the live tail. */ @@ -436,21 +563,8 @@ pre.diff { text-shadow: 0 0 6px rgba(243, 139, 168, 0.55); } .status-badge.status-needs-login { color: var(--amber); border-color: var(--amber); } .status-badge.status-offline { color: var(--muted); border-color: var(--muted); } -.btn-dashlink { - color: var(--cyan); - border: 1px solid var(--cyan); - padding: 0.15em 0.6em; - font-size: 0.55em; - font-family: inherit; - text-decoration: none; - letter-spacing: 0.1em; - margin-left: 0.6em; - vertical-align: middle; -} -.btn-dashlink:hover { - background: rgba(137, 220, 235, 0.1); - box-shadow: 0 0 10px -2px currentColor; -} +/* Orphaned in #394 — `.btn-dashlink` chip beside the title moved + into the overflow menu (`.overflow-item-dashboard` covers it). */ .btn-cancel-turn { font-family: inherit; font-size: 0.8em; @@ -468,27 +582,10 @@ pre.diff { background: rgba(243, 139, 168, 0.1); box-shadow: 0 0 10px -2px currentColor; } -.btn-new-session { - font-family: inherit; - font-size: 0.8em; - letter-spacing: 0.08em; - background: transparent; - color: var(--amber); - border: 1px solid var(--amber); - border-radius: 999px; - padding: 0.2em 0.8em; - cursor: pointer; - text-shadow: 0 0 4px currentColor; - transition: box-shadow 0.15s ease, background 0.15s ease; -} -.btn-new-session:hover { - background: rgba(250, 179, 135, 0.1); - box-shadow: 0 0 10px -2px currentColor; -} -.btn-new-session:disabled { - opacity: 0.4; - cursor: progress; -} +/* Orphaned in #394 — `.btn-new-session` round-pill moved into the + overflow menu (`.overflow-item-new-session` covers it; the + `:disabled` opacity treatment lives on the shared + `.overflow-item:disabled` rule). */ .state-badge { display: inline-block; padding: 0.25em 0.8em; diff --git a/frontend/packages/agent/src/app.js b/frontend/packages/agent/src/app.js index d72d1b8..890b0f9 100644 --- a/frontend/packages/agent/src/app.js +++ b/frontend/packages/agent/src/app.js @@ -141,34 +141,139 @@ window.marked = marked; // ─── state rendering ──────────────────────────────────────────────────── function setHeader(label, dashboardPort) { const title = $('title'); - title.textContent = `◆ ${label} ◆ `; - // ↑ DASHB04RD — back-link to the host dashboard. Opens in a new - // tab to keep the agent page anchored where the operator is. + // Title is now just the glowing identity glyph — DASHB04RD, + // R3BU1LD, NEW SESSION all live in the overflow `⋯` menu now + // (#394). Glow + uppercase styling from h2 / .agent-header-title-row. + title.textContent = `◆ ${label} ◆`; + document.title = `${label} // hyperhive`; const dashUrl = `${location.protocol}//${location.hostname}:${dashboardPort}/`; dashboardBase = dashUrl; - title.append( - el('a', { - href: dashUrl, target: '_blank', rel: 'noopener', - class: 'btn-dashlink', title: 'host dashboard', - }, '↑ DASHB04RD'), - ' ', + populateOverflowMenu(label, dashUrl); + } + + // Overflow popover: dashboard back-link + rebuild + new-session. + // Per #394 mara's spec — rebuild + new-session both moved off the + // header strip into the `⋯` menu (rare actions, both destructive + // enough to warrant one extra click; the operator rebuilds from + // the host dashboard normally). Dashboard link also slotted in so + // every "leave this page" action lives in one menu. + let overflowMenuPopulated = false; + function populateOverflowMenu(label, dashUrl) { + const menu = $('overflow-menu'); + if (!menu) return; + menu.replaceChildren(); + + // ↑ dashboard — host dashboard back-link (was `.btn-dashlink` + // beside the title pre-#394). + menu.append(el('a', { + class: 'overflow-item overflow-item-dashboard', + href: dashUrl, + target: '_blank', + rel: 'noopener', + role: 'menuitem', + }, + el('span', { class: 'overflow-item-icon', 'aria-hidden': 'true' }, '↑'), + 'dashboard', + )); + + // ↻ rebuild — POST `{dash}/rebuild/{label}` after a confirm. + // Same shape as the old `.btn-rebuild` handler. + const rebuildBtn = el('button', { + type: 'button', + class: 'overflow-item overflow-item-rebuild', + role: 'menuitem', + id: 'rebuild-btn', + }, + el('span', { class: 'overflow-item-icon', 'aria-hidden': 'true' }, '↻'), + 'rebuild container', ); - const btn = el('a', { - href: '#', class: 'btn-rebuild', id: 'rebuild-btn', - }, '↻ R3BU1LD'); - btn.addEventListener('click', (e) => { - e.preventDefault(); - if (!confirm(`rebuild ${label}? container will hot-reload.`)) return; + rebuildBtn.addEventListener('click', () => { + if (!window.confirm(`rebuild ${label}? container will hot-reload.`)) return; + closeOverflowMenu(); const f = document.createElement('form'); f.method = 'POST'; f.action = `${dashUrl}rebuild/${label}`; document.body.appendChild(f); f.submit(); }); - title.append(btn); - document.title = `${label} // hyperhive`; + menu.append(rebuildBtn); + + // ↻ new session — arms a one-shot for the next turn. Mildly + // destructive (drops --continue context) so we confirm. + const newSessBtn = el('button', { + type: 'button', + class: 'overflow-item overflow-item-new-session', + role: 'menuitem', + id: 'new-session-btn', + title: 'next turn runs without --continue, starting a fresh claude session', + }, + el('span', { class: 'overflow-item-icon', 'aria-hidden': 'true' }, '↻'), + 'new claude session', + ); + newSessBtn.addEventListener('click', () => { + if (!window.confirm('arm a fresh claude session for the next turn? all prior --continue context will be dropped.')) return; + newSessBtn.disabled = true; + closeOverflowMenu(); + postNewSession().finally(() => { newSessBtn.disabled = false; }); + }); + menu.append(newSessBtn); + + overflowMenuPopulated = true; } + function openOverflowMenu() { + const btn = $('overflow-btn'); + const menu = $('overflow-menu'); + if (!btn || !menu || !overflowMenuPopulated) return; + // Position the menu so its top-right corner anchors just below + // the trigger button's bottom-right edge. Using fixed positioning + // + getBoundingClientRect so we don't get trapped in any of the + // header's stacking contexts. + const r = btn.getBoundingClientRect(); + menu.style.top = `${Math.round(r.bottom + 6)}px`; + // Render off-screen first to measure, then anchor the right edge. + menu.hidden = false; + const mr = menu.getBoundingClientRect(); + menu.style.left = `${Math.round(r.right - mr.width)}px`; + btn.setAttribute('aria-expanded', 'true'); + } + function closeOverflowMenu() { + const btn = $('overflow-btn'); + const menu = $('overflow-menu'); + if (!btn || !menu) return; + menu.hidden = true; + btn.setAttribute('aria-expanded', 'false'); + } + function toggleOverflowMenu() { + const menu = $('overflow-menu'); + if (!menu) return; + if (menu.hidden) openOverflowMenu(); + else closeOverflowMenu(); + } + // Wire once on boot. The trigger itself + click-outside + Escape + // dismissal pattern matches the side-panel flyout (Panel). + (function bindOverflowMenu() { + const btn = $('overflow-btn'); + if (!btn) return; + btn.addEventListener('click', (e) => { + e.stopPropagation(); + toggleOverflowMenu(); + }); + document.addEventListener('click', (e) => { + const menu = $('overflow-menu'); + if (!menu || menu.hidden) return; + if (menu.contains(e.target) || btn.contains(e.target)) return; + closeOverflowMenu(); + }); + document.addEventListener('keydown', (e) => { + const menu = $('overflow-menu'); + if (e.key === 'Escape' && menu && !menu.hidden) { + closeOverflowMenu(); + btn.focus(); + } + }); + })(); + function renderOnline(_label, _root) { // Online state is conveyed by the `#alive-badge` chip in the // state row — no longer a separate paragraph in the status @@ -729,18 +834,9 @@ window.marked = marked; }); })(); - // Wire the new-session button (always visible; arms a one-shot for - // the next turn). Mildly destructive (drops --continue context) so - // we confirm before posting. - (() => { - const btn = $('new-session-btn'); - if (!btn) return; - btn.addEventListener('click', () => { - if (!window.confirm('arm a fresh claude session for the next turn? all prior --continue context will be dropped.')) return; - btn.disabled = true; - postNewSession().finally(() => { btn.disabled = false; }); - }); - })(); + // (#394) — `↻ new session` button moved into the overflow `⋯` + // menu and wired by `populateOverflowMenu()` above. Previously + // wired here as a static `#new-session-btn` in index.html. // Track banner activity by reference-counting in-flight turns. A turn // can begin while the previous turn_end is still in the pipeline (rare @@ -788,7 +884,9 @@ window.marked = marked; rel: 'noopener', title: lnk.label || '', }); - if (i > 0) a.style.marginLeft = '1em'; + // Layout gap comes from `.agent-nav { gap }` (#394) — drop + // the legacy per-link inline `marginLeft`. The trailing `→` + // is the "leaves this page" affordance. a.append(((lnk.icon || '') + ' ' + (lnk.label || '')).trim() + ' →'); metaLinks.append(a); }); diff --git a/frontend/packages/agent/src/index.html b/frontend/packages/agent/src/index.html index 5874e19..f39065c 100644 --- a/frontend/packages/agent/src/index.html +++ b/frontend/packages/agent/src/index.html @@ -8,45 +8,66 @@ - +
-
-

◆ … ◆

- + +
+
+

◆ … ◆

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