Watch
0
0
Fork
You've already forked hyperhive
0

swarm-ui: move dynamic agent-term tabs into the top nav bar

mara, PR review: "the dynamic tab should be in the top bar, not a new
one below". Moves the .shell-tabs group from its own sticky row under
the header into .shell-nav itself, right after the nav indicator.

Also adds a third re-measure effect for the sliding nav indicator,
keyed on tabs.length: with tabs inline in the same flex row the
indicator measures, closing a background tab (no navigation) can
shrink the row without the hop effect's own re-measure ever firing.
Same reflow-not-navigation reasoning as the existing resize-listener
effect.
This commit is contained in:
iris 2026-09-28 22:17:10 +02:00 • committed by mara
commit 88c386c96c
2 changed files with 55 additions and 46 deletions

View file

@ -165,33 +165,22 @@
.shell-nav-indicator.shell-nav-indicator-instant { .shell-nav-indicator.shell-nav-indicator-instant {
transition: none; transition: none;
} }
/* Dynamic tab row — sits below `.shell-header`, its own sticky /* Dynamic tabs group — inline in `.shell-nav` itself, next to the
strip rather than folded into the header itself: the header's own static route links (mara, PR review: "the dynamic tab should be in
sticky offset (`top: 0`) is already claimed, and a header that grows the top bar, not a new one below" — an earlier cut had this as its
taller every time a tab opens would shift `.shell-nav`'s measured own sticky row under the header; that version and the reasoning for
positions (`measureIndex`, Shell.tsx) out from under the sliding it are still in this file's git history if the split ever needs to
indicator mid-animation. `top` below is `.shell-header`'s own come back). `margin-left: 0.5em` + `border-left` reads as "a group
footprint — `0.75em` padding top+bottom either side of the attached to the nav, not another nav item" without a second landmark
`2.75em`-min-height nav row (`.shell-nav-link`, this file), so ~4.25em or a fixed pixel gap that'd fight `.shell-nav`'s own `gap`. */
rounded up to 4.5em for the 1px border + a little slack — not
measured, same fixed-estimate tradeoff `AgentTerminalPage.css`'s own
comment makes for the same reason (not worth a ResizeObserver for one
sticky offset), and the same 4.5em value that file's own height calc
already uses for this header, so the two stay consistent with each
other rather than drifting to two different guesses at one thing.
Undershooting this would mean the sticky header (z-index 10, above
this row's 9) visibly covers the top of the tab row once scrolled —
worth erring high, not low, if the real value ever drifts. */
.shell-tabs { .shell-tabs {
display: flex; display: flex;
align-items: center;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.5em; gap: 0.5em;
padding: 0.4em 1.25em; margin-left: 0.5em;
border-bottom: 1px solid var(--border); padding-left: 0.75em;
background: var(--bg-elev); border-left: 1px solid var(--border);
position: sticky;
top: 4.5em;
z-index: 9; /* one below .shell-header's 10, so the header still wins if both ever overlap during a fast scroll */
} }
.shell-tab { .shell-tab {
display: flex; display: flex;

View file

@ -214,9 +214,13 @@ export function Shell({ children }: { children: ComponentChildren }) {
// Every subsequent change (a real navigation) still animates normally. // Every subsequent change (a real navigation) still animates normally.
const [indicatorSettledOnce, setIndicatorSettledOnce] = useState(false); const [indicatorSettledOnce, setIndicatorSettledOnce] = useState(false);
// Open agent-terminal tabs — the row below `.shell-nav` that // Open agent-terminal tabs — rendered inline in `.shell-nav` itself
// grows/shrinks as tabs open/close, entirely separate from the fixed // (mara, PR review: "the dynamic tab should be in the top bar, not a
// `NAV_ITEMS` list above. // new one below" — an earlier cut had these as their own row under
// the header; see the git history on this file for that version and
// why it was reverted). Entirely separate from the fixed `NAV_ITEMS`
// list above, so they get their own small component (`DynamicTabLink`)
// rather than reusing `NavLink`.
const { tabs, close: closeTab } = useDynamicTabs(); const { tabs, close: closeTab } = useDynamicTabs();
// Applied once here, not inside `SettingsMenu` — every route mounts // Applied once here, not inside `SettingsMenu` — every route mounts
@ -313,6 +317,18 @@ export function Shell({ children }: { children: ComponentChildren }) {
return () => window.removeEventListener("resize", onResize); return () => window.removeEventListener("resize", onResize);
}, []); }, []);
// Same re-measure, for the same "a reflow, not a navigation" reason —
// now that the dynamic tabs render inline in `.shell-nav` (see the
// `useDynamicTabs()` comment above), *closing* a background tab (one
// that isn't the active route) shrinks the row without any navigation
// firing to trigger the hop effect's own re-measure. Opening one
// doesn't need this: `useDynamicTabs().open()` always navigates, so
// the hop effect already covers that case.
useEffect(() => {
if (settledIndexRef.current === -1) return;
setIndicator(measureIndex(settledIndexRef.current));
}, [tabs.length]);
// Fetched once here, not per-page: every route mounts inside one // Fetched once here, not per-page: every route mounts inside one
// `<Shell>`, and the swarm's name doesn't change within a page // `<Shell>`, and the swarm's name doesn't change within a page
// visit. A fetch failure is silently ignored — `swarmName` just stays // visit. A fetch failure is silently ignored — `swarmName` just stays
@ -387,6 +403,30 @@ export function Shell({ children }: { children: ComponentChildren }) {
backgroundColor: indicator?.accent ?? "transparent", backgroundColor: indicator?.accent ?? "transparent",
}} }}
/> />
{/* Open agent-terminal tabs — inline in the same nav row as
the static routes (mara's review call, see the
`useDynamicTabs()` comment above). Only rendered when
there's something to show — an always-present empty group
would just be dead chrome for the far more common case of
no open terminal tabs. `role="group"`, not a second `<nav>`:
there's already exactly one nav landmark in this header. */}
{tabs.length > 0 && (
<div
class="shell-tabs"
role="group"
aria-label="open agent terminals"
>
{tabs.map((tab) => (
<DynamicTabLink
key={tab.href}
href={tab.href}
label={tab.label}
active={tab.href === location}
onClose={() => closeTab(tab.href)}
/>
))}
</div>
)}
</nav> </nav>
{/* Single `margin-left: auto` on the wrapper, not on each menu {/* Single `margin-left: auto` on the wrapper, not on each menu
individually — two adjacent flex items both set to individually — two adjacent flex items both set to
@ -398,26 +438,6 @@ export function Shell({ children }: { children: ComponentChildren }) {
<UserMenu /> <UserMenu />
</div> </div>
</header> </header>
{/* Second row, only when there's something to show — an always-
present empty bar would just be dead chrome for the far more
common case of no open terminal tabs. Own `<nav>`, not folded
into `.shell-nav` above: these come and go independently of
the static route list and shouldn't shift `NAV_ITEMS`'
measured positions (`measureIndex` above) by changing the
header's height mid-sweep. */}
{tabs.length > 0 && (
<nav class="shell-tabs" aria-label="open agent terminals">
{tabs.map((tab) => (
<DynamicTabLink
key={tab.href}
href={tab.href}
label={tab.label}
active={tab.href === location}
onClose={() => closeTab(tab.href)}
/>
))}
</nav>
)}
{/* Keyed by route so it remounts (and replays its entrance {/* Keyed by route so it remounts (and replays its entrance
animation) on every navigation — see the file-top comment. animation) on every navigation — see the file-top comment.
`shell-body-wide` is additive (see `WIDE_BODY_ROUTES` above), `shell-body-wide` is additive (see `WIDE_BODY_ROUTES` above),