Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88c386c96c | ||
|
|
7bf4cfbc4c | ||
|
|
c460e91bd1 |
9 changed files with 454 additions and 11 deletions
|
|
@ -57,6 +57,32 @@ export function LinkIcon() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Four corner-arrows, the standard "expand to fullscreen" glyph — added
|
||||||
|
// for the terminal-preview-to-full-tab trigger (`AgentTermPreview`'s own
|
||||||
|
// expand affordance). Same size/stroke recipe as `LinkIcon`/`GearIcon`
|
||||||
|
// above, not a smaller variant like `FilterIcon` — this one sits as a
|
||||||
|
// standalone `Badge` trigger, not inside a table header cell.
|
||||||
|
export function ExpandIcon() {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="1.3em"
|
||||||
|
height="1.3em"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<polyline points="15 3 21 3 21 9" />
|
||||||
|
<polyline points="9 21 3 21 3 15" />
|
||||||
|
<line x1="21" y1="3" x2="14" y2="10" />
|
||||||
|
<line x1="3" y1="21" x2="10" y2="14" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// A funnel, the standard "filter" glyph — smaller than the other two
|
// A funnel, the standard "filter" glyph — smaller than the other two
|
||||||
// (1em not 1.3em) since its first caller sits inside a table header
|
// (1em not 1.3em) since its first caller sits inside a table header
|
||||||
// cell, not a nav trigger button; scale via the caller's own
|
// cell, not a nav trigger button; scale via the caller's own
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import { Route, Switch } from "wouter-preact";
|
import { Route, Switch } from "wouter-preact";
|
||||||
import { Shell } from "./shell/Shell.js";
|
import { Shell } from "./shell/Shell.js";
|
||||||
import { AgentsPage } from "./pages/agents/AgentsPage.js";
|
import { AgentsPage } from "./pages/agents/AgentsPage.js";
|
||||||
|
import { AgentTerminalPage } from "./pages/agents/AgentTerminalPage.js";
|
||||||
import { ComponentsPage } from "./pages/ComponentsPage.js";
|
import { ComponentsPage } from "./pages/ComponentsPage.js";
|
||||||
import { JobsPage } from "./pages/JobsPage.js";
|
import { JobsPage } from "./pages/JobsPage.js";
|
||||||
import { HivesPage } from "./pages/HivesPage.js";
|
import { HivesPage } from "./pages/HivesPage.js";
|
||||||
|
|
@ -23,6 +24,7 @@ export function App() {
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route path="/" component={HivesPage} />
|
<Route path="/" component={HivesPage} />
|
||||||
<Route path="/agents" component={AgentsPage} />
|
<Route path="/agents" component={AgentsPage} />
|
||||||
|
<Route path="/agents/:name/terminal" component={AgentTerminalPage} />
|
||||||
<Route path="/jobs" component={JobsPage} />
|
<Route path="/jobs" component={JobsPage} />
|
||||||
<Route path="/issues" component={IssueReportPage} />
|
<Route path="/issues" component={IssueReportPage} />
|
||||||
<Route path="/components" component={ComponentsPage} />
|
<Route path="/components" component={ComponentsPage} />
|
||||||
|
|
|
||||||
|
|
@ -8,14 +8,60 @@
|
||||||
margin-block-start: 1rem;
|
margin-block-start: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui-agent-term-preview-status {
|
/* Holds the connection-status badge + (unless `fullHeight`) the
|
||||||
|
expand-to-fullscreen trigger — `margin-left: auto` targets the expand
|
||||||
|
trigger by its OWN class, not `:last-child` (tried that first; it
|
||||||
|
silently broke when `fullHeight` hides the expand trigger, since the
|
||||||
|
status badge then becomes the `:last-child` too and gets pushed to
|
||||||
|
the right edge instead of staying flush left — caught by actually
|
||||||
|
rendering the `fullHeight` case, not just reasoning about the CSS).
|
||||||
|
Same split-row intent `Shell.css`'s `.shell-header-actions` already
|
||||||
|
uses for its own pair of triggers, just addressed directly instead of
|
||||||
|
positionally. */
|
||||||
|
.ui-agent-term-preview-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
margin-block-end: 0.5rem;
|
margin-block-end: 0.5rem;
|
||||||
}
|
}
|
||||||
|
.ui-agent-term-preview-expand {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.ui-agent-term-preview-wrap.terminal-wrap {
|
.ui-agent-term-preview-wrap.terminal-wrap {
|
||||||
max-height: 12em;
|
max-height: 12em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* `AgentTerminalPage`'s full-tab mount (`fullHeight` prop → the
|
||||||
|
`-full` modifier on the OUTER `.ui-agent-term-preview` root, not the
|
||||||
|
wrap) — self-contained flex chain, not dependent on the page around
|
||||||
|
it doing anything beyond giving this root a real height to fill (see
|
||||||
|
AgentTerminalPage.css). Column flex so the toolbar keeps its natural
|
||||||
|
height and the terminal-wrap below it takes the rest; `flex: 1` +
|
||||||
|
`min-height: 0` on the wrap (not `height: 100%`) is deliberate — a
|
||||||
|
flex item's `height` percentage doesn't account for a sibling
|
||||||
|
(the toolbar) already consuming space the way `flex: 1` does, and
|
||||||
|
`min-height: 0` is what actually lets the item shrink below its
|
||||||
|
content's natural height so `.live.terminal`'s own `overflow-y: auto`
|
||||||
|
(terminal.css) scrolls instead of the whole column growing past its
|
||||||
|
container. `!important`-free — the `max-height: none` overrides
|
||||||
|
below already carry one more class in the selector than the
|
||||||
|
un-modified rules above (`.ui-agent-term-preview-full` prefixing
|
||||||
|
each), so they're strictly more specific and win regardless of
|
||||||
|
source order. */
|
||||||
|
.ui-agent-term-preview-full {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.ui-agent-term-preview-full .ui-agent-term-preview-wrap.terminal-wrap {
|
||||||
|
max-height: none;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.ui-agent-term-preview-full .ui-agent-term-preview-wrap .live.terminal {
|
||||||
|
max-height: none;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
/* `box-sizing: border-box`, not the browser's default `content-box` —
|
/* `box-sizing: border-box`, not the browser's default `content-box` —
|
||||||
`.live.terminal`'s own `padding: 0.8em 1em 0.4em` (terminal.css) is
|
`.live.terminal`'s own `padding: 0.8em 1em 0.4em` (terminal.css) is
|
||||||
otherwise added ON TOP of this `max-height`, so the box renders
|
otherwise added ON TOP of this `max-height`, so the box renders
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,28 @@
|
||||||
// here needs to be interactive. Sourced from `useSwarmAgentStateStream`,
|
// here needs to be interactive. Sourced from `useSwarmAgentStateStream`,
|
||||||
// not from `AgentRow` — the roster fetch `AgentsPage` already did has no
|
// not from `AgentRow` — the roster fetch `AgentsPage` already did has no
|
||||||
// notion of turn_state/model/ctx/cost, only `wanted`/`snapshot.running`.
|
// notion of turn_state/model/ctx/cost, only `wanted`/`snapshot.running`.
|
||||||
|
// `showHeaderBadges` (default true) exists for a caller that already
|
||||||
|
// shows this info elsewhere and wants this cluster suppressed — checked
|
||||||
|
// against the original plan for this page, which proposed exactly that
|
||||||
|
// for `AgentsPage`'s embedded usage; that page's detail panel turned out
|
||||||
|
// NOT to duplicate any of this (`AgentRow`/`AgentTypes.ts` carry none of
|
||||||
|
// turn_state/model/ctx/cost), so `AgentsPage` leaves this at its default
|
||||||
|
// rather than hiding the only place swarm-ui shows it — see the commit
|
||||||
|
// message for the full reasoning. The prop still exists because
|
||||||
|
// suppressing it is a real, reusable need in principle, just not here.
|
||||||
|
|
||||||
|
// **Expand-to-fullscreen trigger** — opens/navigates to
|
||||||
|
// `/agents/:name/terminal` (`AgentTerminalPage`) via `useDynamicTabs`,
|
||||||
|
// same header-tab mechanism `Shell`'s own nav row now shares. Lives here
|
||||||
|
// (not on `AgentsPage`) so both the embedded preview and the future full
|
||||||
|
// page mount the identical trigger without either needing to know where
|
||||||
|
// the other one is. Suppressed via `fullHeight` — the full-page mount
|
||||||
|
// already IS the destination, so it has nothing further to expand to.
|
||||||
import { useLayoutEffect, useRef, useState } from "preact/hooks";
|
import { useLayoutEffect, useRef, useState } from "preact/hooks";
|
||||||
import { Badge, type BadgeTone } from "@hive/shared/badge.js";
|
import { Badge, type BadgeTone } from "@hive/shared/badge.js";
|
||||||
|
import { ExpandIcon } from "@hive/shared/icons.js";
|
||||||
import { Row } from "@hive/shared/term-row.js";
|
import { Row } from "@hive/shared/term-row.js";
|
||||||
|
import { useDynamicTabs } from "../../shell/useDynamicTabs.js";
|
||||||
import {
|
import {
|
||||||
useSwarmTermStream,
|
useSwarmTermStream,
|
||||||
type ConnectionState,
|
type ConnectionState,
|
||||||
|
|
@ -81,13 +100,22 @@ function fmtTokens(n: number): string {
|
||||||
return String(n);
|
return String(n);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AgentTermPreview({ agentName }: { agentName: string }) {
|
export function AgentTermPreview({
|
||||||
|
agentName,
|
||||||
|
showHeaderBadges = true,
|
||||||
|
fullHeight = false,
|
||||||
|
}: {
|
||||||
|
agentName: string;
|
||||||
|
showHeaderBadges?: boolean;
|
||||||
|
fullHeight?: boolean;
|
||||||
|
}) {
|
||||||
const { rows, connection } = useSwarmTermStream(
|
const { rows, connection } = useSwarmTermStream(
|
||||||
`/api/agents/${encodeURIComponent(agentName)}/term/stream`,
|
`/api/agents/${encodeURIComponent(agentName)}/term/stream`,
|
||||||
);
|
);
|
||||||
const { header } = useSwarmAgentStateStream(
|
const { header } = useSwarmAgentStateStream(
|
||||||
`/api/agents/${encodeURIComponent(agentName)}/state/stream`,
|
`/api/agents/${encodeURIComponent(agentName)}/state/stream`,
|
||||||
);
|
);
|
||||||
|
const { open: openTab } = useDynamicTabs();
|
||||||
const logRef = useRef<HTMLDivElement>(null);
|
const logRef = useRef<HTMLDivElement>(null);
|
||||||
const [stickToBottom, setStickToBottom] = useState(true);
|
const [stickToBottom, setStickToBottom] = useState(true);
|
||||||
|
|
||||||
|
|
@ -110,14 +138,36 @@ export function AgentTermPreview({ agentName }: { agentName: string }) {
|
||||||
}, [rows, stickToBottom]);
|
}, [rows, stickToBottom]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="ui-agent-term-preview">
|
<div
|
||||||
<Badge
|
class={
|
||||||
class="ui-agent-term-preview-status"
|
"ui-agent-term-preview" +
|
||||||
tone={CONNECTION_TONE[connection]}
|
(fullHeight ? " ui-agent-term-preview-full" : "")
|
||||||
value={CONNECTION_LABEL[connection]}
|
}
|
||||||
/>
|
>
|
||||||
|
<div class="ui-agent-term-preview-toolbar">
|
||||||
|
<Badge
|
||||||
|
class="ui-agent-term-preview-status"
|
||||||
|
tone={CONNECTION_TONE[connection]}
|
||||||
|
value={CONNECTION_LABEL[connection]}
|
||||||
|
/>
|
||||||
|
{!fullHeight && (
|
||||||
|
<Badge
|
||||||
|
class="ui-agent-term-preview-expand"
|
||||||
|
variant="quiet"
|
||||||
|
icon={<ExpandIcon />}
|
||||||
|
value="expand"
|
||||||
|
title={`open ${agentName}'s terminal in its own tab`}
|
||||||
|
onClick={() =>
|
||||||
|
openTab({
|
||||||
|
href: `/agents/${encodeURIComponent(agentName)}/terminal`,
|
||||||
|
label: agentName,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div class="terminal-wrap ui-agent-term-preview-wrap">
|
<div class="terminal-wrap ui-agent-term-preview-wrap">
|
||||||
{header && (
|
{header && showHeaderBadges && (
|
||||||
<div class="ui-agent-term-preview-header-badges">
|
<div class="ui-agent-term-preview-header-badges">
|
||||||
<Badge
|
<Badge
|
||||||
tone={TURN_STATE_TONE[header.turn_state] ?? "neutral"}
|
tone={TURN_STATE_TONE[header.turn_state] ?? "neutral"}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
/* <AgentTerminalPage> — gives `AgentTermPreview`'s `fullHeight` flex
|
||||||
|
chain (`.ui-agent-term-preview-full`, AgentTermPreview.css) something
|
||||||
|
real to fill. `Shell.css`'s `.shell-body` has no fixed height of its
|
||||||
|
own (it's a plain flow column sized by content), so without an
|
||||||
|
explicit height here the chain's `flex: 1` would resolve against
|
||||||
|
nothing and the terminal would collapse to its content's own height
|
||||||
|
instead of filling the viewport — the opposite of what "full-height"
|
||||||
|
mount means for this page specifically (the 12em-capped preview
|
||||||
|
inside `AgentsPage` deliberately has no such wrapper; this is the one
|
||||||
|
place that needs it).
|
||||||
|
|
||||||
|
`100dvh`, not `100vh` — `100vh` on a mobile browser includes the
|
||||||
|
address-bar chrome even while it's visibly on-screen, so the terminal
|
||||||
|
would render taller than the actually-visible viewport there;
|
||||||
|
`100dvh` tracks the real visible area instead. The subtraction below
|
||||||
|
is a fixed estimate (Shell.css's own `.shell-header` padding +
|
||||||
|
`.shell-body`'s), not a measured value — an exact fit isn't worth a
|
||||||
|
ResizeObserver for a single full-bleed page; a few pixels short of
|
||||||
|
the viewport edge reads as intentional breathing room, not a bug. */
|
||||||
|
.ui-agent-terminal-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: calc(100dvh - 4.5em - 3em);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-agent-terminal-page .ui-agent-term-preview-full {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
// <AgentTerminalPage> — the swarm terminal tab's full page, mounted at
|
||||||
|
// `/agents/:name/terminal`. The MVP `AgentTermPreview` (below `AgentsPage`'s
|
||||||
|
// detail panel) is explicitly capped and read-only; this is that same
|
||||||
|
// component's `fullHeight` mount — no 12em cap, floating header badges
|
||||||
|
// shown (there's no detail-panel `<dl>` here to duplicate them). Sending
|
||||||
|
// input back to the agent is still explicit follow-up scope, same as the
|
||||||
|
// preview's own file comment says — this page doesn't change that.
|
||||||
|
//
|
||||||
|
// Registers itself in the dynamic-tabs row on mount (not just when opened
|
||||||
|
// via the preview's expand trigger) — a bookmark, a shared link, or a
|
||||||
|
// plain page reload should all re-open the tab instead of landing on a
|
||||||
|
// page with no way back into the tab row. `useDynamicTabs().open()` is
|
||||||
|
// idempotent (dedupes by href), so this is safe to call every mount.
|
||||||
|
import { useEffect } from "preact/hooks";
|
||||||
|
import type { RouteComponentProps } from "wouter-preact";
|
||||||
|
import { AgentTermPreview } from "./AgentTermPreview.js";
|
||||||
|
import { useDynamicTabs } from "../../shell/useDynamicTabs.js";
|
||||||
|
import "./AgentTerminalPage.css";
|
||||||
|
|
||||||
|
export function AgentTerminalPage({
|
||||||
|
params,
|
||||||
|
}: RouteComponentProps<{ name: string }>) {
|
||||||
|
const { open } = useDynamicTabs();
|
||||||
|
const name = decodeURIComponent(params.name);
|
||||||
|
|
||||||
|
// Runs once per mounted agent name — re-registering on every render
|
||||||
|
// would just be redundant work against an already-deduped list, but
|
||||||
|
// there's no reason to pay it more than once per identity.
|
||||||
|
useEffect(() => {
|
||||||
|
open({ href: `/agents/${encodeURIComponent(name)}/terminal`, label: name });
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- `open` is a
|
||||||
|
// fresh function identity every render (`useDynamicTabs` doesn't
|
||||||
|
// memoize it); including it would re-fire this effect every render
|
||||||
|
// instead of once per agent `name`.
|
||||||
|
}, [name]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="ui-agent-terminal-page">
|
||||||
|
<AgentTermPreview agentName={name} fullHeight showHeaderBadges />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -165,6 +165,64 @@
|
||||||
.shell-nav-indicator.shell-nav-indicator-instant {
|
.shell-nav-indicator.shell-nav-indicator-instant {
|
||||||
transition: none;
|
transition: none;
|
||||||
}
|
}
|
||||||
|
/* Dynamic tabs group — inline in `.shell-nav` itself, next to the
|
||||||
|
static route links (mara, PR review: "the dynamic tab should be in
|
||||||
|
the top bar, not a new one below" — an earlier cut had this as its
|
||||||
|
own sticky row under the header; that version and the reasoning for
|
||||||
|
it are still in this file's git history if the split ever needs to
|
||||||
|
come back). `margin-left: 0.5em` + `border-left` reads as "a group
|
||||||
|
attached to the nav, not another nav item" without a second landmark
|
||||||
|
or a fixed pixel gap that'd fight `.shell-nav`'s own `gap`. */
|
||||||
|
.shell-tabs {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5em;
|
||||||
|
margin-left: 0.5em;
|
||||||
|
padding-left: 0.75em;
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.shell-tab {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25em;
|
||||||
|
padding: 0.2em 0.4em 0.2em 0.7em;
|
||||||
|
border-radius: 0.3em;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.shell-tab-active {
|
||||||
|
border-color: var(--pink);
|
||||||
|
}
|
||||||
|
.shell-tab-link {
|
||||||
|
color: var(--muted);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
.shell-tab-active .shell-tab-link {
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
.shell-tab-link:hover {
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
/* Same reset every icon-only chrome button in this codebase uses
|
||||||
|
(`Badge`'s own `<button>` case) — a bare `<button>` here isn't routed
|
||||||
|
through `Badge` since it's not a status/value pill, just a close
|
||||||
|
glyph, so the reset is inlined rather than reaching for a component
|
||||||
|
that doesn't fit this shape. */
|
||||||
|
.shell-tab-close {
|
||||||
|
all: unset;
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0.1em 0.3em;
|
||||||
|
border-radius: 0.2em;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.shell-tab-close:hover {
|
||||||
|
color: var(--fg);
|
||||||
|
background: color-mix(in srgb, var(--fg) 10%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
.shell-body {
|
.shell-body {
|
||||||
max-width: 60em;
|
max-width: 60em;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ import { UserMenu } from "./UserMenu.js";
|
||||||
import { SettingsMenu } from "@hive/shared/settings-menu.js";
|
import { SettingsMenu } from "@hive/shared/settings-menu.js";
|
||||||
import { useApplyThemeOverride } from "@hive/shared/theme-apply.js";
|
import { useApplyThemeOverride } from "@hive/shared/theme-apply.js";
|
||||||
import { useApplyMotionOverride } from "@hive/shared/motion-apply.js";
|
import { useApplyMotionOverride } from "@hive/shared/motion-apply.js";
|
||||||
|
import { useDynamicTabs } from "./useDynamicTabs.js";
|
||||||
import "./Shell.css";
|
import "./Shell.css";
|
||||||
|
|
||||||
// Storage keys this app owns for `@hive/shared`'s settings mechanism —
|
// 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.
|
// its own table view (plus the list+detail split) hits the same cap.
|
||||||
const WIDE_BODY_ROUTES = new Set(["/issues", "/agents"]);
|
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
|
// Static fallback — matches `index.html`'s `<title>` default, so a page
|
||||||
// never flashes something else before the fetch below resolves, and an
|
// never flashes something else before the fetch below resolves, and an
|
||||||
// operator who never set `services.hyperhive.swarm.name` sees the exact
|
// 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 }) {
|
export function Shell({ children }: { children: ComponentChildren }) {
|
||||||
const [swarmName, setSwarmName] = useState<string | null>(null);
|
const [swarmName, setSwarmName] = useState<string | null>(null);
|
||||||
const [location] = useLocation();
|
const [location] = useLocation();
|
||||||
|
|
@ -155,6 +214,15 @@ export function Shell({ children }: { children: ComponentChildren }) {
|
||||||
// Every subsequent change (a real navigation) still animates normally.
|
// Every subsequent change (a real navigation) still animates normally.
|
||||||
const [indicatorSettledOnce, setIndicatorSettledOnce] = useState(false);
|
const [indicatorSettledOnce, setIndicatorSettledOnce] = useState(false);
|
||||||
|
|
||||||
|
// Open agent-terminal tabs — rendered inline in `.shell-nav` itself
|
||||||
|
// (mara, PR review: "the dynamic tab should be in the top bar, not a
|
||||||
|
// new one below" — an earlier cut had these as their own row under
|
||||||
|
// the header; see the git history on this file for that version and
|
||||||
|
// why it was reverted). Entirely separate from the fixed `NAV_ITEMS`
|
||||||
|
// list above, so they get their own small component (`DynamicTabLink`)
|
||||||
|
// rather than reusing `NavLink`.
|
||||||
|
const { tabs, close: closeTab } = useDynamicTabs();
|
||||||
|
|
||||||
// Applied once here, not inside `SettingsMenu` — every route mounts
|
// Applied once here, not inside `SettingsMenu` — every route mounts
|
||||||
// through this one `<Shell>`, so the override takes effect regardless
|
// through this one `<Shell>`, so the override takes effect regardless
|
||||||
// of which page is showing or whether the menu's ever been opened,
|
// of which page is showing or whether the menu's ever been opened,
|
||||||
|
|
@ -249,6 +317,18 @@ export function Shell({ children }: { children: ComponentChildren }) {
|
||||||
return () => window.removeEventListener("resize", onResize);
|
return () => window.removeEventListener("resize", onResize);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Same re-measure, for the same "a reflow, not a navigation" reason —
|
||||||
|
// now that the dynamic tabs render inline in `.shell-nav` (see the
|
||||||
|
// `useDynamicTabs()` comment above), *closing* a background tab (one
|
||||||
|
// that isn't the active route) shrinks the row without any navigation
|
||||||
|
// firing to trigger the hop effect's own re-measure. Opening one
|
||||||
|
// doesn't need this: `useDynamicTabs().open()` always navigates, so
|
||||||
|
// the hop effect already covers that case.
|
||||||
|
useEffect(() => {
|
||||||
|
if (settledIndexRef.current === -1) return;
|
||||||
|
setIndicator(measureIndex(settledIndexRef.current));
|
||||||
|
}, [tabs.length]);
|
||||||
|
|
||||||
// Fetched once here, not per-page: every route mounts inside one
|
// Fetched once here, not per-page: every route mounts inside one
|
||||||
// `<Shell>`, and the swarm's name doesn't change within a page
|
// `<Shell>`, and the swarm's name doesn't change within a page
|
||||||
// visit. A fetch failure is silently ignored — `swarmName` just stays
|
// visit. A fetch failure is silently ignored — `swarmName` just stays
|
||||||
|
|
@ -323,6 +403,30 @@ export function Shell({ children }: { children: ComponentChildren }) {
|
||||||
backgroundColor: indicator?.accent ?? "transparent",
|
backgroundColor: indicator?.accent ?? "transparent",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{/* Open agent-terminal tabs — inline in the same nav row as
|
||||||
|
the static routes (mara's review call, see the
|
||||||
|
`useDynamicTabs()` comment above). Only rendered when
|
||||||
|
there's something to show — an always-present empty group
|
||||||
|
would just be dead chrome for the far more common case of
|
||||||
|
no open terminal tabs. `role="group"`, not a second `<nav>`:
|
||||||
|
there's already exactly one nav landmark in this header. */}
|
||||||
|
{tabs.length > 0 && (
|
||||||
|
<div
|
||||||
|
class="shell-tabs"
|
||||||
|
role="group"
|
||||||
|
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)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
{/* Single `margin-left: auto` on the wrapper, not on each menu
|
{/* Single `margin-left: auto` on the wrapper, not on each menu
|
||||||
individually — two adjacent flex items both set to
|
individually — two adjacent flex items both set to
|
||||||
|
|
@ -341,8 +445,7 @@ export function Shell({ children }: { children: ComponentChildren }) {
|
||||||
apply, only the max-width cap changes. */}
|
apply, only the max-width cap changes. */}
|
||||||
<div
|
<div
|
||||||
class={
|
class={
|
||||||
"shell-body" +
|
"shell-body" + (isWideBodyRoute(location) ? " shell-body-wide" : "")
|
||||||
(WIDE_BODY_ROUTES.has(location) ? " shell-body-wide" : "")
|
|
||||||
}
|
}
|
||||||
key={location}
|
key={location}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
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