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

@ -0,0 +1,69 @@
// <PaneDropTarget> — a box that takes an agent's terminal dropped on it and
// says which side it fell on. While one is dragged over, the half of the box
// the drop would take is lit, so the operator sees where the new pane goes.
import { useState } from "preact/hooks";
import type { ComponentChildren } from "preact";
import { sideAt, type Side } from "../../agent/terminal/split-layout.js";
import {
AGENT_DRAG_TYPE,
draggedAgent,
} from "../../agent/terminal/termDrag.js";
import "./PaneTree.css";
export function PaneDropTarget({
agent,
onDrop,
children,
}: {
/** The agent whose pane this is: it takes no drop of itself. */
agent: string;
onDrop: (side: Side, dropped: string) => void;
children: ComponentChildren;
}) {
const [side, setSide] = useState<Side | null>(null);
const accepts = (e: DragEvent) =>
e.dataTransfer?.types.includes(AGENT_DRAG_TYPE) === true &&
draggedAgent() !== agent;
const sideOf = (e: DragEvent): Side => {
const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
return sideAt(
(e.clientX - r.left) / r.width,
(e.clientY - r.top) / r.height,
);
};
return (
<div
class="ui-pane-drop"
onDragOver={(e) => {
if (!accepts(e)) return;
e.preventDefault();
setSide(sideOf(e));
}}
onDragLeave={(e) => {
// Moving onto a child fires a leave on the box; only a real exit counts.
const to = e.relatedTarget as Node | null;
if (to === null || !(e.currentTarget as HTMLElement).contains(to)) {
setSide(null);
}
}}
onDrop={(e) => {
if (!accepts(e)) return;
e.preventDefault();
const dropped = draggedAgent();
const where = sideOf(e);
setSide(null);
if (dropped !== null) onDrop(where, dropped);
}}
>
{children}
{side === null ? null : (
<div
class={`ui-pane-drop-zone ui-pane-drop-${side}`}
aria-hidden="true"
/>
)}
</div>
);
}

View file

@ -0,0 +1,59 @@
.ui-pane-tree,
.ui-pane-tree-split,
.ui-pane-tree-cell,
.ui-pane-tree-pane,
.ui-pane-drop {
display: flex;
flex: 1;
min-width: 0;
min-height: 0;
}
.ui-pane-tree-row {
flex-direction: row;
}
.ui-pane-tree-column {
flex-direction: column;
}
.ui-pane-tree-pane,
.ui-pane-drop {
position: relative;
flex-direction: column;
}
.ui-pane-divider {
flex: none;
background: var(--border);
touch-action: none;
}
.ui-pane-divider-row {
width: 5px;
cursor: col-resize;
}
.ui-pane-divider-column {
height: 5px;
cursor: row-resize;
}
.ui-pane-divider:hover,
.ui-pane-divider:focus-visible {
background: var(--purple);
outline: none;
}
/* The half of a pane a drop would take, lit while an agent is dragged over. */
.ui-pane-drop-zone {
position: absolute;
pointer-events: none;
background: color-mix(in srgb, var(--purple) 25%, transparent);
border: 2px solid var(--purple);
z-index: 2;
}
.ui-pane-drop-left {
inset: 0 50% 0 0;
}
.ui-pane-drop-right {
inset: 0 0 0 50%;
}
.ui-pane-drop-top {
inset: 0 0 50% 0;
}
.ui-pane-drop-bottom {
inset: 50% 0 0 0;
}

View file

@ -0,0 +1,125 @@
// <PaneTree> — draws a split tree (`agent/terminal/split-layout.ts`): panes
// in rows and columns that fill their box, with a divider between neighbours
// that is dragged with the pointer or moved with the arrow keys. It owns the
// geometry only; what a pane shows is the caller's (`renderPane`).
import { useRef } from "preact/hooks";
import { Fragment, type ComponentChildren } from "preact";
import type { Node } from "../../agent/terminal/split-layout.js";
import "./PaneTree.css";
function Divider({
dir,
onMove,
}: {
dir: "row" | "column";
/** Pixels moved along the split's axis since the last event. */
onMove: (px: number) => void;
}) {
const last = useRef<number | null>(null);
const along = (e: PointerEvent) => (dir === "row" ? e.clientX : e.clientY);
return (
<div
class={`ui-pane-divider ui-pane-divider-${dir}`}
role="separator"
aria-orientation={dir === "row" ? "vertical" : "horizontal"}
tabIndex={0}
onPointerDown={(e) => {
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
last.current = along(e);
}}
onPointerMove={(e) => {
if (last.current === null) return;
const now = along(e);
onMove(now - last.current);
last.current = now;
}}
onPointerUp={() => {
last.current = null;
}}
onPointerCancel={() => {
last.current = null;
}}
onKeyDown={(e) => {
const back = dir === "row" ? "ArrowLeft" : "ArrowUp";
const fwd = dir === "row" ? "ArrowRight" : "ArrowDown";
if (e.key !== back && e.key !== fwd) return;
e.preventDefault();
// A fixed nudge, turned into a share of the box like a drag.
onMove(e.key === fwd ? 20 : -20);
}}
/>
);
}
function Branch({
node,
path,
renderPane,
onResize,
}: {
node: Node;
path: number[];
renderPane: (agent: string) => ComponentChildren;
onResize: (path: number[], index: number, delta: number) => void;
}) {
const box = useRef<HTMLDivElement>(null);
if (node.kind === "pane") {
return (
<div class="ui-pane-tree-pane" key={node.agent}>
{renderPane(node.agent)}
</div>
);
}
const size = () => {
const el = box.current;
if (el === null) return 1;
return Math.max(1, node.dir === "row" ? el.clientWidth : el.clientHeight);
};
return (
<div class={`ui-pane-tree-split ui-pane-tree-${node.dir}`} ref={box}>
{node.children.map((child, i) => (
<Fragment key={child.kind === "pane" ? child.agent : `split-${i}`}>
{i > 0 ? (
<Divider
dir={node.dir}
onMove={(px) => onResize(path, i - 1, px / size())}
/>
) : null}
<div
class="ui-pane-tree-cell"
style={{ flex: `${node.sizes[i] ?? 0} 1 0` }}
>
<Branch
node={child}
path={[...path, i]}
renderPane={renderPane}
onResize={onResize}
/>
</div>
</Fragment>
))}
</div>
);
}
export function PaneTree({
root,
renderPane,
onResize,
}: {
root: Node;
renderPane: (agent: string) => ComponentChildren;
/** The divider after child `index` of the split at `path` moved by `delta`, a share of that split's box. */
onResize: (path: number[], index: number, delta: number) => void;
}) {
return (
<div class="ui-pane-tree">
<Branch
node={root}
path={[]}
renderPane={renderPane}
onResize={onResize}
/>
</div>
);
}