diff --git a/frontend/packages/agent/src/agent.css b/frontend/packages/agent/src/agent.css
index 72318bb..7549741 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. */
@@ -292,12 +431,9 @@ 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;
-}
+/* `#state-row` layout is provided by `.agent-state-row` /
+ `.agent-header-row` above (#394) — kept as a no-op selector
+ anchor in case any future rule needs to scope by id. */
/* 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. */
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 @@