feat(workspace): session view console as tabs, with New terminal

The 1/2/4 split view becomes a tab bar (ConsoleTabs.tsx): one tab per live
tmux slot (0-3) of the workspace, the active one being ?run=, every live tab's
pane kept mounted and hidden with CSS. The + menu opens a new tab in the first
free slot: New terminal (a plain shell - shell: true on POST
/api/lanes/:id/start, spawnShell, tool 'shell', no run_id/status change), any
installed enabled AI tool started bare, or Settings -> Agents. A tab's x stops
its session. splitViewStorage, &pane= and the sidebar's Ctrl/Cmd+click are gone.
This commit is contained in:
2026-10-02 18:09:31 +07:00
parent fadecaddd0
commit d9511ea446
21 changed files with 611 additions and 617 deletions
+14 -13
View File
@@ -2,10 +2,11 @@
* @file LaneConsolePane.tsx
* @description One lane's run console: a TerminalView on the pane's tmux
* session, or — with no run — an empty pane whose button goes back to the
* workspace home, the ONE place a new session is started (its prompt box,
* `WorkspaceHome.tsx`; there is no run form in a pane any more). Moved out of
* Workspace.tsx so the Workspace page can render 1, 2, or 4 of these side by
* side (split terminal view). Owns its own handle/busy/runHistory state —
* workspace home, where a session starts with a prompt (its prompt box,
* `WorkspaceHome.tsx`; there is no run form in a pane — the tab bar's `+`,
* `ConsoleTabs.tsx`, starts a bare CLI or a shell). Moved out of
* Workspace.tsx so the Workspace page can keep one per TAB (slot 0–3) mounted,
* the inactive ones hidden with CSS. Owns its own handle/busy/runHistory state —
* nothing is shared between panes. `lanes`, `binaryStatus`, `activeRuns`, and
* `externalSessions` are supplied as props because they are global, not
* lane-specific, and fetching them per pane would mean N redundant identical
@@ -16,8 +17,8 @@
* and its live tmux session — instead of leaving the previous lane's terminal
* on screen under a new lane's header.
*
* Each pane is one tmux session on its lane — split panes all show the same
* lane, pane `slot` N owning `agenthub-lane-<id>[-N]` — so starting or resuming
* Each pane is one tmux session on its lane — every tab shows the same
* lane, tab `slot` N owning `agenthub-lane-<id>[-N]` — so starting or resuming
* into a pane already holding a live run answers 409 ERUNLIVE rather than overwriting a
* prompt in flight — `onResumeFromHistory` catches
* that and attaches to the still-live session instead of failing outright
@@ -65,7 +66,7 @@ export interface LaneConsolePaneProps {
externalSessions?: Session[];
wsConnected: boolean;
onHasActiveRunChange?: (active: boolean) => void;
/** Which split pane this is. Every pane shows the SAME lane, each in its own
/** Which tab this is. Every tab shows the SAME lane, each in its own
* tmux session: slot 0 is the lane's own, 1–3 are `agenthub-lane-<id>-<slot>`. */
slot?: number;
/** Runs the OTHER panes are showing — left out of this pane's Active runs so
@@ -78,8 +79,8 @@ export interface LaneConsolePaneProps {
attachRequest?: { runId: string; nonce: string } | null;
/** Opens the workspace home, where a new session is started. */
onNewSession?: () => void;
/** The pane's run exiting goes back to the home too (a single pane). */
homeOnExit?: boolean;
/** The pane's run exited (the tab bar opens another tab, or the home). */
onRunExit?: () => void;
}
/** The tmux session a lane's split pane owns (see server/lib/pty-run.js). */
@@ -103,7 +104,7 @@ export function LaneConsolePane({
onHandleChange,
attachRequest,
onNewSession,
homeOnExit = false,
onRunExit,
}: LaneConsolePaneProps) {
const { t } = useTranslation("run");
const { t: tLanes } = useTranslation("lanes");
@@ -490,12 +491,12 @@ export function LaneConsolePane({
runId={handle!.id}
wsBaseUrl={window.location.origin.replace(/^http/, "ws")}
// The pane died (double Ctrl+C out of `claude`, `exit`, a crash) —
// a single pane goes straight back to the home's prompt box rather
// than leaving a dead terminal the user has to click out of.
// 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.
onExit={() => {
newRun();
refreshList();
if (homeOnExit) onNewSession?.();
onRunExit?.();
}}
/>
<button