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
+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" />