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