agent: redesign terminal header — full-height icon, two-row main, overflow menu (#394)

Operator brief (#394): the header had thirteen distinct visual
elements in one flex row with three different border-radius
languages, four colour treatments, three label styles. Mara's
direction:

- agent icon bigger (full header height) as the identity anchor
- title glow stays
- nav links lose their default-anchor underline
- overflow `⋯` absorbs `↻ R3BU1LD` + `↻ new session` (rare,
  destructive — both worth one extra click; rebuild is normally
  done from the dashboard)
- accent stacking in the state strip stays — that's the vibe

## Layout shape

Three flex columns in `.agent-header`:

  [icon · full height] [main column · 2 rows] [pills + ⋯]

The main column carries row 1 (`◆ AGENT ◆` title + meta-nav) on
top and row 2 (alive · state · model · ctx · cost · last-turn ·
cancel-turn) below.

## Changes

### `index.html`
- Wrap title + nav in `.agent-header-row .agent-header-title-row`;
  wrap state-row siblings in `.agent-header-row .agent-state-row`;
  both go inside a new `.agent-header-main` column.
- Right cluster `.agent-header-pills` contains the inbox + loose
  pills + a new `<button id="overflow-btn">⋯</button>` trigger.
- Drop static `#new-session-btn` from `#state-row` — moved into the
  overflow menu, populated dynamically.
- Add `<div id="overflow-menu" role="menu" hidden>` as a sibling
  of `<header>` (lives outside the header so its `position: fixed`
  popover isn't trapped by any header stacking context).

### `agent.css`
- `--agent-header-h: 4.6em → 6em` so the icon can be square + full
  height without crowding the two-row main column. Terminal
  padding-top + status overlay top + tail-pill all derive from
  this variable, so they follow automatically.
- `.agent-header { align-items: stretch }` lets the icon stretch
  to full height; `.agent-icon { height: 100%; aspect-ratio: 1 }`
  sizes it as a square off the stretched height.
- `.agent-nav-link` rule added — `text-decoration: none`, cyan +
  soft glow, hover lights brighter (mara's spec).
- `.overflow-btn` (round trigger) + `.overflow-menu` (frosted
  popover, fixed-position) + `.overflow-item` (rows with an icon
  column + label, hover ink matches per-action accent — cyan for
  dashboard, amber for rebuild/new-session).
- Remove the old `#state-row` selector (layout now provided by
  `.agent-state-row` + `.agent-header-row`).

### `app.js`
- `setHeader` no longer appends DASHB04RD / R3BU1LD chips into the
  title — title is just the identity glyph now. Both actions get
  rendered into the overflow menu by `populateOverflowMenu()`.
- `populateOverflowMenu(label, dashUrl)` builds three rows:
  `↑ dashboard` (anchor), `↻ rebuild container` (button — same
  POST-form action as before), `↻ new claude session` (button —
  same `/api/new-session` call as the legacy header button).
- Overflow toggle / outside-click / Escape dismissal — same
  pattern as the side-panel flyout (`Panel`).
- Drop the static `new-session-btn` IIFE binder; the dynamically-
  rendered menu item owns its handler now.
- Drop the per-nav-link inline `marginLeft` (layout gap comes from
  the new `.agent-nav { gap }` rule).

## Validation

- `npm run build` clean.
- Build deltas: agent.css 21.0kb → 23.6kb (overflow + nav rules
  + comments), app.js 117.4kb → 118.9kb (menu builder + toggle).
- Browser smoke test isn't possible from inside iris's container.
  Worth eyeballing post-deploy:
    - Icon fills the full header height as a square
    - Title glow + uppercase styling preserved
    - Nav links render without underline; hover lights brighter
    - `⋯` opens a frosted popover with `↑ dashboard`, `↻ rebuild
      container`, `↻ new claude session`
    - Rebuild confirm + POST works the same as the legacy chip
    - New-session confirm + POST works the same as the legacy button
    - State strip still wraps when crowded (model/ctx/cost
      multi-line on narrow viewports)
    - Cancel-turn button still appears while thinking and clears
      on turn end
    - Terminal padding-top adjusts to the new 6em header height
      (no row hidden under the chrome)
This commit is contained in:
iris 2026-05-25 01:13:16 +02:00 committed by Mara
parent 88bc07fbbe
commit 69312b8553
3 changed files with 337 additions and 82 deletions

View file

@ -12,7 +12,10 @@
surface their counts as the only chrome they get. */ surface their counts as the only chrome they get. */
:root { :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-composer-h: 3.6em;
--agent-frost-bg: rgba(30, 30, 46, 0.72); --agent-frost-bg: rgba(30, 30, 46, 0.72);
--agent-frost-blur: blur(12px) saturate(140%); --agent-frost-blur: blur(12px) saturate(140%);
@ -70,31 +73,41 @@ body.agent-shell {
z-index: 30; z-index: 30;
min-height: var(--agent-header-h); min-height: var(--agent-header-h);
display: flex; display: flex;
align-items: center; /* align-items: stretch lets the icon take the full header height
gap: 1em; (it sizes itself via aspect-ratio off the stretched height). The
padding: 0.55em 1em; main column + pills column self-centre via inner layout. */
align-items: stretch;
gap: 0.9em;
padding: 0.5em 1em;
background: var(--agent-frost-bg); background: var(--agent-frost-bg);
-webkit-backdrop-filter: var(--agent-frost-blur); -webkit-backdrop-filter: var(--agent-frost-blur);
backdrop-filter: var(--agent-frost-blur); backdrop-filter: var(--agent-frost-blur);
border-bottom: 1px solid var(--purple-dim); border-bottom: 1px solid var(--purple-dim);
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.35); 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; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.1em; justify-content: center;
gap: 0.45em;
min-width: 0; min-width: 0;
flex: 1 1 auto; 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; margin: 0;
line-height: 1; line-height: 1;
} }
.agent-nav { .agent-nav {
/* Slim nav row directly under the title keeps the operator's
fingers near stats/screen/forge without dominating the header. */
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.4em 0.8em; gap: 0.4em 0.8em;
@ -103,10 +116,21 @@ body.agent-shell {
.agent-state-row { .agent-state-row {
margin: 0; 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; display: flex;
align-items: center; align-items: center;
gap: 0.5em; gap: 0.5em;
flex-shrink: 0;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: flex-end;
align-self: center;
} }
h2, h3 { h2, h3 {
@ -116,11 +140,126 @@ h2, h3 {
text-shadow: 0 0 8px rgba(203, 166, 247, 0.4); text-shadow: 0 0 8px rgba(203, 166, 247, 0.4);
} }
.agent-icon { .agent-icon {
width: 44px; /* Full-height square identity anchor (#394 mara's spec). The
height: 44px; `align-items: stretch` on .agent-header stretches the icon's
border-radius: 6px; `<img>` 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; 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. */ /* Header pill — inbox / loose-ends triggers. Compact, count-prominent. */
@ -292,12 +431,9 @@ pre.diff {
word-break: break-all; word-break: break-all;
max-height: 30em; max-height: 30em;
} }
#state-row { /* `#state-row` layout is provided by `.agent-state-row` /
margin: 0.4em 0 0.2em; `.agent-header-row` above (#394) kept as a no-op selector
display: flex; anchor in case any future rule needs to scope by id. */
align-items: center;
gap: 0.6em;
}
/* Per-agent inbox section collapsible, dim, lives between the /* Per-agent inbox section collapsible, dim, lives between the
state row and the terminal so the operator can peek at what state row and the terminal so the operator can peek at what
landed without scrolling through the live tail. */ landed without scrolling through the live tail. */

View file

@ -141,34 +141,139 @@ window.marked = marked;
// ─── state rendering ──────────────────────────────────────────────────── // ─── state rendering ────────────────────────────────────────────────────
function setHeader(label, dashboardPort) { function setHeader(label, dashboardPort) {
const title = $('title'); const title = $('title');
// 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}`; title.textContent = `${label}`;
// ↑ DASHB04RD — back-link to the host dashboard. Opens in a new document.title = `${label} // hyperhive`;
// tab to keep the agent page anchored where the operator is.
const dashUrl = `${location.protocol}//${location.hostname}:${dashboardPort}/`; const dashUrl = `${location.protocol}//${location.hostname}:${dashboardPort}/`;
dashboardBase = dashUrl; dashboardBase = dashUrl;
title.append( populateOverflowMenu(label, dashUrl);
el('a', { }
href: dashUrl, target: '_blank', rel: 'noopener',
class: 'btn-dashlink', title: 'host dashboard', // Overflow popover: dashboard back-link + rebuild + new-session.
}, '↑ DASHB04RD'), // 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', { rebuildBtn.addEventListener('click', () => {
href: '#', class: 'btn-rebuild', id: 'rebuild-btn', if (!window.confirm(`rebuild ${label}? container will hot-reload.`)) return;
}, '↻ R3BU1LD'); closeOverflowMenu();
btn.addEventListener('click', (e) => {
e.preventDefault();
if (!confirm(`rebuild ${label}? container will hot-reload.`)) return;
const f = document.createElement('form'); const f = document.createElement('form');
f.method = 'POST'; f.method = 'POST';
f.action = `${dashUrl}rebuild/${label}`; f.action = `${dashUrl}rebuild/${label}`;
document.body.appendChild(f); document.body.appendChild(f);
f.submit(); f.submit();
}); });
title.append(btn); menu.append(rebuildBtn);
document.title = `${label} // hyperhive`;
// ↻ 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) { function renderOnline(_label, _root) {
// Online state is conveyed by the `#alive-badge` chip in the // Online state is conveyed by the `#alive-badge` chip in the
// state row — no longer a separate paragraph in the status // 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 // (#394) — `↻ new session` button moved into the overflow `⋯`
// the next turn). Mildly destructive (drops --continue context) so // menu and wired by `populateOverflowMenu()` above. Previously
// we confirm before posting. // wired here as a static `#new-session-btn` in index.html.
(() => {
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; });
});
})();
// Track banner activity by reference-counting in-flight turns. A turn // Track banner activity by reference-counting in-flight turns. A turn
// can begin while the previous turn_end is still in the pipeline (rare // can begin while the previous turn_end is still in the pipeline (rare
@ -788,7 +884,9 @@ window.marked = marked;
rel: 'noopener', rel: 'noopener',
title: lnk.label || '', 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() + ' →'); a.append(((lnk.icon || '') + ' ' + (lnk.label || '')).trim() + ' →');
metaLinks.append(a); metaLinks.append(a);
}); });

View file

@ -8,16 +8,27 @@
</head> </head>
<body class="agent-shell"> <body class="agent-shell">
<!-- Fixed-overlay header. Frosted glass over the terminal — <!-- Fixed-overlay header (#394 redesign): two-row layout in the
backdrop-filter blur shows the scrolled terminal text behind. --> main column — row 1 carries the title + meta-nav, row 2 carries
the live state strip. The agent icon eats the full header
height on the left as the identity anchor; flyout pills + an
overflow menu trigger sit on the right. Frosted glass over the
terminal — backdrop-filter blur shows the scrolled terminal
text behind. -->
<header class="agent-header" id="agent-header"> <header class="agent-header" id="agent-header">
<img class="agent-icon" src="/icon" alt=""> <img class="agent-icon" src="/icon" alt="">
<div class="agent-header-title">
<div class="agent-header-main">
<div class="agent-header-row agent-header-title-row">
<h2 id="title">◆ … ◆</h2> <h2 id="title">◆ … ◆</h2>
<!-- Meta-nav: backend-supplied links (stats / screen / forge /
…) plus a client-injected `↑ dashboard` link prepended in
setHeader so the host dashboard stays one click away
without a separate button styled differently. -->
<nav class="meta agent-nav" id="meta-links"></nav> <nav class="meta agent-nav" id="meta-links"></nav>
</div> </div>
<div id="state-row" class="agent-state-row"> <div id="state-row" class="agent-state-row agent-header-row">
<span id="alive-badge" class="status-badge status-loading" title="harness reachability"></span> <span id="alive-badge" class="status-badge status-loading" title="harness reachability"></span>
<span id="state-badge" class="state-badge state-loading">… booting</span> <span id="state-badge" class="state-badge state-loading">… booting</span>
<span id="model-chip" class="model-chip" hidden></span> <span id="model-chip" class="model-chip" hidden></span>
@ -25,14 +36,15 @@
<span id="cost-badge" class="ctx-badge" hidden title="cumulative tokens billed across the last turn (sum across every inference; tool-heavy turns rebill the cached prompt per call)"></span> <span id="cost-badge" class="ctx-badge" hidden title="cumulative tokens billed across the last turn (sum across every inference; tool-heavy turns rebill the cached prompt per call)"></span>
<span id="last-turn" class="last-turn" hidden></span> <span id="last-turn" class="last-turn" hidden></span>
<button type="button" id="cancel-btn" class="btn-cancel-turn" hidden>■ cancel turn</button> <button type="button" id="cancel-btn" class="btn-cancel-turn" hidden>■ cancel turn</button>
<button type="button" id="new-session-btn" class="btn-new-session" </div>
title="next turn runs without --continue, starting a fresh claude session">↻ new session</button>
</div> </div>
<!-- Flyout triggers. The inbox + loose-ends lists live in the <!-- Right cluster: flyout triggers + overflow menu. Pills stay
side panel now; these pills surface the count and act as the hidden until their list is non-empty; the overflow `⋯` is
single click target. The pill stays hidden until there's at always visible (rebuild + new-session live inside it per
least one item to show. --> #394 — both rare, both destructive, both deserve one extra
click). -->
<div class="agent-header-pills">
<button type="button" id="inbox-pill" class="header-pill header-pill-inbox" hidden <button type="button" id="inbox-pill" class="header-pill header-pill-inbox" hidden
title="open inbox flyout"> title="open inbox flyout">
<span class="header-pill-icon" aria-hidden="true">📬</span> <span class="header-pill-icon" aria-hidden="true">📬</span>
@ -45,8 +57,17 @@
<span class="header-pill-label">loose ends</span> <span class="header-pill-label">loose ends</span>
<span class="header-pill-count" id="loose-ends-count">0</span> <span class="header-pill-count" id="loose-ends-count">0</span>
</button> </button>
<button type="button" id="overflow-btn" class="overflow-btn"
aria-haspopup="menu" aria-expanded="false"
title="more actions">⋯</button>
</div>
</header> </header>
<!-- Overflow popover. Sits outside the header so the header's
`overflow: hidden`-adjacent ancestors don't clip it; positioned
in JS relative to the overflow button (top-right anchor). -->
<div id="overflow-menu" class="overflow-menu" role="menu" hidden></div>
<!-- Main content area. The terminal fills it edge-to-edge and <!-- Main content area. The terminal fills it edge-to-edge and
scrolls behind the floating header + composer. The `#status` scrolls behind the floating header + composer. The `#status`
overlay renders only when login is required (transient first- overlay renders only when login is required (transient first-