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
|
|
@ -34,6 +34,7 @@ import { UserMenu } from "./UserMenu.js";
|
|||
import { SettingsMenu } from "@hive/shared/settings-menu.js";
|
||||
import { useApplyThemeOverride } from "@hive/shared/theme-apply.js";
|
||||
import { useApplyMotionOverride } from "@hive/shared/motion-apply.js";
|
||||
import { useDynamicTabs } from "./useDynamicTabs.js";
|
||||
import "./Shell.css";
|
||||
|
||||
// Storage keys this app owns for `@hive/shared`'s settings mechanism —
|
||||
|
|
@ -61,6 +62,20 @@ const NAV_ITEMS: { href: string; label: string; accent: string }[] = [
|
|||
// its own table view (plus the list+detail split) hits the same cap.
|
||||
const WIDE_BODY_ROUTES = new Set(["/issues", "/agents"]);
|
||||
|
||||
// `/agents/:name/terminal` needs the same widening but can't join
|
||||
// the `Set` above — it's a parameterized path, one literal string can't
|
||||
// match every agent name. A 60em-capped terminal pane reads as cramped
|
||||
// (this is a full tab, not a sidebar preview), same reasoning as the
|
||||
// table routes above just for a different reason (a wide pane, not a
|
||||
// wide table).
|
||||
const WIDE_BODY_ROUTE_PATTERN = /^\/agents\/[^/]+\/terminal$/;
|
||||
|
||||
function isWideBodyRoute(location: string): boolean {
|
||||
return (
|
||||
WIDE_BODY_ROUTES.has(location) || WIDE_BODY_ROUTE_PATTERN.test(location)
|
||||
);
|
||||
}
|
||||
|
||||
// Static fallback — matches `index.html`'s `<title>` default, so a page
|
||||
// never flashes something else before the fetch below resolves, and an
|
||||
// operator who never set `services.hyperhive.swarm.name` sees the exact
|
||||
|
|
@ -135,6 +150,50 @@ function NavLink({
|
|||
);
|
||||
}
|
||||
|
||||
// One open agent-terminal tab (`useDynamicTabs`) — deliberately
|
||||
// its own small component, not folded into `NavLink` above: dynamic tabs
|
||||
// don't ride the sliding `.shell-nav-indicator` (there's no fixed set to
|
||||
// hop between — see `WIDE_BODY_ROUTE_PATTERN`'s own comment on why they
|
||||
// can't share `NAV_ITEMS`'s Set-based route list either), so "active"
|
||||
// here is a plain class toggle, not a measured position. The close ×
|
||||
// is a real sibling button, not nested inside the `<Link>` — a button
|
||||
// inside an `<a>` is invalid HTML and (worse) means a click anywhere on
|
||||
// the tab, including the ×, triggers the link navigation first.
|
||||
function DynamicTabLink({
|
||||
href,
|
||||
label,
|
||||
active,
|
||||
onClose,
|
||||
}: {
|
||||
href: string;
|
||||
label: string;
|
||||
active: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<span class={"shell-tab" + (active ? " shell-tab-active" : "")}>
|
||||
<Link href={href} className="shell-tab-link">
|
||||
{label}
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
class="shell-tab-close"
|
||||
title={`close ${label}`}
|
||||
onClick={(e) => {
|
||||
// Not a navigation — stop it reaching `Link`'s own click
|
||||
// handling even though this button sits outside the anchor,
|
||||
// since the two are visually one row and a stray bubble
|
||||
// shouldn't be able to trigger a route change on close.
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function Shell({ children }: { children: ComponentChildren }) {
|
||||
const [swarmName, setSwarmName] = useState<string | null>(null);
|
||||
const [location] = useLocation();
|
||||
|
|
@ -155,6 +214,11 @@ export function Shell({ children }: { children: ComponentChildren }) {
|
|||
// Every subsequent change (a real navigation) still animates normally.
|
||||
const [indicatorSettledOnce, setIndicatorSettledOnce] = useState(false);
|
||||
|
||||
// Open agent-terminal tabs — the row below `.shell-nav` that
|
||||
// grows/shrinks as tabs open/close, entirely separate from the fixed
|
||||
// `NAV_ITEMS` list above.
|
||||
const { tabs, close: closeTab } = useDynamicTabs();
|
||||
|
||||
// Applied once here, not inside `SettingsMenu` — every route mounts
|
||||
// through this one `<Shell>`, so the override takes effect regardless
|
||||
// of which page is showing or whether the menu's ever been opened,
|
||||
|
|
@ -334,6 +398,26 @@ export function Shell({ children }: { children: ComponentChildren }) {
|
|||
<UserMenu />
|
||||
</div>
|
||||
</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
|
||||
animation) on every navigation — see the file-top comment.
|
||||
`shell-body-wide` is additive (see `WIDE_BODY_ROUTES` above),
|
||||
|
|
@ -341,8 +425,7 @@ export function Shell({ children }: { children: ComponentChildren }) {
|
|||
apply, only the max-width cap changes. */}
|
||||
<div
|
||||
class={
|
||||
"shell-body" +
|
||||
(WIDE_BODY_ROUTES.has(location) ? " shell-body-wide" : "")
|
||||
"shell-body" + (isWideBodyRoute(location) ? " shell-body-wide" : "")
|
||||
}
|
||||
key={location}
|
||||
>
|
||||
|
|
|
|||
Loading…
Reference in a new issue