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:
parent
acc480c4f3
commit
839dbd642a
3 changed files with 1270 additions and 33 deletions
|
|
@ -281,3 +281,8 @@
|
|||
:root[data-motion="allow"] .shell-body {
|
||||
animation: shell-page-enter 160ms ease;
|
||||
}
|
||||
|
||||
.shell-tab-rename {
|
||||
width: 10em;
|
||||
font: inherit;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue