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
@@ -238,11 +238,6 @@ describe("WorkspaceTree", () => {
await waitFor(() => expect(live).toHaveAttribute("data-live", "true"));
await user.click(live);
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=1&run=agenthub-lane-1-2");
// Ctrl+click asks for a second pane.
fireEvent.click(live, { ctrlKey: true });
expect(screen.getByTestId("where").textContent).toBe(
"/workspace?lane=1&run=agenthub-lane-1-2&pane=1"
);
await user.click(screen.getByTestId("workspace-session-s-old"));
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=1&session=s-old");
expect(api.lanes.action).not.toHaveBeenCalled();
@@ -2,7 +2,9 @@
* @file WorkspaceHome — what `/workspace?lane=<id>` opens on, shaped like a
* Claude Desktop project page: the name, a prompt box that starts a NEW
* session in the first free pane slot (0–3, `POST /api/lanes/:id/start`) and
* then opens it (`&run=<runId>`) — the ONE place a session is started. Inside
* then opens it (`&run=<runId>`) — the one place a session starts WITH a
* prompt (the session view's tab bar `+` only starts a bare CLI or a shell,
* `ConsoleTabs.tsx`). Inside
* the box, like Claude Desktop's: AI (the session's own tool, the workspace's
* by default) and one Model chip (`ModelMenu`: the tool's own list or the
* router's models, plus Effort); under it the quieter choices — AI Router
@@ -8,8 +8,8 @@
* 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>`, Ctrl/Cmd+click `&pane=1` to open it in a second
* split pane) — attaching, never starting or resuming anything;
* (`?lane=<id>&run=<runId>`, its tab in the session view) — 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,13 +314,10 @@ export function WorkspaceSessionList({
type="button"
data-testid={`workspace-session-${s.id}`}
data-live={run ? "true" : undefined}
onClick={(e) =>
onClick={() =>
navigate(
run
? // Ctrl/Cmd+click opens it beside the current pane.
`/workspace?lane=${lane.id}&run=${encodeURIComponent(run.id)}${
e.ctrlKey || e.metaKey ? "&pane=1" : ""
}`
? `/workspace?lane=${lane.id}&run=${encodeURIComponent(run.id)}`
: `/workspace?lane=${lane.id}&session=${encodeURIComponent(s.id)}`
)
}
+214
View File
@@ -0,0 +1,214 @@
/**
* @file ConsoleTabs.tsx
* @description The Workspace session view's tab bar: one tab per live tmux
* slot of the workspace (0–3, `GET /api/run`), the active one being the URL's
* `?run=`. A tab's X stops its session (`DELETE /api/run/:id`). The `+` menu
* opens a new tab in the first free slot — a plain shell
* (`POST /api/lanes/:id/start {shell: true}`) or an installed, enabled AI
* tool started bare on its default model, no prompt (a prompt, router or
* 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`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { Plus, Settings2, SquareTerminal, X } from "lucide-react";
import { api } from "../../lib/api";
import type { RunHandle } from "../../lib/api";
import type { Lane } from "../../lib/types";
import { useAiTools } from "../../hooks/useAiTools";
import { AiToolIcon } from "../AiToolIcon";
import { freeSlot } from "../lanes/WorkspaceHome";
export function ConsoleTabs({
lane,
tabs,
activeRunId,
onOpen,
onClosed,
}: {
lane: Lane;
/** The workspace's live runs, in slot order. */
tabs: RunHandle[];
activeRunId: string | null;
onOpen: (runId: string) => void;
/** A tab's session was stopped. */
onClosed: (runId: string) => void;
}) {
const { t } = useTranslation("lanes");
const navigate = useNavigate();
const aiTools = useAiTools();
const [menuOpen, setMenuOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!menuOpen) return;
const onDown = (e: MouseEvent) => {
if (!menuRef.current?.contains(e.target as Node)) setMenuOpen(false);
};
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setMenuOpen(false);
document.addEventListener("mousedown", onDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDown);
document.removeEventListener("keydown", onKey);
};
}, [menuOpen]);
const slot = freeSlot(lane.id, tabs);
const label = (r: RunHandle) => (r.tool === "shell" ? t("tabs.terminal") : aiTools.label(r.tool));
// The workspace's own tool whatever its state, then every installed, enabled one.
const toolIds = [
...new Set([
lane.tool,
...aiTools.tools.filter((x) => x.installed && x.enabled).map((x) => x.id),
]),
];
const open = async (body: Record<string, unknown>) => {
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);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
};
const close = async (runId: string) => {
setError(null);
try {
await api.run.kill(runId);
onClosed(runId);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
};
const item =
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs text-fg-secondary hover:bg-surface-4 hover:text-fg-primary";
return (
<div className="flex shrink-0 items-center gap-1 border-b border-border">
<div role="tablist" aria-label={t("tabs.label")} className="flex min-w-0 items-end gap-0.5">
{tabs.map((r) => {
const active = r.id === activeRunId;
return (
<div
key={r.id}
className={`group flex min-w-0 max-w-[14rem] items-center rounded-t-md border border-b-0 ${
active
? "border-border bg-surface-2 text-fg-primary"
: "border-transparent text-fg-muted hover:bg-surface-2/60 hover:text-fg-secondary"
}`}
>
<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}
className="flex min-w-0 items-center gap-1.5 py-1.5 pl-2.5 pr-1 text-xs"
>
{r.tool === "shell" ? (
<SquareTerminal className="h-3.5 w-3.5 shrink-0" />
) : (
<AiToolIcon tool={{ id: r.tool, label: label(r) }} className="h-3.5 w-3.5 !p-0" />
)}
<span className="truncate">{label(r)}</span>
</button>
<button
type="button"
data-testid={`console-tab-close-${r.slot ?? 0}`}
onClick={() => void close(r.id)}
aria-label={t("tabs.close")}
title={t("tabs.close")}
className="mr-1 shrink-0 rounded p-0.5 opacity-60 hover:bg-surface-4 hover:text-status-danger hover:opacity-100"
>
<X className="h-3 w-3" />
</button>
</div>
);
})}
</div>
<div ref={menuRef} className="relative">
<button
type="button"
data-testid="console-tab-new"
disabled={slot === null}
onClick={() => setMenuOpen((v) => !v)}
aria-haspopup="menu"
aria-expanded={menuOpen}
aria-label={t("tabs.new")}
title={slot === null ? t("tabs.full") : t("tabs.new")}
className="rounded p-1 text-fg-muted hover:bg-surface-4 hover:text-fg-primary disabled:opacity-40 disabled:hover:bg-transparent"
>
<Plus className="h-4 w-4" />
</button>
{menuOpen && (
<div
role="menu"
data-testid="console-tab-menu"
className="absolute left-0 top-full z-50 mt-1 w-56 rounded-lg border border-border bg-surface-2 p-1 shadow-lg"
>
<button
type="button"
role="menuitem"
className={item}
onClick={() => void open({ shell: true })}
>
<SquareTerminal className="h-4 w-4" />
{t("tabs.newTerminal")}
</button>
<div className="my-1 border-t border-border" />
{toolIds.map((id) => {
const model = aiTools.byId(id)?.defaultModel;
return (
<button
key={id}
type="button"
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 } : {}),
})
}
>
<AiToolIcon tool={{ id, label: aiTools.label(id) }} className="h-4 w-4 !p-0" />
{aiTools.label(id)}
</button>
);
})}
<div className="my-1 border-t border-border" />
<button
type="button"
role="menuitem"
className={item}
onClick={() => navigate("/settings/agents")}
>
<Settings2 className="h-4 w-4" />
{t("tabs.agentSettings")}
</button>
</div>
)}
</div>
{error && (
<span role="alert" className="ml-2 min-w-0 truncate text-xs text-status-danger">
{error}
</span>
)}
</div>
);
}
+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
@@ -134,7 +134,7 @@ describe("LaneConsolePane", () => {
{...baseProps()}
activeRuns={{ items: [live] }}
onNewSession={onNewSession}
homeOnExit
onRunExit={onNewSession}
/>
);
await screen.findByTestId("terminal-view");