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");
+8 -4
View File
@@ -227,7 +227,6 @@
"runtime.stop": "■ down",
"runtime.toggleTitle": "Boot or stop this workspace's own application stack (separate from its Claude run)",
"splitView.emptyPane": "No workspace selected for this pane.",
"splitView.paneCount": "{{count}} pane",
"splitView.paneLaneLabel": "Pane workspace selector",
"splitView.pickLane": "Pick a workspace",
"status.failed": "failed",
@@ -313,8 +312,6 @@
"menu.copyPath": "Copy path",
"menu.reset": "Reset worktree…",
"menu.remove": "Delete workspace…",
"pane.slot": "slot {{n}}",
"pane.close": "Stop this pane's session",
"panel.files": "Files",
"panel.changes": "Changes",
"panel.analyst": "Analyst",
@@ -392,5 +389,12 @@
"home.missing": "missing",
"home.unsupported": "{{tool}} cannot take instructions, context files or extra folders: they are kept, but its sessions start without them.",
"home.back": "Back to the workspace home",
"home.close": "Close"
"home.close": "Close",
"tabs.label": "Session tabs",
"tabs.new": "New tab",
"tabs.newTerminal": "New terminal",
"tabs.terminal": "Terminal",
"tabs.close": "Close tab (stops its session)",
"tabs.full": "All 4 tabs are in use — close one first",
"tabs.agentSettings": "Agent settings…"
}
+8 -4
View File
@@ -227,7 +227,6 @@
"runtime.stop": "■ dừng",
"runtime.toggleTitle": "Chạy hoặc dừng stack ứng dụng của workspace này (khác với phiên Claude của nó)",
"splitView.emptyPane": "Chưa chọn workspace cho ô này.",
"splitView.paneCount": "{{count}} ô",
"splitView.paneLaneLabel": "Bộ chọn workspace cho ô",
"splitView.pickLane": "Chọn workspace",
"status.failed": "thất bại",
@@ -313,8 +312,6 @@
"menu.copyPath": "Sao chép đường dẫn",
"menu.reset": "Đặt lại worktree…",
"menu.remove": "Xóa workspace…",
"pane.slot": "ô {{n}}",
"pane.close": "Dừng phiên của ô này",
"panel.files": "Tệp",
"panel.changes": "Thay đổi",
"panel.analyst": "Analyst",
@@ -392,5 +389,12 @@
"home.missing": "không còn",
"home.unsupported": "{{tool}} không nhận được hướng dẫn, file ngữ cảnh hay thư mục bổ sung: chúng vẫn được giữ, nhưng session của nó khởi động mà không có chúng.",
"home.back": "Quay lại trang workspace",
"home.close": "Đóng"
"home.close": "Đóng",
"tabs.label": "Các tab phiên",
"tabs.new": "Tab mới",
"tabs.newTerminal": "Terminal mới",
"tabs.terminal": "Terminal",
"tabs.close": "Đóng tab (dừng phiên của nó)",
"tabs.full": "Đã dùng hết 4 tab — hãy đóng bớt một tab",
"tabs.agentSettings": "Cài đặt agent…"
}
@@ -1,44 +0,0 @@
/**
* @file splitViewStorage.test.ts
* @description Tests for the splitViewStorage module.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, beforeEach } from "vitest";
import {
readSplitViewState,
writeSplitViewState,
defaultSplitViewState,
} from "../splitViewStorage";
describe("splitViewStorage", () => {
beforeEach(() => {
localStorage.clear();
});
it("returns the default state when nothing is stored", () => {
expect(readSplitViewState()).toEqual(defaultSplitViewState());
});
it("defaults to a single unselected pane", () => {
expect(defaultSplitViewState()).toEqual({ layout: 1, paneLaneIds: [null] });
});
it("round-trips a written state", () => {
writeSplitViewState({ layout: 4, paneLaneIds: [1, 2, null, null] });
expect(readSplitViewState()).toEqual({ layout: 4, paneLaneIds: [1, 2, null, null] });
});
it("falls back to the default when stored JSON is malformed", () => {
localStorage.setItem("agenthub.workspace.splitView", "{not json");
expect(readSplitViewState()).toEqual(defaultSplitViewState());
});
it("falls back to the default when the stored layout is not 1, 2, or 4", () => {
localStorage.setItem(
"agenthub.workspace.splitView",
JSON.stringify({ layout: 3, paneLaneIds: [] })
);
expect(readSplitViewState()).toEqual(defaultSplitViewState());
});
});
+2 -3
View File
@@ -1,11 +1,10 @@
/**
* @file laneOrderStorage.ts
* @description Persists the Workspace lane strip's user-chosen card order to
* localStorage, the same read/validate/fallback convention as
* splitViewStorage.ts.
* localStorage, the same read/validate/fallback convention as useTheme.ts.
*
* The order lives in the browser rather than the lanes table on purpose: it is
* a per-viewer preference, like the split-pane layout beside it, and nothing
* a per-viewer preference, and nothing
* server-side (the CLI's `agenthub lanes`, the pipeline-stats picker) has an
* opinion about it. Ceiling: the order does not follow the user to another
* browser or to the CLI — move it to a `lanes.sort_order` column if it ever
-54
View File
@@ -1,54 +0,0 @@
/**
* @file splitViewStorage.ts
* @description Persists the Workspace page's split-terminal layout (1/2/4
* panes) and each pane's chosen lane id to localStorage, so the layout
* survives a page reload. Follows the same read/write-with-fallback
* convention as useTheme.ts.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
export type SplitLayout = 1 | 2 | 4;
export interface SplitViewState {
layout: SplitLayout;
paneLaneIds: (number | null)[];
}
const STORAGE_KEY = "agenthub.workspace.splitView";
export function defaultSplitViewState(): SplitViewState {
return { layout: 1, paneLaneIds: [null] };
}
function isValidLayout(value: unknown): value is SplitLayout {
return value === 1 || value === 2 || value === 4;
}
function isValidState(value: unknown): value is SplitViewState {
if (!value || typeof value !== "object") return false;
const v = value as Record<string, unknown>;
return (
isValidLayout(v.layout) &&
Array.isArray(v.paneLaneIds) &&
v.paneLaneIds.every((id) => id === null || typeof id === "number")
);
}
export function readSplitViewState(): SplitViewState {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return defaultSplitViewState();
const parsed: unknown = JSON.parse(raw);
return isValidState(parsed) ? parsed : defaultSplitViewState();
} catch {
return defaultSplitViewState();
}
}
export function writeSplitViewState(state: SplitViewState): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch {
/* ignore quota / disabled storage */
}
}
+79 -228
View File
@@ -4,8 +4,9 @@
* (CLI spawns: Claude Code or Codex, determined per lane). The workspace shown
* is picked in the sidebar's project tree and named by `?lane=<id>` (mirrored to
* `agenthub.workspace.selected`, so a bare /workspace reopens the last one).
* Shows the selected lane's one-row header, pipeline map and run console (1, 2
* or 4 panes, each split pane with its own header), and the right panel —
* Shows the selected lane's one-row header, pipeline map and run console (a
* tab per live tmux slot, 0–3, with a `+` menu — `ConsoleTabs.tsx`), and the
* right panel —
* Files · Changes · Sessions. Runs are tied to lanes: starting a run
* posts to POST /api/lanes/:id/start with tool-appropriate fields. When a cwd
* isn't owned by any lane, calls POST /api/lanes/ensure first. Polls GET
@@ -43,15 +44,13 @@
*
* ----------------------------------------------------------------------------- */
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
ArrowLeft,
Plus,
Square,
Columns2,
Grid2x2,
Layers,
PanelLeftClose,
PanelRightClose,
@@ -59,7 +58,6 @@ import {
FolderTree,
GitCompare,
ChartColumn,
X,
} from "lucide-react";
import { api } from "../lib/api";
import type { CwdSuggestion, RunListResponse } from "../lib/api";
@@ -67,9 +65,8 @@ import type { Lane, LaneFeature, ProofFeature, Session } from "../lib/types";
import { eventBus } from "../lib/eventBus";
import { keepIfSame } from "../lib/keepIfSame";
import { useLanes } from "../hooks/useLanes";
import { useAiTools } from "../hooks/useAiTools";
import { AiToolIcon } from "../components/AiToolIcon";
import { LaneConsolePane } from "../components/run/LaneConsolePane";
import { ConsoleTabs } from "../components/run/ConsoleTabs";
import PipelineMap from "../components/lanes/PipelineMap";
import LaneCard, { since, LIVENESS_DOT, LaneToolsMenu } from "../components/lanes/LaneCard";
import { AddLaneModal } from "../components/lanes/AddLaneModal";
@@ -77,8 +74,6 @@ import { LaneChangesPanel } from "../components/lanes/LaneChangesPanel";
import { WorkspaceHome } from "../components/lanes/WorkspaceHome";
import { SessionReplay } from "../components/lanes/SessionReplay";
import { DestructiveLaneModal } from "../components/lanes/DestructiveLaneModal";
import { readSplitViewState, writeSplitViewState } from "../lib/splitViewStorage";
import type { SplitViewState, SplitLayout } from "../lib/splitViewStorage";
/** localStorage key for the Changes panel's open/closed state. */
const CHANGES_OPEN_KEY = "agenthub.workspace.changesOpen";
@@ -119,104 +114,19 @@ const CHANGES_NEXT_ICON: Record<ChangesSize, typeof Columns2> = {
// ── Page ──────────────────────────────────────────────────────────────
/** One pane, two side by side, four in a grid — the conventional icons for
* each, so the toggle reads at a glance without spending three words on it. */
const LAYOUT_CHOICES = [
{ n: 1 as const, Icon: Square },
{ n: 2 as const, Icon: Columns2 },
{ n: 4 as const, Icon: Grid2x2 },
];
function SplitLayoutToggle({
splitView,
setLayout,
}: {
splitView: SplitViewState;
setLayout: (layout: SplitLayout) => void;
}) {
const { t: tLanes } = useTranslation("lanes");
return (
<div className="flex items-center gap-1.5">
{LAYOUT_CHOICES.map(({ n, Icon }) => {
// The count still reaches assistive tech and the tooltip — only the
// rendered word is gone, and the icon says the same thing in the
// shape every other app uses for it.
const label = tLanes("splitView.paneCount", { count: n });
return (
<button
key={n}
type="button"
aria-pressed={splitView.layout === n}
aria-label={label}
title={label}
onClick={() => setLayout(n)}
className={`rounded border p-1.5 ${
splitView.layout === n
? "border-accent bg-accent/15 text-accent"
: "border-border text-fg-secondary hover:border-border-light"
}`}
>
<Icon className="h-3.5 w-3.5" />
</button>
);
})}
</div>
);
}
/** A split pane's one-line header: the run's AI tool, which lane, which slot, the run's model,
* and — for slots 1–3 — a button that kills that pane's own tmux session
* (`DELETE /api/run/<runId>`). Slot 0 is the lane's run and has no close. */
function PaneHeader({
lane,
slot,
runId,
model,
tool,
}: {
lane: Lane | null;
slot: number;
runId: string | null;
model: string | null;
/** The tool the pane's run is on — a session's own, not the workspace's. */
tool: string | null;
}) {
const { t } = useTranslation("lanes");
const tools = useAiTools();
return (
<div
data-testid={`pane-header-${slot}`}
className="flex h-6 shrink-0 items-center gap-1.5 rounded border border-border bg-surface-2 px-2 text-[11px] text-fg-muted"
>
{tool && (
<AiToolIcon tool={{ id: tool, label: tools.label(tool) }} className="h-3.5 w-3.5 !p-0" />
)}
<span className="min-w-0 truncate font-medium text-fg-secondary">
{lane ? lane.title || lane.cwd.split("/").pop() : ""}
</span>
<span className="shrink-0">{t("pane.slot", { n: slot })}</span>
{model && <span className="min-w-0 truncate">{model}</span>}
{slot > 0 && runId && (
<button
type="button"
onClick={() => void api.run.kill(runId).catch(() => undefined)}
aria-label={t("pane.close")}
title={t("pane.close")}
className="ml-auto shrink-0 rounded p-0.5 hover:bg-surface-4 hover:text-status-danger"
>
<X className="h-3 w-3" />
</button>
)}
</div>
);
/** The pane slot a run id names (`agenthub-lane-<id>[-<slot>]`). */
function slotOf(runId: string | null | undefined): number {
const m = runId?.match(/^agenthub-lane-\d+(?:-([1-3]))?$/);
return m ? Number(m[1] || 0) : 0;
}
/** The session view's console: a tab bar over one `LaneConsolePane` per slot
* (0–3, each its own tmux session on the selected lane). The active tab is
* the URL's `?run=`; every other tab with a live run stays MOUNTED, hidden
* with CSS, so switching tabs never drops a terminal. */
function ConsoleArea({
lanes,
selectedLaneId,
splitView,
setLayout,
binaryStatus,
activeRuns,
externalSessions,
@@ -224,14 +134,13 @@ function ConsoleArea({
onHasActiveRunChange,
onLaneCreated,
onLaneIdChange,
showToggle = true,
attachRequest,
onNewSession,
onOpenRun,
onRunsChanged,
}: {
lanes: Lane[];
selectedLaneId: number | null;
splitView: SplitViewState;
setLayout: (layout: SplitLayout) => void;
binaryStatus: Record<string, { found: boolean; path: string | null }> | null;
activeRuns: RunListResponse | null;
externalSessions: Session[];
@@ -239,91 +148,73 @@ function ConsoleArea({
onHasActiveRunChange: (val: boolean) => void;
onLaneCreated: (lane: Lane) => void;
onLaneIdChange: (id: number | null) => void;
showToggle?: boolean;
/** A live run to attach a pane to, from the sidebar (`?run=`, `&pane=`). */
attachRequest: { runId: string; nonce: string; pane: number } | null;
/** The opened run (`?run=`): its tab is the active one. */
attachRequest: { runId: string; nonce: string } | null;
/** Back to the workspace home, where a new session is started. */
onNewSession?: () => void;
/** Opens a tab's run (`?run=`). */
onOpenRun: (runId: string) => void;
/** A tab was opened or closed: re-read the run list now. */
onRunsChanged: () => void;
}) {
// Every split pane shows the SELECTED lane, each in its own tmux session
// (slot = pane index). Tracking what each pane shows lets the others leave
// that run out of their Active runs, so no terminal is attached twice.
const [paneRunIds, setPaneRunIds] = useState<(string | null)[]>([]);
const onPaneHandle = useCallback((i: number, id: string | null) => {
setPaneRunIds((prev) => {
if (prev[i] === id) return prev;
const next = [...prev];
next[i] = id;
return next;
});
}, []);
const paneHandlers = useMemo(
() => [0, 1, 2, 3].map((i) => (id: string | null) => onPaneHandle(i, id)),
[onPaneHandle]
);
const hiddenFor = useMemo(
const lane = lanes.find((l) => l.id === selectedLaneId) ?? null;
const tabs = useMemo(
() =>
[0, 1, 2, 3].map((i) =>
paneRunIds.filter((id, j): id is string => j !== i && j < splitView.layout && !!id)
),
[paneRunIds, splitView.layout]
(activeRuns?.items ?? [])
.filter((r) => r.laneId === selectedLaneId && r.status === "running")
.sort((a, b) => (a.slot ?? 0) - (b.slot ?? 0)),
[activeRuns, selectedLaneId]
);
const activeSlot = slotOf(attachRequest?.runId);
const slots = [...new Set([activeSlot, ...tabs.map((r) => r.slot ?? 0)])].sort();
// Off the active tab: the next one still open, else the home.
const leave = (slot: number) => {
const next = tabs.find((r) => (r.slot ?? 0) !== slot);
if (next) onOpenRun(next.id);
else onNewSession?.();
};
return (
<>
{showToggle && <SplitLayoutToggle splitView={splitView} setLayout={setLayout} />}
{splitView.layout === 1 ? (
<LaneConsolePane
lanes={lanes}
laneId={selectedLaneId}
onHandleChange={paneHandlers[0]}
showLaneSelector={false}
onLaneIdChange={onLaneIdChange}
onLaneCreated={onLaneCreated}
binaryStatus={binaryStatus}
activeRuns={activeRuns}
externalSessions={externalSessions}
wsConnected={wsConnected}
onHasActiveRunChange={onHasActiveRunChange}
onNewSession={onNewSession}
homeOnExit
attachRequest={attachRequest?.pane === 0 ? attachRequest : null}
{lane && (
<ConsoleTabs
lane={lane}
tabs={tabs}
activeRunId={attachRequest?.runId ?? null}
onOpen={(runId) => {
onRunsChanged();
onOpenRun(runId);
}}
onClosed={(runId) => {
onRunsChanged();
if (slotOf(runId) === activeSlot) leave(activeSlot);
}}
/>
) : (
<div
className={`grid flex-1 min-h-0 gap-3 ${
splitView.layout === 2 ? "grid-cols-2" : "grid-cols-2 grid-rows-2"
}`}
>
{Array.from({ length: splitView.layout }, (_, i) => (
<div key={i} className="flex min-h-0 min-w-0 flex-col gap-1">
<PaneHeader
lane={lanes.find((l) => l.id === selectedLaneId) ?? null}
slot={i}
runId={paneRunIds[i] ?? null}
model={activeRuns?.items.find((r) => r.id === paneRunIds[i])?.model ?? null}
tool={activeRuns?.items.find((r) => r.id === paneRunIds[i])?.tool ?? null}
/>
<LaneConsolePane
lanes={lanes}
laneId={selectedLaneId}
slot={i}
hiddenRunIds={hiddenFor[i]}
onHandleChange={paneHandlers[i]}
showLaneSelector={false}
onLaneIdChange={onLaneIdChange}
onLaneCreated={onLaneCreated}
binaryStatus={binaryStatus}
activeRuns={activeRuns}
externalSessions={externalSessions}
wsConnected={wsConnected}
onNewSession={onNewSession}
attachRequest={attachRequest?.pane === i ? attachRequest : null}
/>
</div>
))}
</div>
)}
{slots.map((slot) => (
<div key={slot} className={slot === activeSlot ? "contents" : "hidden"}>
<LaneConsolePane
lanes={lanes}
laneId={selectedLaneId}
slot={slot}
// The other tabs' runs: never attached twice.
hiddenRunIds={tabs.filter((r) => (r.slot ?? 0) !== slot).map((r) => r.id)}
showLaneSelector={false}
onLaneIdChange={onLaneIdChange}
onLaneCreated={onLaneCreated}
binaryStatus={binaryStatus}
activeRuns={activeRuns}
externalSessions={externalSessions}
wsConnected={wsConnected}
onHasActiveRunChange={slot === activeSlot ? onHasActiveRunChange : undefined}
onNewSession={onNewSession}
onRunExit={() => slot === activeSlot && leave(slot)}
attachRequest={
attachRequest && slotOf(attachRequest.runId) === slot ? attachRequest : null
}
/>
</div>
))}
</>
);
}
@@ -346,12 +237,9 @@ export function Workspace() {
// conversation, with a prompt box whose message resumes it (SessionReplay).
const sessionParam = runParam ? null : searchParams.get("session");
const navigate = useNavigate();
// `&pane=<n>` — which split pane attaches it (Ctrl/Cmd+click in the sidebar
// asks for pane 1); pane 0 otherwise.
const paneParam = Math.min(Math.max(Number(searchParams.get("pane")) || 0, 0), 3);
const attachRequest = useMemo(
() => (runParam ? { runId: runParam, nonce: location.key, pane: paneParam } : null),
[runParam, location.key, paneParam]
() => (runParam ? { runId: runParam, nonce: location.key } : null),
[runParam, location.key]
);
const paramId = laneParam === null ? null : Number(laneParam);
const fallbackId = (() => {
@@ -427,8 +315,6 @@ export function Workspace() {
const [cwdSuggestions, setCwdSuggestions] = useState<CwdSuggestion[]>([]);
const [paneHasActiveRun, setPaneHasActiveRun] = useState(false);
// Split view state
const [splitView, setSplitView] = useState<SplitViewState>(() => readSplitViewState());
const [panelTab, setPanelTab] = useState<PanelTab>(() => {
try {
const saved = window.localStorage.getItem(PANEL_TAB_KEY) as PanelTab | null;
@@ -483,30 +369,11 @@ export function Workspace() {
? tLanes("changes.collapse")
: tLanes("changes.resize", { size: tLanes(`changes.size.${nextChangesSize}`) });
const setLayout = useCallback((layout: SplitLayout) => {
setSplitView((prev) => {
const paneLaneIds = Array.from({ length: layout }, (_, i) => prev.paneLaneIds[i] ?? null);
const next = { layout, paneLaneIds };
writeSplitViewState(next);
return next;
});
}, []);
// The session the opened run holds, when that run works in a worktree of
// its own (`claude --worktree`): the Changes panel shows that tree.
const openedRun = runParam ? activeRuns?.items.find((r) => r.id === runParam) : undefined;
const gitSession = openedRun?.worktree && openedRun.sessionId ? openedRun.sessionId : undefined;
// A pane the layout does not show yet (`&pane=1` on one pane) widens the
// split so the run lands where it was asked for — once per request, so the
// user can still go back to one pane afterwards.
const layoutRef = useRef(splitView.layout);
layoutRef.current = splitView.layout;
useEffect(() => {
const pane = attachRequest?.pane ?? 0;
if (pane >= layoutRef.current) setLayout(pane < 2 ? 2 : 4);
}, [attachRequest, setLayout]);
// Probe the CLI of every tool a lane uses — fresh on the server, so a CLI
// installed since the page loaded clears its missing-binary banner. Claude is
// always probed: the console's no-lane state runs it.
@@ -536,20 +403,6 @@ export function Workspace() {
.catch(() => undefined);
}, []);
// Clean up stale pane lane IDs when lanes load
useEffect(() => {
if (!lanes.length) return;
setSplitView((prev) => {
const paneLaneIds = prev.paneLaneIds.map((id) =>
id !== null && lanes.some((l) => l.id === id) ? id : null
);
if (paneLaneIds.every((id, i) => id === prev.paneLaneIds[i])) return prev;
const next = { ...prev, paneLaneIds };
writeSplitViewState(next);
return next;
});
}, [lanes]);
// Both setters go through `keepIfSame`: these fire every 5 s below, and a
// fresh-but-identical payload would otherwise re-render the lane strip, the
// lane card and the whole console area on every tick for nothing.
@@ -896,7 +749,6 @@ export function Workspace() {
{/* Per-lane actions (mcp sync, sync-base check)
belong beside the lane they act on, not in a page header. */}
<LaneToolsMenu lane={currentLane} />
<SplitLayoutToggle splitView={splitView} setLayout={setLayout} />
</div>
</div>
)}
@@ -986,16 +838,12 @@ export function Workspace() {
}
/>
)}
{/* `showToggle`: the lane header carries its own split toggle; with
no lane there is no header, so the console shows one instead.
A replay only HIDES it: the console has one render site and a
{/* A replay only HIDES the console: it has one render site and a
live terminal must never unmount. */}
<div className={replaying ? "hidden" : "contents"}>
<ConsoleArea
lanes={lanes}
selectedLaneId={selectedLaneId}
splitView={splitView}
setLayout={setLayout}
binaryStatus={binaryStatus}
activeRuns={activeRuns}
externalSessions={externalSessions}
@@ -1003,13 +851,16 @@ export function Workspace() {
onHasActiveRunChange={setPaneHasActiveRun}
onLaneCreated={() => void refreshLanes()}
onLaneIdChange={setSelectedLaneId}
showToggle={!currentLane}
attachRequest={attachRequest}
onNewSession={
currentLane
? () => setSearchParams({ lane: String(currentLane.id) })
: undefined
}
onOpenRun={(runId) =>
currentLane && setSearchParams({ lane: String(currentLane.id), run: runId })
}
onRunsChanged={refreshList}
/>
</div>
</div>
@@ -59,6 +59,7 @@ vi.mock("../../components/lanes/WorkspaceHome", () => ({
WorkspaceHome: () => <div data-testid="workspace-home" />,
paneRunId: (id: number, slot: number) =>
slot ? `agenthub-lane-${id}-${slot}` : `agenthub-lane-${id}`,
freeSlot: () => 0,
}));
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
+67 -57
View File
@@ -1,7 +1,7 @@
/**
* @file Workspace.test.tsx
* @description Workspace page integration tests covering the lane-based run flow.
* Verifies: lanes API contract, the home vs session view, split panes, and
* Verifies: lanes API contract, the home vs session view, the console tabs, and
* that a pane has no run form of its own (a new session starts from the home).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -56,6 +56,12 @@ vi.mock("../../components/lanes/WorkspaceHome", () => ({
WorkspaceHome: () => <div data-testid="workspace-home" />,
paneRunId: (id: number, slot: number) =>
slot ? `agenthub-lane-${id}-${slot}` : `agenthub-lane-${id}`,
freeSlot: (laneId: number, runs: Array<{ laneId: number; slot?: number; status: string }>) => {
const busy = new Set(
runs.filter((r) => r.laneId === laneId && r.status === "running").map((r) => r.slot ?? 0)
);
return [0, 1, 2, 3].find((s) => !busy.has(s)) ?? null;
},
}));
// An ended session's replay (its own suite): a stub that names its session.
vi.mock("../../components/lanes/SessionReplay", () => ({
@@ -536,50 +542,66 @@ describe("Workspace — proof gallery", () => {
});
});
describe("split terminal view", () => {
beforeEach(() => {
localStorage.clear();
describe("console tabs", () => {
const live = (slot: number, tool = "claude") => ({
id: slot ? `agenthub-lane-1-${slot}` : "agenthub-lane-1",
laneId: 1,
slot,
status: "running",
tool,
model: null,
promptPreview: null,
});
it("defaults to a single pane with no layout toggle pressed state implying 2 or 4", async () => {
await renderWorkspace();
expect(screen.getAllByTestId("console-body")).toHaveLength(1);
expect(screen.queryAllByTestId("pane-lane-select")).toHaveLength(0);
const original = vi.mocked(api.run.list).getMockImplementation();
afterEach(() => {
vi.mocked(api.run.list).mockImplementation(original!);
});
it("switching to 2-pane layout renders two panes bound to the selected lane", async () => {
await renderWorkspace();
fireEvent.click(screen.getByRole("button", { name: /2.*pane/i }));
await settle();
expect(screen.getAllByTestId("console-body")).toHaveLength(2);
expect(screen.queryAllByTestId("pane-lane-select")).toHaveLength(0);
});
it("switching to 4-pane layout renders four panes", async () => {
await renderWorkspace();
fireEvent.click(screen.getByRole("button", { name: /4.*pane/i }));
await settle();
expect(screen.getAllByTestId("console-body")).toHaveLength(4);
});
it("persists the layout to localStorage across remounts", async () => {
const { unmount } = await renderWorkspace();
fireEvent.click(screen.getByRole("button", { name: /2.*pane/i }));
await settle();
unmount();
await renderWorkspace();
expect(screen.getAllByTestId("console-body")).toHaveLength(2);
});
it("ignores a persisted per-pane lane id: every pane follows the selected lane", async () => {
localStorage.setItem(
"agenthub.workspace.splitView",
JSON.stringify({ layout: 2, paneLaneIds: [9999, null] })
const withRuns = (items: ReturnType<typeof live>[]) =>
vi.mocked(api.run.list).mockResolvedValue({ runs: [], items } as never);
const open = async (url: string) => {
render(
<MemoryRouter initialEntries={[url]}>
<Workspace />
</MemoryRouter>
);
await settle();
await settle();
};
it("shows a tab per live session and keeps every tab's pane mounted, showing only the active one", async () => {
withRuns([live(0), live(2, "shell")]);
await open("/workspace?lane=1&run=agenthub-lane-1-2");
expect(screen.getByTestId("console-tab-0")).toHaveAttribute("aria-selected", "false");
expect(screen.getByTestId("console-tab-2")).toHaveAttribute("aria-selected", "true");
expect(screen.getByTestId("console-tab-2")).toHaveTextContent("Terminal");
const bodies = screen.getAllByTestId("console-body");
expect(bodies).toHaveLength(2);
expect(bodies.filter((b) => b.closest(".hidden"))).toHaveLength(1);
});
it("opens a new terminal in the first free slot from the + menu", async () => {
withRuns([live(0)]);
await open("/workspace?lane=1&run=agenthub-lane-1");
const user = userEvent.setup();
await user.click(screen.getByTestId("console-tab-new"));
await user.click(within(screen.getByTestId("console-tab-menu")).getByText("New terminal"));
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", { slot: 1, shell: true });
});
it("disables + once all four slots are taken", async () => {
withRuns([live(0), live(1), live(2), live(3)]);
await open("/workspace?lane=1&run=agenthub-lane-1");
expect(screen.getByTestId("console-tab-new")).toBeDisabled();
});
it("closing the active tab stops its session and opens the next one", async () => {
withRuns([live(0), live(1)]);
await open("/workspace?lane=1&run=agenthub-lane-1");
await userEvent.setup().click(screen.getByTestId("console-tab-close-0"));
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1");
await waitFor(() =>
expect(screen.getByTestId("console-tab-1")).toHaveAttribute("aria-selected", "true")
);
await renderWorkspace();
expect(screen.queryAllByTestId("pane-empty")).toHaveLength(0);
expect(screen.getAllByTestId("console-body")).toHaveLength(2);
});
});
@@ -638,9 +660,10 @@ describe("Workspace — home and session view", () => {
});
it("has no run form in a pane: an empty pane's New session opens the home", async () => {
// The run opens in pane 1, so pane 0 is left with nothing to show.
// Nothing live in slot 0: its pane is empty.
vi.mocked(api.run.get).mockRejectedValueOnce(new Error("gone"));
render(
<MemoryRouter initialEntries={["/workspace?lane=1&run=agenthub-lane-1-1&pane=1"]}>
<MemoryRouter initialEntries={["/workspace?lane=1&run=agenthub-lane-1"]}>
<Workspace />
</MemoryRouter>
);
@@ -679,17 +702,4 @@ describe("Workspace — home and session view", () => {
vi.mocked(api.run.list).mockImplementation(original!);
localStorage.removeItem("agenthub.workspace.changesOpen");
});
it("widens one pane to two for &pane=1, so the run opens beside the first", async () => {
localStorage.removeItem("agenthub.workspace.splitView");
render(
<MemoryRouter initialEntries={["/workspace?lane=1&run=agenthub-lane-1&pane=1"]}>
<Workspace />
</MemoryRouter>
);
await settle();
await settle();
expect(screen.getAllByTestId("console-body")).toHaveLength(2);
expect(JSON.parse(localStorage.getItem("agenthub.workspace.splitView")!).layout).toBe(2);
});
});
@@ -5410,200 +5410,107 @@ exports[`screen snapshots > Run 1`] = `
class="contents"
>
<div
class="flex items-center gap-1.5"
class="contents"
>
<button
aria-label="1 pane"
aria-pressed="true"
class="rounded border p-1.5 border-accent bg-accent/15 text-accent"
title="1 pane"
type="button"
<div
class="flex min-h-0 flex-1 flex-col gap-5"
data-testid="console-body"
>
<svg
class="lucide lucide-square h-3.5 w-3.5"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<rect
height="18"
rx="2"
width="18"
x="3"
y="3"
/>
</svg>
</button>
<button
aria-label="2 pane"
aria-pressed="false"
class="rounded border p-1.5 border-border text-fg-secondary hover:border-border-light"
title="2 pane"
type="button"
>
<svg
class="lucide lucide-columns2 h-3.5 w-3.5"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<rect
height="18"
rx="2"
width="18"
x="3"
y="3"
/>
<path
d="M12 3v18"
/>
</svg>
</button>
<button
aria-label="4 pane"
aria-pressed="false"
class="rounded border p-1.5 border-border text-fg-secondary hover:border-border-light"
title="4 pane"
type="button"
>
<svg
class="lucide lucide-grid2x2 h-3.5 w-3.5"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 3v18"
/>
<path
d="M3 12h18"
/>
<rect
height="18"
rx="2"
width="18"
x="3"
y="3"
/>
</svg>
</button>
</div>
<div
class="flex min-h-0 flex-1 flex-col gap-5"
data-testid="console-body"
>
<header
class="flex items-center gap-2.5"
>
<div
class="w-7 h-7 rounded-lg bg-accent/15 flex items-center justify-center flex-shrink-0"
>
<svg
class="lucide lucide-play w-3.5 h-3.5 text-accent"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<polygon
points="6 3 20 12 6 21 6 3"
/>
</svg>
</div>
<div
class="flex min-w-0 flex-1 items-center gap-2"
<header
class="flex items-center gap-2.5"
>
<div
class="flex shrink-0 items-center gap-2"
class="w-7 h-7 rounded-lg bg-accent/15 flex items-center justify-center flex-shrink-0"
>
<h1
class="text-base font-semibold text-fg-primary"
<svg
class="lucide lucide-play w-3.5 h-3.5 text-accent"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
Run Claude
</h1>
<span
class="flex items-center gap-1.5 text-[11px] text-status-success bg-status-success/10 border border-status-success/20 px-2 py-0.5 rounded-full"
>
<span
class="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse-dot"
<polygon
points="6 3 20 12 6 21 6 3"
/>
Live
</span>
</svg>
</div>
<p
class="hidden min-w-0 truncate text-[11px] text-fg-muted sm:block"
<div
class="flex min-w-0 flex-1 items-center gap-2"
>
Streamed output, multi-turn, same hooks and analytics as your terminal.
<div
class="flex shrink-0 items-center gap-2"
>
<h1
class="text-base font-semibold text-fg-primary"
>
Run Claude
</h1>
<span
class="flex items-center gap-1.5 text-[11px] text-status-success bg-status-success/10 border border-status-success/20 px-2 py-0.5 rounded-full"
>
<span
class="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse-dot"
/>
Live
</span>
</div>
<p
class="hidden min-w-0 truncate text-[11px] text-fg-muted sm:block"
>
Streamed output, multi-turn, same hooks and analytics as your terminal.
</p>
</div>
<button
class="inline-flex items-center gap-2 rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed border-border bg-surface-2 text-fg-secondary hover:bg-surface-3"
disabled=""
>
<svg
class="lucide lucide-list-ordered w-3.5 h-3.5"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M10 12h11"
/>
<path
d="M10 18h11"
/>
<path
d="M10 6h11"
/>
<path
d="M4 10h2"
/>
<path
d="M4 6h1v4"
/>
<path
d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"
/>
</svg>
Active runs
</button>
</header>
<div
class="flex flex-1 flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-border p-6 text-center text-sm text-fg-muted"
data-testid="pane-no-run"
>
<p>
No session in this pane. Start one from the workspace's prompt box.
</p>
</div>
<button
class="inline-flex items-center gap-2 rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed border-border bg-surface-2 text-fg-secondary hover:bg-surface-3"
disabled=""
>
<svg
class="lucide lucide-list-ordered w-3.5 h-3.5"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M10 12h11"
/>
<path
d="M10 18h11"
/>
<path
d="M10 6h11"
/>
<path
d="M4 10h2"
/>
<path
d="M4 6h1v4"
/>
<path
d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"
/>
</svg>
Active runs
</button>
</header>
<div
class="flex flex-1 flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-border p-6 text-center text-sm text-fg-muted"
data-testid="pane-no-run"
>
<p>
No session in this pane. Start one from the workspace's prompt box.
</p>
</div>
</div>
</div>