feat(workspace): AI tool per session, one place to start a session, ended sessions open like Claude Desktop

- A session picks its own AI tool: `tool` on POST /api/lanes/:id/start
  (a catalog id, known + enabled, else 400 EBADTOOL/EDISABLED); a resume
  keeps the tool its session ran on (`sessions.tool`). The workspace's
  `lanes.tool` is now only the default.
- The workspace home's prompt box is the one place a session starts. Its
  fields run in order: AI → AI Router (Native or a profile) → Model (the
  tool's own list, or the router's models) → Permission (Claude mode /
  Codex sandbox+approval / catalog bypass) → Effort.
- The pane's run form (RunSetup) is gone: New run, a single pane's exit and
  an empty split pane's New session all go to the home. RunSetup.tsx keeps
  PromptEditor, CwdAutocomplete and ModelPicker (new `choices` prop).
- The tool icon moved from the sidebar's workspace row to each session row
  (right edge, `session.tool`); the split-pane header shows the run's tool.
- An ended session opens in its workspace (`&session=`, SessionReplay.tsx):
  its conversation read-only plus a prompt box; only a SENT message resumes
  it (resumeSessionId + prompt, first free slot). The console is only
  hidden behind it.
- Analyst button beside Files/Changes in the right panel opens
  /sessions/:id for the opened session.
- Gemini CLI ships its own icon (client/public/ai-tools/gemini.svg, from
  zonalogo.com).
- RouterPicker always shows (Native disabled for tools without router
  support); useAiRouters() exported.
- Tests: server start-with-tool cases; SessionReplay, home field order/tool/
  router models, empty pane, Analyst; dropped the cwd-field suites
  (Run.defaultCwd, Workspace.laneCwd). Docs: CLAUDE.md rules, API.md,
  LANES.md, README, ARCHITECTURE, INSTALL.
This commit is contained in:
2026-10-02 15:29:38 +07:00
parent c507d37baf
commit 4802684d2f
34 changed files with 1137 additions and 2504 deletions
+76 -51
View File
@@ -44,7 +44,7 @@
* ----------------------------------------------------------------------------- */
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { useLocation, useSearchParams } from "react-router-dom";
import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
ArrowLeft,
@@ -58,6 +58,7 @@ import {
Maximize2,
FolderTree,
GitCompare,
ChartColumn,
X,
} from "lucide-react";
import { api } from "../lib/api";
@@ -74,6 +75,7 @@ import LaneCard, { since, LIVENESS_DOT, LaneToolsMenu } from "../components/lane
import { AddLaneModal } from "../components/lanes/AddLaneModal";
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";
@@ -163,7 +165,7 @@ function SplitLayoutToggle({
);
}
/** A split pane's one-line header: which lane, which slot, the run's model,
/** 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({
@@ -171,11 +173,14 @@ function PaneHeader({
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();
@@ -184,11 +189,8 @@ function PaneHeader({
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"
>
{lane && (
<AiToolIcon
tool={{ id: lane.tool, label: tools.label(lane.tool) }}
className="h-3.5 w-3.5 !p-0"
/>
{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() : ""}
@@ -216,33 +218,32 @@ function ConsoleArea({
splitView,
setLayout,
binaryStatus,
cwdSuggestions,
activeRuns,
externalSessions,
wsConnected,
defaultCwd,
onHasActiveRunChange,
onLaneCreated,
onLaneIdChange,
showToggle = true,
attachRequest,
onNewSession,
}: {
lanes: Lane[];
selectedLaneId: number | null;
splitView: SplitViewState;
setLayout: (layout: SplitLayout) => void;
binaryStatus: Record<string, { found: boolean; path: string | null }> | null;
cwdSuggestions: CwdSuggestion[];
activeRuns: RunListResponse | null;
externalSessions: Session[];
wsConnected: boolean;
defaultCwd: string;
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;
/** Back to the workspace home, where a new session is started. */
onNewSession?: () => 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
@@ -280,12 +281,12 @@ function ConsoleArea({
onLaneIdChange={onLaneIdChange}
onLaneCreated={onLaneCreated}
binaryStatus={binaryStatus}
cwdSuggestions={cwdSuggestions}
activeRuns={activeRuns}
externalSessions={externalSessions}
wsConnected={wsConnected}
defaultCwd={defaultCwd}
onHasActiveRunChange={onHasActiveRunChange}
onNewSession={onNewSession}
homeOnExit
attachRequest={attachRequest?.pane === 0 ? attachRequest : null}
/>
) : (
@@ -301,6 +302,7 @@ function ConsoleArea({
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}
@@ -312,11 +314,10 @@ function ConsoleArea({
onLaneIdChange={onLaneIdChange}
onLaneCreated={onLaneCreated}
binaryStatus={binaryStatus}
cwdSuggestions={cwdSuggestions}
activeRuns={activeRuns}
externalSessions={externalSessions}
wsConnected={wsConnected}
defaultCwd={defaultCwd}
onNewSession={onNewSession}
attachRequest={attachRequest?.pane === i ? attachRequest : null}
/>
</div>
@@ -341,6 +342,10 @@ export function Workspace() {
// second pick of the same session a new request.
const location = useLocation();
const runParam = searchParams.get("run");
// `?session=` — an ENDED session picked in the sidebar or Recents: its
// 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);
@@ -420,7 +425,6 @@ export function Workspace() {
{ found: boolean; path: string | null }
> | null>(null);
const [cwdSuggestions, setCwdSuggestions] = useState<CwdSuggestion[]>([]);
const [defaultCwd, setDefaultCwd] = useState<string>("");
const [paneHasActiveRun, setPaneHasActiveRun] = useState(false);
// Split view state
@@ -528,22 +532,7 @@ export function Workspace() {
.catch(() => undefined);
api.run
.cwds()
.then((r) => {
setCwdSuggestions(r.items);
// Pre-fill cwd with the user's home directory — a neutral default.
// Spawning in the dashboard's own cwd would make ad-hoc runs inherit
// this repo's project context (.claude/agents, skills, rules,
// CLAUDE.md, .mcp.json), which is almost never what an ad-hoc run
// wants and can bloat the initial request (issue #202). Fall back to
// the dashboard cwd when no home suggestion exists. The user can
// change it; we just don't want an invisible default.
const home = r.items.find((s) => s.kind === "home");
const dashboard = r.items.find((s) => s.kind === "dashboard");
const preferred = home || dashboard;
if (preferred) {
setDefaultCwd(preferred.path);
}
})
.then((r) => setCwdSuggestions(r.items))
.catch(() => undefined);
}, []);
@@ -676,7 +665,11 @@ export function Workspace() {
// A workspace opens on its HOME (WorkspaceHome); `?run=` opens a session —
// the console, panes and Changes panel, which the home only hides (CSS), so
// a live terminal never unmounts behind it.
const home = !!currentLane && !runParam;
const home = !!currentLane && !runParam && !sessionParam;
const replaying = !!currentLane && !!sessionParam;
// The session the Analyst button opens (`/sessions/:id`): the replayed one,
// or the one the opened pane holds.
const analystSessionId = sessionParam ?? openedRun?.sessionId ?? null;
const viewportLocked =
!home && (paneHasActiveRun || (!!currentLane && changesOpen && changesSize === "full"));
@@ -983,25 +976,42 @@ export function Workspace() {
currentLane ? "border-t border-border pt-3" : ""
}`}
>
{replaying && (
<SessionReplay
lane={currentLane}
sessionId={sessionParam}
liveRuns={activeRuns?.items ?? []}
onResumed={(runId) =>
setSearchParams({ lane: String(currentLane.id), run: runId })
}
/>
)}
{/* `showToggle`: the lane header carries its own split toggle; with
no lane there is no header, so the console shows one instead. */}
<ConsoleArea
lanes={lanes}
selectedLaneId={selectedLaneId}
splitView={splitView}
setLayout={setLayout}
binaryStatus={binaryStatus}
cwdSuggestions={cwdSuggestions}
activeRuns={activeRuns}
externalSessions={externalSessions}
wsConnected={wsConnected}
defaultCwd={defaultCwd}
onHasActiveRunChange={setPaneHasActiveRun}
onLaneCreated={() => void refreshLanes()}
onLaneIdChange={setSelectedLaneId}
showToggle={!currentLane}
attachRequest={attachRequest}
/>
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
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}
wsConnected={wsConnected}
onHasActiveRunChange={setPaneHasActiveRun}
onLaneCreated={() => void refreshLanes()}
onLaneIdChange={setSelectedLaneId}
showToggle={!currentLane}
attachRequest={attachRequest}
onNewSession={
currentLane
? () => setSearchParams({ lane: String(currentLane.id) })
: undefined
}
/>
</div>
</div>
</section>
@@ -1032,6 +1042,21 @@ export function Workspace() {
<Icon className="h-4 w-4" />
</button>
))}
{/* The opened session's analysis page — a page of its own. */}
<button
type="button"
data-testid="panel-analyst"
disabled={!analystSessionId}
onClick={() =>
analystSessionId &&
navigate(`/sessions/${encodeURIComponent(analystSessionId)}`)
}
title={tLanes("panel.analyst")}
aria-label={tLanes("panel.analyst")}
className="flex h-7 w-7 items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:opacity-40 disabled:hover:bg-transparent"
>
<ChartColumn className="h-4 w-4" />
</button>
<button
type="button"
data-testid="lane-changes-resize"
@@ -1,133 +0,0 @@
/**
* @file Run.defaultCwd.test.tsx
* @description The Run page must default its working directory to the user's
* HOME directory (a neutral spawn location) rather than the dashboard's own
* cwd, which would make ad-hoc runs inherit this repo's project context
* (.claude/agents, skills, rules, CLAUDE.md, .mcp.json) — issue #202. Falls
* back to the dashboard cwd only when no home suggestion is available.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, act, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import i18n from "i18next";
const HOME = { kind: "home", path: "/Users/tester", label: "Home" };
const DASHBOARD = {
kind: "dashboard",
path: "/Users/tester/projects/dashboard",
label: "Dashboard server",
};
const RECENT = { kind: "recent", path: "/Users/tester/projects/other", label: "other" };
// Mutable per-test cwd suggestion payload, read by the api mock below.
let cwdItems: Array<{ kind: string; path: string; label: string }> = [];
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
const r = (value: unknown) => vi.fn().mockResolvedValue(value);
return {
...actual,
api: {
aiRouters: { list: r({ items: [] }) },
run: {
list: r({ runs: [], items: [] }),
history: r({ items: [] }),
binary: r({ found: true, path: "/usr/bin/claude" }),
cwds: vi.fn().mockImplementation(() => Promise.resolve({ items: cwdItems })),
files: r({ items: [] }),
start: r({ id: "run-1", status: "running" }),
get: r({ id: "run-1", status: "running", messages: [], envelopes: [] }),
send: r({ messageId: "m-1" }),
kill: r({ ok: true }),
},
lanes: {
list: r({ lanes: [], counts: { total: 0, running: 0, needs_you: 0, dead: 0 } }),
pipelines: r({ pipelines: [] }),
},
ccConfig: {
commands: r({ items: [] }),
plugins: r({ plugins: [] }),
file: r({ content: "" }),
},
sessions: {
list: r({ sessions: [], total: 0, limit: 50, offset: 0 }),
transcript: r({ messages: [] }),
},
},
};
});
vi.mock("../../lib/eventBus", () => ({
eventBus: {
subscribe: () => () => {},
publish: () => {},
onConnection: () => () => {},
connected: true,
setConnected: () => {},
},
}));
import { Workspace as Run } from "../Workspace";
class ObserverStub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver =
globalThis.ResizeObserver || (ObserverStub as unknown as typeof ResizeObserver);
async function settle() {
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
await new Promise((r) => setTimeout(r, 0));
});
}
async function renderRun() {
const utils = render(
<MemoryRouter initialEntries={["/run"]}>
<Run />
</MemoryRouter>
);
await settle();
return utils;
}
function cwdInput(): HTMLInputElement {
const placeholder = i18n.t("run:fields.cwdPlaceholder");
return screen.getByPlaceholderText(placeholder) as HTMLInputElement;
}
beforeEach(() => {
i18n.changeLanguage("en");
});
afterEach(() => {
vi.clearAllMocks();
});
describe("Run page — default working directory (issue #202)", () => {
it("pre-fills cwd with the HOME suggestion, not the dashboard cwd", async () => {
cwdItems = [DASHBOARD, HOME, RECENT];
await renderRun();
expect(cwdInput().value).toBe(HOME.path);
});
it("falls back to the dashboard cwd when no home suggestion exists", async () => {
cwdItems = [DASHBOARD, RECENT];
await renderRun();
expect(cwdInput().value).toBe(DASHBOARD.path);
});
it("leaves cwd empty when there are no suggestions at all", async () => {
cwdItems = [];
await renderRun();
expect(cwdInput().value).toBe("");
});
});
@@ -1,180 +0,0 @@
/**
* @file Workspace.laneCwd.test.tsx
* @description The console's working directory must be the selected lane's own
* `cwd` — on first paint (the page auto-selects the first lane) and after every
* lane switch. A stale cwd is not cosmetic: `RunSetup` submits it verbatim, so
* the run would be started in the previously selected lane's folder.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, act, screen, fireEvent } from "@testing-library/react";
import { MemoryRouter, useSearchParams } from "react-router-dom";
import i18n from "i18next";
// Hoisted: `vi.mock`'s factory is lifted above the module body, so the
// fixtures it reads have to be lifted with it.
const { HOME, LANE_A, LANE_B } = vi.hoisted(() => {
const laneStub = (id: number, cwd: string) => ({
id,
title: `lane-${id}`,
cwd,
branch: null,
kind: "adopted",
source_repo: null,
pipeline: "default",
session_id: null,
run_id: null,
stage: "idle",
stage_since: null,
status: "idle",
gate_decision: null,
ci_status: null,
needs_action: null,
links: {},
stages: {},
notes: null,
pipeline_name: "Default",
pipeline_nodes: [],
progress: 0,
stage_seconds: null,
last_event_seconds: null,
liveness: "idle",
detected_stage: null,
detected_signal: null,
slot: null,
ports: {},
active_feature_id: null,
});
return {
HOME: { kind: "home", path: "/Users/tester", label: "Home" },
LANE_A: laneStub(1, "/workspace/alpha"),
LANE_B: laneStub(2, "/workspace/beta"),
};
});
// The workspace's home (its own suite) fetches sources and memory; these
// tests are about the session view it hides, so it is a stub here.
vi.mock("../../components/lanes/WorkspaceHome", () => ({
WorkspaceHome: () => <div data-testid="workspace-home" />,
paneRunId: (id: number, slot: number) =>
slot ? `agenthub-lane-${id}-${slot}` : `agenthub-lane-${id}`,
}));
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
const r = (value: unknown) => vi.fn().mockResolvedValue(value);
return {
...actual,
api: {
run: {
list: r({ items: [] }),
history: r({ items: [] }),
binary: r({ found: true, path: "/usr/bin/claude" }),
cwds: r({ items: [HOME] }),
files: r({ items: [] }),
start: r({ id: "run-1", status: "running" }),
get: r({ id: "run-1", status: "running" }),
},
lanes: {
list: r({
lanes: [LANE_A, LANE_B],
counts: { total: 2, running: 0, needs_you: 0, dead: 0 },
}),
pipelines: r({ pipelines: [] }),
git: r({ available: false }),
runtime: r({ up: false, ports: {}, lastError: null }),
features: { list: r({ features: [] }), show: r({ feature: null }) },
proof: { list: r({ features: [] }), imageUrl: () => "" },
},
aiRouters: { list: r({ items: [] }) },
sessions: { list: r({ sessions: [], total: 0, limit: 50, offset: 0 }) },
},
};
});
vi.mock("../../lib/eventBus", () => ({
eventBus: {
subscribe: () => () => {},
publish: () => {},
onConnection: () => () => {},
connected: true,
setConnected: () => {},
},
}));
import { Workspace } from "../Workspace";
class ObserverStub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver =
globalThis.ResizeObserver || (ObserverStub as unknown as typeof ResizeObserver);
async function settle() {
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
await new Promise((r) => setTimeout(r, 0));
});
}
function cwdInput(): HTMLInputElement {
return screen.getByPlaceholderText(i18n.t("run:fields.cwdPlaceholder")) as HTMLInputElement;
}
beforeEach(() => {
i18n.changeLanguage("en");
});
afterEach(() => {
vi.clearAllMocks();
});
describe("Workspace — the console cwd follows the selected lane", () => {
it("shows the auto-selected first lane's cwd on load, not the home default", async () => {
render(
<MemoryRouter initialEntries={["/run"]}>
<Workspace />
</MemoryRouter>
);
await settle();
expect(cwdInput().value).toBe(LANE_A.cwd);
});
it("swaps the cwd when another lane is selected in the sidebar", async () => {
// Stands in for the sidebar's project tree: picking sets `?lane=`.
function Picker() {
const [, setParams] = useSearchParams();
return (
<>
{[1, 2].map((id) => (
<button
key={id}
data-testid={`pick-${id}`}
onClick={() => setParams({ lane: String(id) })}
/>
))}
</>
);
}
render(
<MemoryRouter initialEntries={["/run"]}>
<Picker />
<Workspace />
</MemoryRouter>
);
await settle();
fireEvent.click(screen.getByTestId("pick-2"));
await settle();
expect(cwdInput().value).toBe(LANE_B.cwd);
fireEvent.click(screen.getByTestId("pick-1"));
await settle();
expect(cwdInput().value).toBe(LANE_A.cwd);
});
});
+61 -131
View File
@@ -1,14 +1,14 @@
/**
* @file Workspace.test.tsx
* @description Workspace page integration tests covering the lane-based run flow.
* Verifies: lanes API contract, ensure before start flow, lane start vs /api/run,
* and that no /stage endpoint is called.
* Verifies: lanes API contract, the home vs session view, split panes, 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>
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, act, screen, waitFor, fireEvent } from "@testing-library/react";
import { MemoryRouter, useSearchParams } from "react-router-dom";
import { render, act, screen, waitFor, fireEvent, within } from "@testing-library/react";
import { MemoryRouter, Route, Routes, useSearchParams } from "react-router-dom";
import userEvent from "@testing-library/user-event";
// Track all API calls made by recording the method and endpoint
@@ -57,6 +57,12 @@ vi.mock("../../components/lanes/WorkspaceHome", () => ({
paneRunId: (id: number, slot: number) =>
slot ? `agenthub-lane-${id}-${slot}` : `agenthub-lane-${id}`,
}));
// An ended session's replay (its own suite): a stub that names its session.
vi.mock("../../components/lanes/SessionReplay", () => ({
SessionReplay: ({ sessionId }: { sessionId: string }) => (
<div data-testid="session-replay">{sessionId}</div>
),
}));
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
const recordCall = (method: string, path: string) => {
@@ -323,6 +329,8 @@ async function renderWorkspace() {
beforeEach(() => {
recordedCalls.length = 0;
// The selected workspace is remembered in localStorage; each test starts on the first.
window.localStorage.clear();
lanesToReturn = [
{
id: 1,
@@ -382,133 +390,6 @@ describe("Workspace page — lane integration", () => {
expect(screen.queryByTestId("lane-card-1")).toBeNull();
expect(screen.queryByTestId("lane-card-1")).toBeNull();
});
it("starting a run requests /api/lanes/<id>/start and not /api/run/start", async () => {
await renderWorkspace();
recordedCalls.length = 0; // Clear initial setup calls
const user = userEvent.setup();
// Get all textboxes and find the ones we need
const textboxes = screen.getAllByRole("textbox");
const cwdInput = textboxes.find((el) =>
(el as HTMLInputElement).placeholder?.includes("absolute path")
);
const promptInput = textboxes.find((el) =>
(el as HTMLInputElement).placeholder?.includes("Ask Claude")
);
if (!cwdInput || !promptInput) throw new Error("Could not find cwd or prompt input");
// Set cwd to an existing lane's cwd
await user.clear(cwdInput as HTMLInputElement);
await user.type(cwdInput as HTMLInputElement, "/workspace/a");
await settle();
// Set prompt
await user.type(promptInput as HTMLInputElement, "test prompt");
await settle();
// Find the Run button in the RunSetup form
const runButton = screen.getByRole("button", { name: /^Run$/i });
const startButton = runButton;
await user.click(startButton);
await waitFor(() => {
expect(
recordedCalls.some((c) => c.path.includes("/api/lanes/") && c.path.includes("/start"))
).toBe(true);
});
// Verify /api/run/start was NOT called
const runStartCalled = recordedCalls.some((c) => c.path === "/api/run/start");
expect(runStartCalled).toBe(false);
});
it("picking a cwd that no lane owns requests /api/lanes/ensure BEFORE start", async () => {
await renderWorkspace();
recordedCalls.length = 0; // Clear initial setup calls
const user = userEvent.setup();
// Get all textboxes and find the ones we need
const textboxes = screen.getAllByRole("textbox");
const cwdInput = textboxes.find((el) =>
(el as HTMLInputElement).placeholder?.includes("absolute path")
);
const promptInput = textboxes.find((el) =>
(el as HTMLInputElement).placeholder?.includes("Ask Claude")
);
if (!cwdInput || !promptInput) throw new Error("Could not find cwd or prompt input");
// Set cwd to a path no lane owns
await user.clear(cwdInput as HTMLInputElement);
await user.type(cwdInput as HTMLInputElement, "/new/project/path");
await settle();
// Set prompt
await user.type(promptInput as HTMLInputElement, "test prompt");
await settle();
// Find the Run button in the RunSetup form
const runButton = screen.getByRole("button", { name: /^Run$/i });
await user.click(runButton);
await waitFor(() => {
expect(recordedCalls.some((c) => c.path === "/api/lanes/ensure")).toBe(true);
expect(
recordedCalls.some((c) => c.path.includes("/api/lanes/") && c.path.includes("/start"))
).toBe(true);
});
// Verify ensure was called BEFORE start
const ensureIndex = recordedCalls.findIndex((c) => c.path === "/api/lanes/ensure");
const startIndex = recordedCalls.findIndex(
(c) => c.path.includes("/api/lanes/") && c.path.includes("/start")
);
expect(ensureIndex).toBeLessThan(startIndex);
});
it("after a full start-then-message cycle, no recorded request URL matches /stage", async () => {
await renderWorkspace();
recordedCalls.length = 0; // Clear initial setup calls
const user = userEvent.setup();
// Get all textboxes and find the ones we need
const textboxes = screen.getAllByRole("textbox");
const cwdInput = textboxes.find((el) =>
(el as HTMLInputElement).placeholder?.includes("absolute path")
);
const promptInput = textboxes.find((el) =>
(el as HTMLInputElement).placeholder?.includes("Ask Claude")
);
if (!cwdInput || !promptInput) throw new Error("Could not find cwd or prompt input");
// Set cwd to an existing lane's cwd
await user.clear(cwdInput as HTMLInputElement);
await user.type(cwdInput as HTMLInputElement, "/workspace/a");
await settle();
// Set prompt
await user.type(promptInput as HTMLInputElement, "test prompt");
await settle();
// Find the Run button in the RunSetup form
const runButton = screen.getByRole("button", { name: /^Run$/i });
await user.click(runButton);
await waitFor(() => {
expect(
recordedCalls.some((c) => c.path.includes("/api/lanes/") && c.path.includes("/start"))
).toBe(true);
});
// After start, verify no /stage call was made
const stageCalls = recordedCalls.filter((c) => c.path.includes("/stage"));
expect(stageCalls).toHaveLength(0);
});
});
describe("Workspace layout", () => {
@@ -722,6 +603,55 @@ describe("Workspace — home and session view", () => {
await userEvent.setup().click(screen.getByTestId("workspace-back-home"));
expect(await screen.findByTestId("workspace-home")).toBeInTheDocument();
});
it("opens an ended session's replay for ?session=, hiding (not unmounting) the console", async () => {
window.localStorage.setItem("agenthub.workspace.changesOpen", "1");
render(
<MemoryRouter initialEntries={["/workspace?lane=1&session=s-ended"]}>
<Workspace />
<Routes>
<Route path="/sessions/:id" element={<div data-testid="analyst-page" />} />
<Route path="*" element={null} />
</Routes>
</MemoryRouter>
);
await settle();
await settle();
expect(screen.queryByTestId("workspace-home")).toBeNull();
expect(screen.getByTestId("session-replay")).toHaveTextContent("s-ended");
expect(screen.getByTestId("console-body").closest(".hidden")).not.toBeNull();
// Analyst, beside Files and Changes, opens the session's analysis page.
await userEvent.setup().click(screen.getByTestId("panel-analyst"));
expect(await screen.findByTestId("analyst-page")).toBeInTheDocument();
});
it("has no Analyst target on a pane with no session yet", async () => {
window.localStorage.setItem("agenthub.workspace.changesOpen", "1");
render(
<MemoryRouter initialEntries={["/workspace?lane=1&run=agenthub-lane-1"]}>
<Workspace />
</MemoryRouter>
);
await settle();
await settle();
expect(screen.getByTestId("panel-analyst")).toBeDisabled();
});
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.
render(
<MemoryRouter initialEntries={["/workspace?lane=1&run=agenthub-lane-1-1&pane=1"]}>
<Workspace />
</MemoryRouter>
);
await settle();
await settle();
expect(screen.queryByPlaceholderText(/absolute path/)).toBeNull();
const empty = screen.getByTestId("pane-no-run");
await userEvent.setup().click(within(empty).getByRole("button", { name: "New session" }));
expect(await screen.findByTestId("workspace-home")).toBeInTheDocument();
expect(recordedCalls.some((c) => c.path.includes("/start"))).toBe(false);
});
it("points the Changes panel at the opened run's worktree session", async () => {
localStorage.setItem("agenthub.workspace.changesOpen", "1");
const original = vi.mocked(api.run.list).getMockImplementation();
@@ -5478,419 +5478,117 @@ exports[`screen snapshots > Run 1`] = `
class="flex min-h-0 flex-1 flex-col gap-2 "
>
<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"
>
<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"
>
<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="rounded-xl border border-border bg-surface-1"
class="flex items-center gap-1.5"
>
<div
class="flex flex-wrap items-end gap-3 border-b border-border px-4 py-3"
<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="grid min-w-0 flex-1 grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4"
<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"
>
<div>
<label
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
>
Working directory
</label>
<div
title="Absolute path. Defaults to the dashboard's own cwd."
>
<div
class="relative"
>
<div
class="relative"
>
<svg
class="lucide lucide-folder-open absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-fg-muted pointer-events-none"
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="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2"
/>
</svg>
<input
autocomplete="off"
class="w-full bg-surface-2 border border-border rounded-md pl-7 pr-3 py-1.5 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50"
placeholder="Type to search or paste an absolute path…"
spellcheck="false"
type="text"
value=""
/>
</div>
</div>
</div>
</div>
<div>
<label
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
>
Model
</label>
<div
class="space-y-1.5"
>
<div
class="relative"
>
<button
class="w-full flex items-center justify-between gap-2 bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] text-fg-primary focus:outline-none focus:border-accent/50 hover:bg-surface-3 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
type="button"
>
<span
class="truncate"
>
Inherit from settings
</span>
<svg
class="lucide lucide-chevron-down w-3.5 h-3.5 text-fg-muted flex-shrink-0"
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="m6 9 6 6 6-6"
/>
</svg>
</button>
</div>
</div>
</div>
<div>
<label
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
>
Permission mode
</label>
<div
class="relative"
>
<button
class="w-full flex items-center justify-between gap-2 bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] text-fg-primary focus:outline-none focus:border-accent/50 hover:bg-surface-3 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
type="button"
>
<span
class="truncate"
>
auto (recommended - asks only for risky actions)
</span>
<svg
class="lucide lucide-chevron-down w-3.5 h-3.5 text-fg-muted flex-shrink-0"
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="m6 9 6 6 6-6"
/>
</svg>
</button>
</div>
</div>
<div>
<label
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
>
Thinking effort
</label>
<div
class="relative"
>
<button
class="w-full flex items-center justify-between gap-2 bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] text-fg-primary focus:outline-none focus:border-accent/50 hover:bg-surface-3 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
type="button"
>
<span
class="truncate"
>
Default (model decides)
</span>
<svg
class="lucide lucide-chevron-down w-3.5 h-3.5 text-fg-muted flex-shrink-0"
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="m6 9 6 6 6-6"
/>
</svg>
</button>
</div>
</div>
</div>
<div
class="flex shrink-0 items-center rounded-md border border-border bg-surface-2 p-0.5 text-[11.5px]"
>
<button
aria-pressed="true"
class="rounded px-2 py-0.5 font-medium transition-colors bg-accent/20 text-accent"
title="Start a fresh Claude Code session."
type="button"
>
New session
</button>
<button
aria-pressed="false"
class="rounded px-2 py-0.5 font-medium transition-colors text-fg-secondary hover:text-fg-primary"
title="Pick a session from your history and continue the conversation. Cwd is locked to the original."
type="button"
>
Resume existing session
</button>
</div>
</div>
<div
class="px-4 py-3 border-b border-border"
>
<label
class="block text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-1.5"
>
Prompt
</label>
<div
class="relative"
>
<textarea
class="w-full bg-surface-2 border border-border rounded-md px-3 py-2 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50 resize-none"
placeholder="Ask Claude anything…"
rows="5"
spellcheck="false"
<rect
height="18"
rx="2"
width="18"
x="3"
y="3"
/>
</div>
<div
class="mt-1 text-[10px] text-fg-muted"
</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"
>
Enter to send · Shift+Enter for newline
· / for slash commands · @ for file references
</div>
</div>
<div
class="border-t border-border px-4 py-3 flex items-center justify-between gap-3 flex-wrap"
<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="flex items-center gap-3 text-[11px] min-w-0"
/>
<button
class="inline-flex items-center gap-2 rounded-lg border border-accent/40 bg-accent/15 hover:bg-accent/25 text-accent px-4 py-1.5 text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
disabled=""
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"
class="lucide lucide-play w-3.5 h-3.5 text-accent"
fill="none"
height="24"
stroke="currentColor"
@@ -5905,8 +5603,78 @@ exports[`screen snapshots > Run 1`] = `
points="6 3 20 12 6 21 6 3"
/>
</svg>
Run
</div>
<div
class="flex min-w-0 flex-1 items-center gap-2"
>
<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>
</div>
</div>