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:
@@ -9,7 +9,9 @@
|
||||
* worktree is still the workspace home's prompt box) — plus a link to
|
||||
* Settings → Agents. Four tabs is the server's limit (`MAX_PANE_SLOT`). The
|
||||
* bar is the console's only header; `actions` (the workspace's Tools menu)
|
||||
* sits at its right end.
|
||||
* sits at its right end. A click shows a tab in the focused pane, Ctrl/Cmd+click
|
||||
* (on a tab or a `+` item) opens it in a split to the right, and a tab drags
|
||||
* onto the panes (`PaneGrid.tsx`); a tab another pane shows is marked.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -23,11 +25,13 @@ import type { Lane } from "../../lib/types";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
import { freeSlot } from "../lanes/WorkspaceHome";
|
||||
import { RunIcon, startPaneDrag } from "./PaneGrid";
|
||||
|
||||
export function ConsoleTabs({
|
||||
lane,
|
||||
tabs,
|
||||
activeRunId,
|
||||
shownRunIds = [],
|
||||
onOpen,
|
||||
onClosed,
|
||||
actions,
|
||||
@@ -35,8 +39,12 @@ export function ConsoleTabs({
|
||||
lane: Lane;
|
||||
/** The workspace's live runs, in slot order. */
|
||||
tabs: RunHandle[];
|
||||
/** The focused pane's run. */
|
||||
activeRunId: string | null;
|
||||
onOpen: (runId: string) => void;
|
||||
/** Runs the other panes show. */
|
||||
shownRunIds?: string[];
|
||||
/** `split`: Ctrl/Cmd+click — open beside the focused pane. */
|
||||
onOpen: (runId: string, split?: boolean) => void;
|
||||
/** A tab's session was stopped. */
|
||||
onClosed: (runId: string) => void;
|
||||
/** Right end of the bar (the workspace's Tools menu). */
|
||||
@@ -73,13 +81,13 @@ export function ConsoleTabs({
|
||||
]),
|
||||
];
|
||||
|
||||
const open = async (body: Record<string, unknown>) => {
|
||||
const open = async (body: Record<string, unknown>, split = false) => {
|
||||
setMenuOpen(false);
|
||||
if (slot === null) return;
|
||||
setError(null);
|
||||
try {
|
||||
const r = await api.lanes.action(lane.id, "start", { slot, ...body });
|
||||
if (r.run_id) onOpen(r.run_id);
|
||||
if (r.run_id) onOpen(r.run_id, split);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
@@ -99,36 +107,42 @@ export function ConsoleTabs({
|
||||
"flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm text-fg-secondary hover:bg-surface-4 hover:text-fg-primary";
|
||||
|
||||
return (
|
||||
<div className="flex h-10 shrink-0 items-stretch border-b border-border">
|
||||
<div className="flex h-10 shrink-0 items-stretch border-b border-border bg-surface-1">
|
||||
<div role="tablist" aria-label={t("tabs.label")} className="flex min-w-0 items-stretch">
|
||||
{tabs.map((r) => {
|
||||
const active = r.id === activeRunId;
|
||||
const shown = !active && shownRunIds.includes(r.id);
|
||||
return (
|
||||
<div
|
||||
key={r.id}
|
||||
draggable
|
||||
onDragStart={(e) => startPaneDrag(e, r.slot ?? 0)}
|
||||
data-shown={shown ? "true" : undefined}
|
||||
className={`group relative flex min-w-0 max-w-[15rem] items-center rounded-t-md ${
|
||||
active
|
||||
? "text-fg-primary"
|
||||
: "text-fg-muted hover:bg-surface-4 hover:text-fg-secondary"
|
||||
? "bg-surface-0 text-fg-primary"
|
||||
: shown
|
||||
? "text-fg-secondary hover:bg-surface-4"
|
||||
: "text-fg-muted hover:bg-surface-4 hover:text-fg-secondary"
|
||||
}`}
|
||||
>
|
||||
{active && (
|
||||
<span className="absolute inset-x-2 -bottom-px h-0.5 rounded-full bg-accent" />
|
||||
{(active || shown) && (
|
||||
<span
|
||||
className={`absolute inset-x-2 -bottom-px h-0.5 rounded-full ${
|
||||
active ? "bg-accent" : "bg-fg-muted/50"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
data-testid={`console-tab-${r.slot ?? 0}`}
|
||||
onClick={() => onOpen(r.id)}
|
||||
title={[r.model, r.promptPreview].filter(Boolean).join(" · ") || undefined}
|
||||
onClick={(e) => onOpen(r.id, e.ctrlKey || e.metaKey)}
|
||||
title={[r.model, r.promptPreview, t("tabs.splitHint")].filter(Boolean).join("\n")}
|
||||
className="flex h-full min-w-0 items-center gap-2 pl-3 pr-1.5 text-sm"
|
||||
>
|
||||
{r.tool === "shell" ? (
|
||||
<SquareTerminal className="h-4 w-4 shrink-0" />
|
||||
) : (
|
||||
<AiToolIcon tool={{ id: r.tool, label: label(r) }} className="h-4 w-4 !p-0" />
|
||||
)}
|
||||
<RunIcon run={r} label={label(r)} />
|
||||
<span className="truncate">{label(r)}</span>
|
||||
</button>
|
||||
<button
|
||||
@@ -171,7 +185,7 @@ export function ConsoleTabs({
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={item}
|
||||
onClick={() => void open({ shell: true })}
|
||||
onClick={(e) => void open({ shell: true }, e.ctrlKey || e.metaKey)}
|
||||
>
|
||||
<SquareTerminal className="h-4 w-4" />
|
||||
{t("tabs.newTerminal")}
|
||||
@@ -186,14 +200,17 @@ export function ConsoleTabs({
|
||||
role="menuitem"
|
||||
data-testid={`console-tab-new-${id}`}
|
||||
className={item}
|
||||
onClick={() =>
|
||||
void open({
|
||||
prompt: "",
|
||||
// Only a different tool is named, as the home does: the
|
||||
// workspace's own keeps starting even once disabled.
|
||||
...(id !== lane.tool ? { tool: id } : {}),
|
||||
...(model ? { model } : {}),
|
||||
})
|
||||
onClick={(e) =>
|
||||
void open(
|
||||
{
|
||||
prompt: "",
|
||||
// Only a different tool is named, as the home does: the
|
||||
// workspace's own keeps starting even once disabled.
|
||||
...(id !== lane.tool ? { tool: id } : {}),
|
||||
...(model ? { model } : {}),
|
||||
},
|
||||
e.ctrlKey || e.metaKey
|
||||
)
|
||||
}
|
||||
>
|
||||
<AiToolIcon tool={{ id, label: aiTools.label(id) }} className="h-4 w-4 !p-0" />
|
||||
|
||||
@@ -40,6 +40,8 @@ export interface LaneConsolePaneProps {
|
||||
onNewSession?: () => void;
|
||||
/** The pane's run exited (the tab bar opens another tab, or the home). */
|
||||
onRunExit?: () => void;
|
||||
/** The terminal takes the keyboard when it appears (default). */
|
||||
autoFocus?: boolean;
|
||||
}
|
||||
|
||||
/** The tmux session a lane's tab owns (see server/lib/pty-run.js). */
|
||||
@@ -57,6 +59,7 @@ export function LaneConsolePane({
|
||||
attachRequest,
|
||||
onNewSession,
|
||||
onRunExit,
|
||||
autoFocus,
|
||||
}: LaneConsolePaneProps) {
|
||||
const { t } = useTranslation("run");
|
||||
const [handle, setHandle] = useState<RunHandle | null>(null);
|
||||
@@ -169,6 +172,7 @@ export function LaneConsolePane({
|
||||
<TerminalView
|
||||
runId={handle.id}
|
||||
wsBaseUrl={window.location.origin.replace(/^http/, "ws")}
|
||||
autoFocus={autoFocus}
|
||||
// The pane died (double Ctrl+C out of `claude`, `exit`, a crash) —
|
||||
// the tab bar moves on (another tab, or the home's prompt box)
|
||||
// rather than leaving a dead terminal the user has to click out of.
|
||||
|
||||
@@ -0,0 +1,518 @@
|
||||
/**
|
||||
* @file PaneGrid.tsx
|
||||
* @description The session view's split panes (layout model in
|
||||
* `lib/paneLayout.ts`). Every mounted slot is ONE absolutely placed child with
|
||||
* a stable key, positioned from `layoutRects`; a slot that is not in the
|
||||
* layout is mounted and hidden (CSS). So splitting, swapping, moving and
|
||||
* resizing only move boxes — a live terminal is never re-parented, which would
|
||||
* remount it and drop what it drew. With more than one pane each gets a slim
|
||||
* header: drag it to move the pane (drop on another pane's edge to split
|
||||
* there, on its middle to swap), pick another live session for it, split it
|
||||
* right or down, or close the pane (its session keeps running as a tab).
|
||||
* Tabs from `ConsoleTabs` drag in the same way (`startPaneDrag`). Dividers
|
||||
* resize by pointer or by arrow keys.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
Columns2,
|
||||
GripVertical,
|
||||
Plus,
|
||||
Rows2,
|
||||
SquareTerminal,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import type { RunHandle } from "../../lib/api";
|
||||
import {
|
||||
EMPTY_LAYOUT,
|
||||
type Edge,
|
||||
type PaneLayout,
|
||||
type Rect,
|
||||
type SplitDir,
|
||||
MAX_PANES,
|
||||
closeInLayout,
|
||||
layoutRects,
|
||||
leaves,
|
||||
loadLayout,
|
||||
moveInLayout,
|
||||
saveLayout,
|
||||
setRatio,
|
||||
swapInLayout,
|
||||
} from "../../lib/paneLayout";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
|
||||
export const PANE_DRAG_TYPE = "application/x-agenthub-pane";
|
||||
/** The slot being dragged: `dataTransfer` data cannot be read during dragover. */
|
||||
let dragSlot: number | null = null;
|
||||
|
||||
/** Starts dragging a pane or a tab (`slot`) onto the grid. */
|
||||
export function startPaneDrag(e: React.DragEvent, slot: number): void {
|
||||
dragSlot = slot;
|
||||
e.dataTransfer?.setData(PANE_DRAG_TYPE, String(slot));
|
||||
if (e.dataTransfer) e.dataTransfer.effectAllowed = "move";
|
||||
}
|
||||
|
||||
/** A run's icon: a shell's terminal glyph, else its AI tool's mark. */
|
||||
export function RunIcon({ run, label }: { run: RunHandle; label: string }) {
|
||||
return run.tool === "shell" ? (
|
||||
<SquareTerminal className="h-4 w-4 shrink-0" />
|
||||
) : (
|
||||
<AiToolIcon tool={{ id: run.tool, label }} className="h-4 w-4 !p-0" />
|
||||
);
|
||||
}
|
||||
|
||||
export type LayoutUpdate = (fn: (l: PaneLayout) => PaneLayout) => void;
|
||||
|
||||
/** A workspace's pane layout, loaded and saved per workspace. A layout update
|
||||
* that lands after a workspace switch never writes into the new one. */
|
||||
export function usePaneLayout(laneId: number | null): [PaneLayout, LayoutUpdate] {
|
||||
const load = (id: number | null) => ({
|
||||
laneId: id,
|
||||
layout: id === null ? EMPTY_LAYOUT : loadLayout(id),
|
||||
});
|
||||
const [state, setState] = useState(() => load(laneId));
|
||||
let current = state;
|
||||
if (state.laneId !== laneId) {
|
||||
current = load(laneId);
|
||||
setState(current);
|
||||
}
|
||||
useEffect(() => {
|
||||
if (state.laneId !== null) saveLayout(state.laneId, state.layout);
|
||||
}, [state]);
|
||||
const update = useCallback<LayoutUpdate>(
|
||||
(fn) =>
|
||||
setState((s) => {
|
||||
if (s.laneId !== laneId) return s;
|
||||
const layout = fn(s.layout);
|
||||
return layout === s.layout ? s : { ...s, layout };
|
||||
}),
|
||||
[laneId]
|
||||
);
|
||||
return [current.layout, update];
|
||||
}
|
||||
|
||||
type Zone = Edge | "center";
|
||||
const pct = (r: Rect) => ({
|
||||
left: `${r.x * 100}%`,
|
||||
top: `${r.y * 100}%`,
|
||||
width: `${r.w * 100}%`,
|
||||
height: `${r.h * 100}%`,
|
||||
});
|
||||
/** The half (or the whole) of a pane a drop zone would fill. */
|
||||
function zoneRect(r: Rect, zone: Zone): Rect {
|
||||
if (zone === "left") return { ...r, w: r.w / 2 };
|
||||
if (zone === "right") return { ...r, x: r.x + r.w / 2, w: r.w / 2 };
|
||||
if (zone === "top") return { ...r, h: r.h / 2 };
|
||||
if (zone === "bottom") return { ...r, y: r.y + r.h / 2, h: r.h / 2 };
|
||||
return r;
|
||||
}
|
||||
|
||||
/** A small anchored menu: a trigger button and a list that closes on a pick,
|
||||
* an outside click or Escape. */
|
||||
function PaneMenu({
|
||||
label,
|
||||
icon,
|
||||
disabled,
|
||||
testId,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
disabled?: boolean;
|
||||
testId: string;
|
||||
children: (close: () => void) => ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (!ref.current?.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
|
||||
document.addEventListener("mousedown", onDown);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDown);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [open]);
|
||||
return (
|
||||
<div ref={ref} className="relative flex">
|
||||
<button
|
||||
type="button"
|
||||
data-testid={testId}
|
||||
disabled={disabled}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="flex h-6 w-6 items-center justify-center rounded text-fg-muted hover:bg-surface-4 hover:text-fg-primary disabled:opacity-40 disabled:hover:bg-transparent"
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
role="menu"
|
||||
className="absolute right-0 top-full z-50 mt-1 w-60 rounded-xl border border-border bg-surface-2 p-1.5 shadow-lg"
|
||||
>
|
||||
{children(() => setOpen(false))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ITEM =
|
||||
"flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm text-fg-secondary hover:bg-surface-4 hover:text-fg-primary";
|
||||
|
||||
export function PaneGrid({
|
||||
layout,
|
||||
mounted,
|
||||
runs,
|
||||
label,
|
||||
renderPane,
|
||||
onChange,
|
||||
canStart,
|
||||
onNewSplit,
|
||||
}: {
|
||||
layout: PaneLayout;
|
||||
/** Every slot to keep mounted; those not in the layout are hidden. */
|
||||
mounted: number[];
|
||||
/** The workspace's live runs (its tabs). */
|
||||
runs: RunHandle[];
|
||||
label: (r: RunHandle) => string;
|
||||
renderPane: (slot: number) => ReactNode;
|
||||
onChange: LayoutUpdate;
|
||||
/** A slot is free for a new terminal. */
|
||||
canStart: boolean;
|
||||
/** Start a new terminal into a split of `target`. */
|
||||
onNewSplit: (target: number, edge: Edge) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const gridRef = useRef<HTMLDivElement>(null);
|
||||
const [drop, setDrop] = useState<{ slot: number; zone: Zone } | null>(null);
|
||||
const { panes, dividers } = layoutRects(layout.root);
|
||||
const shown = leaves(layout.root);
|
||||
const rectOf = new Map(panes.map((p) => [p.slot, p]));
|
||||
const multi = panes.length > 1;
|
||||
const full = shown.length >= MAX_PANES;
|
||||
const runOf = (slot: number) => runs.find((r) => (r.slot ?? 0) === slot);
|
||||
|
||||
useEffect(() => {
|
||||
const end = () => {
|
||||
dragSlot = null;
|
||||
setDrop(null);
|
||||
};
|
||||
window.addEventListener("dragend", end);
|
||||
window.addEventListener("drop", end);
|
||||
return () => {
|
||||
window.removeEventListener("dragend", end);
|
||||
window.removeEventListener("drop", end);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const focus = (slot: number) => onChange((l) => (l.focus === slot ? l : { ...l, focus: slot }));
|
||||
|
||||
// The keyboard follows the focused pane (a tab click, a close, a swap) —
|
||||
// unless the user is typing in a field outside the panes.
|
||||
useEffect(() => {
|
||||
if (layout.focus === null) return;
|
||||
const term = gridRef.current?.querySelector<HTMLElement>(
|
||||
`[data-testid="pane-${layout.focus}"] textarea.xterm-helper-textarea`
|
||||
);
|
||||
const active = document.activeElement as HTMLElement | null;
|
||||
const typingElsewhere =
|
||||
!!active &&
|
||||
!gridRef.current?.contains(active) &&
|
||||
(active.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(active.tagName));
|
||||
if (term && active !== term && !typingElsewhere) term.focus({ preventScroll: true });
|
||||
}, [layout.focus]);
|
||||
|
||||
/** Which pane and which part of it the pointer is over. */
|
||||
const zoneAt = (e: React.DragEvent): { slot: number; zone: Zone } | null => {
|
||||
const g = gridRef.current?.getBoundingClientRect();
|
||||
if (!g || !g.width || !g.height) return null;
|
||||
const fx = (e.clientX - g.left) / g.width;
|
||||
const fy = (e.clientY - g.top) / g.height;
|
||||
const p = panes.find((r) => fx >= r.x && fx <= r.x + r.w && fy >= r.y && fy <= r.y + r.h);
|
||||
if (!p) return null;
|
||||
const src = dragSlot;
|
||||
// A run not on screen cannot become a fifth pane: only a swap.
|
||||
if (src !== null && !shown.includes(src) && full) return { slot: p.slot, zone: "center" };
|
||||
const dx = (fx - p.x) / p.w;
|
||||
const dy = (fy - p.y) / p.h;
|
||||
const edges: [Zone, number][] = [
|
||||
["left", dx],
|
||||
["right", 1 - dx],
|
||||
["top", dy],
|
||||
["bottom", 1 - dy],
|
||||
];
|
||||
const [edge, dist] = edges.reduce((a, b) => (b[1] < a[1] ? b : a));
|
||||
return { slot: p.slot, zone: dist < 0.25 ? edge : "center" };
|
||||
};
|
||||
|
||||
const isPaneDrag = (e: React.DragEvent) =>
|
||||
dragSlot !== null || !!e.dataTransfer?.types?.includes(PANE_DRAG_TYPE);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={gridRef}
|
||||
data-testid="pane-grid"
|
||||
className="relative min-h-0 flex-1"
|
||||
onDragOver={(e) => {
|
||||
if (!isPaneDrag(e)) return;
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
|
||||
const z = zoneAt(e);
|
||||
setDrop((d) => (d?.slot === z?.slot && d?.zone === z?.zone ? d : z));
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
if (!gridRef.current?.contains(e.relatedTarget as Node | null)) setDrop(null);
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
if (!isPaneDrag(e)) return;
|
||||
e.preventDefault();
|
||||
const src = dragSlot ?? Number(e.dataTransfer?.getData(PANE_DRAG_TYPE));
|
||||
const z = zoneAt(e);
|
||||
dragSlot = null;
|
||||
setDrop(null);
|
||||
if (!z || !Number.isInteger(src) || src === z.slot) return;
|
||||
onChange((l) =>
|
||||
z.zone === "center"
|
||||
? swapInLayout(l, src, z.slot)
|
||||
: moveInLayout(l, src, z.slot, z.zone as Edge)
|
||||
);
|
||||
}}
|
||||
>
|
||||
{mounted.map((slot) => {
|
||||
const r = rectOf.get(slot);
|
||||
const run = runOf(slot);
|
||||
const focused = layout.focus === slot;
|
||||
return (
|
||||
<div
|
||||
key={slot}
|
||||
data-testid={`pane-${slot}`}
|
||||
data-focused={r && focused ? "true" : undefined}
|
||||
className={r ? "absolute flex flex-col" : "hidden"}
|
||||
style={r ? pct(r) : undefined}
|
||||
onPointerDownCapture={() => r && focus(slot)}
|
||||
// Tabbing into a pane focuses it as a click does.
|
||||
onFocusCapture={() => r && focus(slot)}
|
||||
>
|
||||
{multi && r && (
|
||||
<div
|
||||
data-testid={`pane-header-${slot}`}
|
||||
draggable
|
||||
onDragStart={(e) => startPaneDrag(e, slot)}
|
||||
title={t("panes.drag")}
|
||||
className={`relative flex h-8 shrink-0 cursor-grab items-center gap-1 border-b border-border bg-surface-1 pl-1.5 pr-1 text-xs ${
|
||||
focused ? "text-fg-primary" : "text-fg-muted"
|
||||
}`}
|
||||
>
|
||||
{focused && <span className="absolute inset-x-0 top-0 h-0.5 bg-accent" />}
|
||||
<GripVertical className="h-3.5 w-3.5 shrink-0 opacity-60" aria-hidden />
|
||||
{run && <RunIcon run={run} label={label(run)} />}
|
||||
<span className="min-w-0 truncate">{run ? label(run) : t("panes.empty")}</span>
|
||||
<PaneMenu
|
||||
testId={`pane-pick-${slot}`}
|
||||
label={t("panes.pick")}
|
||||
icon={<ChevronDown className="h-3.5 w-3.5" />}
|
||||
>
|
||||
{(close) =>
|
||||
runs.map((x) => {
|
||||
const s = x.slot ?? 0;
|
||||
return (
|
||||
<button
|
||||
key={x.id}
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={s === slot}
|
||||
className={ITEM}
|
||||
onClick={() => {
|
||||
close();
|
||||
onChange((l) => swapInLayout(l, s, slot));
|
||||
}}
|
||||
>
|
||||
<RunIcon run={x} label={label(x)} />
|
||||
<span className="min-w-0 flex-1 truncate">{label(x)}</span>
|
||||
{x.model && <span className="text-xs text-fg-muted">{x.model}</span>}
|
||||
{s === slot && <Check className="h-3.5 w-3.5 text-accent" />}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
}
|
||||
</PaneMenu>
|
||||
<div className="ml-auto flex items-center">
|
||||
{(["right", "bottom"] as const).map((edge) => (
|
||||
<PaneMenu
|
||||
key={edge}
|
||||
testId={`pane-split-${edge}-${slot}`}
|
||||
label={full ? t("panes.full") : t(`panes.split.${edge}`)}
|
||||
disabled={full}
|
||||
icon={
|
||||
edge === "right" ? (
|
||||
<Columns2 className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Rows2 className="h-3.5 w-3.5" />
|
||||
)
|
||||
}
|
||||
>
|
||||
{(close) => (
|
||||
<>
|
||||
{runs
|
||||
.filter((x) => !shown.includes(x.slot ?? 0))
|
||||
.map((x) => (
|
||||
<button
|
||||
key={x.id}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={ITEM}
|
||||
onClick={() => {
|
||||
close();
|
||||
onChange((l) => moveInLayout(l, x.slot ?? 0, slot, edge));
|
||||
}}
|
||||
>
|
||||
<RunIcon run={x} label={label(x)} />
|
||||
<span className="min-w-0 truncate">{label(x)}</span>
|
||||
</button>
|
||||
))}
|
||||
{runs.some((x) => !shown.includes(x.slot ?? 0)) && (
|
||||
<div className="my-1.5 border-t border-border" />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
data-testid={`pane-split-new-${edge}-${slot}`}
|
||||
disabled={!canStart}
|
||||
title={canStart ? undefined : t("tabs.full")}
|
||||
className={`${ITEM} disabled:opacity-40 disabled:hover:bg-transparent`}
|
||||
onClick={() => {
|
||||
close();
|
||||
onNewSplit(slot, edge);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("panes.newTerminal")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</PaneMenu>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`pane-close-${slot}`}
|
||||
title={t("panes.close")}
|
||||
aria-label={t("panes.close")}
|
||||
onClick={() => onChange((l) => closeInLayout(l, slot))}
|
||||
className="flex h-6 w-6 items-center justify-center rounded text-fg-muted hover:bg-surface-4 hover:text-fg-primary"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex min-h-0 flex-1 flex-col pb-1 pl-3 pr-1 pt-1.5">
|
||||
{renderPane(slot)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{dividers.map((d) => (
|
||||
<Divider
|
||||
key={d.path}
|
||||
gridRef={gridRef}
|
||||
{...d}
|
||||
onRatio={(r) =>
|
||||
onChange((l) => (l.root ? { ...l, root: setRatio(l.root, d.path, r) } : l))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{drop && rectOf.has(drop.slot) && (
|
||||
<div
|
||||
data-testid="pane-drop-preview"
|
||||
className="pointer-events-none absolute z-20 rounded-md border-2 border-accent/60 bg-accent/15 transition-all duration-100"
|
||||
style={pct(zoneRect(rectOf.get(drop.slot) as Rect, drop.zone))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A split's divider: a 1px line with a wider grab area. */
|
||||
function Divider({
|
||||
gridRef,
|
||||
dir,
|
||||
at,
|
||||
box,
|
||||
onRatio,
|
||||
}: {
|
||||
gridRef: React.RefObject<HTMLDivElement>;
|
||||
dir: SplitDir;
|
||||
at: number;
|
||||
box: Rect;
|
||||
onRatio: (ratio: number) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const row = dir === "row";
|
||||
const ratioAt = (e: React.PointerEvent) => {
|
||||
const g = gridRef.current?.getBoundingClientRect();
|
||||
if (!g || !g.width || !g.height) return null;
|
||||
return row
|
||||
? ((e.clientX - g.left) / g.width - box.x) / box.w
|
||||
: ((e.clientY - g.top) / g.height - box.y) / box.h;
|
||||
};
|
||||
const style = row
|
||||
? { left: `${(box.x + box.w * at) * 100}%`, top: `${box.y * 100}%`, height: `${box.h * 100}%` }
|
||||
: { top: `${(box.y + box.h * at) * 100}%`, left: `${box.x * 100}%`, width: `${box.w * 100}%` };
|
||||
return (
|
||||
<div
|
||||
role="separator"
|
||||
tabIndex={0}
|
||||
aria-orientation={row ? "vertical" : "horizontal"}
|
||||
aria-valuenow={Math.round(at * 100)}
|
||||
aria-valuemin={10}
|
||||
aria-valuemax={90}
|
||||
aria-label={t("panes.resize")}
|
||||
className={`group absolute z-10 flex justify-center outline-none ${
|
||||
row
|
||||
? "w-2 -translate-x-1/2 cursor-col-resize"
|
||||
: "h-2 -translate-y-1/2 cursor-row-resize flex-col"
|
||||
}`}
|
||||
style={style}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.currentTarget.setPointerCapture?.(e.pointerId);
|
||||
setDragging(true);
|
||||
}}
|
||||
onPointerMove={(e) => {
|
||||
if (!dragging) return;
|
||||
const r = ratioAt(e);
|
||||
if (r !== null) onRatio(r);
|
||||
}}
|
||||
onPointerUp={() => setDragging(false)}
|
||||
onPointerCancel={() => setDragging(false)}
|
||||
onKeyDown={(e) => {
|
||||
const back = row ? "ArrowLeft" : "ArrowUp";
|
||||
const fwd = row ? "ArrowRight" : "ArrowDown";
|
||||
if (e.key !== back && e.key !== fwd) return;
|
||||
e.preventDefault();
|
||||
onRatio(at + (e.key === fwd ? 0.05 : -0.05));
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={`${row ? "h-full w-px" : "h-px w-full"} transition-colors ${
|
||||
dragging
|
||||
? "bg-accent"
|
||||
: "bg-border group-hover:bg-accent/70 group-focus-visible:bg-accent"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -39,15 +39,20 @@ interface TerminalViewProps {
|
||||
* There is nothing left to attach to, so the owner swaps this terminal back
|
||||
* out for its run setup form instead of leaving a dead pane on screen. */
|
||||
onExit?: () => void;
|
||||
/** Take the keyboard when it appears (default). Off for a split pane that
|
||||
* is not the focused one, or keys would land in whichever mounted last. */
|
||||
autoFocus?: boolean;
|
||||
}
|
||||
|
||||
export function TerminalView({ runId, wsBaseUrl, onExit }: TerminalViewProps) {
|
||||
export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: TerminalViewProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
// Read through a ref: an inline `onExit` from the parent changes identity on
|
||||
// every render, and as an effect dep it would tear the terminal down and
|
||||
// re-attach on each one.
|
||||
const onExitRef = useRef(onExit);
|
||||
onExitRef.current = onExit;
|
||||
const autoFocusRef = useRef(autoFocus);
|
||||
autoFocusRef.current = autoFocus;
|
||||
// Always the DARK theme's palette, even in light mode: Claude Code's TUI
|
||||
// assumes a dark background. Read through a ref at construction so a theme
|
||||
// change never re-runs the attach effect; the effect below recolours live.
|
||||
@@ -143,7 +148,7 @@ export function TerminalView({ runId, wsBaseUrl, onExit }: TerminalViewProps) {
|
||||
// Take the keyboard as soon as the pane is on screen: the terminal IS the
|
||||
// page here, and having to click it first meant the first thing typed went
|
||||
// nowhere. A click elsewhere still moves focus away, as in any editor.
|
||||
term.focus();
|
||||
if (autoFocusRef.current) term.focus();
|
||||
|
||||
// When the pane's program has NOT enabled wheel reporting, xterm falls back
|
||||
// to converting each wheel notch on an alt-screen buffer into a cursor-key
|
||||
|
||||
Reference in New Issue
Block a user