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:
2026-10-03 15:13:06 +07:00
parent 1597c110d4
commit 326dfa9d19
15 changed files with 1656 additions and 140 deletions
+2 -1
View File
File diff suppressed because one or more lines are too long
@@ -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={() => {
+42 -25
View File
@@ -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.
+518
View File
@@ -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>
);
}
+7 -2
View File
@@ -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
+12 -2
View File
@@ -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"
}
+12 -2
View File
@@ -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"
}
+223
View File
@@ -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 });
});
});
+250
View File
@@ -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
View File
@@ -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]">
+272 -2
View File
@@ -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>
+5 -3
View File
@@ -391,7 +391,7 @@ The dashboard web UI merges workspaces and runs into a single **Workspace** page
- **No page header.** The page title and the four counters (`lanes`, `running`, `needs you`, `dead`) are gone: the sidebar's workspace list shows every workspace with its own status dot, and the status bar at the bottom of every page counts running and need-you workspaces. `GET /api/lanes` still returns `counts` for other callers.
- **The sidebar's workspace list picks the workspace** — on every page, below the nav (`client/src/components/WorkspaceTree.tsx`), shaped like Claude Desktop's project list: every workspace on ONE level (no repo grouping, no worktree rows), in your stored order. A row (`WorkspaceRow.tsx`) is a chevron, a status dot (green = active, pulsing while running; amber row = needs you; red = dead or error; grey = idle), the title or folder name and the tool's icon, with `+` (open it for a new session) and `⋯` on hover. Clicking the name opens `/workspace?lane=<id>` and expands the row: the URL is the selection, so a notification link, F5 and the back button all land on the same workspace, and a bare `/workspace` reopens the last one shown (`agenthub.workspace.selected`) or the first. The header has a **tool filter** (shown once workspaces of two tools exist; kept in `agenthub.sidebar.projects` → `tool`, a stored tool no workspace has filters nothing), search and `+` (Add workspace). Search matches workspace names and paths — such a workspace keeps all its sessions — and session names: a workspace whose name does not match still shows when one of its sessions does, with only the matching sessions under it (every list stays mounted, hidden, to load and count). In the collapsed rail the list is a single icon that opens the sidebar.
- **A workspace's sessions** — the chevron expands the row to the sessions the workspace OWNS and has not archived (`WorkspaceSessionList.tsx`: `GET /api/sessions?lane=<id>&archived=0`, the newest 20, re-read when the workspace's status or session moves; which rows are expanded is kept in `agenthub.sidebar.projects` → `expanded`). A workspace owns a session its pane held — `bindRunSession` stamps `sessions.lane_id` from the pane's run, first writer wins, a nested `claude -p` excluded — and lists nothing else: a `claude` typed into a terminal in the same folder, or history from before the workspace existed, belongs to no workspace and is found on the Sessions page. Sessions older than this ownership column are not guessed: only those `dashboard_runs` still named were backfilled. A session started by a tool with no hooks (Codex without its hooks, any catalog tool) is never stamped. A row's dot is the session's state (`sessionDot`: amber waiting for you, red failed, green a live pane, hollow at rest); double-click renames it (`PATCH {name}`), and its hover button **archives** it (`PATCH {archived: true}` — hidden from the workspace, nothing deleted; the home lists the archived ones folded with **Unarchive**). A filled dot marks a session that still has a live pane (a `GET /api/run` handle carrying its session id — every pane the dashboard starts carries `AGENTHUB_RUN_ID=<its tmux name>`, the hook handler forwards it as `run_id`, and `bindRunSession` in `server/routes/hooks.js` records the hook's session on that run: the first session always, afterwards — for a Claude pane — only a `SessionStart` that is not a fresh `startup` (`/clear`, `/resume`), so a `claude -p` the agent runs inside the pane never takes it over; the rule is Claude-only, and a Codex or Antigravity pane follows every new session): clicking it opens `/workspace?lane=<id>&run=<runId>`, which ATTACHES pane 0 to that tmux session (`attachRequest` on `LaneConsolePane`) — it starts nothing and resumes nothing. The session opens on its own tab (the tab of its tmux slot). Any other session opens in its workspace the way Claude Desktop opens an old one: `/workspace?lane=<id>&session=<id>` shows its conversation, read-only (`SessionReplay.tsx`, the console hidden — never unmounted — behind it), with a prompt box under it. Opening it resumes nothing; **sending** a message resumes it (`POST /api/lanes/:id/start {slot, resumeSessionId, prompt}` in the first free pane slot, on the tool the session ran on) and opens that pane — a resume re-reads the whole transcript into context, so it waits for that explicit send. The session's analysis page (`/sessions/<id>`: agents, events, cost, transcript) is the **Analyst** button in the session view's right panel, beside Files and Changes (disabled while the pane's run has reported no session yet).
- **A workspace's sessions** — the chevron expands the row to the sessions the workspace OWNS and has not archived (`WorkspaceSessionList.tsx`: `GET /api/sessions?lane=<id>&archived=0`, the newest 20, re-read when the workspace's status or session moves; which rows are expanded is kept in `agenthub.sidebar.projects` → `expanded`). A workspace owns a session its pane held — `bindRunSession` stamps `sessions.lane_id` from the pane's run, first writer wins, a nested `claude -p` excluded — and lists nothing else: a `claude` typed into a terminal in the same folder, or history from before the workspace existed, belongs to no workspace and is found on the Sessions page. Sessions older than this ownership column are not guessed: only those `dashboard_runs` still named were backfilled. A session started by a tool with no hooks (Codex without its hooks, any catalog tool) is never stamped. A row's dot is the session's state (`sessionDot`: amber waiting for you, red failed, green a live pane, hollow at rest); double-click renames it (`PATCH {name}`), and its hover button **archives** it (`PATCH {archived: true}` — hidden from the workspace, nothing deleted; the home lists the archived ones folded with **Unarchive**). A filled dot marks a session that still has a live pane (a `GET /api/run` handle carrying its session id — every pane the dashboard starts carries `AGENTHUB_RUN_ID=<its tmux name>`, the hook handler forwards it as `run_id`, and `bindRunSession` in `server/routes/hooks.js` records the hook's session on that run: the first session always, afterwards — for a Claude pane — only a `SessionStart` that is not a fresh `startup` (`/clear`, `/resume`), so a `claude -p` the agent runs inside the pane never takes it over; the rule is Claude-only, and a Codex or Antigravity pane follows every new session): clicking it opens `/workspace?lane=<id>&run=<runId>`, which ATTACHES to that tmux session (`attachRequest` on `LaneConsolePane`) — it starts nothing and resumes nothing. The session opens on its own tab (the tab of its tmux slot): in the pane already showing it (focused), else in the focused pane; **Ctrl/Cmd+click** opens it in a split beside the focused pane (see **Split panes** below). Any other session opens in its workspace the way Claude Desktop opens an old one: `/workspace?lane=<id>&session=<id>` shows its conversation, read-only (`SessionReplay.tsx`, the console hidden — never unmounted — behind it), with a prompt box under it. Opening it resumes nothing; **sending** a message resumes it (`POST /api/lanes/:id/start {slot, resumeSessionId, prompt}` in the first free pane slot, on the tool the session ran on) and opens that pane — a resume re-reads the whole transcript into context, so it waits for that explicit send. The session's analysis page (`/sessions/<id>`: agents, events, cost, transcript) is the **Analyst** button in the session view's right panel, beside Files and Changes (disabled while the pane's run has reported no session yet).
- **Row menu** — right-click a row, or its `⋯` button: Start/Stop (slot 0), Rename, the Model (AI Router) select, Create/update readme file (Claude only), New workspace here…, Copy path, Reset worktree… (managed only) and Delete workspace…, each the call the rest of the UI already makes. Delete is record-only exactly as before (see "Removing a workspace").
- **The workspace home** — `/workspace?lane=<id>` opens on the workspace's home (`client/src/components/lanes/WorkspaceHome.tsx`), shaped like a Claude Desktop project page: the name (rename from its `⋯` menu, which is the sidebar row menu) with the repository it belongs to under it (root, the main worktree's branch, the worktree count, from `GET /api/projects` — the facts Settings → Projects used to show), a prompt box — the one place a session starts WITH a prompt (a pane has no run form; the session view's tab bar `+` only starts a bare CLI or a shell) — whose fields run in order **AI** (the session's own tool: every installed, enabled one, the workspace's by default), **AI Router** (Native or a saved profile; Native alone, disabled, for a tool that cannot use one), **Model** (the tool's own list, or on a router that router's models), **Permission** (Claude's permission mode; Codex's sandbox and approval; a catalog tool's per-session bypass switch) and **Effort** (Claude and Codex), and whose **Start** opens a NEW session in the first pane slot with no live run (0–3, `POST /api/lanes/:id/start {slot}`; all four busy is refused) and then shows it, and **Recents** — the workspace's own sessions (`?lane=<id>`) with their cost and time, a live one opening its pane and any other its conversation (`&session=`), exactly like the sidebar. The transcript page (`/sessions/<id>`) of a Claude session started in a workspace's own folder carries an explicit **Resume** (`client/src/components/lanes/SessionResumeButton.tsx`, the one place that resumes) and beside it **Fork**, which resumes the history into a NEW session id (`{forkSession: true}` → `--fork-session`, the original untouched, offered even while the original has a live pane): it warns that a resume re-reads the whole transcript into context, re-reads the live panes, then starts it in the workspace's first free pane (`POST /api/lanes/:id/start {slot, resumeSessionId}`) and opens it. A session from a SUBFOLDER gets no Resume — the pane starts in the workspace's folder and Claude finds a session by the folder it ran in — and a session that still has a live pane shows **Open pane** instead. On the right sit the workspace's own context: **Instructions** (appended to every new session's system prompt), **Memory** (read-only: the folder's CLAUDE.md / AGENTS.md family and Claude's auto memory), **Context** (uploaded files, ≤ 10 MB each, stored by AgentHub) and **Folder** (the workspace's own folder plus extra ones, with the repo's untracked worktrees suggested). Only Claude and Codex panes can take them (`--add-dir`, `--append-system-prompt-file=` / `-c developer_instructions=`); for any other tool the page says so and its panes start without them. See `docs/API.md` → Workspace context. The home HIDES the session view with CSS — the console never unmounts behind it.
- **A session in its own worktree** — Claude Desktop's worktree toggle: the row under the home's prompt box has a **Local / New worktree** select (Claude workspaces in a git repo). Claude Code itself makes a worktree only when started with `--worktree` or when asked mid-session — and a worktree it enters mid-session is invisible to AgentHub, which reads a session's folder once, at its start, and the new session's pane runs `claude --worktree=<name>`, so CLAUDE CODE makes `<repo>/.claude/worktrees/<name>` on branch `worktree-<name>`, copies `.worktreeinclude`, runs your `WorktreeCreate` hook, and on exit removes a worktree with no changes (or asks). AgentHub adds `/.claude/worktrees/` to the repo's `info/exclude` (Claude does not, and the main repo would list it). The session stays this workspace's own — listed with a branch icon and the worktree's name — and the session view's Changes panel works in ITS worktree (every git route with `?session=`, resolved from the session row: owned by the workspace, registered by `git worktree list`; `ENOWORKTREE` otherwise, never the main folder). Resume and Fork start in that worktree. **Archive** of a worktree session with no live pane asks first and removes the worktree like Claude Desktop — `git worktree remove` without force (git refuses unsaved work, and then nothing is archived) and `git branch -d` (unmerged work keeps its branch); a lock held by a live process refuses, a stale one is cleared. The workspace's own folder is never removed.
@@ -402,9 +402,11 @@ The dashboard web UI merges workspaces and runs into a single **Workspace** page
- **Renaming a workspace** — double-click its sidebar row (or Rename in the row menu) to edit in place. **Enter** saves via `PATCH /api/lanes/:id` with `{title}`, **Escape** and clicking away both cancel, and saving an empty name clears the title, which puts the folder name back in its place. Renaming is display-only — a managed workspace's branch name was slugified from its title once, at creation.
- **No header row** — the session view has no header of its own: the console's tab bar is it (status, the workspace's name and the back arrow are gone — the name is on the sidebar row and the home). Only a workspace with an installed pipeline template shows its time-on-stage, pipeline picker and feature picker above the console; there are no stage chips — the declared stage and the `auto: <stage>` detection both render in the PipelineMap. The **Tools** menu (mcp sync / sync-base check / create-update readme, hidden when the workspace declares no runtime profile) sits at the right end of the tab bar. The detail block (the workspace card, the PipelineMap, the proof gallery) is always shown, and Delete lives in the sidebar row menu. Tools sits on the workspace rather than in Settings because every one of its actions runs against one workspace's `cwd`. **Create/update readme file** (Claude workspaces only — Codex has no slash commands) starts the workspace's terminal run with `/sg-all:sg-md-writing` as its first message, so the workspace opens already writing/refreshing its `CLAUDE.md`/`AGENTS.md` set; it needs the `sg-all` plugin installed in Claude Code, and without it the session just reports an unknown command.
- **Detail panel** — the selected workspace's declared stage, its inferred stage when detection leads, a full-width pipeline map, and a legend naming all five node states plus the dashed-amber inferred treatment Hidden while no pipeline template is installed (see "Pipeline stages and the five node states").
- **Terminal** — a real interactive terminal (xterm.js) displaying the tmux session's PTY output, with full support for interactive commands, editors, and pagers. A live run keeps its rendered history and scroll position when scrolling. It grabs the keyboard the moment it appears, so typing goes into the pane without clicking it first. A pane has **no run form of its own**: a session with a prompt starts from the workspace home's prompt box. There is no **New run** button: when the active tab's process ends (a double Ctrl+C out of `claude`, `exit`, a crash) the next open tab is shown, or the home when none is left; a tab with nothing to show offers **New session**, which opens the home too.
- **Terminal** — a real interactive terminal (xterm.js) displaying the tmux session's PTY output, with full support for interactive commands, editors, and pagers. A live run keeps its rendered history and scroll position when scrolling. It grabs the keyboard the moment it appears, so typing goes into the pane without clicking it first. A pane has **no run form of its own**: a session with a prompt starts from the workspace home's prompt box. There is no **New run** button: when a pane's process ends (a double Ctrl+C out of `claude`, `exit`, a crash) its pane closes; the last one shows the next open tab, or the home when none is left. A pane with nothing to show offers **New session**, which opens the home too.
- **Clipboard in a pane** — the workspace's tmux session is started with the graphical session's own `DISPLAY`/`WAYLAND_DISPLAY`/`XAUTHORITY`/`XDG_RUNTIME_DIR` (read from `systemctl --user show-environment`, this process's own values winning; `server/lib/desktop-env.js`). The dashboard normally runs as a systemd *user* service, which has none of them, so a pane it started had no clipboard at all and `claude` answered "no image found in clipboard" on every image paste while the same CLI pasted fine in a terminal. Only those four names are forwarded, and only into panes this dashboard spawns — an already-running pane keeps the environment it was started with, so it needs a restart to gain one.
- **Tabs** — the session view's console is a tab bar (`client/src/components/run/ConsoleTabs.tsx`), one tab per live tmux session of the workspace — at most four, its slots 0–3: slot 0 is the workspace's `agenthub-lane-<id>` (its `run_id`), slots 1–3 are `agenthub-lane-<id>-<slot>`. A tab shows its AI tool's icon and name (or **Terminal**); the active tab is the URL's `?run=`, so the Changes panel and Analyst follow it. Every tab with a live run stays mounted and the inactive ones are hidden with CSS, so switching tabs never drops a terminal. A tab's **×** stops its session (`DELETE /api/run/<runId>`) and moves to the next tab, or the home. The **+** menu opens a new tab in the first free slot: **New terminal** (your own default shell in the workspace folder — `POST /api/lanes/:id/start {slot, shell: true}`, recorded as tool `shell`; it is not an agent, so the workspace's `run_id`/`status` are left alone), each installed, enabled AI tool (started bare, on its default model, no prompt), and **Agent settings…** (Settings → Agents). With four tabs open **+** is disabled. EVERY tab is bound to the sidebar's selection. A tab has no header of its own (no title, no **Active runs** list, no **New run**): other sessions are opened from the sidebar or the home's Recents. The 1/2/4 split layout and its `agenthub.workspace.splitView` key are gone.
- **Tabs** — the session view's console is a tab bar (`client/src/components/run/ConsoleTabs.tsx`), one tab per live tmux session of the workspace — at most four, its slots 0–3: slot 0 is the workspace's `agenthub-lane-<id>` (its `run_id`), slots 1–3 are `agenthub-lane-<id>-<slot>`. A tab shows its AI tool's icon and name (or **Terminal**); the active tab is the focused pane's, which is the URL's `?run=`, so the Changes panel and Analyst follow it, and a tab another pane shows carries a grey underline. A click shows a tab in the focused pane (or focuses the pane already showing it), **Ctrl/Cmd+click** — on a tab or on a `+` item — opens it in a split, and a tab drags onto the panes. Every tab with a live run stays mounted and those no pane shows are hidden with CSS, so switching tabs never drops a terminal. A tab's **×** stops its session (`DELETE /api/run/<runId>`) and closes its pane; with no pane left it moves to the next tab, or the home. The **+** menu opens a new tab in the first free slot: **New terminal** (your own default shell in the workspace folder — `POST /api/lanes/:id/start {slot, shell: true}`, recorded as tool `shell`; it is not an agent, so the workspace's `run_id`/`status` are left alone), each installed, enabled AI tool (started bare, on its default model, no prompt), and **Agent settings…** (Settings → Agents). With four tabs open **+** is disabled. EVERY tab is bound to the sidebar's selection. A tab has no header of its own (no title, no **Active runs** list, no **New run**): other sessions are opened from the sidebar or the home's Recents. (The old fixed 1/2/4 split layout and its `agenthub.workspace.splitView` key are gone; **Split panes** replaces them.)
- **Split panes** — the console splits like VS Code (`client/src/components/run/PaneGrid.tsx`, model in `client/src/lib/paneLayout.ts`): a binary tree of splits, each side by side or stacked at a ratio, whose leaves are the workspace's slots — at most four panes, one per live session, a session never shown twice. **Ctrl/Cmd+click** a tab, a `+` item or a sidebar session row opens it to the right of the focused pane (the intent rides on the navigation's state, never in the URL); with more than one pane each gets a slim header: **drag** it (or a tab) onto another pane — its edge splits there, its middle swaps the two —, its **▾** shows another live session in it (swapping with the pane that showed it), **Split right / Split down** puts a not-shown session or a **New terminal** beside it, and **×** closes the pane only (its session stays a tab). Dividers drag (or move by arrow keys) between 10% and 90%. Clicking in (or tabbing into) a pane focuses it, `?run=` follows — so the right panel, Analyst and the sidebar read the focused session — and the keyboard goes with it: only the focused pane's terminal takes it when it appears. A pane closes when its terminal reports that its session ended (exited, closed from another browser); the run list itself only prunes a remembered layout on a workspace's first read, and a pane opened here whose session never got listed — one empty read, which the server returns on any tmux error, never wipes a split. A session ending while the home or a replay is shown only closes its pane; the URL stays. The layout is a viewer preference kept per workspace in `localStorage` (`agenthub.workspace.paneLayout`), never on the server. Every pane is an absolutely placed sibling keyed by its slot, so splitting, swapping or resizing never re-parents — and so never remounts — a live terminal; the session view itself fills the page edge to edge, its tab bar and the right panel's activity bar one header strip.
- **Workspace grid** — one card per workspace, 1 column, 2 at `md`, 3 at `xl`. Each card carries the workspace id, liveness dot and status, title, declared stage and time-on-stage, the `auto:` chip when detection leads, the kind and CI tags, the working-copy facts from `GET /api/lanes/:id/git`, and the action row. The needs-you message (`needs_action`) is on the workspace list tile only — the detail card no longer repeats it as a banner.
Run history is per workspace, queryable via `GET /api/run/history?laneId=<n>`.