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
@@ -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={() => {