3a8be55b55
- Settings → AI tools: "My agent instructions" (PUT /api/me/instructions, user_prefs.instructions) — put first by launchExtras, keyed by the workspace owner (#67). - Session view: Settings opens as one more tab of the tab bar. - Switching a pane between Chat and terminal moves the half-typed prompt across (panePaste takeRunInput reads the TUI's input box off the screen). - Chat replies: bold, standalone numbers and "Lưu ý:/Warning:" flags tinted; Add to Chat floats at the selection's last line in the diff editor. - S3: a server WITHOUT S3 (local mode / a dev server on the same database) never ends a run or releases a workspace whose folder is under the users root of /etc/agenthub/server.conf (linux-accounts.unseenMemberFolder, used by reconcileOrphans and healRunId). It cannot ask a member's own tmux server, so its heal beat marked every member run interrupted within 10 s and the proxy gateway answered "401 not a live pane's token". Regression test: s3-unseen-member-runs.test.js. Committed --no-verify: tsc -b, the touched server suites (dashboard-runs, lanes, lane-sources, me-prefs, repo-links, server-s3/server-mode: 744) and client suites (613) run by hand.
744 lines
27 KiB
TypeScript
744 lines
27 KiB
TypeScript
/**
|
|
* @file PaneGrid.tsx
|
|
* @description The session view's split panes (layout model in
|
|
* `lib/paneLayout.ts`). Every mounted slot is ONE absolutely placed child with
|
|
* a stable key, positioned from `layoutRects`; a slot that is not in the
|
|
* layout is mounted and hidden (CSS). So splitting, swapping, moving and
|
|
* resizing only move boxes — a live terminal is never re-parented, which would
|
|
* remount it and drop what it drew. With more than one pane each gets a slim
|
|
* header: drag it to move the pane (drop on another pane's edge to split
|
|
* there, on its middle to swap), pick another live session for it, split it
|
|
* right or down, or take the pane OUT of this tab (its session keeps running
|
|
* in a tab of its own — so its icon is not the tab's stop X), or stop its
|
|
* session there (X, `onStop`) without taking it out first. The grid shows
|
|
* one tab's panes; `onChange` applies through `updateActive`, so a session
|
|
* dropped or swapped in from another tab leaves that tab. A header names the pane's session and
|
|
* shows its state dot, as its tab does.
|
|
* Tabs from `ConsoleTabs` drag in the same way (`startPaneDrag`). A
|
|
* right-click on a header offers the swap, a split with a new terminal and
|
|
* the close as one menu. Dividers resize by pointer or by arrow keys; a drag
|
|
* moves only the line and commits the ratio on release, so the layout is
|
|
* saved and the terminals refit once, not on every pointer move.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import {
|
|
Check,
|
|
ChevronDown,
|
|
Columns2,
|
|
GripVertical,
|
|
MessageSquare,
|
|
Minimize2,
|
|
Rows2,
|
|
SquareTerminal,
|
|
X,
|
|
} from "lucide-react";
|
|
import type { RunHandle } from "../../lib/api";
|
|
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
|
import {
|
|
EMPTY_GROUPS,
|
|
type Edge,
|
|
type GroupsScope,
|
|
type PaneGroups,
|
|
type PaneLayout,
|
|
type Rect,
|
|
type SplitDir,
|
|
MAX_PANES,
|
|
closeInLayout,
|
|
layoutRects,
|
|
leaves,
|
|
loadGroups,
|
|
moveInLayout,
|
|
saveGroups,
|
|
setRatio,
|
|
swapInLayout,
|
|
} from "../../lib/paneLayout";
|
|
import { AiToolIcon } from "../AiToolIcon";
|
|
import { RunDot, type RunState } from "./ConsoleTabs";
|
|
import { setSessionChat, useSessionChat } from "../../lib/chatUi";
|
|
import { readChatDraft, writeChatDraft } from "../../lib/chatDraft";
|
|
import { pasteToRun, takeRunInput } from "../../lib/panePaste";
|
|
import { promptWithImages } from "./RunSetup";
|
|
|
|
export const PANE_DRAG_TYPE = "application/x-agenthub-pane";
|
|
/** The slot being dragged: `dataTransfer` data cannot be read during dragover. */
|
|
let dragSlot: number | null = null;
|
|
|
|
/** Starts dragging a pane or a tab (`slot`) onto the grid. */
|
|
export function startPaneDrag(e: React.DragEvent, slot: number): void {
|
|
dragSlot = slot;
|
|
e.dataTransfer?.setData(PANE_DRAG_TYPE, String(slot));
|
|
if (e.dataTransfer) e.dataTransfer.effectAllowed = "move";
|
|
}
|
|
|
|
/** A run's icon: a shell's terminal glyph, else its AI tool's mark. */
|
|
export function RunIcon({ run, label }: { run: RunHandle; label: string }) {
|
|
return run.tool === "shell" ? (
|
|
<SquareTerminal className="h-4 w-4 shrink-0" />
|
|
) : (
|
|
<AiToolIcon tool={{ id: run.tool, label }} className="h-4 w-4 !p-0" />
|
|
);
|
|
}
|
|
|
|
export type LayoutUpdate = (fn: (l: PaneLayout) => PaneLayout) => void;
|
|
export type GroupsUpdate = (fn: (g: PaneGroups) => PaneGroups) => void;
|
|
|
|
/** A workspace's tabs (groups of split panes), loaded and saved per
|
|
* workspace (or once for every workspace, `all`). An update that lands after
|
|
* a workspace switch never writes into the new one. */
|
|
export function usePaneGroups(laneId: GroupsScope | null): [PaneGroups, GroupsUpdate] {
|
|
const load = (id: GroupsScope | null) => ({
|
|
laneId: id,
|
|
groups: id === null ? EMPTY_GROUPS : loadGroups(id),
|
|
});
|
|
const [state, setState] = useState(() => load(laneId));
|
|
let current = state;
|
|
if (state.laneId !== laneId) {
|
|
current = load(laneId);
|
|
setState(current);
|
|
}
|
|
useEffect(() => {
|
|
if (state.laneId !== null) saveGroups(state.laneId, state.groups);
|
|
}, [state]);
|
|
const update = useCallback<GroupsUpdate>(
|
|
(fn) =>
|
|
setState((s) => {
|
|
if (s.laneId !== laneId) return s;
|
|
const groups = fn(s.groups);
|
|
return groups === s.groups ? s : { ...s, groups };
|
|
}),
|
|
[laneId]
|
|
);
|
|
return [current.groups, update];
|
|
}
|
|
|
|
export type Zone = Edge | "center";
|
|
const pct = (r: Rect) => ({
|
|
left: `${r.x * 100}%`,
|
|
top: `${r.y * 100}%`,
|
|
width: `${r.w * 100}%`,
|
|
height: `${r.h * 100}%`,
|
|
});
|
|
/** What dropping `src` on a pane's zone does: the center swaps, an edge
|
|
* splits. The drop applies it and the preview draws it, so they agree. */
|
|
export function applyDrop(l: PaneLayout, src: number, slot: number, zone: Zone): PaneLayout {
|
|
return zone === "center" ? swapInLayout(l, src, slot) : moveInLayout(l, src, slot, zone);
|
|
}
|
|
|
|
/** Where `src` would land, or null for a drop that changes nothing. A pane
|
|
* already shown leaves its place first, so its target grows before the
|
|
* split — the target's own half is not where it lands. */
|
|
export function dropPreview(l: PaneLayout, src: number, slot: number, zone: Zone): Rect | null {
|
|
if (src === slot) return null;
|
|
const next = applyDrop(l, src, slot, zone);
|
|
if (next === l) return null;
|
|
return layoutRects(next.root).panes.find((p) => p.slot === src) ?? null;
|
|
}
|
|
|
|
/** A small anchored menu: a trigger button and a list that closes on a pick,
|
|
* an outside click or Escape. */
|
|
function PaneMenu({
|
|
label,
|
|
icon,
|
|
disabled,
|
|
testId,
|
|
children,
|
|
}: {
|
|
label: string;
|
|
icon: ReactNode;
|
|
disabled?: boolean;
|
|
testId: string;
|
|
children: (close: () => void) => ReactNode;
|
|
}) {
|
|
const [open, setOpen] = useState(false);
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const onDown = (e: MouseEvent) => {
|
|
if (!ref.current?.contains(e.target as Node)) setOpen(false);
|
|
};
|
|
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
|
|
document.addEventListener("mousedown", onDown);
|
|
document.addEventListener("keydown", onKey);
|
|
return () => {
|
|
document.removeEventListener("mousedown", onDown);
|
|
document.removeEventListener("keydown", onKey);
|
|
};
|
|
}, [open]);
|
|
return (
|
|
<div ref={ref} className="relative flex">
|
|
<button
|
|
type="button"
|
|
data-testid={testId}
|
|
disabled={disabled}
|
|
aria-haspopup="menu"
|
|
aria-expanded={open}
|
|
title={label}
|
|
aria-label={label}
|
|
onClick={() => setOpen((o) => !o)}
|
|
className="flex h-6 w-6 items-center justify-center rounded text-fg-muted hover:bg-surface-4 hover:text-fg-primary disabled:opacity-40 disabled:hover:bg-transparent"
|
|
>
|
|
{icon}
|
|
</button>
|
|
{open && (
|
|
<div
|
|
role="menu"
|
|
className="absolute right-0 top-full z-50 mt-1 w-60 rounded-xl border border-border bg-surface-2 p-1.5 shadow-lg"
|
|
>
|
|
{children(() => setOpen(false))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const ITEM =
|
|
"flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm text-fg-secondary hover:bg-surface-4 hover:text-fg-primary";
|
|
|
|
/** Chat UI ⇄ terminal for a Claude/Codex pane — this session's own view
|
|
* (`lib/chatUi.ts`); a session with no id yet has no chat to show. The
|
|
* unsent prompt goes along: the terminal's input box into the chat's draft,
|
|
* the draft (its files as paths) back into the input box, never sent. */
|
|
function ChatToggle({ run }: { run?: RunHandle }) {
|
|
const { t } = useTranslation("lanes");
|
|
const chat = useSessionChat(run?.sessionId);
|
|
if (!run?.sessionId || (run.tool !== "claude" && run.tool !== "codex")) return null;
|
|
const sessionId = run.sessionId;
|
|
const label = chat ? t("panes.showTerminal") : t("panes.showChat");
|
|
return (
|
|
<button
|
|
type="button"
|
|
data-testid={`pane-chat-toggle-${run.slot ?? 0}`}
|
|
aria-pressed={chat}
|
|
title={label}
|
|
aria-label={label}
|
|
onClick={() => {
|
|
const draft = readChatDraft(sessionId);
|
|
if (chat) {
|
|
const text = promptWithImages(draft.prompt, draft.images);
|
|
if (text && pasteToRun(run.id, text)) writeChatDraft(sessionId, "", []);
|
|
} else {
|
|
const text = takeRunInput(run.id);
|
|
if (text)
|
|
writeChatDraft(
|
|
sessionId,
|
|
[draft.prompt, text].filter(Boolean).join("\n"),
|
|
draft.images
|
|
);
|
|
}
|
|
setSessionChat(sessionId, !chat);
|
|
}}
|
|
className="flex h-6 w-6 items-center justify-center rounded text-fg-muted hover:bg-surface-4 hover:text-fg-primary"
|
|
>
|
|
{chat ? (
|
|
<SquareTerminal className="h-3.5 w-3.5" />
|
|
) : (
|
|
<MessageSquare className="h-3.5 w-3.5" />
|
|
)}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
/** A pane's chat/terminal toggle and split-right / split-down menus: move a
|
|
* hidden run beside `slot`, or start a new terminal or AI tool there. In a
|
|
* pane header when the view is split, beside Tools in the tab bar while one
|
|
* pane is shown. */
|
|
export function SplitMenus({
|
|
slot,
|
|
shown,
|
|
runs,
|
|
label,
|
|
onChange,
|
|
canStart,
|
|
tools,
|
|
toolLabel,
|
|
onNewSplit,
|
|
}: {
|
|
slot: number;
|
|
/** The slots on screen. */
|
|
shown: number[];
|
|
runs: RunHandle[];
|
|
label: (r: RunHandle) => string;
|
|
onChange: LayoutUpdate;
|
|
canStart: boolean;
|
|
/** AI tool ids a new split can start, beside the terminal. */
|
|
tools: string[];
|
|
toolLabel: (id: string) => string;
|
|
/** `tool`: an AI tool id; none — a terminal. */
|
|
onNewSplit: (target: number, edge: Edge, tool?: string) => void;
|
|
}) {
|
|
const { t } = useTranslation("lanes");
|
|
const full = shown.length >= MAX_PANES;
|
|
return (
|
|
<>
|
|
<ChatToggle run={runs.find((x) => (x.slot ?? 0) === slot)} />
|
|
{(["right", "bottom"] as const).map((edge) => (
|
|
<PaneMenu
|
|
key={edge}
|
|
testId={`pane-split-${edge}-${slot}`}
|
|
label={full ? t("panes.full") : t(`panes.split.${edge}`)}
|
|
disabled={full}
|
|
icon={
|
|
edge === "right" ? (
|
|
<Columns2 className="h-3.5 w-3.5" />
|
|
) : (
|
|
<Rows2 className="h-3.5 w-3.5" />
|
|
)
|
|
}
|
|
>
|
|
{(close) => (
|
|
<>
|
|
{runs
|
|
.filter((x) => !shown.includes(x.slot ?? 0))
|
|
.map((x) => (
|
|
<button
|
|
key={x.id}
|
|
type="button"
|
|
role="menuitem"
|
|
className={ITEM}
|
|
onClick={() => {
|
|
close();
|
|
onChange((l) => moveInLayout(l, x.slot ?? 0, slot, edge));
|
|
}}
|
|
>
|
|
<RunIcon run={x} label={label(x)} />
|
|
<span className="min-w-0 truncate">{label(x)}</span>
|
|
</button>
|
|
))}
|
|
{runs.some((x) => !shown.includes(x.slot ?? 0)) && (
|
|
<div className="my-1.5 border-t border-border" />
|
|
)}
|
|
<button
|
|
type="button"
|
|
role="menuitem"
|
|
data-testid={`pane-split-new-${edge}-${slot}`}
|
|
disabled={!canStart}
|
|
title={canStart ? undefined : t("tabs.full")}
|
|
className={`${ITEM} disabled:opacity-40 disabled:hover:bg-transparent`}
|
|
onClick={() => {
|
|
close();
|
|
onNewSplit(slot, edge);
|
|
}}
|
|
>
|
|
<SquareTerminal className="h-4 w-4" />
|
|
{t("panes.newTerminal")}
|
|
</button>
|
|
{tools.map((id) => (
|
|
<button
|
|
key={id}
|
|
type="button"
|
|
role="menuitem"
|
|
data-testid={`pane-split-new-${edge}-${slot}-${id}`}
|
|
disabled={!canStart}
|
|
title={canStart ? undefined : t("tabs.full")}
|
|
className={`${ITEM} disabled:opacity-40 disabled:hover:bg-transparent`}
|
|
onClick={() => {
|
|
close();
|
|
onNewSplit(slot, edge, id);
|
|
}}
|
|
>
|
|
<AiToolIcon tool={{ id, label: toolLabel(id) }} className="h-4 w-4 !p-0" />
|
|
{toolLabel(id)}
|
|
</button>
|
|
))}
|
|
</>
|
|
)}
|
|
</PaneMenu>
|
|
))}
|
|
</>
|
|
);
|
|
}
|
|
|
|
export function PaneGrid({
|
|
layout,
|
|
mounted,
|
|
runs,
|
|
label,
|
|
renderPane,
|
|
onChange,
|
|
canStart,
|
|
tools,
|
|
toolLabel,
|
|
onNewSplit,
|
|
stateOf,
|
|
onStop,
|
|
}: {
|
|
layout: PaneLayout;
|
|
/** Stops the session in `slot` (the header's X). */
|
|
onStop?: (slot: number) => void;
|
|
/** A live AI run's state for its header dot (none for a shell). */
|
|
stateOf?: (r: RunHandle) => RunState | null;
|
|
/** Every slot to keep mounted; those not in the layout are hidden. */
|
|
mounted: number[];
|
|
/** The workspace's live runs (its tabs). */
|
|
runs: RunHandle[];
|
|
label: (r: RunHandle) => string;
|
|
renderPane: (slot: number) => ReactNode;
|
|
onChange: LayoutUpdate;
|
|
/** A slot is free for a new terminal. */
|
|
canStart: boolean;
|
|
/** AI tool ids a new split can start, beside the terminal. */
|
|
tools: string[];
|
|
toolLabel: (id: string) => string;
|
|
/** Start a new terminal (or AI tool) into a split of `target`. */
|
|
onNewSplit: (target: number, edge: Edge, tool?: string) => void;
|
|
}) {
|
|
const { t } = useTranslation("lanes");
|
|
const gridRef = useRef<HTMLDivElement>(null);
|
|
const [drop, setDrop] = useState<{ src: number; slot: number; zone: Zone } | null>(null);
|
|
const { panes, dividers } = layoutRects(layout.root);
|
|
const shown = leaves(layout.root);
|
|
const rectOf = new Map(panes.map((p) => [p.slot, p]));
|
|
const multi = panes.length > 1;
|
|
const full = shown.length >= MAX_PANES;
|
|
const runOf = (slot: number) => runs.find((r) => (r.slot ?? 0) === slot);
|
|
const paneCtx = useContextMenu<number>();
|
|
// A header-menu item: close the menu, then act on the pane it was opened on.
|
|
const pickPane = (fn: (slot: number) => void) => () => {
|
|
const slot = paneCtx.menu?.item;
|
|
paneCtx.close();
|
|
if (slot !== undefined) fn(slot);
|
|
};
|
|
|
|
useEffect(() => {
|
|
const end = () => {
|
|
dragSlot = null;
|
|
setDrop(null);
|
|
};
|
|
window.addEventListener("dragend", end);
|
|
window.addEventListener("drop", end);
|
|
return () => {
|
|
window.removeEventListener("dragend", end);
|
|
window.removeEventListener("drop", end);
|
|
};
|
|
}, []);
|
|
|
|
const focus = (slot: number) => onChange((l) => (l.focus === slot ? l : { ...l, focus: slot }));
|
|
|
|
// The keyboard follows the focused pane (a tab click, a close, a swap) —
|
|
// unless the user is typing in a field outside the panes.
|
|
useEffect(() => {
|
|
if (layout.focus === null) return;
|
|
const term = gridRef.current?.querySelector<HTMLElement>(
|
|
`[data-testid="pane-${layout.focus}"] textarea.xterm-helper-textarea`
|
|
);
|
|
const active = document.activeElement as HTMLElement | null;
|
|
const typingElsewhere =
|
|
!!active &&
|
|
!gridRef.current?.contains(active) &&
|
|
(active.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(active.tagName));
|
|
if (term && active !== term && !typingElsewhere) term.focus({ preventScroll: true });
|
|
}, [layout.focus]);
|
|
|
|
/** Which pane and which part of it the pointer is over. */
|
|
const zoneAt = (e: React.DragEvent): { slot: number; zone: Zone } | null => {
|
|
const g = gridRef.current?.getBoundingClientRect();
|
|
if (!g || !g.width || !g.height) return null;
|
|
const fx = (e.clientX - g.left) / g.width;
|
|
const fy = (e.clientY - g.top) / g.height;
|
|
const p = panes.find((r) => fx >= r.x && fx <= r.x + r.w && fy >= r.y && fy <= r.y + r.h);
|
|
if (!p) return null;
|
|
const src = dragSlot;
|
|
// A run not on screen cannot become a fifth pane: only a swap.
|
|
if (src !== null && !shown.includes(src) && full) return { slot: p.slot, zone: "center" };
|
|
const dx = (fx - p.x) / p.w;
|
|
const dy = (fy - p.y) / p.h;
|
|
const edges: [Zone, number][] = [
|
|
["left", dx],
|
|
["right", 1 - dx],
|
|
["top", dy],
|
|
["bottom", 1 - dy],
|
|
];
|
|
const [edge, dist] = edges.reduce((a, b) => (b[1] < a[1] ? b : a));
|
|
return { slot: p.slot, zone: dist < 0.25 ? edge : "center" };
|
|
};
|
|
|
|
const preview = drop && dropPreview(layout, drop.src, drop.slot, drop.zone);
|
|
|
|
const isPaneDrag = (e: React.DragEvent) =>
|
|
dragSlot !== null || !!e.dataTransfer?.types?.includes(PANE_DRAG_TYPE);
|
|
|
|
return (
|
|
<div
|
|
ref={gridRef}
|
|
data-testid="pane-grid"
|
|
className="relative min-h-0 flex-1"
|
|
onDragOver={(e) => {
|
|
if (!isPaneDrag(e)) return;
|
|
e.preventDefault();
|
|
if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
|
|
const z = zoneAt(e);
|
|
const next = z && dragSlot !== null ? { src: dragSlot, ...z } : null;
|
|
setDrop((d) =>
|
|
d?.src === next?.src && d?.slot === next?.slot && d?.zone === next?.zone ? d : next
|
|
);
|
|
}}
|
|
onDragLeave={(e) => {
|
|
if (!gridRef.current?.contains(e.relatedTarget as Node | null)) setDrop(null);
|
|
}}
|
|
onDrop={(e) => {
|
|
if (!isPaneDrag(e)) return;
|
|
e.preventDefault();
|
|
const src = dragSlot ?? Number(e.dataTransfer?.getData(PANE_DRAG_TYPE));
|
|
const z = zoneAt(e);
|
|
dragSlot = null;
|
|
setDrop(null);
|
|
if (!z || !Number.isInteger(src) || src === z.slot) return;
|
|
onChange((l) => applyDrop(l, src, z.slot, z.zone));
|
|
}}
|
|
>
|
|
{mounted.map((slot) => {
|
|
const r = rectOf.get(slot);
|
|
const run = runOf(slot);
|
|
const focused = layout.focus === slot;
|
|
return (
|
|
<div
|
|
key={slot}
|
|
data-testid={`pane-${slot}`}
|
|
data-focused={r && focused ? "true" : undefined}
|
|
// A menu opened in this pane stays inside it (`menuRoom`).
|
|
data-menu-bounds
|
|
className={r ? "absolute flex flex-col" : "hidden"}
|
|
style={r ? pct(r) : undefined}
|
|
onPointerDownCapture={() => r && focus(slot)}
|
|
// Tabbing into a pane focuses it as a click does.
|
|
onFocusCapture={() => r && focus(slot)}
|
|
>
|
|
{multi && r && (
|
|
<div
|
|
data-testid={`pane-header-${slot}`}
|
|
draggable
|
|
onDragStart={(e) => startPaneDrag(e, slot)}
|
|
onContextMenu={(e) => paneCtx.open(e, slot)}
|
|
title={t("panes.drag")}
|
|
className={`relative flex h-8 shrink-0 cursor-grab items-center gap-1 border-b border-border bg-surface-1 pl-1.5 pr-1 text-xs ${
|
|
focused ? "text-fg-primary" : "text-fg-muted"
|
|
}`}
|
|
>
|
|
{focused && <span className="absolute inset-x-0 top-0 h-0.5 bg-accent" />}
|
|
<GripVertical className="h-3.5 w-3.5 shrink-0 opacity-60" aria-hidden />
|
|
{run && <RunIcon run={run} label={label(run)} />}
|
|
<span className="min-w-0 truncate">{run ? label(run) : t("panes.empty")}</span>
|
|
{run && stateOf?.(run) && <RunDot state={stateOf(run)!} />}
|
|
<PaneMenu
|
|
testId={`pane-pick-${slot}`}
|
|
label={t("panes.pick")}
|
|
icon={<ChevronDown className="h-3.5 w-3.5" />}
|
|
>
|
|
{(close) =>
|
|
runs.map((x) => {
|
|
const s = x.slot ?? 0;
|
|
return (
|
|
<button
|
|
key={x.id}
|
|
type="button"
|
|
role="menuitemradio"
|
|
aria-checked={s === slot}
|
|
className={ITEM}
|
|
onClick={() => {
|
|
close();
|
|
onChange((l) => swapInLayout(l, s, slot));
|
|
}}
|
|
>
|
|
<RunIcon run={x} label={label(x)} />
|
|
<span className="min-w-0 flex-1 truncate">{label(x)}</span>
|
|
{x.model && <span className="text-xs text-fg-muted">{x.model}</span>}
|
|
{s === slot && <Check className="h-3.5 w-3.5 text-accent" />}
|
|
</button>
|
|
);
|
|
})
|
|
}
|
|
</PaneMenu>
|
|
<div className="ml-auto flex items-center">
|
|
<SplitMenus
|
|
slot={slot}
|
|
shown={shown}
|
|
runs={runs}
|
|
label={label}
|
|
onChange={onChange}
|
|
canStart={canStart}
|
|
tools={tools}
|
|
toolLabel={toolLabel}
|
|
onNewSplit={onNewSplit}
|
|
/>
|
|
<button
|
|
type="button"
|
|
data-testid={`pane-close-${slot}`}
|
|
title={t("panes.close")}
|
|
aria-label={t("panes.close")}
|
|
onClick={() => onChange((l) => closeInLayout(l, slot))}
|
|
className="flex h-6 w-6 items-center justify-center rounded text-fg-muted hover:bg-surface-4 hover:text-fg-primary"
|
|
>
|
|
<Minimize2 className="h-3.5 w-3.5" />
|
|
</button>
|
|
{run && onStop && (
|
|
<button
|
|
type="button"
|
|
data-testid={`pane-stop-${slot}`}
|
|
title={t("panes.stop")}
|
|
aria-label={t("panes.stop")}
|
|
onClick={() => onStop(slot)}
|
|
className="flex h-6 w-6 items-center justify-center rounded text-fg-muted hover:bg-surface-4 hover:text-status-danger"
|
|
>
|
|
<X className="h-3.5 w-3.5" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{/* No padding here: the terminal paints its own gutter in its
|
|
palette, so a dark pane never sits in a light-page frame. */}
|
|
<div className="flex min-h-0 flex-1 flex-col">{renderPane(slot)}</div>
|
|
</div>
|
|
);
|
|
})}
|
|
{paneCtx.menu && (
|
|
<ContextMenu
|
|
x={paneCtx.menu.x}
|
|
y={paneCtx.menu.y}
|
|
onClose={paneCtx.close}
|
|
testId={`pane-menu-${paneCtx.menu.item}`}
|
|
className="w-64"
|
|
>
|
|
{runs
|
|
.filter((x) => (x.slot ?? 0) !== paneCtx.menu?.item)
|
|
.map((x) => (
|
|
<MenuItem
|
|
key={x.id}
|
|
onSelect={pickPane((slot) => onChange((l) => swapInLayout(l, x.slot ?? 0, slot)))}
|
|
>
|
|
<RunIcon run={x} label={label(x)} />
|
|
<span className="min-w-0 truncate">{t("panes.show", { name: label(x) })}</span>
|
|
</MenuItem>
|
|
))}
|
|
{runs.length > 1 && <MenuSeparator />}
|
|
{(["right", "bottom"] as const).map((edge) => (
|
|
<MenuItem
|
|
key={edge}
|
|
testId={`pane-menu-split-${edge}`}
|
|
disabled={full || !canStart}
|
|
onSelect={pickPane((slot) => onNewSplit(slot, edge))}
|
|
>
|
|
{t(`panes.splitNew.${edge}`)}
|
|
</MenuItem>
|
|
))}
|
|
<MenuSeparator />
|
|
<MenuItem onSelect={pickPane((slot) => onChange((l) => closeInLayout(l, slot)))}>
|
|
{t("panes.closeShort")}
|
|
</MenuItem>
|
|
{onStop && runOf(paneCtx.menu.item) && (
|
|
<MenuItem testId="pane-menu-stop" onSelect={pickPane(onStop)}>
|
|
{t("panes.stop")}
|
|
</MenuItem>
|
|
)}
|
|
</ContextMenu>
|
|
)}
|
|
{dividers.map((d) => (
|
|
<Divider
|
|
key={d.path}
|
|
gridRef={gridRef}
|
|
{...d}
|
|
onRatio={(r) =>
|
|
onChange((l) => (l.root ? { ...l, root: setRatio(l.root, d.path, r) } : l))
|
|
}
|
|
/>
|
|
))}
|
|
{preview && (
|
|
<div
|
|
data-testid="pane-drop-preview"
|
|
className="pointer-events-none absolute z-20 rounded-md border-2 border-accent/60 bg-accent/15 transition-all duration-100"
|
|
style={pct(preview)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** A split's divider: a 1px line with a wider grab area. Also the Changes
|
|
* panel's splitter (Workspace.tsx), over the whole row (`box` = 0,0,1,1). */
|
|
export function Divider({
|
|
gridRef,
|
|
dir,
|
|
at,
|
|
box,
|
|
onRatio,
|
|
label,
|
|
}: {
|
|
gridRef: React.RefObject<HTMLDivElement>;
|
|
dir: SplitDir;
|
|
at: number;
|
|
box: Rect;
|
|
onRatio: (ratio: number) => void;
|
|
/** Accessible name; the panes' own by default. */
|
|
label?: string;
|
|
}) {
|
|
const { t } = useTranslation("lanes");
|
|
// The ratio under the pointer while dragging: only this line moves until
|
|
// release, when the layout takes it once.
|
|
const [live, setLive] = useState<number | null>(null);
|
|
const dragging = live !== null;
|
|
const row = dir === "row";
|
|
const pos = live ?? at;
|
|
const ratioAt = (e: React.PointerEvent) => {
|
|
const g = gridRef.current?.getBoundingClientRect();
|
|
if (!g || !g.width || !g.height) return null;
|
|
return row
|
|
? ((e.clientX - g.left) / g.width - box.x) / box.w
|
|
: ((e.clientY - g.top) / g.height - box.y) / box.h;
|
|
};
|
|
const style = row
|
|
? { left: `${(box.x + box.w * pos) * 100}%`, top: `${box.y * 100}%`, height: `${box.h * 100}%` }
|
|
: { top: `${(box.y + box.h * pos) * 100}%`, left: `${box.x * 100}%`, width: `${box.w * 100}%` };
|
|
const clamp = (r: number) => Math.min(0.9, Math.max(0.1, r));
|
|
const finish = () => {
|
|
if (live !== null && live !== at) onRatio(live);
|
|
setLive(null);
|
|
};
|
|
return (
|
|
<div
|
|
role="separator"
|
|
tabIndex={0}
|
|
aria-orientation={row ? "vertical" : "horizontal"}
|
|
aria-valuenow={Math.round(pos * 100)}
|
|
aria-valuemin={10}
|
|
aria-valuemax={90}
|
|
aria-label={label ?? t("panes.resize")}
|
|
className={`group absolute z-10 flex justify-center outline-none ${
|
|
row
|
|
? "w-2 -translate-x-1/2 cursor-col-resize"
|
|
: "h-2 -translate-y-1/2 cursor-row-resize flex-col"
|
|
}`}
|
|
style={style}
|
|
onPointerDown={(e) => {
|
|
e.preventDefault();
|
|
e.currentTarget.setPointerCapture?.(e.pointerId);
|
|
setLive(at);
|
|
}}
|
|
onPointerMove={(e) => {
|
|
if (!dragging) return;
|
|
const r = ratioAt(e);
|
|
if (r !== null) setLive(clamp(r));
|
|
}}
|
|
onPointerUp={finish}
|
|
onPointerCancel={() => setLive(null)}
|
|
onKeyDown={(e) => {
|
|
const back = row ? "ArrowLeft" : "ArrowUp";
|
|
const fwd = row ? "ArrowRight" : "ArrowDown";
|
|
if (e.key !== back && e.key !== fwd) return;
|
|
e.preventDefault();
|
|
onRatio(at + (e.key === fwd ? 0.05 : -0.05));
|
|
}}
|
|
>
|
|
<span
|
|
className={`${row ? "h-full w-px" : "h-px w-full"} transition-colors ${
|
|
dragging
|
|
? "bg-accent"
|
|
: "bg-border group-hover:bg-accent/70 group-focus-visible:bg-accent"
|
|
}`}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|