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
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
59
frontend/packages/swarm-ui/src/ui/pane-tree/PaneTree.css
Normal file
59
frontend/packages/swarm-ui/src/ui/pane-tree/PaneTree.css
Normal 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;
|
||||
}
|
||||
125
frontend/packages/swarm-ui/src/ui/pane-tree/PaneTree.tsx
Normal file
125
frontend/packages/swarm-ui/src/ui/pane-tree/PaneTree.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue