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.
87 lines
4.1 KiB
TypeScript
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 };
|
|
}
|