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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user