Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/frontend/packages/swarm-ui/src/shell/useDynamicTabs.ts
iris c460e91bd1 swarm-ui: full-tab agent terminal (#4506)
Adds a full, non-capped agent terminal reachable from a new expand
trigger on the embedded AgentTermPreview (the detail-panel preview on
AgentsPage stays as-is, just gains the trigger). Opens
/agents/:name/terminal in a new dynamic tab in Shell's header, next to
the static nav row — tabs persist across a reload via useDynamicTabs,
a small localStorage-backed hook built on @hive/shared's existing
settings-storage primitive.

AgentTermPreview gains two new props to support both mounts from one
component: fullHeight (drops the 12em preview cap, fills its page)
and showHeaderBadges (default true — lets a future caller that
already shows turn_state/model/ctx/cost elsewhere suppress this
cluster; AgentsPage doesn't use it, see below).

Deviation from the originally posted plan (issue comment 80596): that
plan proposed AgentsPage's embedded preview pass showHeaderBadges as
false, reasoning the detail panel already duplicates that info.
Checked the actual code before implementing — it doesn't; AgentRow/
AgentTypes.ts carry none of turn_state/model/ctx/cost, and
AgentTermPreview's own floating badges are the only place swarm-ui
shows them. Left the badges visible there instead of shipping a
regression the plan's own stated justification didn't hold up to.

Also fixed a same-tab pub/sub race found by actually rendering a cold
load of /agents/:name/terminal (headless chromium, not just reasoning
about the code): useLocalSetting subscribes inside a useEffect, and
mount effects fire children-before-parents, so a descendant's
mount-time write (AgentTerminalPage registering its own tab) can beat
an ancestor's (Shell's) subscription into existence, leaving Shell's
tab row silently empty on a direct/reload load. Fixed by having
useDynamicTabs re-sync from storage on every location change, not
just on notify() — the fix lives in the new hook itself, not in the
shared settings-storage primitive theme/motion overrides also use.
2026-09-28 23:17:44 +02:00

87 lines
4.1 KiB
TypeScript

// useDynamicTabs — the small piece of shared state behind the swarm-ui
// header's second tab row: agent terminal tabs a user has opened, on top
// of `Shell`'s always-present static `NAV_ITEMS`. Not a generic "any page
// can open a tab" mechanism — the swarm-level terminal-tab work's own
// scope note keeps this to just agent terminals for now.
//
// Built directly on `@hive/shared/settings-storage.js`'s `useLocalSetting`
// — same browser-local persistence + same-tab pub/sub `theme-apply.js`/
// `motion-apply.js` already use, so an opened terminal tab survives a
// reload instead of vanishing (mara's own bar for "browser-local setting"
// framing in the issue's plan comment). `Shell` is the one mount point
// that renders the row; `AgentTermPreview`'s expand trigger and
// `AgentTerminalPage`'s own mount effect are the two callers of `open()`.
// See the `useEffect` below for a same-tab pub/sub race this hook works
// around — found by actually rendering a cold terminal-tab load, not
// just reasoning about the code.
import { useEffect } from "preact/hooks";
import { useLocation } from "wouter-preact";
import {
readLocalSetting,
useLocalSetting,
} from "@hive/shared/settings-storage.js";
export interface DynamicTab {
href: string;
label: string;
}
const TABS_KEY = "swarm-ui:dynamic-tabs";
export interface UseDynamicTabsResult {
tabs: DynamicTab[];
/** Adds `tab` if not already open (deduped by `href`) and navigates to it. Re-opening an already-open tab just navigates — it doesn't duplicate or reorder the row. */
open: (tab: DynamicTab) => void;
/** Removes the tab. If it was the active route, navigates to `fallbackHref` (default `/agents`, since agent terminal tabs are the only kind today). */
close: (href: string, fallbackHref?: string) => void;
}
export function useDynamicTabs(): UseDynamicTabsResult {
const [tabs, setTabs] = useLocalSetting<DynamicTab[]>(TABS_KEY, []);
const [location, navigate] = useLocation();
// Load-bearing, not decoration — a same-tab pub/sub race, confirmed by
// actually rendering a cold load of `/agents/:name/terminal` (headless
// chromium) and finding the tab row silently empty without this.
// `useLocalSetting` subscribes inside a `useEffect`, and mount effects
// fire children-before-parents (Preact mirrors React here). `Shell`
// wraps `AgentTerminalPage` as a descendant, so on a direct/reload
// load both mount in the same commit — `AgentTerminalPage`'s own
// "register this tab" effect (a call to `open()` below) can fire and
// notify subscribers *before* `Shell`'s own `useLocalSetting`
// subscription (set up in ITS mount effect, which per that ordering
// runs after the child's) even exists to catch it. `Shell`'s first
// `tabs` snapshot then silently stays stale forever, since there's no
// second write coming to re-trigger it.
//
// Fix: re-read storage on every `location` change, not just on a
// pub/sub notification. This effect is *this hook's own* mount effect,
// so it's guaranteed to run after any descendant's mount effect that
// just wrote — by the time it fires, a same-commit write from a child
// has already landed. Costs a redundant, idempotent write-back on
// every navigation across every `useDynamicTabs()` call site — traded
// deliberately for not touching `@hive/shared/settings-storage.ts`
// itself (a primitive theme/motion overrides also depend on) for a fix
// specific to this one hook's own race.
useEffect(() => {
setTabs(readLocalSetting(TABS_KEY, []));
// eslint-disable-next-line react-hooks/exhaustive-deps -- `setTabs` is
// a fresh function identity every render (`useLocalSetting` doesn't
// memoize it); including it would re-run this every render instead
// of once per navigation.
}, [location]);
function open(tab: DynamicTab) {
if (!tabs.some((t) => t.href === tab.href)) {
setTabs([...tabs, tab]);
}
navigate(tab.href);
}
function close(href: string, fallbackHref = "/agents") {
setTabs(tabs.filter((t) => t.href !== href));
if (location === href) navigate(fallbackHref);
}
return { tabs, open, close };
}