feat(workspace): VS Code-style split panes, edge-to-edge session view
The console splits into up to four panes: a binary split tree of tmux slots (client/src/lib/paneLayout.ts) drawn by PaneGrid.tsx, every pane an absolutely placed sibling keyed by slot so splitting, swapping, resizing or closing never remounts a live terminal. Ctrl/Cmd+click on a tab, a + item or a sidebar session row opens it beside the focused pane (router state, never the URL); a pane header drags onto another pane's edge to split there or onto its middle to swap, picks another live session, splits right/down (a not-shown session or a new terminal), or closes the pane only. Dividers drag or take arrow keys. ?run= is the focused pane: every layout change goes through change(), which pushes the URL with focusOnly instead of an effect watching the focus (that ping-ponged with open requests). The layout is kept per workspace in localStorage. Only the focused pane's terminal takes the keyboard when it appears, and the keyboard follows a focus change. A pane closes when its terminal reports the exit; the run list prunes only a remembered layout on a workspace's first read and a just-opened slot that never got listed, so one empty read cannot wipe a split. A run ending on the home or a replay closes its pane without leaving the page. The session view now fills the page edge to edge: no page padding, the tab bar and the right panel's activity bar one h-10 strip, and the tab bar, pane headers and right panel on the sidebar/status bar colour (surface-1) around terminals on the page colour.
This commit is contained in:
@@ -984,7 +984,7 @@ export function LaneChangesPanel({
|
||||
<div data-testid="lane-changes-browse">
|
||||
{/* Pinned: the finder is what you reach for after scrolling a
|
||||
long tree, not only from its top. */}
|
||||
<div className="sticky top-0 z-10 flex items-center gap-1.5 bg-surface-0 pb-2">
|
||||
<div className="sticky top-0 z-10 flex items-center gap-1.5 bg-surface-1 pb-2">
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
@@ -1320,7 +1320,7 @@ export function LaneChangesPanel({
|
||||
{(stagedCount > 0 || toStage.length > 0) && (
|
||||
<div
|
||||
data-testid="lane-changes-actions"
|
||||
className="sticky top-0 z-10 mb-1.5 flex items-center gap-1.5 border-b border-border-light bg-surface-0 pb-2"
|
||||
className="sticky top-0 z-10 mb-1.5 flex items-center gap-1.5 border-b border-border-light bg-surface-1 pb-2"
|
||||
>
|
||||
{/* ONE row: message, commit, then the two bulk index moves. */}
|
||||
{stagedCount > 0 && (
|
||||
@@ -1619,7 +1619,7 @@ export function LaneChangesPanel({
|
||||
: t("changes.collapseFile", { path: f.path })
|
||||
}
|
||||
data-sticky-top=""
|
||||
className={`sticky top-0 z-10 flex cursor-pointer items-baseline gap-1.5 rounded-t bg-surface-1 px-2 py-1 hover:bg-surface-2 ${
|
||||
className={`sticky top-0 z-10 flex cursor-pointer items-baseline gap-1.5 rounded-t bg-surface-2 px-2 py-1 hover:bg-surface-3 ${
|
||||
shut ? "" : "border-b border-border-light"
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -8,8 +8,9 @@
|
||||
* renames it and its hover button archives it (`PATCH {name}` / `{archived}`,
|
||||
* which deletes nothing). A session with a LIVE pane (a `GET /api/run` handle
|
||||
* carrying its session id) opens that tmux pane on the Workspace page
|
||||
* (`?lane=<id>&run=<runId>`, its tab in the session view) — attaching, never
|
||||
* starting or resuming anything;
|
||||
* (`?lane=<id>&run=<runId>`, its tab in the session view; Ctrl/Cmd+click opens
|
||||
* it in a split beside the focused pane) — attaching, never starting or
|
||||
* resuming anything;
|
||||
* any other session opens in its workspace too (`?lane=<id>&session=<id>`,
|
||||
* `SessionReplay`): its conversation with a prompt box, resumed only when a
|
||||
* message is SENT, because a resume re-reads the whole transcript into context.
|
||||
@@ -314,11 +315,14 @@ export function WorkspaceSessionList({
|
||||
type="button"
|
||||
data-testid={`workspace-session-${s.id}`}
|
||||
data-live={run ? "true" : undefined}
|
||||
onClick={() =>
|
||||
onClick={(e) =>
|
||||
navigate(
|
||||
run
|
||||
? `/workspace?lane=${lane.id}&run=${encodeURIComponent(run.id)}`
|
||||
: `/workspace?lane=${lane.id}&session=${encodeURIComponent(s.id)}`
|
||||
: `/workspace?lane=${lane.id}&session=${encodeURIComponent(s.id)}`,
|
||||
// Ctrl/Cmd+click a live one: open it in a split beside the
|
||||
// focused pane (Workspace reads this state, not the URL).
|
||||
run && (e.ctrlKey || e.metaKey) ? { state: { split: true } } : undefined
|
||||
)
|
||||
}
|
||||
onDoubleClick={() => {
|
||||
|
||||
@@ -9,7 +9,9 @@
|
||||
* worktree is still the workspace home's prompt box) — plus a link to
|
||||
* Settings → Agents. Four tabs is the server's limit (`MAX_PANE_SLOT`). The
|
||||
* bar is the console's only header; `actions` (the workspace's Tools menu)
|
||||
* sits at its right end.
|
||||
* sits at its right end. A click shows a tab in the focused pane, Ctrl/Cmd+click
|
||||
* (on a tab or a `+` item) opens it in a split to the right, and a tab drags
|
||||
* onto the panes (`PaneGrid.tsx`); a tab another pane shows is marked.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -23,11 +25,13 @@ import type { Lane } from "../../lib/types";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
import { freeSlot } from "../lanes/WorkspaceHome";
|
||||
import { RunIcon, startPaneDrag } from "./PaneGrid";
|
||||
|
||||
export function ConsoleTabs({
|
||||
lane,
|
||||
tabs,
|
||||
activeRunId,
|
||||
shownRunIds = [],
|
||||
onOpen,
|
||||
onClosed,
|
||||
actions,
|
||||
@@ -35,8 +39,12 @@ export function ConsoleTabs({
|
||||
lane: Lane;
|
||||
/** The workspace's live runs, in slot order. */
|
||||
tabs: RunHandle[];
|
||||
/** The focused pane's run. */
|
||||
activeRunId: string | null;
|
||||
onOpen: (runId: string) => void;
|
||||
/** Runs the other panes show. */
|
||||
shownRunIds?: string[];
|
||||
/** `split`: Ctrl/Cmd+click — open beside the focused pane. */
|
||||
onOpen: (runId: string, split?: boolean) => void;
|
||||
/** A tab's session was stopped. */
|
||||
onClosed: (runId: string) => void;
|
||||
/** Right end of the bar (the workspace's Tools menu). */
|
||||
@@ -73,13 +81,13 @@ export function ConsoleTabs({
|
||||
]),
|
||||
];
|
||||
|
||||
const open = async (body: Record<string, unknown>) => {
|
||||
const open = async (body: Record<string, unknown>, split = false) => {
|
||||
setMenuOpen(false);
|
||||
if (slot === null) return;
|
||||
setError(null);
|
||||
try {
|
||||
const r = await api.lanes.action(lane.id, "start", { slot, ...body });
|
||||
if (r.run_id) onOpen(r.run_id);
|
||||
if (r.run_id) onOpen(r.run_id, split);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
@@ -99,36 +107,42 @@ export function ConsoleTabs({
|
||||
"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";
|
||||
|
||||
return (
|
||||
<div className="flex h-10 shrink-0 items-stretch border-b border-border">
|
||||
<div className="flex h-10 shrink-0 items-stretch border-b border-border bg-surface-1">
|
||||
<div role="tablist" aria-label={t("tabs.label")} className="flex min-w-0 items-stretch">
|
||||
{tabs.map((r) => {
|
||||
const active = r.id === activeRunId;
|
||||
const shown = !active && shownRunIds.includes(r.id);
|
||||
return (
|
||||
<div
|
||||
key={r.id}
|
||||
draggable
|
||||
onDragStart={(e) => startPaneDrag(e, r.slot ?? 0)}
|
||||
data-shown={shown ? "true" : undefined}
|
||||
className={`group relative flex min-w-0 max-w-[15rem] items-center rounded-t-md ${
|
||||
active
|
||||
? "text-fg-primary"
|
||||
: "text-fg-muted hover:bg-surface-4 hover:text-fg-secondary"
|
||||
? "bg-surface-0 text-fg-primary"
|
||||
: shown
|
||||
? "text-fg-secondary hover:bg-surface-4"
|
||||
: "text-fg-muted hover:bg-surface-4 hover:text-fg-secondary"
|
||||
}`}
|
||||
>
|
||||
{active && (
|
||||
<span className="absolute inset-x-2 -bottom-px h-0.5 rounded-full bg-accent" />
|
||||
{(active || shown) && (
|
||||
<span
|
||||
className={`absolute inset-x-2 -bottom-px h-0.5 rounded-full ${
|
||||
active ? "bg-accent" : "bg-fg-muted/50"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
data-testid={`console-tab-${r.slot ?? 0}`}
|
||||
onClick={() => onOpen(r.id)}
|
||||
title={[r.model, r.promptPreview].filter(Boolean).join(" · ") || undefined}
|
||||
onClick={(e) => onOpen(r.id, e.ctrlKey || e.metaKey)}
|
||||
title={[r.model, r.promptPreview, t("tabs.splitHint")].filter(Boolean).join("\n")}
|
||||
className="flex h-full min-w-0 items-center gap-2 pl-3 pr-1.5 text-sm"
|
||||
>
|
||||
{r.tool === "shell" ? (
|
||||
<SquareTerminal className="h-4 w-4 shrink-0" />
|
||||
) : (
|
||||
<AiToolIcon tool={{ id: r.tool, label: label(r) }} className="h-4 w-4 !p-0" />
|
||||
)}
|
||||
<RunIcon run={r} label={label(r)} />
|
||||
<span className="truncate">{label(r)}</span>
|
||||
</button>
|
||||
<button
|
||||
@@ -171,7 +185,7 @@ export function ConsoleTabs({
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={item}
|
||||
onClick={() => void open({ shell: true })}
|
||||
onClick={(e) => void open({ shell: true }, e.ctrlKey || e.metaKey)}
|
||||
>
|
||||
<SquareTerminal className="h-4 w-4" />
|
||||
{t("tabs.newTerminal")}
|
||||
@@ -186,14 +200,17 @@ export function ConsoleTabs({
|
||||
role="menuitem"
|
||||
data-testid={`console-tab-new-${id}`}
|
||||
className={item}
|
||||
onClick={() =>
|
||||
void open({
|
||||
prompt: "",
|
||||
// Only a different tool is named, as the home does: the
|
||||
// workspace's own keeps starting even once disabled.
|
||||
...(id !== lane.tool ? { tool: id } : {}),
|
||||
...(model ? { model } : {}),
|
||||
})
|
||||
onClick={(e) =>
|
||||
void open(
|
||||
{
|
||||
prompt: "",
|
||||
// Only a different tool is named, as the home does: the
|
||||
// workspace's own keeps starting even once disabled.
|
||||
...(id !== lane.tool ? { tool: id } : {}),
|
||||
...(model ? { model } : {}),
|
||||
},
|
||||
e.ctrlKey || e.metaKey
|
||||
)
|
||||
}
|
||||
>
|
||||
<AiToolIcon tool={{ id, label: aiTools.label(id) }} className="h-4 w-4 !p-0" />
|
||||
|
||||
@@ -40,6 +40,8 @@ export interface LaneConsolePaneProps {
|
||||
onNewSession?: () => void;
|
||||
/** The pane's run exited (the tab bar opens another tab, or the home). */
|
||||
onRunExit?: () => void;
|
||||
/** The terminal takes the keyboard when it appears (default). */
|
||||
autoFocus?: boolean;
|
||||
}
|
||||
|
||||
/** The tmux session a lane's tab owns (see server/lib/pty-run.js). */
|
||||
@@ -57,6 +59,7 @@ export function LaneConsolePane({
|
||||
attachRequest,
|
||||
onNewSession,
|
||||
onRunExit,
|
||||
autoFocus,
|
||||
}: LaneConsolePaneProps) {
|
||||
const { t } = useTranslation("run");
|
||||
const [handle, setHandle] = useState<RunHandle | null>(null);
|
||||
@@ -169,6 +172,7 @@ export function LaneConsolePane({
|
||||
<TerminalView
|
||||
runId={handle.id}
|
||||
wsBaseUrl={window.location.origin.replace(/^http/, "ws")}
|
||||
autoFocus={autoFocus}
|
||||
// The pane died (double Ctrl+C out of `claude`, `exit`, a crash) —
|
||||
// the tab bar moves on (another tab, or the home's prompt box)
|
||||
// rather than leaving a dead terminal the user has to click out of.
|
||||
|
||||
@@ -0,0 +1,518 @@
|
||||
/**
|
||||
* @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 close the pane (its session keeps running as a tab).
|
||||
* Tabs from `ConsoleTabs` drag in the same way (`startPaneDrag`). Dividers
|
||||
* resize by pointer or by arrow keys.
|
||||
* @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,
|
||||
Plus,
|
||||
Rows2,
|
||||
SquareTerminal,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import type { RunHandle } from "../../lib/api";
|
||||
import {
|
||||
EMPTY_LAYOUT,
|
||||
type Edge,
|
||||
type PaneLayout,
|
||||
type Rect,
|
||||
type SplitDir,
|
||||
MAX_PANES,
|
||||
closeInLayout,
|
||||
layoutRects,
|
||||
leaves,
|
||||
loadLayout,
|
||||
moveInLayout,
|
||||
saveLayout,
|
||||
setRatio,
|
||||
swapInLayout,
|
||||
} from "../../lib/paneLayout";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
|
||||
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;
|
||||
|
||||
/** A workspace's pane layout, loaded and saved per workspace. A layout update
|
||||
* that lands after a workspace switch never writes into the new one. */
|
||||
export function usePaneLayout(laneId: number | null): [PaneLayout, LayoutUpdate] {
|
||||
const load = (id: number | null) => ({
|
||||
laneId: id,
|
||||
layout: id === null ? EMPTY_LAYOUT : loadLayout(id),
|
||||
});
|
||||
const [state, setState] = useState(() => load(laneId));
|
||||
let current = state;
|
||||
if (state.laneId !== laneId) {
|
||||
current = load(laneId);
|
||||
setState(current);
|
||||
}
|
||||
useEffect(() => {
|
||||
if (state.laneId !== null) saveLayout(state.laneId, state.layout);
|
||||
}, [state]);
|
||||
const update = useCallback<LayoutUpdate>(
|
||||
(fn) =>
|
||||
setState((s) => {
|
||||
if (s.laneId !== laneId) return s;
|
||||
const layout = fn(s.layout);
|
||||
return layout === s.layout ? s : { ...s, layout };
|
||||
}),
|
||||
[laneId]
|
||||
);
|
||||
return [current.layout, update];
|
||||
}
|
||||
|
||||
type Zone = Edge | "center";
|
||||
const pct = (r: Rect) => ({
|
||||
left: `${r.x * 100}%`,
|
||||
top: `${r.y * 100}%`,
|
||||
width: `${r.w * 100}%`,
|
||||
height: `${r.h * 100}%`,
|
||||
});
|
||||
/** The half (or the whole) of a pane a drop zone would fill. */
|
||||
function zoneRect(r: Rect, zone: Zone): Rect {
|
||||
if (zone === "left") return { ...r, w: r.w / 2 };
|
||||
if (zone === "right") return { ...r, x: r.x + r.w / 2, w: r.w / 2 };
|
||||
if (zone === "top") return { ...r, h: r.h / 2 };
|
||||
if (zone === "bottom") return { ...r, y: r.y + r.h / 2, h: r.h / 2 };
|
||||
return r;
|
||||
}
|
||||
|
||||
/** 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";
|
||||
|
||||
export function PaneGrid({
|
||||
layout,
|
||||
mounted,
|
||||
runs,
|
||||
label,
|
||||
renderPane,
|
||||
onChange,
|
||||
canStart,
|
||||
onNewSplit,
|
||||
}: {
|
||||
layout: PaneLayout;
|
||||
/** 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;
|
||||
/** Start a new terminal into a split of `target`. */
|
||||
onNewSplit: (target: number, edge: Edge) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const gridRef = useRef<HTMLDivElement>(null);
|
||||
const [drop, setDrop] = useState<{ 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);
|
||||
|
||||
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 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);
|
||||
setDrop((d) => (d?.slot === z?.slot && d?.zone === z?.zone ? d : z));
|
||||
}}
|
||||
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) =>
|
||||
z.zone === "center"
|
||||
? swapInLayout(l, src, z.slot)
|
||||
: moveInLayout(l, src, z.slot, z.zone as Edge)
|
||||
);
|
||||
}}
|
||||
>
|
||||
{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}
|
||||
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)}
|
||||
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>
|
||||
<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">
|
||||
{(["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);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("panes.newTerminal")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</PaneMenu>
|
||||
))}
|
||||
<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"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex min-h-0 flex-1 flex-col pb-1 pl-3 pr-1 pt-1.5">
|
||||
{renderPane(slot)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{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))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{drop && rectOf.has(drop.slot) && (
|
||||
<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(zoneRect(rectOf.get(drop.slot) as Rect, drop.zone))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A split's divider: a 1px line with a wider grab area. */
|
||||
function Divider({
|
||||
gridRef,
|
||||
dir,
|
||||
at,
|
||||
box,
|
||||
onRatio,
|
||||
}: {
|
||||
gridRef: React.RefObject<HTMLDivElement>;
|
||||
dir: SplitDir;
|
||||
at: number;
|
||||
box: Rect;
|
||||
onRatio: (ratio: number) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const row = dir === "row";
|
||||
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 * at) * 100}%`, top: `${box.y * 100}%`, height: `${box.h * 100}%` }
|
||||
: { top: `${(box.y + box.h * at) * 100}%`, left: `${box.x * 100}%`, width: `${box.w * 100}%` };
|
||||
return (
|
||||
<div
|
||||
role="separator"
|
||||
tabIndex={0}
|
||||
aria-orientation={row ? "vertical" : "horizontal"}
|
||||
aria-valuenow={Math.round(at * 100)}
|
||||
aria-valuemin={10}
|
||||
aria-valuemax={90}
|
||||
aria-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);
|
||||
setDragging(true);
|
||||
}}
|
||||
onPointerMove={(e) => {
|
||||
if (!dragging) return;
|
||||
const r = ratioAt(e);
|
||||
if (r !== null) onRatio(r);
|
||||
}}
|
||||
onPointerUp={() => setDragging(false)}
|
||||
onPointerCancel={() => setDragging(false)}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -39,15 +39,20 @@ interface TerminalViewProps {
|
||||
* There is nothing left to attach to, so the owner swaps this terminal back
|
||||
* out for its run setup form instead of leaving a dead pane on screen. */
|
||||
onExit?: () => void;
|
||||
/** Take the keyboard when it appears (default). Off for a split pane that
|
||||
* is not the focused one, or keys would land in whichever mounted last. */
|
||||
autoFocus?: boolean;
|
||||
}
|
||||
|
||||
export function TerminalView({ runId, wsBaseUrl, onExit }: TerminalViewProps) {
|
||||
export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: TerminalViewProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
// Read through a ref: an inline `onExit` from the parent changes identity on
|
||||
// every render, and as an effect dep it would tear the terminal down and
|
||||
// re-attach on each one.
|
||||
const onExitRef = useRef(onExit);
|
||||
onExitRef.current = onExit;
|
||||
const autoFocusRef = useRef(autoFocus);
|
||||
autoFocusRef.current = autoFocus;
|
||||
// Always the DARK theme's palette, even in light mode: Claude Code's TUI
|
||||
// assumes a dark background. Read through a ref at construction so a theme
|
||||
// change never re-runs the attach effect; the effect below recolours live.
|
||||
@@ -143,7 +148,7 @@ export function TerminalView({ runId, wsBaseUrl, onExit }: TerminalViewProps) {
|
||||
// Take the keyboard as soon as the pane is on screen: the terminal IS the
|
||||
// page here, and having to click it first meant the first thing typed went
|
||||
// nowhere. A click elsewhere still moves focus away, as in any editor.
|
||||
term.focus();
|
||||
if (autoFocusRef.current) term.focus();
|
||||
|
||||
// When the pane's program has NOT enabled wheel reporting, xterm falls back
|
||||
// to converting each wheel notch on an alt-screen buffer into a cursor-key
|
||||
|
||||
@@ -332,7 +332,7 @@
|
||||
"tree.newSession": "New session",
|
||||
"tree.noSessions": "No sessions yet",
|
||||
"tree.transcriptHint": "No live pane — opens its conversation; a message you send resumes it",
|
||||
"tree.splitHint": "Live pane — Ctrl/Cmd+click opens it in a second pane",
|
||||
"tree.splitHint": "Live pane — Ctrl/Cmd+click opens it in a split beside the focused pane",
|
||||
"tree.renameSession": "Rename session",
|
||||
"tree.archiveSession": "Archive — hide from this workspace (deletes nothing)",
|
||||
"tree.worktreeSession": "In its own worktree: .claude/worktrees/{{name}}",
|
||||
@@ -391,5 +391,15 @@
|
||||
"tabs.terminal": "Terminal",
|
||||
"tabs.close": "Close tab (stops its session)",
|
||||
"tabs.full": "All 4 tabs are in use — close one first",
|
||||
"tabs.agentSettings": "Agent settings…"
|
||||
"tabs.agentSettings": "Agent settings…",
|
||||
"tabs.splitHint": "Ctrl/Cmd+click: open in a split · drag onto a pane to place it",
|
||||
"panes.drag": "Drag to move this pane — onto another pane's edge to split there, onto its middle to swap",
|
||||
"panes.pick": "Show another session in this pane",
|
||||
"panes.empty": "No session",
|
||||
"panes.split.right": "Split right",
|
||||
"panes.split.bottom": "Split down",
|
||||
"panes.full": "4 panes is the most a workspace can show",
|
||||
"panes.newTerminal": "New terminal",
|
||||
"panes.close": "Close pane (its session keeps running as a tab)",
|
||||
"panes.resize": "Resize panes"
|
||||
}
|
||||
|
||||
@@ -332,7 +332,7 @@
|
||||
"tree.newSession": "Session mới",
|
||||
"tree.noSessions": "Chưa có session",
|
||||
"tree.transcriptHint": "Không còn pane — mở hội thoại; gửi tin nhắn sẽ tiếp tục (resume) session",
|
||||
"tree.splitHint": "Pane đang chạy — Ctrl/Cmd+click để mở ở pane thứ hai",
|
||||
"tree.splitHint": "Pane đang chạy — Ctrl/Cmd+click để mở ở split cạnh pane đang focus",
|
||||
"tree.renameSession": "Đổi tên session",
|
||||
"tree.archiveSession": "Lưu trữ — ẩn khỏi workspace này (không xoá gì)",
|
||||
"tree.worktreeSession": "Trong worktree riêng: .claude/worktrees/{{name}}",
|
||||
@@ -391,5 +391,15 @@
|
||||
"tabs.terminal": "Terminal",
|
||||
"tabs.close": "Đóng tab (dừng phiên của nó)",
|
||||
"tabs.full": "Đã dùng hết 4 tab — hãy đóng bớt một tab",
|
||||
"tabs.agentSettings": "Cài đặt agent…"
|
||||
"tabs.agentSettings": "Cài đặt agent…",
|
||||
"tabs.splitHint": "Ctrl/Cmd+click: mở ở split · kéo thả vào một pane để đặt vị trí",
|
||||
"panes.drag": "Kéo để di chuyển pane — thả vào cạnh pane khác để tách ở đó, vào giữa để đổi chỗ",
|
||||
"panes.pick": "Hiện session khác trong pane này",
|
||||
"panes.empty": "Không có session",
|
||||
"panes.split.right": "Tách sang phải",
|
||||
"panes.split.bottom": "Tách xuống dưới",
|
||||
"panes.full": "Một workspace hiện tối đa 4 pane",
|
||||
"panes.newTerminal": "Terminal mới",
|
||||
"panes.close": "Đóng pane (session vẫn chạy dưới dạng tab)",
|
||||
"panes.resize": "Đổi kích thước pane"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* @file paneLayout.test.ts
|
||||
* @description Tests for the session view's split-pane tree: opening a run
|
||||
* (replace the focused pane, or split it), closing a pane and handing focus to
|
||||
* the pane that takes its place, swapping/moving panes, resizing a split,
|
||||
* pruning panes whose run is gone, the rectangles the grid draws, and the
|
||||
* per-workspace storage that must survive bad or stale data.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import {
|
||||
type PaneLayout,
|
||||
type PaneNode,
|
||||
MAX_PANES,
|
||||
closeInLayout,
|
||||
layoutRects,
|
||||
leaves,
|
||||
loadLayout,
|
||||
moveInLayout,
|
||||
openInLayout,
|
||||
pane,
|
||||
parseLayout,
|
||||
pruneLayout,
|
||||
saveLayout,
|
||||
setRatio,
|
||||
split,
|
||||
swapInLayout,
|
||||
} from "../paneLayout";
|
||||
|
||||
const one: PaneLayout = { root: pane(0), focus: 0 };
|
||||
|
||||
describe("openInLayout", () => {
|
||||
it("starts a single pane on an empty layout", () => {
|
||||
const l = openInLayout({ root: null, focus: null }, 2, "replace");
|
||||
expect(l).toEqual({ root: pane(2), focus: 2 });
|
||||
});
|
||||
|
||||
it("only focuses a slot that is already shown", () => {
|
||||
const l: PaneLayout = { root: split("row", pane(0), pane(1)), focus: 0 };
|
||||
expect(openInLayout(l, 1, "split")).toEqual({ ...l, focus: 1 });
|
||||
});
|
||||
|
||||
it("replaces the focused pane on a plain open", () => {
|
||||
const l: PaneLayout = { root: split("row", pane(0), pane(1)), focus: 1 };
|
||||
const out = openInLayout(l, 3, "replace");
|
||||
expect(out.root).toEqual(split("row", pane(0), pane(3)));
|
||||
expect(out.focus).toBe(3);
|
||||
});
|
||||
|
||||
it("splits the focused pane to the right on a split open", () => {
|
||||
const l: PaneLayout = { root: split("row", pane(0), pane(1)), focus: 0 };
|
||||
const out = openInLayout(l, 2, "split");
|
||||
expect(out.root).toEqual(split("row", split("row", pane(0), pane(2)), pane(1)));
|
||||
expect(out.focus).toBe(2);
|
||||
});
|
||||
|
||||
it("replaces instead of splitting once MAX_PANES are shown", () => {
|
||||
let l: PaneLayout = one;
|
||||
for (const s of [1, 2, 3]) l = openInLayout(l, s, "split");
|
||||
expect(leaves(l.root)).toHaveLength(MAX_PANES);
|
||||
// A fifth run cannot exist (4 slots), but a stale focus must still be safe.
|
||||
const out = openInLayout({ ...l, focus: 9 }, 3, "split");
|
||||
expect(leaves(out.root)).toHaveLength(MAX_PANES);
|
||||
});
|
||||
|
||||
it("falls back to the first pane when the stored focus is not in the tree", () => {
|
||||
const l: PaneLayout = { root: split("row", pane(0), pane(1)), focus: 7 };
|
||||
expect(openInLayout(l, 2, "replace").root).toEqual(split("row", pane(2), pane(1)));
|
||||
});
|
||||
});
|
||||
|
||||
describe("closeInLayout", () => {
|
||||
it("hands focus to the pane that takes the closed one's place", () => {
|
||||
const l: PaneLayout = {
|
||||
root: split("row", pane(0), split("col", pane(1), pane(2))),
|
||||
focus: 0,
|
||||
};
|
||||
const out = closeInLayout(l, 0);
|
||||
expect(out.root).toEqual(split("col", pane(1), pane(2)));
|
||||
expect(out.focus).toBe(1);
|
||||
});
|
||||
|
||||
it("keeps focus when another pane closes", () => {
|
||||
const l: PaneLayout = { root: split("row", pane(0), pane(1)), focus: 0 };
|
||||
expect(closeInLayout(l, 1)).toEqual({ root: pane(0), focus: 0 });
|
||||
});
|
||||
|
||||
it("empties the layout when the last pane closes", () => {
|
||||
expect(closeInLayout(one, 0)).toEqual({ root: null, focus: null });
|
||||
});
|
||||
|
||||
it("ignores a slot that is not shown", () => {
|
||||
expect(closeInLayout(one, 3)).toBe(one);
|
||||
});
|
||||
});
|
||||
|
||||
describe("swapInLayout / moveInLayout", () => {
|
||||
it("swaps two shown panes", () => {
|
||||
const l: PaneLayout = { root: split("row", pane(0), pane(1)), focus: 0 };
|
||||
expect(swapInLayout(l, 0, 1).root).toEqual(split("row", pane(1), pane(0)));
|
||||
});
|
||||
|
||||
it("puts a hidden run into the target pane when swapping", () => {
|
||||
const l: PaneLayout = { root: split("row", pane(0), pane(1)), focus: 0 };
|
||||
expect(swapInLayout(l, 3, 1).root).toEqual(split("row", pane(0), pane(3)));
|
||||
});
|
||||
|
||||
it("moves a pane to another pane's edge", () => {
|
||||
const l: PaneLayout = { root: split("row", pane(0), split("row", pane(1), pane(2))), focus: 2 };
|
||||
const out = moveInLayout(l, 0, 2, "bottom");
|
||||
expect(out.root).toEqual(split("row", pane(1), split("col", pane(2), pane(0))));
|
||||
expect(out.focus).toBe(0);
|
||||
});
|
||||
|
||||
it("puts the moved pane first on a left or top edge", () => {
|
||||
const l: PaneLayout = { root: split("row", pane(0), pane(1)), focus: 0 };
|
||||
expect(moveInLayout(l, 1, 0, "top").root).toEqual(split("col", pane(1), pane(0)));
|
||||
});
|
||||
|
||||
it("does nothing when a pane is dropped on itself", () => {
|
||||
const l: PaneLayout = { root: split("row", pane(0), pane(1)), focus: 0 };
|
||||
expect(moveInLayout(l, 1, 1, "left")).toBe(l);
|
||||
});
|
||||
|
||||
it("refuses to add a fifth pane", () => {
|
||||
let l: PaneLayout = one;
|
||||
for (const s of [1, 2, 3]) l = openInLayout(l, s, "split");
|
||||
const four = { ...l, root: closeInLayout(l, 3).root };
|
||||
// 3 shown + slot 3 hidden: moving the hidden one in is allowed …
|
||||
expect(leaves(moveInLayout({ ...four, focus: 0 }, 3, 0, "right").root)).toHaveLength(4);
|
||||
// … a full layout keeps its shape.
|
||||
expect(moveInLayout(l, 9, 0, "right")).toBe(l);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setRatio / layoutRects", () => {
|
||||
it("clamps a dragged ratio so no pane can vanish", () => {
|
||||
const root = split("row", pane(0), pane(1));
|
||||
expect((setRatio(root, "", 0.01) as Extract<PaneNode, { type: "split" }>).ratio).toBe(0.1);
|
||||
expect((setRatio(root, "", 0.99) as Extract<PaneNode, { type: "split" }>).ratio).toBe(0.9);
|
||||
});
|
||||
|
||||
it("resizes the split a path names", () => {
|
||||
const root = split("row", pane(0), split("col", pane(1), pane(2)));
|
||||
const out = setRatio(root, "b", 0.25) as Extract<PaneNode, { type: "split" }>;
|
||||
expect((out.b as Extract<PaneNode, { type: "split" }>).ratio).toBe(0.25);
|
||||
expect(out.ratio).toBe(0.5);
|
||||
});
|
||||
|
||||
it("turns the tree into pane rectangles and divider lines", () => {
|
||||
const root = split("row", pane(0), split("col", pane(1), pane(2), 0.25), 0.6);
|
||||
const { panes, dividers } = layoutRects(root);
|
||||
expect(panes).toEqual([
|
||||
{ slot: 0, x: 0, y: 0, w: 0.6, h: 1 },
|
||||
{ slot: 1, x: 0.6, y: 0, w: 0.4, h: 0.25 },
|
||||
{ slot: 2, x: 0.6, y: 0.25, w: 0.4, h: 0.75 },
|
||||
]);
|
||||
expect(dividers).toEqual([
|
||||
{ path: "", dir: "row", at: 0.6, box: { x: 0, y: 0, w: 1, h: 1 } },
|
||||
{ path: "b", dir: "col", at: 0.25, box: { x: 0.6, y: 0, w: 0.4, h: 1 } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("has nothing to draw for an empty layout", () => {
|
||||
expect(layoutRects(null)).toEqual({ panes: [], dividers: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("pruneLayout", () => {
|
||||
it("drops panes whose run is gone, keeping the pending ones", () => {
|
||||
const l: PaneLayout = {
|
||||
root: split("row", pane(0), split("row", pane(1), pane(2))),
|
||||
focus: 1,
|
||||
};
|
||||
const out = pruneLayout(l, new Set([0]), new Set([2]));
|
||||
expect(out.root).toEqual(split("row", pane(0), pane(2)));
|
||||
expect(out.focus).toBe(2);
|
||||
});
|
||||
|
||||
it("returns the same object when nothing is gone", () => {
|
||||
expect(pruneLayout(one, new Set([0]), new Set())).toBe(one);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseLayout / storage", () => {
|
||||
beforeEach(() => window.localStorage.clear());
|
||||
|
||||
it("accepts a valid stored layout", () => {
|
||||
const l: PaneLayout = { root: split("col", pane(1), pane(3), 0.3), focus: 3 };
|
||||
expect(parseLayout(JSON.parse(JSON.stringify(l)))).toEqual(l);
|
||||
});
|
||||
|
||||
it("rejects junk, out-of-range slots, duplicates and oversized trees", () => {
|
||||
expect(parseLayout(null)).toEqual({ root: null, focus: null });
|
||||
expect(parseLayout({ root: { type: "pane", slot: 7 }, focus: 7 }).root).toBeNull();
|
||||
expect(parseLayout({ root: split("row", pane(1), pane(1)), focus: 1 }).root).toBeNull();
|
||||
const five = split(
|
||||
"row",
|
||||
split("row", pane(0), pane(1)),
|
||||
split("row", pane(2), split("row", pane(3), pane(0)))
|
||||
);
|
||||
expect(parseLayout({ root: five, focus: 0 }).root).toBeNull();
|
||||
});
|
||||
|
||||
it("clamps a stored ratio and repairs a focus that is not shown", () => {
|
||||
const out = parseLayout({ root: { ...split("row", pane(0), pane(1)), ratio: 5 }, focus: 3 });
|
||||
expect(out.root).toEqual(split("row", pane(0), pane(1), 0.9));
|
||||
expect(out.focus).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps one layout per workspace", () => {
|
||||
saveLayout(1, { root: split("row", pane(0), pane(1)), focus: 1 });
|
||||
saveLayout(2, one);
|
||||
expect(loadLayout(1).focus).toBe(1);
|
||||
expect(loadLayout(2)).toEqual(one);
|
||||
expect(loadLayout(3)).toEqual({ root: null, focus: null });
|
||||
});
|
||||
|
||||
it("survives unreadable storage", () => {
|
||||
window.localStorage.setItem("agenthub.workspace.paneLayout", "{not json");
|
||||
expect(loadLayout(1)).toEqual({ root: null, focus: null });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* @file paneLayout.ts
|
||||
* @description The session view's split panes, VS Code style: a binary tree
|
||||
* whose leaves are pane SLOTS (0–3, one tmux session each — see
|
||||
* `server/lib/pty-run.js`) and whose inner nodes split their box in a row
|
||||
* (side by side) or a column (stacked) at a ratio. Pure functions only: the
|
||||
* page keeps the layout in state, and `layoutRects` turns it into the
|
||||
* rectangles `PaneGrid` draws every pane at — each pane is an absolutely
|
||||
* placed sibling, so reshaping the tree never re-parents (and so never
|
||||
* remounts) a live terminal. A slot appears at most once: one terminal is
|
||||
* never attached twice. The layout is a viewer preference, kept per workspace
|
||||
* in `localStorage` (`agenthub.workspace.paneLayout`), never on the server.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
/** As many panes as a workspace has tmux slots. */
|
||||
export const MAX_PANES = 4;
|
||||
const MIN_RATIO = 0.1;
|
||||
const STORAGE_KEY = "agenthub.workspace.paneLayout";
|
||||
|
||||
export type SplitDir = "row" | "col";
|
||||
export type Edge = "left" | "right" | "top" | "bottom";
|
||||
export type PaneNode =
|
||||
| { type: "pane"; slot: number }
|
||||
| { type: "split"; dir: SplitDir; ratio: number; a: PaneNode; b: PaneNode };
|
||||
/** The tree plus the focused pane — the one a plain open replaces. */
|
||||
export interface PaneLayout {
|
||||
root: PaneNode | null;
|
||||
focus: number | null;
|
||||
}
|
||||
export interface Rect {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
export const EMPTY_LAYOUT: PaneLayout = { root: null, focus: null };
|
||||
const EMPTY = EMPTY_LAYOUT;
|
||||
const clampRatio = (r: number) => Math.min(1 - MIN_RATIO, Math.max(MIN_RATIO, r));
|
||||
|
||||
export const pane = (slot: number): PaneNode => ({ type: "pane", slot });
|
||||
export const split = (dir: SplitDir, a: PaneNode, b: PaneNode, ratio = 0.5): PaneNode => ({
|
||||
type: "split",
|
||||
dir,
|
||||
ratio,
|
||||
a,
|
||||
b,
|
||||
});
|
||||
|
||||
/** The slots shown, in reading order. */
|
||||
export function leaves(node: PaneNode | null): number[] {
|
||||
if (!node) return [];
|
||||
return node.type === "pane" ? [node.slot] : [...leaves(node.a), ...leaves(node.b)];
|
||||
}
|
||||
|
||||
function mapLeaves(node: PaneNode, fn: (slot: number) => PaneNode): PaneNode {
|
||||
if (node.type === "pane") return fn(node.slot);
|
||||
return { ...node, a: mapLeaves(node.a, fn), b: mapLeaves(node.b, fn) };
|
||||
}
|
||||
|
||||
/** Takes `slot`'s leaf out; its sibling takes the parent's place. `heir` is
|
||||
* the first slot of whatever now fills that space. */
|
||||
function removeLeaf(node: PaneNode, slot: number): { root: PaneNode | null; heir: number | null } {
|
||||
if (node.type === "pane")
|
||||
return node.slot === slot ? { root: null, heir: null } : { root: node, heir: null };
|
||||
if (node.a.type === "pane" && node.a.slot === slot)
|
||||
return { root: node.b, heir: leaves(node.b)[0] ?? null };
|
||||
if (node.b.type === "pane" && node.b.slot === slot)
|
||||
return { root: node.a, heir: leaves(node.a)[0] ?? null };
|
||||
const a = removeLeaf(node.a, slot);
|
||||
if (a.root !== node.a) return { root: { ...node, a: a.root as PaneNode }, heir: a.heir };
|
||||
const b = removeLeaf(node.b, slot);
|
||||
return { root: b.root === node.b ? node : { ...node, b: b.root as PaneNode }, heir: b.heir };
|
||||
}
|
||||
|
||||
/** Splits `target`'s leaf, putting `slot` on its `edge` side. */
|
||||
function splitLeaf(node: PaneNode, target: number, slot: number, edge: Edge): PaneNode {
|
||||
const dir: SplitDir = edge === "left" || edge === "right" ? "row" : "col";
|
||||
const first = edge === "left" || edge === "top";
|
||||
return mapLeaves(node, (s) =>
|
||||
s === target
|
||||
? first
|
||||
? split(dir, pane(slot), pane(s))
|
||||
: split(dir, pane(s), pane(slot))
|
||||
: pane(s)
|
||||
);
|
||||
}
|
||||
|
||||
function focusOf(layout: PaneLayout): number | null {
|
||||
const shown = leaves(layout.root);
|
||||
return layout.focus !== null && shown.includes(layout.focus) ? layout.focus : (shown[0] ?? null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows `slot`: focuses it when it is already on screen; otherwise `split`
|
||||
* opens it to the right of the focused pane (while there is room) and
|
||||
* `replace` puts it in the focused pane.
|
||||
*/
|
||||
export function openInLayout(
|
||||
layout: PaneLayout,
|
||||
slot: number,
|
||||
mode: "replace" | "split"
|
||||
): PaneLayout {
|
||||
if (!layout.root) return { root: pane(slot), focus: slot };
|
||||
const shown = leaves(layout.root);
|
||||
if (shown.includes(slot)) return { ...layout, focus: slot };
|
||||
const target = focusOf(layout) as number;
|
||||
const root =
|
||||
mode === "split" && shown.length < MAX_PANES
|
||||
? splitLeaf(layout.root, target, slot, "right")
|
||||
: mapLeaves(layout.root, (s) => pane(s === target ? slot : s));
|
||||
return { root, focus: slot };
|
||||
}
|
||||
|
||||
/** Closes `slot`'s pane (its session keeps running as a tab). */
|
||||
export function closeInLayout(layout: PaneLayout, slot: number): PaneLayout {
|
||||
if (!layout.root || !leaves(layout.root).includes(slot)) return layout;
|
||||
const { root, heir } = removeLeaf(layout.root, slot);
|
||||
if (!root) return EMPTY;
|
||||
return { root, focus: layout.focus === slot ? heir : focusOf({ root, focus: layout.focus }) };
|
||||
}
|
||||
|
||||
/** Swaps `slot` into `target`'s pane; when `slot` was shown elsewhere, the two
|
||||
* panes trade places. `slot` ends up focused. */
|
||||
export function swapInLayout(layout: PaneLayout, slot: number, target: number): PaneLayout {
|
||||
if (!layout.root || slot === target) return layout;
|
||||
return {
|
||||
root: mapLeaves(layout.root, (s) => pane(s === target ? slot : s === slot ? target : s)),
|
||||
focus: slot,
|
||||
};
|
||||
}
|
||||
|
||||
/** Moves `slot` (shown or not) onto `target`'s `edge`, splitting that pane. */
|
||||
export function moveInLayout(
|
||||
layout: PaneLayout,
|
||||
slot: number,
|
||||
target: number,
|
||||
edge: Edge
|
||||
): PaneLayout {
|
||||
if (!layout.root || slot === target) return layout;
|
||||
const shown = leaves(layout.root);
|
||||
if (!shown.includes(target)) return layout;
|
||||
if (!shown.includes(slot) && shown.length >= MAX_PANES) return layout;
|
||||
const rest = shown.includes(slot) ? removeLeaf(layout.root, slot).root : layout.root;
|
||||
return { root: splitLeaf(rest as PaneNode, target, slot, edge), focus: slot };
|
||||
}
|
||||
|
||||
/** Drops every pane whose slot is neither `live` nor `pending` (just opened,
|
||||
* not yet in the run list). Same object back when nothing went. */
|
||||
export function pruneLayout(
|
||||
layout: PaneLayout,
|
||||
live: Set<number>,
|
||||
pending: Set<number>
|
||||
): PaneLayout {
|
||||
let out = layout;
|
||||
for (const s of leaves(layout.root))
|
||||
if (!live.has(s) && !pending.has(s)) out = closeInLayout(out, s);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Sets the ratio of the split at `path` ("" = root, then "a"/"b" per level). */
|
||||
export function setRatio(node: PaneNode, path: string, ratio: number): PaneNode {
|
||||
if (node.type !== "split") return node;
|
||||
if (!path) return { ...node, ratio: clampRatio(ratio) };
|
||||
const side = path[0] as "a" | "b";
|
||||
return { ...node, [side]: setRatio(node[side], path.slice(1), ratio) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Where to draw everything, as fractions of the grid's box: each pane's
|
||||
* rectangle, and each split's divider — `at` is its ratio, `box` the split's
|
||||
* own rectangle, which is what a drag converts a pointer position against.
|
||||
*/
|
||||
export function layoutRects(node: PaneNode | null): {
|
||||
panes: (Rect & { slot: number })[];
|
||||
dividers: { path: string; dir: SplitDir; at: number; box: Rect }[];
|
||||
} {
|
||||
const panes: (Rect & { slot: number })[] = [];
|
||||
const dividers: { path: string; dir: SplitDir; at: number; box: Rect }[] = [];
|
||||
const walk = (n: PaneNode, box: Rect, path: string) => {
|
||||
if (n.type === "pane") {
|
||||
panes.push({ slot: n.slot, ...box });
|
||||
return;
|
||||
}
|
||||
dividers.push({ path, dir: n.dir, at: n.ratio, box });
|
||||
if (n.dir === "row") {
|
||||
const w = box.w * n.ratio;
|
||||
walk(n.a, { ...box, w }, path + "a");
|
||||
walk(n.b, { ...box, x: box.x + w, w: box.w - w }, path + "b");
|
||||
} else {
|
||||
const h = box.h * n.ratio;
|
||||
walk(n.a, { ...box, h }, path + "a");
|
||||
walk(n.b, { ...box, y: box.y + h, h: box.h - h }, path + "b");
|
||||
}
|
||||
};
|
||||
if (node) walk(node, { x: 0, y: 0, w: 1, h: 1 }, "");
|
||||
return { panes, dividers };
|
||||
}
|
||||
|
||||
function parseNode(raw: unknown): PaneNode | null {
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
const n = raw as Record<string, unknown>;
|
||||
if (n.type === "pane") {
|
||||
return Number.isInteger(n.slot) && (n.slot as number) >= 0 && (n.slot as number) < MAX_PANES
|
||||
? pane(n.slot as number)
|
||||
: null;
|
||||
}
|
||||
if (n.type !== "split" || (n.dir !== "row" && n.dir !== "col")) return null;
|
||||
const a = parseNode(n.a);
|
||||
const b = parseNode(n.b);
|
||||
if (!a || !b) return null;
|
||||
const ratio = typeof n.ratio === "number" && Number.isFinite(n.ratio) ? clampRatio(n.ratio) : 0.5;
|
||||
return split(n.dir, a, b, ratio);
|
||||
}
|
||||
|
||||
/** A stored layout, checked: anything malformed, a slot out of range, a slot
|
||||
* shown twice or more than `MAX_PANES` panes is no layout at all. */
|
||||
export function parseLayout(raw: unknown): PaneLayout {
|
||||
if (!raw || typeof raw !== "object") return EMPTY;
|
||||
const root = parseNode((raw as { root?: unknown }).root);
|
||||
const shown = leaves(root);
|
||||
if (!root || shown.length > MAX_PANES || new Set(shown).size !== shown.length) return EMPTY;
|
||||
const focus = (raw as { focus?: unknown }).focus;
|
||||
return { root, focus: focusOf({ root, focus: typeof focus === "number" ? focus : null }) };
|
||||
}
|
||||
|
||||
function readAll(): Record<string, unknown> {
|
||||
try {
|
||||
const all = JSON.parse(window.localStorage.getItem(STORAGE_KEY) ?? "{}");
|
||||
return all && typeof all === "object" ? all : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function loadLayout(laneId: number): PaneLayout {
|
||||
return parseLayout(readAll()[String(laneId)]);
|
||||
}
|
||||
|
||||
export function saveLayout(laneId: number, layout: PaneLayout): void {
|
||||
const all = readAll();
|
||||
if (layout.root) all[String(laneId)] = layout;
|
||||
else delete all[String(laneId)];
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(all));
|
||||
} catch {
|
||||
/* disabled storage: the layout lives for this page only */
|
||||
}
|
||||
}
|
||||
+279
-85
@@ -41,7 +41,7 @@
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
@@ -56,12 +56,25 @@ import {
|
||||
ChartColumn,
|
||||
} from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import type { CwdSuggestion, RunListResponse } from "../lib/api";
|
||||
import type { CwdSuggestion, RunHandle, RunListResponse } from "../lib/api";
|
||||
import type { Lane, LaneFeature, ProofFeature } from "../lib/types";
|
||||
import { keepIfSame } from "../lib/keepIfSame";
|
||||
import { useLanes } from "../hooks/useLanes";
|
||||
import { LaneConsolePane } from "../components/run/LaneConsolePane";
|
||||
import { ConsoleTabs } from "../components/run/ConsoleTabs";
|
||||
import { type LayoutUpdate, PaneGrid, usePaneLayout } from "../components/run/PaneGrid";
|
||||
import {
|
||||
type Edge,
|
||||
type PaneLayout,
|
||||
closeInLayout,
|
||||
leaves,
|
||||
moveInLayout,
|
||||
openInLayout,
|
||||
pane,
|
||||
pruneLayout,
|
||||
} from "../lib/paneLayout";
|
||||
import { useAiTools } from "../hooks/useAiTools";
|
||||
import { freeSlot } from "../components/lanes/WorkspaceHome";
|
||||
import PipelineMap from "../components/lanes/PipelineMap";
|
||||
import LaneCard, { since, LaneToolsMenu } from "../components/lanes/LaneCard";
|
||||
import { AddLaneModal } from "../components/lanes/AddLaneModal";
|
||||
@@ -115,10 +128,16 @@ function slotOf(runId: string | null | undefined): number {
|
||||
return m ? Number(m[1] || 0) : 0;
|
||||
}
|
||||
|
||||
/** The session view's console: a tab bar over one `LaneConsolePane` per slot
|
||||
* (0–3, each its own tmux session on the selected lane). The active tab is
|
||||
* the URL's `?run=`; every other tab with a live run stays MOUNTED, hidden
|
||||
* with CSS, so switching tabs never drops a terminal. */
|
||||
/** The session view's console: a tab bar over the workspace's split panes
|
||||
* (`PaneGrid`; layout model in `lib/paneLayout.ts`), one `LaneConsolePane`
|
||||
* per slot 0–3, each its own tmux session on the selected lane. The focused
|
||||
* pane is the URL's `?run=`: a plain open (a tab, a sidebar row) shows its run
|
||||
* in the focused pane — or focuses the pane already showing it — and a split
|
||||
* open (Ctrl/Cmd+click) opens it to the right of the focused one. Every tab
|
||||
* with a live run stays MOUNTED, hidden with CSS while no pane shows it, so
|
||||
* neither switching tabs nor reshaping the panes ever drops a terminal. A
|
||||
* pane whose run ends closes; the last one leaves for another tab, else the
|
||||
* home. */
|
||||
function ConsoleArea({
|
||||
lanes,
|
||||
selectedLaneId,
|
||||
@@ -135,15 +154,19 @@ function ConsoleArea({
|
||||
binaryStatus: Record<string, { found: boolean; path: string | null }> | null;
|
||||
activeRuns: RunListResponse | null;
|
||||
onHasActiveRunChange: (val: boolean) => void;
|
||||
/** The opened run (`?run=`): its tab is the active one. */
|
||||
attachRequest: { runId: string; nonce: string } | null;
|
||||
/** The opened run (`?run=`): its pane is the focused one. `split` opens it
|
||||
* beside the focused pane instead of in it; `focusOnly` (the URL following a
|
||||
* focus change made here) never opens anything. */
|
||||
attachRequest: { runId: string; nonce: string; split?: boolean; focusOnly?: boolean } | null;
|
||||
/** Back to the workspace home, where a new session is started. */
|
||||
onNewSession?: () => void;
|
||||
/** Opens a tab's run (`?run=`). */
|
||||
onOpenRun: (runId: string) => void;
|
||||
/** Opens a run (`?run=`); `split` splits, `focusOnly` only follows the focus. */
|
||||
onOpenRun: (runId: string, opts?: { split?: boolean; focusOnly?: boolean }) => void;
|
||||
/** A tab was opened or closed: re-read the run list now. */
|
||||
onRunsChanged: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const aiTools = useAiTools();
|
||||
const lane = lanes.find((l) => l.id === selectedLaneId) ?? null;
|
||||
const tabs = useMemo(
|
||||
() =>
|
||||
@@ -152,53 +175,191 @@ function ConsoleArea({
|
||||
.sort((a, b) => (a.slot ?? 0) - (b.slot ?? 0)),
|
||||
[activeRuns, selectedLaneId]
|
||||
);
|
||||
const activeSlot = slotOf(attachRequest?.runId);
|
||||
const slots = [...new Set([activeSlot, ...tabs.map((r) => r.slot ?? 0)])].sort();
|
||||
// Off the active tab: the next one still open, else the home.
|
||||
const leave = (slot: number) => {
|
||||
const next = tabs.find((r) => (r.slot ?? 0) !== slot);
|
||||
if (next) onOpenRun(next.id);
|
||||
const [layout, setLayout] = usePaneLayout(selectedLaneId);
|
||||
// The latest layout, updated the moment a change is made: two changes in
|
||||
// one commit (an open request and a prune) must build on each other.
|
||||
const layoutRef = useRef(layout);
|
||||
layoutRef.current = layout;
|
||||
// Slots opened here whose run the polled list has not caught up with yet,
|
||||
// each with the list it was opened against: pruning spares one until a
|
||||
// NEWER list still lacks it (a run that died before it was ever listed).
|
||||
const pending = useRef(new Map<number, RunListResponse | null>());
|
||||
// The workspace whose first run list was read. That first read prunes, so a
|
||||
// remembered layout loses its dead panes on arrival; after it a shown pane
|
||||
// closes on its terminal's exit, not on the polled list — one bad read (the
|
||||
// server answers an empty list on any tmux error) must not wipe a split.
|
||||
const listed = useRef<number | null>(null);
|
||||
// Runs seen ending here (`id@startedAt`): a list not yet refreshed must not
|
||||
// send the last pane to one of them.
|
||||
const exited = useRef(new Set<string>());
|
||||
const laneRef = useRef(selectedLaneId);
|
||||
laneRef.current = selectedLaneId;
|
||||
const [splitError, setSplitError] = useState<string | null>(null);
|
||||
const label = (r: RunHandle) => (r.tool === "shell" ? t("tabs.terminal") : aiTools.label(r.tool));
|
||||
const runOf = (slot: number | null) => tabs.find((r) => (r.slot ?? 0) === slot);
|
||||
const runKey = (r: RunHandle) => `${r.id}@${r.startedAt}`;
|
||||
|
||||
// The URL's `?run=` names the focused pane, so the right panel and the
|
||||
// sidebar read it. A change made HERE that moves the focus pushes it; there
|
||||
// is no effect watching the focus — an open request and that effect would
|
||||
// keep overriding each other. Session view only: the home and a replay
|
||||
// keep their URL.
|
||||
const followFocus = (next: PaneLayout) => {
|
||||
if (!attachRequest) return;
|
||||
const run = runOf(next.focus);
|
||||
if (run && run.id !== attachRequest.runId) onOpenRun(run.id, { focusOnly: true });
|
||||
};
|
||||
const change: LayoutUpdate = (fn) => {
|
||||
const next = fn(layoutRef.current);
|
||||
if (next === layoutRef.current) return;
|
||||
layoutRef.current = next;
|
||||
setLayout(() => next);
|
||||
followFocus(next);
|
||||
};
|
||||
|
||||
// Another workspace: nothing pending or exited carries over.
|
||||
useEffect(() => {
|
||||
pending.current.clear();
|
||||
exited.current.clear();
|
||||
}, [selectedLaneId]);
|
||||
|
||||
// An open request (a new nonce): focus, replace or split. Re-run when the
|
||||
// lane arrives on a cold load; only this lane's own tabs.
|
||||
useEffect(() => {
|
||||
if (!attachRequest || selectedLaneId === null) return;
|
||||
if (!new RegExp(`^agenthub-lane-${selectedLaneId}(-[1-3])?$`).test(attachRequest.runId)) return;
|
||||
const slot = slotOf(attachRequest.runId);
|
||||
// The URL catching up with a focus change: by now that pane may be closed
|
||||
// or swapped, and re-opening it would undo what the user just did. Back/
|
||||
// Forward onto such an entry puts the real focus back in the URL instead.
|
||||
if (attachRequest.focusOnly) {
|
||||
if (leaves(layoutRef.current.root).includes(slot))
|
||||
change((l) => (l.focus === slot ? l : { ...l, focus: slot }));
|
||||
else followFocus(layoutRef.current);
|
||||
return;
|
||||
}
|
||||
if (!tabs.some((r) => (r.slot ?? 0) === slot)) pending.current.set(slot, activeRuns);
|
||||
change((l) => openInLayout(l, slot, attachRequest.split ? "split" : "replace"));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [attachRequest?.nonce, selectedLaneId]);
|
||||
|
||||
// The run list caught up: on a workspace's first read every pane whose run
|
||||
// is gone closes; after that only a pane opened here whose run never got
|
||||
// listed (a newer list still lacks it) does. A pane opened here whose run
|
||||
// just got listed takes the URL if it holds the focus.
|
||||
useEffect(() => {
|
||||
if (!activeRuns || selectedLaneId === null) return;
|
||||
const live = new Set(tabs.map((r) => r.slot ?? 0));
|
||||
const arrived: number[] = [];
|
||||
const expired = new Set<number>();
|
||||
for (const [s, list] of pending.current) {
|
||||
if (live.has(s)) arrived.push(s);
|
||||
else if (list !== activeRuns) expired.add(s);
|
||||
if (live.has(s) || list !== activeRuns) pending.current.delete(s);
|
||||
}
|
||||
const first = listed.current !== selectedLaneId;
|
||||
listed.current = selectedLaneId;
|
||||
const keep = first
|
||||
? new Set(pending.current.keys())
|
||||
: new Set(leaves(layoutRef.current.root).filter((s) => !expired.has(s)));
|
||||
const before = layoutRef.current;
|
||||
change((l) => pruneLayout(l, live, keep));
|
||||
if (layoutRef.current === before && before.focus !== null && arrived.includes(before.focus))
|
||||
followFocus(before);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeRuns, tabs, selectedLaneId]);
|
||||
|
||||
// A pane's run ended (it exited, or its tab was closed): its pane closes,
|
||||
// and in the session view the last one leaves for another tab, else the
|
||||
// home — the home and a replay only lose the pane, never their URL.
|
||||
const ended = (slot: number) => {
|
||||
onRunsChanged();
|
||||
const dead = runOf(slot);
|
||||
if (dead) exited.current.add(runKey(dead));
|
||||
if (!leaves(layoutRef.current.root).includes(slot)) return;
|
||||
change((l) => closeInLayout(l, slot));
|
||||
if (layoutRef.current.root || !attachRequest) return;
|
||||
const other = tabs.find((r) => (r.slot ?? 0) !== slot && !exited.current.has(runKey(r)));
|
||||
if (other) onOpenRun(other.id);
|
||||
else onNewSession?.();
|
||||
};
|
||||
|
||||
// A split menu's "New terminal": a shell in the first free slot, placed on
|
||||
// that side of the pane — unless the workspace changed while it started.
|
||||
const free = lane ? freeSlot(lane.id, tabs) : null;
|
||||
const newSplit = async (target: number, edge: Edge) => {
|
||||
if (!lane || free === null) return;
|
||||
const laneId = lane.id;
|
||||
const slot = free;
|
||||
setSplitError(null);
|
||||
try {
|
||||
const r = await api.lanes.action(laneId, "start", { slot, shell: true });
|
||||
onRunsChanged();
|
||||
if (!r.run_id || laneRef.current !== laneId) return;
|
||||
pending.current.set(slot, activeRuns);
|
||||
change((l) => moveInLayout(l, slot, target, edge));
|
||||
} catch (err) {
|
||||
setSplitError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
const requested = attachRequest ? [slotOf(attachRequest.runId)] : [];
|
||||
// No layout yet (a first visit, a request not yet applied, or no run at
|
||||
// all): one pane, so the console always has its single render site.
|
||||
const fallback = requested[0] ?? 0;
|
||||
const view: PaneLayout = layout.root ? layout : { root: pane(fallback), focus: fallback };
|
||||
const shown = leaves(view.root);
|
||||
const mounted = [...new Set([...shown, ...tabs.map((r) => r.slot ?? 0)])].sort();
|
||||
const focusSlot = view.focus;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{lane && (
|
||||
<ConsoleTabs
|
||||
lane={lane}
|
||||
tabs={tabs}
|
||||
activeRunId={attachRequest?.runId ?? null}
|
||||
onOpen={(runId) => {
|
||||
activeRunId={runOf(focusSlot)?.id ?? attachRequest?.runId ?? null}
|
||||
shownRunIds={shown.map((s) => runOf(s)?.id).filter((id): id is string => !!id)}
|
||||
onOpen={(runId, split) => {
|
||||
onRunsChanged();
|
||||
onOpenRun(runId);
|
||||
}}
|
||||
onClosed={(runId) => {
|
||||
onRunsChanged();
|
||||
if (slotOf(runId) === activeSlot) leave(activeSlot);
|
||||
onOpenRun(runId, { split });
|
||||
}}
|
||||
onClosed={(runId) => ended(slotOf(runId))}
|
||||
// Per-workspace actions (mcp sync, sync-base check, readme).
|
||||
actions={<LaneToolsMenu lane={lane} />}
|
||||
/>
|
||||
)}
|
||||
<div className="flex min-h-0 flex-1 flex-col pt-2">
|
||||
{slots.map((slot) => (
|
||||
<div key={slot} className={slot === activeSlot ? "contents" : "hidden"}>
|
||||
<LaneConsolePane
|
||||
lanes={lanes}
|
||||
laneId={selectedLaneId}
|
||||
slot={slot}
|
||||
binaryStatus={binaryStatus}
|
||||
activeRuns={activeRuns}
|
||||
onHasActiveRunChange={slot === activeSlot ? onHasActiveRunChange : undefined}
|
||||
onNewSession={onNewSession}
|
||||
onRunExit={() => slot === activeSlot && leave(slot)}
|
||||
attachRequest={
|
||||
attachRequest && slotOf(attachRequest.runId) === slot ? attachRequest : null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{splitError && (
|
||||
<p role="alert" className="px-3 pt-2 text-xs text-status-danger">
|
||||
{splitError}
|
||||
</p>
|
||||
)}
|
||||
<PaneGrid
|
||||
layout={view}
|
||||
mounted={mounted}
|
||||
runs={tabs}
|
||||
label={label}
|
||||
onChange={change}
|
||||
canStart={free !== null}
|
||||
onNewSplit={(target, edge) => void newSplit(target, edge)}
|
||||
renderPane={(slot) => (
|
||||
<LaneConsolePane
|
||||
lanes={lanes}
|
||||
laneId={selectedLaneId}
|
||||
slot={slot}
|
||||
binaryStatus={binaryStatus}
|
||||
activeRuns={activeRuns}
|
||||
onHasActiveRunChange={slot === focusSlot ? onHasActiveRunChange : undefined}
|
||||
onNewSession={onNewSession}
|
||||
onRunExit={() => ended(slot)}
|
||||
// Only the focused pane takes the keyboard when it appears.
|
||||
autoFocus={slot === focusSlot}
|
||||
attachRequest={
|
||||
attachRequest && slotOf(attachRequest.runId) === slot ? attachRequest : null
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -220,9 +381,14 @@ export function Workspace() {
|
||||
// conversation, with a prompt box whose message resumes it (SessionReplay).
|
||||
const sessionParam = runParam ? null : searchParams.get("session");
|
||||
const navigate = useNavigate();
|
||||
// A Ctrl/Cmd+click (tab or sidebar) opens the run beside the focused pane;
|
||||
// it rides on the navigation's state, never in the URL.
|
||||
const navState = location.state as { split?: unknown; focusOnly?: unknown } | null;
|
||||
const splitOpen = navState?.split === true;
|
||||
const focusOnly = navState?.focusOnly === true;
|
||||
const attachRequest = useMemo(
|
||||
() => (runParam ? { runId: runParam, nonce: location.key } : null),
|
||||
[runParam, location.key]
|
||||
() => (runParam ? { runId: runParam, nonce: location.key, split: splitOpen, focusOnly } : null),
|
||||
[runParam, location.key, splitOpen, focusOnly]
|
||||
);
|
||||
const paramId = laneParam === null ? null : Number(laneParam);
|
||||
const fallbackId = (() => {
|
||||
@@ -496,8 +662,12 @@ export function Workspace() {
|
||||
// The session the Analyst button opens (`/sessions/:id`): the replayed one,
|
||||
// or the one the opened pane holds.
|
||||
const analystSessionId = sessionParam ?? openedRun?.sessionId ?? null;
|
||||
// The session view (console or replay) fills the page edge to edge, like an
|
||||
// editor: no page padding, no cards, dividers between its regions.
|
||||
const sessionView = !!currentLane && !home;
|
||||
const viewportLocked =
|
||||
!home && (paneHasActiveRun || (!!currentLane && changesOpen && changesSize === "full"));
|
||||
sessionView ||
|
||||
(!home && (paneHasActiveRun || (!!currentLane && changesOpen && changesSize === "full")));
|
||||
|
||||
const handleLaneAction = async (id: number, action: string, body?: Record<string, unknown>) => {
|
||||
setLaneActionError(null);
|
||||
@@ -545,9 +715,12 @@ export function Workspace() {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
viewportLocked
|
||||
? "h-[calc(100vh-4rem)] lg:h-[calc(100vh-4.5rem)] flex flex-col gap-5"
|
||||
: "space-y-5"
|
||||
sessionView
|
||||
? // Cancels Layout's page padding; the 1.5rem left is the status bar's.
|
||||
"-m-5 flex h-[calc(100vh-1.5rem)] flex-col lg:-m-6"
|
||||
: viewportLocked
|
||||
? "h-[calc(100vh-4rem)] lg:h-[calc(100vh-4.5rem)] flex flex-col gap-5"
|
||||
: "space-y-5"
|
||||
}
|
||||
>
|
||||
{/* Workspaces are picked in the sidebar's project tree; this page shows
|
||||
@@ -618,7 +791,7 @@ export function Workspace() {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className={`flex min-h-0 flex-1 gap-4${home ? " hidden" : ""}`}>
|
||||
<div className={`flex min-h-0 flex-1${home ? " hidden" : ""}`}>
|
||||
<section
|
||||
data-testid="lane-detail"
|
||||
className={`flex min-h-0 min-w-0 flex-1 flex-col${
|
||||
@@ -685,7 +858,7 @@ export function Workspace() {
|
||||
</div>
|
||||
)}
|
||||
{currentLane && (
|
||||
<div className="max-h-[45vh] shrink-0 overflow-y-auto">
|
||||
<div className="max-h-[45vh] shrink-0 overflow-y-auto px-3">
|
||||
{/* No margin when the card rendered nothing (see LaneCard). */}
|
||||
<div className="mb-3 has-[>div:empty]:mb-0">
|
||||
<LaneCard
|
||||
@@ -755,16 +928,18 @@ export function Workspace() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2">
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{replaying && (
|
||||
<SessionReplay
|
||||
lane={currentLane}
|
||||
sessionId={sessionParam}
|
||||
liveRuns={activeRuns?.items ?? []}
|
||||
onResumed={(runId) =>
|
||||
setSearchParams({ lane: String(currentLane.id), run: runId })
|
||||
}
|
||||
/>
|
||||
<div className="flex min-h-0 flex-1 flex-col px-4 pb-3 pt-3">
|
||||
<SessionReplay
|
||||
lane={currentLane}
|
||||
sessionId={sessionParam}
|
||||
liveRuns={activeRuns?.items ?? []}
|
||||
onResumed={(runId) =>
|
||||
setSearchParams({ lane: String(currentLane.id), run: runId })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* A replay only HIDES the console: it has one render site and a
|
||||
live terminal must never unmount. */}
|
||||
@@ -781,8 +956,20 @@ export function Workspace() {
|
||||
? () => setSearchParams({ lane: String(currentLane.id) })
|
||||
: undefined
|
||||
}
|
||||
onOpenRun={(runId) =>
|
||||
currentLane && setSearchParams({ lane: String(currentLane.id), run: runId })
|
||||
onOpenRun={(runId, opts) =>
|
||||
currentLane &&
|
||||
setSearchParams(
|
||||
{ lane: String(currentLane.id), run: runId },
|
||||
{
|
||||
// Following the focus is not a new history entry.
|
||||
replace: opts?.focusOnly,
|
||||
state: opts?.split
|
||||
? { split: true }
|
||||
: opts?.focusOnly
|
||||
? { focusOnly: true }
|
||||
: undefined,
|
||||
}
|
||||
)
|
||||
}
|
||||
onRunsChanged={refreshList}
|
||||
/>
|
||||
@@ -795,12 +982,17 @@ export function Workspace() {
|
||||
<aside
|
||||
id="lane-changes-panel"
|
||||
data-testid="lane-changes-card"
|
||||
className={`flex shrink-0 flex-col ${CHANGES_WIDTH[changesSize]}${
|
||||
changesSize === "full" ? "" : " border-l border-border pl-4"
|
||||
className={`flex shrink-0 flex-col bg-surface-1 ${CHANGES_WIDTH[changesSize]}${
|
||||
changesSize === "full" ? "" : " border-l border-border"
|
||||
}`}
|
||||
>
|
||||
{/* Activity bar: Files · Changes · Sessions, then the resize control. */}
|
||||
<div className="mb-2 flex items-center gap-1" role="toolbar">
|
||||
{/* Activity bar: Files · Changes · Sessions, then the resize
|
||||
control — the same height as the console's tab bar, so the
|
||||
two read as one header strip. */}
|
||||
<div
|
||||
className="flex h-10 shrink-0 items-center gap-1 border-b border-border px-2"
|
||||
role="toolbar"
|
||||
>
|
||||
{PANEL_TABS.map(({ id, Icon }) => (
|
||||
<button
|
||||
key={id}
|
||||
@@ -864,25 +1056,27 @@ export function Workspace() {
|
||||
})()}
|
||||
</button>
|
||||
</div>
|
||||
<LaneChangesPanel
|
||||
// A new tree is a new panel: no read for the previous one
|
||||
// can land in it.
|
||||
key={`${currentLane.id}:${gitSession ?? ""}`}
|
||||
laneId={currentLane.id}
|
||||
session={gitSession}
|
||||
tab={panelTab}
|
||||
full={changesSize === "full"}
|
||||
// A sibling worktree forks from the lane's SOURCE repo when
|
||||
// it has one (this lane is itself a worktree of it); an
|
||||
// adopted lane is its own repo.
|
||||
onNewLane={(branch) => {
|
||||
setAddLanePrefill({
|
||||
sourceRepo: currentLane.source_repo || currentLane.cwd,
|
||||
branch,
|
||||
});
|
||||
setAddLaneOpen(true);
|
||||
}}
|
||||
/>
|
||||
<div className="flex min-h-0 flex-1 flex-col px-3 pt-2">
|
||||
<LaneChangesPanel
|
||||
// A new tree is a new panel: no read for the previous one
|
||||
// can land in it.
|
||||
key={`${currentLane.id}:${gitSession ?? ""}`}
|
||||
laneId={currentLane.id}
|
||||
session={gitSession}
|
||||
tab={panelTab}
|
||||
full={changesSize === "full"}
|
||||
// A sibling worktree forks from the lane's SOURCE repo when
|
||||
// it has one (this lane is itself a worktree of it); an
|
||||
// adopted lane is its own repo.
|
||||
onNewLane={(branch) => {
|
||||
setAddLanePrefill({
|
||||
sourceRepo: currentLane.source_repo || currentLane.cwd,
|
||||
branch,
|
||||
});
|
||||
setAddLaneOpen(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
) : (
|
||||
<button
|
||||
@@ -893,7 +1087,7 @@ export function Workspace() {
|
||||
aria-controls="lane-changes-panel"
|
||||
aria-label={tLanes("changes.expand")}
|
||||
title={tLanes("changes.expand")}
|
||||
className="flex w-9 shrink-0 flex-col items-center gap-2 border-l border-border py-3 text-fg-muted transition-colors hover:text-fg-primary"
|
||||
className="flex w-9 shrink-0 flex-col items-center gap-2 border-l border-border bg-surface-1 py-3 text-fg-muted transition-colors hover:text-fg-primary"
|
||||
>
|
||||
<PanelLeftClose className="h-4 w-4" />
|
||||
<span className="text-[10px] uppercase tracking-wide [writing-mode:vertical-rl]">
|
||||
|
||||
@@ -259,8 +259,23 @@ vi.mock("../../lib/eventBus", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../../components/run/TerminalView", () => ({
|
||||
TerminalView: ({ runId }: { runId: string }) => (
|
||||
<div data-testid="terminal-view" data-run-id={runId} />
|
||||
// `exit-<runId>` stands in for the server's exit frame.
|
||||
TerminalView: ({
|
||||
runId,
|
||||
onExit,
|
||||
autoFocus,
|
||||
}: {
|
||||
runId: string;
|
||||
onExit?: () => void;
|
||||
autoFocus?: boolean;
|
||||
}) => (
|
||||
<div
|
||||
data-testid="terminal-view"
|
||||
data-run-id={runId}
|
||||
data-autofocus={autoFocus === false ? "false" : "true"}
|
||||
>
|
||||
<button data-testid={`exit-${runId}`} onClick={() => onExit?.()} />
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -598,6 +613,261 @@ describe("console tabs", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("split panes", () => {
|
||||
const live = (slot: number, tool = "claude") => ({
|
||||
id: slot ? `agenthub-lane-1-${slot}` : "agenthub-lane-1",
|
||||
laneId: 1,
|
||||
slot,
|
||||
status: "running",
|
||||
tool,
|
||||
model: null,
|
||||
promptPreview: null,
|
||||
});
|
||||
const original = vi.mocked(api.run.list).getMockImplementation();
|
||||
afterEach(() => {
|
||||
vi.mocked(api.run.list).mockImplementation(original!);
|
||||
});
|
||||
const withRuns = (items: ReturnType<typeof live>[]) =>
|
||||
vi.mocked(api.run.list).mockResolvedValue({ runs: [], items } as never);
|
||||
/** The URL's `?run=` — the focused pane's run. */
|
||||
function RunParam() {
|
||||
const [params] = useSearchParams();
|
||||
return <span data-testid="run-param">{params.get("run")}</span>;
|
||||
}
|
||||
const open = async (entry: string | { pathname: string; search: string; state?: unknown }) => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={[entry]}>
|
||||
<RunParam />
|
||||
<Workspace />
|
||||
</MemoryRouter>
|
||||
);
|
||||
await settle();
|
||||
await settle();
|
||||
};
|
||||
/** The slots a pane is drawn for (the rest are mounted and hidden). */
|
||||
const shownPanes = () =>
|
||||
screen
|
||||
.getAllByTestId(/^pane-\d$/)
|
||||
.filter((p) => !p.classList.contains("hidden"))
|
||||
.map((p) => p.dataset.testid);
|
||||
const ctrlClick = async (el: HTMLElement) => {
|
||||
const user = userEvent.setup();
|
||||
await user.keyboard("{Control>}");
|
||||
await user.click(el);
|
||||
await user.keyboard("{/Control}");
|
||||
await settle();
|
||||
};
|
||||
const stored = () =>
|
||||
JSON.parse(window.localStorage.getItem("agenthub.workspace.paneLayout") ?? "{}")["1"];
|
||||
|
||||
it("Ctrl+click on a tab opens it in a split beside the focused pane, and remembers it", async () => {
|
||||
withRuns([live(0), live(1)]);
|
||||
await open("/workspace?lane=1&run=agenthub-lane-1");
|
||||
expect(shownPanes()).toEqual(["pane-0"]);
|
||||
expect(screen.queryByTestId("pane-header-0")).toBeNull();
|
||||
|
||||
await ctrlClick(screen.getByTestId("console-tab-1"));
|
||||
expect(shownPanes()).toEqual(["pane-0", "pane-1"]);
|
||||
expect(screen.getByTestId("run-param").textContent).toBe("agenthub-lane-1-1");
|
||||
expect(screen.getByTestId("pane-1")).toHaveAttribute("data-focused", "true");
|
||||
expect(screen.getByTestId("pane-header-0")).toBeInTheDocument();
|
||||
// The other pane's tab is marked as shown; the focused one is the active tab.
|
||||
expect(screen.getByTestId("console-tab-1")).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByTestId("console-tab-0").parentElement).toHaveAttribute("data-shown", "true");
|
||||
expect(stored().root).toMatchObject({ type: "split", dir: "row" });
|
||||
});
|
||||
|
||||
it("a plain click focuses a tab another pane shows, and replaces the focused pane otherwise", async () => {
|
||||
withRuns([live(0), live(1), live(2)]);
|
||||
await open("/workspace?lane=1&run=agenthub-lane-1");
|
||||
await ctrlClick(screen.getByTestId("console-tab-1"));
|
||||
|
||||
await userEvent.setup().click(screen.getByTestId("console-tab-0"));
|
||||
await settle();
|
||||
expect(shownPanes()).toEqual(["pane-0", "pane-1"]);
|
||||
expect(screen.getByTestId("run-param").textContent).toBe("agenthub-lane-1");
|
||||
|
||||
await userEvent.setup().click(screen.getByTestId("console-tab-2"));
|
||||
await settle();
|
||||
expect(shownPanes()).toEqual(["pane-1", "pane-2"]);
|
||||
expect(screen.getByTestId("pane-2")).toHaveAttribute("data-focused", "true");
|
||||
});
|
||||
|
||||
it("closing a pane keeps its session as a tab; closing a tab closes its pane", async () => {
|
||||
withRuns([live(0), live(1), live(2)]);
|
||||
await open("/workspace?lane=1&run=agenthub-lane-1");
|
||||
await ctrlClick(screen.getByTestId("console-tab-1"));
|
||||
await ctrlClick(screen.getByTestId("console-tab-2"));
|
||||
expect(shownPanes()).toEqual(["pane-0", "pane-1", "pane-2"]);
|
||||
|
||||
await userEvent.setup().click(screen.getByTestId("pane-close-1"));
|
||||
await settle();
|
||||
expect(shownPanes()).toEqual(["pane-0", "pane-2"]);
|
||||
expect(api.run.kill).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId("console-tab-1")).toBeInTheDocument();
|
||||
|
||||
withRuns([live(0), live(1)]);
|
||||
await userEvent.setup().click(screen.getByTestId("console-tab-close-2"));
|
||||
await settle();
|
||||
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1-2");
|
||||
expect(shownPanes()).toEqual(["pane-0"]);
|
||||
expect(screen.getByTestId("run-param").textContent).toBe("agenthub-lane-1");
|
||||
});
|
||||
|
||||
it("drops a remembered pane whose session is gone", async () => {
|
||||
window.localStorage.setItem(
|
||||
"agenthub.workspace.paneLayout",
|
||||
JSON.stringify({
|
||||
1: {
|
||||
root: {
|
||||
type: "split",
|
||||
dir: "row",
|
||||
ratio: 0.5,
|
||||
a: { type: "pane", slot: 0 },
|
||||
b: { type: "pane", slot: 3 },
|
||||
},
|
||||
focus: 3,
|
||||
},
|
||||
})
|
||||
);
|
||||
withRuns([live(0)]);
|
||||
await open("/workspace?lane=1&run=agenthub-lane-1");
|
||||
expect(shownPanes()).toEqual(["pane-0"]);
|
||||
});
|
||||
|
||||
it("a sidebar Ctrl/Cmd+click (router state) splits; a plain one goes into the focused pane", async () => {
|
||||
withRuns([live(0), live(1)]);
|
||||
window.localStorage.setItem(
|
||||
"agenthub.workspace.paneLayout",
|
||||
JSON.stringify({ 1: { root: { type: "pane", slot: 0 }, focus: 0 } })
|
||||
);
|
||||
await open({
|
||||
pathname: "/workspace",
|
||||
search: "?lane=1&run=agenthub-lane-1-1",
|
||||
state: { split: true },
|
||||
});
|
||||
expect(shownPanes()).toEqual(["pane-0", "pane-1"]);
|
||||
});
|
||||
|
||||
it("a pane's picker swaps two shown panes", async () => {
|
||||
withRuns([live(0), live(1, "shell")]);
|
||||
await open("/workspace?lane=1&run=agenthub-lane-1");
|
||||
await ctrlClick(screen.getByTestId("console-tab-1"));
|
||||
expect(screen.getByTestId("pane-0").style.left).toBe("0%");
|
||||
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByTestId("pane-pick-0"));
|
||||
await user.click(screen.getByRole("menuitemradio", { name: /Terminal/ }));
|
||||
await settle();
|
||||
expect(screen.getByTestId("pane-1").style.left).toBe("0%");
|
||||
expect(screen.getByTestId("pane-0").style.left).toBe("50%");
|
||||
});
|
||||
|
||||
it("a split menu starts a new terminal in the first free slot", async () => {
|
||||
withRuns([live(0), live(1)]);
|
||||
await open("/workspace?lane=1&run=agenthub-lane-1");
|
||||
await ctrlClick(screen.getByTestId("console-tab-1"));
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByTestId("pane-split-bottom-1"));
|
||||
await user.click(screen.getByTestId("pane-split-new-bottom-1"));
|
||||
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", { slot: 2, shell: true });
|
||||
});
|
||||
|
||||
it("a pane whose run exits closes, and the URL follows the pane that takes its place", async () => {
|
||||
withRuns([live(0), live(1)]);
|
||||
await open("/workspace?lane=1&run=agenthub-lane-1");
|
||||
await ctrlClick(screen.getByTestId("console-tab-1"));
|
||||
expect(screen.getByTestId("run-param").textContent).toBe("agenthub-lane-1-1");
|
||||
withRuns([live(0)]);
|
||||
await act(async () => screen.getByTestId("exit-agenthub-lane-1-1").click());
|
||||
await settle();
|
||||
expect(shownPanes()).toEqual(["pane-0"]);
|
||||
expect(screen.getByTestId("run-param").textContent).toBe("agenthub-lane-1");
|
||||
});
|
||||
|
||||
it("a run ending while the home is shown closes its pane but never leaves the home", async () => {
|
||||
window.localStorage.setItem(
|
||||
"agenthub.workspace.paneLayout",
|
||||
JSON.stringify({
|
||||
1: {
|
||||
root: {
|
||||
type: "split",
|
||||
dir: "row",
|
||||
ratio: 0.5,
|
||||
a: { type: "pane", slot: 0 },
|
||||
b: { type: "pane", slot: 1 },
|
||||
},
|
||||
focus: 1,
|
||||
},
|
||||
})
|
||||
);
|
||||
withRuns([live(0), live(1)]);
|
||||
await open("/workspace?lane=1");
|
||||
expect(screen.getByTestId("run-param").textContent).toBe("");
|
||||
await act(async () => screen.getByTestId("exit-agenthub-lane-1-1").click());
|
||||
await settle();
|
||||
expect(screen.getByTestId("run-param").textContent).toBe("");
|
||||
expect(stored().root).toEqual({ type: "pane", slot: 0 });
|
||||
// The last one too: still the home.
|
||||
await act(async () => screen.getByTestId("exit-agenthub-lane-1").click());
|
||||
await settle();
|
||||
expect(screen.getByTestId("run-param").textContent).toBe("");
|
||||
});
|
||||
|
||||
it("an empty run-list read after the first does not wipe a split", async () => {
|
||||
withRuns([live(0), live(1)]);
|
||||
await open("/workspace?lane=1&run=agenthub-lane-1");
|
||||
await ctrlClick(screen.getByTestId("console-tab-1"));
|
||||
withRuns([]);
|
||||
await userEvent.setup().click(screen.getByTestId("console-tab-0"));
|
||||
await settle();
|
||||
expect(shownPanes()).toEqual(["pane-0", "pane-1"]);
|
||||
});
|
||||
|
||||
it("only the focused pane's terminal takes the keyboard when it appears", async () => {
|
||||
window.localStorage.setItem(
|
||||
"agenthub.workspace.paneLayout",
|
||||
JSON.stringify({
|
||||
1: {
|
||||
root: {
|
||||
type: "split",
|
||||
dir: "row",
|
||||
ratio: 0.5,
|
||||
a: { type: "pane", slot: 0 },
|
||||
b: { type: "pane", slot: 1 },
|
||||
},
|
||||
focus: 1,
|
||||
},
|
||||
})
|
||||
);
|
||||
withRuns([live(0), live(1)]);
|
||||
await open("/workspace?lane=1&run=agenthub-lane-1-1");
|
||||
const term = (id: string) =>
|
||||
screen.getAllByTestId("terminal-view").find((t) => t.dataset.runId === id);
|
||||
expect(term("agenthub-lane-1-1")).toHaveAttribute("data-autofocus", "true");
|
||||
expect(term("agenthub-lane-1")).toHaveAttribute("data-autofocus", "false");
|
||||
});
|
||||
|
||||
it("offers no new terminal in a split once every slot is busy", async () => {
|
||||
withRuns([live(0), live(1), live(2), live(3)]);
|
||||
await open("/workspace?lane=1&run=agenthub-lane-1");
|
||||
await ctrlClick(screen.getByTestId("console-tab-1"));
|
||||
await userEvent.setup().click(screen.getByTestId("pane-split-right-1"));
|
||||
expect(screen.getByTestId("pane-split-new-right-1")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("a divider resizes by keyboard", async () => {
|
||||
withRuns([live(0), live(1)]);
|
||||
await open("/workspace?lane=1&run=agenthub-lane-1");
|
||||
await ctrlClick(screen.getByTestId("console-tab-1"));
|
||||
const sep = screen.getByRole("separator");
|
||||
sep.focus();
|
||||
await userEvent.setup().keyboard("{ArrowRight}");
|
||||
await settle();
|
||||
expect(screen.getByTestId("pane-0").style.width).toBe("55.00000000000001%");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Workspace — home and session view", () => {
|
||||
it("opens a workspace on its home, hiding (not unmounting) the session view", async () => {
|
||||
await renderWorkspace();
|
||||
|
||||
@@ -5397,14 +5397,14 @@ exports[`screen snapshots > Run 1`] = `
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
class="flex min-h-0 flex-1 gap-4"
|
||||
class="flex min-h-0 flex-1"
|
||||
>
|
||||
<section
|
||||
class="flex min-h-0 min-w-0 flex-1 flex-col"
|
||||
data-testid="lane-detail"
|
||||
>
|
||||
<div
|
||||
class="flex min-h-0 flex-1 flex-col gap-2"
|
||||
class="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<div
|
||||
class="contents"
|
||||
@@ -5413,22 +5413,30 @@ exports[`screen snapshots > Run 1`] = `
|
||||
class="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<div
|
||||
class="flex min-h-0 flex-1 flex-col pt-2"
|
||||
class="relative min-h-0 flex-1"
|
||||
data-testid="pane-grid"
|
||||
>
|
||||
<div
|
||||
class="contents"
|
||||
class="absolute flex flex-col"
|
||||
data-focused="true"
|
||||
data-testid="pane-0"
|
||||
style="left: 0%; top: 0%; width: 100%; height: 100%;"
|
||||
>
|
||||
<div
|
||||
class="flex min-h-0 flex-1 flex-col gap-3"
|
||||
data-testid="console-body"
|
||||
class="flex min-h-0 flex-1 flex-col pb-1 pl-3 pr-1 pt-1.5"
|
||||
>
|
||||
<div
|
||||
class="flex flex-1 flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-border p-6 text-center text-sm text-fg-muted"
|
||||
data-testid="pane-no-run"
|
||||
class="flex min-h-0 flex-1 flex-col gap-3"
|
||||
data-testid="console-body"
|
||||
>
|
||||
<p>
|
||||
No session in this pane. Start one from the workspace's prompt box.
|
||||
</p>
|
||||
<div
|
||||
class="flex flex-1 flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-border p-6 text-center text-sm text-fg-muted"
|
||||
data-testid="pane-no-run"
|
||||
>
|
||||
<p>
|
||||
No session in this pane. Start one from the workspace's prompt box.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user