Watch
0
0
Fork
You've already forked hyperhive
0

swarm-ui: groups of agent terminals: drag a terminal tab onto a terminal to split it, tmux style, with resizable panes, a split button, and a name you can edit

This commit is contained in:
iris 2026-11-05 08:24:58 +01:00
commit 839dbd642a
3 changed files with 1270 additions and 33 deletions

View file

@ -281,3 +281,8 @@
:root[data-motion="allow"] .shell-body {
animation: shell-page-enter 160ms ease;
}
.shell-tab-rename {
width: 10em;
font: inherit;
}

View file

@ -43,6 +43,13 @@ import {
} from "@hive/shared/solid-backgrounds.js";
import { isFullBleedRoute, visibleNavItems } from "./routes.js";
import { useDynamicTabs } from "./useDynamicTabs.js";
import {
groupIdOf,
groupTitle,
terminalAgentOf,
} from "../agent/terminal/split-layout.js";
import { endAgentDrag, startAgentDrag } from "../agent/terminal/termDrag.js";
import { useTermGroups } from "../agent/terminal/useTermGroups.js";
import { useSwarmName } from "./useSwarmName.js";
import "./Shell.css";
@ -151,17 +158,59 @@ function DynamicTabLink({
label,
active,
onClose,
agent,
onRename,
}: {
href: string;
label: string;
active: boolean;
onClose: () => void;
/** Set on an agent's terminal tab: it can be dragged onto a terminal to split it. */
agent?: string;
/** Set on a group's tab: a double click renames the group. */
onRename?: (name: string) => void;
}) {
const [editing, setEditing] = useState(false);
return (
<span class={"shell-tab" + (active ? " shell-tab-active" : "")}>
<Link href={href} className="shell-tab-link">
{label}
</Link>
<span
class={"shell-tab" + (active ? " shell-tab-active" : "")}
draggable={agent !== undefined && !editing}
onDragStart={
agent === undefined ? undefined : (e) => startAgentDrag(e, agent)
}
onDragEnd={agent === undefined ? undefined : endAgentDrag}
>
{editing && onRename !== undefined ? (
<input
class="shell-tab-rename"
aria-label="group name"
value={label}
ref={(el) => el?.select()}
onKeyDown={(e) => {
if (e.key === "Escape") setEditing(false);
if (e.key === "Enter") {
onRename((e.currentTarget as HTMLInputElement).value);
setEditing(false);
}
}}
onBlur={(e) => {
onRename((e.currentTarget as HTMLInputElement).value);
setEditing(false);
}}
/>
) : (
<Link
href={href}
className="shell-tab-link"
draggable={false}
title={onRename === undefined ? undefined : "double click to rename"}
onDblClick={
onRename === undefined ? undefined : () => setEditing(true)
}
>
{label}
</Link>
)}
<button
type="button"
class="shell-tab-close"
@ -210,6 +259,7 @@ export function Shell({ children }: { children: ComponentChildren }) {
// list above, so they get their own small component (`DynamicTabLink`)
// rather than reusing `NavLink`.
const { tabs, close: closeTab } = useDynamicTabs();
const termGroups = useTermGroups();
// The components tab is a developer page: it goes while "hide developer
// details" is on. The page itself still answers at `/components`.
@ -410,15 +460,32 @@ export function Shell({ children }: { children: ComponentChildren }) {
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)}
/>
))}
{tabs.map((tab) => {
// A group's tab shows the group's live name, not the label it
// was opened with, so a rename shows at once.
const groupId = groupIdOf(tab.href);
const group =
groupId === null ? undefined : termGroups.find(groupId);
return (
<DynamicTabLink
key={tab.href}
href={tab.href}
label={group === undefined ? tab.label : groupTitle(group)}
active={tab.href === location}
agent={terminalAgentOf(tab.href) ?? undefined}
onRename={
groupId === null
? undefined
: (name) => termGroups.rename(groupId, name.trim())
}
onClose={() => {
// Closing a group's tab is closing the group.
if (groupId !== null) termGroups.remove(groupId);
closeTab(tab.href);
}}
/>
);
})}
</div>
)}
</nav>

View file

@ -10,10 +10,17 @@
const FULL_BLEED_ROUTE_PATTERN =
/^\/agents\/[^/]+(\/subagents\/[^/]+)?\/terminal$/;
// A group of terminals (`/agents/group/:id`) is full-bleed as well.
const GROUP_ROUTE_PATTERN = /^\/agents\/group\/[^/]+$/;
// The start page (`/`) is full-bleed too: its rain backdrop fills the whole
// body below the header.
export function isFullBleedRoute(location: string): boolean {
return location === "/" || FULL_BLEED_ROUTE_PATTERN.test(location);
return (
location === "/" ||
FULL_BLEED_ROUTE_PATTERN.test(location) ||
GROUP_ROUTE_PATTERN.test(location)
);
}
// Routes that only a developer needs. "hide developer details" takes their