// 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(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 }; }