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.
This commit is contained in:
parent
433429ebfd
commit
c460e91bd1
9 changed files with 445 additions and 11 deletions
87
frontend/packages/swarm-ui/src/shell/useDynamicTabs.ts
Normal file
87
frontend/packages/swarm-ui/src/shell/useDynamicTabs.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
// 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 };
|
||||
}
|
||||
Loading…
Reference in a new issue