Files
AgentHub/client/src/components/lanes/__tests__/AddLaneModal.test.tsx
T
nntrivi2001 fa156d6a59 feat: v2.6.0 — Chat UI composer (+ menu, project files, activity panel, permission mode, Stop), git on a workspace's extra folders (#70), long paste as a file (#68), S3 server mode (experimental)
Chat UI:
- Each Claude/Codex session keeps its own chat/terminal view; Settings only
  sets how new sessions open.
- Composer + menu: upload, project files and folders (#54, also dragged from
  the Files/Changes tree), context files, recent uploads, skills, plugins.
- Activity side panel (progress, outputs, files read, uploads, memory,
  skills, connectors) with a file preview; edited-file counts per turn.
- Permission-mode chip (Shift+Tab steps, read back from the pane:
  server/lib/pane-mode.js, GET|POST /api/run/:id/permission-mode).
- Stop (Esc) while the main agent works; the status is checked per agent
  (with session_id /api/agents ignores its status filter) and re-read every
  5 s while Stop shows.
- Dropped images shown in the conversation (GET /api/run/drop/:name, the
  requester's own drop dir), full-size viewer.
- A plain-text paste over 5,000 characters is attached as pasted-text.txt,
  so no prompt reaches tmux/argv limits (#68).

Changes panel: every /api/lanes/:id/git* route takes ?source=<lane_sources
id> (sourceFolder; ENOSOURCE otherwise) and a folder picker switches the
whole panel to a workspace's extra folder (#70).

Server mode S3 (one Linux user per account) — experimental, verified with
fake sudo plus a P6 managed-settings spike on a test account:
- member tmux listing runs in the background (cached, 15 s => unknown);
- transcript snapshot copy is async, chunked, temp file + rename;
- Express Layer patch only on Express 4;
- tmux capturePane/sendKey go to the member's own server.

Docs: the multi-user design moved in as docs/plans/server-mode/README.md
(the AgentHub-Server repo is gone); S3-PLAN P6 result; LANES/API/openapi.
2026-10-09 16:35:07 +07:00

782 lines
30 KiB
TypeScript

/**
* @file AddLaneModal.test.tsx
* @description Pins the "+ Add lane" flow's two modes: "Worktree" (default -
* pick a source repo, fork a branch, type a new branch name, submit through
* the provisioning endpoint) and "Repo" (adopt a directory as-is through
* `ensure`, no branch fields). Also covers the branch-lookup debounce, the
* unresolvable-path degrade, server-error handling, the auto-setup summary,
* the provisioning-wait race, and the folder-browse modal.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { AddLaneModal } from "../AddLaneModal";
import { api } from "../../../lib/api";
import type { AiTool, AiToolsView, CwdSuggestion } from "../../../lib/api";
import { __resetAiTools } from "../../../hooks/useAiTools";
import type { Lane } from "../../../lib/types";
vi.mock("../../../lib/api", () => ({
api: {
lanes: {
branches: vi.fn(),
pipelines: vi.fn(),
worktree: vi.fn(),
ensure: vi.fn(),
browse: vi.fn(),
pickPath: vi.fn(),
get: vi.fn(),
profileInit: vi.fn(),
mcpSync: vi.fn(),
},
aiProxies: { list: vi.fn() },
aiTools: { list: vi.fn(), iconUrl: (id: string) => `/api/ai-tools/${id}/icon` },
run: { start: vi.fn() },
},
}));
const tool = (id: string, label: string, over: Partial<AiTool> = {}): AiTool => ({
id,
label,
command: id,
homepageUrl: null,
installed: true,
path: `/usr/bin/${id}`,
enabled: true,
source: "builtin",
launcher: null,
prompt: { mode: "argv" },
resume: false,
bypass: false,
proxyCapable: false,
install: null,
update: null,
modelFlag: false,
defaultModel: null,
modelVendor: null,
configFiles: 0,
...over,
});
const TOOLS: AiToolsView = {
default: "claude",
tools: [
tool("claude", "Claude Code", { launcher: "claude", proxyCapable: true, prompt: null }),
tool("codex", "Codex", { launcher: "codex", proxyCapable: true, prompt: null }),
tool("gemini", "Gemini"),
tool("aider", "Aider", { installed: false, path: null }),
tool("grok", "Grok", { enabled: false }),
],
};
function laneFixture(over: Partial<Lane> = {}): Lane {
// `as Lane`: spreading a Partial<Lane> widens every field it may carry to
// `T | undefined`, which no longer satisfies Lane's required fields. The
// base object below still lists all of them, so the cast asserts what the
// literal already proves.
return {
id: 9,
title: "",
cwd: "/lanes/repo__feature",
branch: "feat/feature",
kind: "managed",
source_repo: null,
pipeline: "default",
session_id: null,
run_id: null,
stage: "idle",
stage_since: null,
status: "provisioning",
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: {},
...over,
} as Lane;
}
const SUGGESTIONS: CwdSuggestion[] = [
{ kind: "home", path: "/Users/tester", label: "Home" },
{ kind: "recent", path: "/Users/tester/projects/repo", label: "repo" },
];
function renderModal(over: Partial<React.ComponentProps<typeof AddLaneModal>> = {}) {
return render(
<AddLaneModal open onClose={vi.fn()} onAdded={vi.fn()} cwdSuggestions={SUGGESTIONS} {...over} />
);
}
/** The modal opens on Repo; worktree tests switch to Worktree first. */
function renderWorktree(over: Partial<React.ComponentProps<typeof AddLaneModal>> = {}) {
const r = renderModal(over);
fireEvent.click(screen.getByRole("button", { name: "Worktree" }));
return r;
}
/** ConfirmModal focuses its Cancel button on a 0ms timer after mount, which
* races userEvent.type() and can eat the first keystroke. Let that timer fire,
* then click the field to reclaim focus before typing. */
async function focusField(user: ReturnType<typeof userEvent.setup>, el: HTMLElement) {
await new Promise((r) => setTimeout(r, 0));
await user.click(el);
}
/** Fills the "Worktree" mode's form up through a resolved branch
* list, title, and new-branch name - everything Add lane needs to enable. */
async function fillWorktreeForm(
user: ReturnType<typeof userEvent.setup>,
{ repo = "/Users/tester/projects/repo", title = "demo", branch = "feat/demo" } = {}
) {
const repoField = screen.getByLabelText("Source repository");
await focusField(user, repoField);
await user.type(repoField, repo);
await screen.findByLabelText("Branch to fork from");
await user.type(screen.getByLabelText("Title"), title);
await user.type(screen.getByLabelText("New branch name"), branch);
}
beforeEach(() => {
__resetAiTools();
vi.mocked(api.aiTools.list).mockReset().mockResolvedValue(TOOLS);
vi.mocked(api.lanes.branches).mockReset();
vi.mocked(api.lanes.worktree).mockReset();
vi.mocked(api.lanes.ensure).mockReset();
vi.mocked(api.lanes.browse).mockReset();
vi.mocked(api.lanes.pickPath).mockReset();
// Provisioning finishes instantly by default - tests that care about the
// provisioning-in-progress race override this per-test.
vi.mocked(api.lanes.get)
.mockReset()
.mockResolvedValue({ lane: laneFixture({ status: "idle" }) });
vi.mocked(api.lanes.profileInit)
.mockReset()
.mockResolvedValue({ scaffolded: false, reason: "no detectable Node.js project" });
vi.mocked(api.lanes.mcpSync).mockReset().mockResolvedValue({ servers: [], profilesSeeded: [] });
vi.mocked(api.aiProxies.list).mockReset();
});
describe("AddLaneModal — picking an existing branch (issue #34)", () => {
const withWorktrees = {
branches: ["main", "feat/other", "feat/held"],
current: "main",
worktrees: [
{ path: "/Users/tester/projects/repo", branch: "main" },
{ path: "/lanes/repo__held", branch: "feat/held" },
],
};
it("lists the repo's existing worktrees once the path resolves", async () => {
vi.mocked(api.lanes.branches).mockResolvedValue(withWorktrees);
renderWorktree();
const user = userEvent.setup();
const repoField = screen.getByLabelText("Source repository");
await focusField(user, repoField);
await user.type(repoField, "/Users/tester/projects/repo");
const panel = await screen.findByTestId("add-lane-worktrees");
expect(panel).toHaveTextContent("/lanes/repo__held");
expect(panel).toHaveTextContent("feat/held");
});
it("checks an existing branch out, sending no base", async () => {
vi.mocked(api.lanes.branches).mockResolvedValue(withWorktrees);
vi.mocked(api.lanes.worktree).mockResolvedValue({ lane: laneFixture({ id: 9 }) });
renderWorktree();
const user = userEvent.setup();
const repoField = screen.getByLabelText("Source repository");
await focusField(user, repoField);
await user.type(repoField, "/Users/tester/projects/repo");
await screen.findByLabelText("Branch to fork from");
await user.type(screen.getByLabelText("Title"), "Reuse a branch");
await user.click(screen.getByRole("button", { name: "Existing branch" }));
const select = await screen.findByLabelText("Branch to check out");
await user.selectOptions(select, "feat/other");
await user.click(screen.getByRole("button", { name: "Add workspace" }));
await waitFor(() =>
expect(api.lanes.worktree).toHaveBeenCalledWith(
expect.objectContaining({ branch: "feat/other", base: undefined })
)
);
});
it("greys out a branch another worktree already holds", async () => {
vi.mocked(api.lanes.branches).mockResolvedValue(withWorktrees);
renderWorktree();
const user = userEvent.setup();
const repoField = screen.getByLabelText("Source repository");
await focusField(user, repoField);
await user.type(repoField, "/Users/tester/projects/repo");
await screen.findByLabelText("Branch to fork from");
await user.click(screen.getByRole("button", { name: "Existing branch" }));
const held = await screen.findByRole("option", { name: /feat\/held/ });
expect(held).toBeDisabled();
expect(held).toHaveTextContent("/lanes/repo__held");
expect(screen.getByRole("option", { name: "feat/other" })).toBeEnabled();
});
it("lets the server name a new branch when the field is left empty", async () => {
vi.mocked(api.lanes.branches).mockResolvedValue(withWorktrees);
vi.mocked(api.lanes.worktree).mockResolvedValue({ lane: laneFixture({ id: 9 }) });
renderWorktree();
const user = userEvent.setup();
const repoField = screen.getByLabelText("Source repository");
await focusField(user, repoField);
await user.type(repoField, "/Users/tester/projects/repo");
await screen.findByLabelText("Branch to fork from");
await user.type(screen.getByLabelText("Title"), "Auto named");
// No `feat/…` guess: the prefix is the server's LANE_BRANCH_PREFIX, which
// the form cannot see, so the placeholder only says the field is optional.
expect(screen.getByLabelText("New branch name")).toHaveAttribute(
"placeholder",
"Automatic — named from the title"
);
await user.click(screen.getByRole("button", { name: "Add workspace" }));
await waitFor(() =>
expect(api.lanes.worktree).toHaveBeenCalledWith(
expect.objectContaining({ branch: undefined, base: "main" })
)
);
});
});
describe("AddLaneModal — worktree mode", () => {
// The branch NAME is no longer part of this list: an empty one means "let
// the server name it" (issue #34).
it("disables confirm until a repo, a title and a resolved branch list are all present", () => {
renderWorktree();
expect(screen.getByRole("button", { name: "Add workspace" })).toBeDisabled();
});
it("looks up branches once the repo path settles, and shows them as a picker", async () => {
vi.mocked(api.lanes.branches).mockResolvedValue({
branches: ["main", "feat/other"],
current: "main",
worktrees: [],
});
renderWorktree();
const user = userEvent.setup();
const repoField = screen.getByLabelText("Source repository");
await focusField(user, repoField);
await user.type(repoField, "/Users/tester/projects/repo");
await waitFor(() =>
expect(api.lanes.branches).toHaveBeenCalledWith("/Users/tester/projects/repo")
);
const base = await screen.findByLabelText("Branch to fork from");
expect(base).toHaveValue("main"); // the repo's current branch is preselected
expect(screen.getByRole("option", { name: "feat/other" })).toBeInTheDocument();
});
it("stays disabled and shows a quiet hint when the path is not a resolvable repo", async () => {
vi.mocked(api.lanes.branches).mockRejectedValue(new Error("EBADSOURCEREPO"));
renderWorktree();
const user = userEvent.setup();
const repoField = screen.getByLabelText("Source repository");
await focusField(user, repoField);
await user.type(repoField, "/not/a/repo");
await waitFor(() => expect(api.lanes.branches).toHaveBeenCalled());
expect(await screen.findByText(/Not a git repository/)).toBeInTheDocument();
expect(screen.queryByLabelText("Branch to fork from")).toBeNull();
expect(screen.getByRole("button", { name: "Add workspace" })).toBeDisabled();
});
it("refuses a worktree of a repo with no commits and steers to Repo mode", async () => {
vi.mocked(api.lanes.branches).mockResolvedValue({
branches: [],
current: "main",
worktrees: [],
});
renderWorktree();
const user = userEvent.setup();
const repoField = screen.getByLabelText("Source repository");
await focusField(user, repoField);
await user.type(repoField, "/Users/tester/projects/empty");
expect(await screen.findByText(/commit first, then publish/i)).toBeInTheDocument();
await user.type(screen.getByLabelText("Title"), "demo");
// There is nothing to branch from, so the worktree cannot be created.
expect(screen.getByRole("button", { name: "Add workspace" })).toBeDisabled();
await user.click(screen.getByRole("button", { name: "Use as a Repo workspace" }));
expect(screen.getByRole("button", { name: "Repo" })).toHaveAttribute("aria-pressed", "true");
expect(screen.getByRole("button", { name: "Add workspace" })).toBeEnabled();
});
it("submits through the worktree provisioning endpoint, with the typed branch name", async () => {
vi.mocked(api.lanes.branches).mockResolvedValue({
branches: ["main"],
current: "main",
worktrees: [],
});
vi.mocked(api.lanes.worktree).mockResolvedValue({ lane: laneFixture({ id: 9 }) });
const onAdded = vi.fn();
const onClose = vi.fn();
renderWorktree({ onClose, onAdded });
const user = userEvent.setup();
await fillWorktreeForm(user, { title: "New feature", branch: "feat/new-feature" });
await user.click(screen.getByRole("button", { name: "Add workspace" }));
await waitFor(() => {
expect(api.lanes.worktree).toHaveBeenCalledWith({
sourceRepo: "/Users/tester/projects/repo",
title: "New feature",
base: "main",
branch: "feat/new-feature",
tool: "claude",
});
});
expect(onAdded).toHaveBeenCalledWith(
expect.objectContaining({ id: 9, status: "provisioning" })
);
// Setup came back clean, so there is nothing to report and the dialog is
// done. Leaving it up read as "still working" — its confirm button is dead
// once the lane exists — which is how it looked stuck.
await waitFor(() => expect(onClose).toHaveBeenCalled());
});
it("shows a server error and leaves the modal open instead of closing silently", async () => {
vi.mocked(api.lanes.branches).mockResolvedValue({
branches: ["main"],
current: "main",
worktrees: [],
});
vi.mocked(api.lanes.worktree).mockRejectedValue(new Error("EWORKTREEDIRCOLLISION"));
const onClose = vi.fn();
renderWorktree({ onClose });
const user = userEvent.setup();
await fillWorktreeForm(user, { title: "New feature" });
await user.click(screen.getByRole("button", { name: "Add workspace" }));
expect(await screen.findByText("EWORKTREEDIRCOLLISION")).toBeInTheDocument();
expect(onClose).not.toHaveBeenCalled();
});
it("renders nothing when closed", () => {
renderModal({ open: false });
expect(screen.queryByRole("dialog")).toBeNull();
});
it("fires profileInit and mcpSync after a successful worktree call", async () => {
vi.mocked(api.lanes.branches).mockResolvedValue({
branches: ["main"],
current: "main",
worktrees: [],
});
vi.mocked(api.lanes.worktree).mockResolvedValue({
lane: { id: 42, title: "demo", cwd: "/lanes/demo", status: "provisioning" } as Lane,
});
const onAdded = vi.fn();
renderWorktree({ onAdded });
const user = userEvent.setup();
await fillWorktreeForm(user);
await user.click(screen.getByRole("button", { name: "Add workspace" }));
await waitFor(() => expect(api.lanes.worktree).toHaveBeenCalled());
await waitFor(() => expect(api.lanes.profileInit).toHaveBeenCalledWith(42));
expect(api.lanes.mcpSync).toHaveBeenCalledWith(42);
await waitFor(() => expect(onAdded).toHaveBeenCalled());
});
it("still calls onAdded even when every setup call fails", async () => {
vi.mocked(api.lanes.branches).mockResolvedValue({
branches: ["main"],
current: "main",
worktrees: [],
});
vi.mocked(api.lanes.worktree).mockResolvedValue({
lane: { id: 43, title: "demo2", cwd: "/lanes/demo2", status: "provisioning" } as Lane,
});
vi.mocked(api.lanes.profileInit).mockRejectedValue(new Error("boom"));
vi.mocked(api.lanes.mcpSync).mockRejectedValue(new Error("boom"));
const onAdded = vi.fn();
const onClose = vi.fn();
renderWorktree({ onAdded, onClose });
const user = userEvent.setup();
await fillWorktreeForm(user, { title: "demo2" });
await user.click(screen.getByRole("button", { name: "Add workspace" }));
await waitFor(() => expect(onAdded).toHaveBeenCalled());
expect(onClose).not.toHaveBeenCalled();
});
it("keeps the summary up when a setup step FAILED, and closes on the button that replaces Add lane", async () => {
vi.mocked(api.lanes.branches).mockResolvedValue({
branches: ["main"],
current: "main",
worktrees: [],
});
vi.mocked(api.lanes.worktree).mockResolvedValue({
lane: { id: 44, title: "demo3", cwd: "/lanes/demo3", status: "provisioning" } as Lane,
});
vi.mocked(api.lanes.mcpSync).mockRejectedValue(new Error("no mcp"));
const onClose = vi.fn();
renderWorktree({ onClose });
const user = userEvent.setup();
await fillWorktreeForm(user, { title: "demo3" });
await user.click(screen.getByRole("button", { name: "Add workspace" }));
// A failure is the one case worth reading before the dialog goes away.
expect(await screen.findByText("Setup")).toBeInTheDocument();
expect(onClose).not.toHaveBeenCalled();
// And then the primary button must close, not sit there disabled: the lane
// already exists, so "Add lane" has nothing left to do.
expect(screen.queryByRole("button", { name: "Add workspace" })).toBeNull();
await user.click(screen.getByRole("button", { name: "Close" }));
expect(onClose).toHaveBeenCalled();
});
it("waits for the background worktree provisioning to finish before running setup, and skips setup if it fails", async () => {
vi.mocked(api.lanes.branches).mockResolvedValue({
branches: ["main"],
current: "main",
worktrees: [],
});
vi.mocked(api.lanes.worktree).mockResolvedValue({
lane: { id: 45, title: "demo4", cwd: "/lanes/demo4", status: "provisioning" } as Lane,
});
// First poll still provisioning, second poll reports the background
// `git worktree add` failed.
vi.mocked(api.lanes.get)
.mockReset()
.mockResolvedValueOnce({ lane: laneFixture({ id: 45, status: "provisioning" }) })
.mockResolvedValueOnce({ lane: laneFixture({ id: 45, status: "failed" }) });
const onAdded = vi.fn();
renderWorktree({ onAdded });
const user = userEvent.setup();
await fillWorktreeForm(user, { title: "demo4" });
await user.click(screen.getByRole("button", { name: "Add workspace" }));
await waitFor(() => expect(onAdded).toHaveBeenCalled());
expect(await screen.findByText(/auto-setup was skipped/)).toBeInTheDocument();
expect(api.lanes.profileInit).not.toHaveBeenCalled();
expect(api.lanes.mcpSync).not.toHaveBeenCalled();
});
});
describe("AddLaneModal — repo mode (adopt)", () => {
it("opens in Repo mode by default, with no branch fields", () => {
renderModal();
expect(screen.getByRole("button", { name: "Repo" })).toHaveAttribute("aria-pressed", "true");
expect(screen.queryByTestId("add-lane-branch-section")).toBeNull();
});
it("hides the branch fields and enables confirm on a path alone", async () => {
renderModal();
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: "Repo" }));
expect(screen.getByRole("button", { name: "Add workspace" })).toBeDisabled();
const dirField = screen.getByLabelText("Directory");
await focusField(user, dirField);
await user.type(dirField, "/Users/tester/projects/repo");
expect(screen.getByRole("button", { name: "Add workspace" })).toBeEnabled();
expect(screen.queryByLabelText("Branch to fork from")).toBeNull();
expect(screen.queryByLabelText("New branch name")).toBeNull();
expect(api.lanes.branches).not.toHaveBeenCalled();
});
it("submits through ensure, not worktree, and closes immediately", async () => {
vi.mocked(api.lanes.ensure).mockResolvedValue({
lane: laneFixture({ id: 23, kind: "adopted", status: "idle" }),
created: true,
});
const onAdded = vi.fn();
const onClose = vi.fn();
renderModal({ onAdded, onClose });
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: "Repo" }));
const dirField = screen.getByLabelText("Directory");
await focusField(user, dirField);
await user.type(dirField, "/Users/tester/projects/repo");
await user.type(screen.getByLabelText("Title"), "main repo");
await user.click(screen.getByRole("button", { name: "Add workspace" }));
await waitFor(() =>
expect(api.lanes.ensure).toHaveBeenCalledWith({
cwd: "/Users/tester/projects/repo",
title: "main repo",
tool: "claude",
exact: true,
})
);
expect(api.lanes.worktree).not.toHaveBeenCalled();
await waitFor(() => expect(onAdded).toHaveBeenCalledWith(expect.objectContaining({ id: 23 })));
// Repo mode never runs the profile/agents/mcp setup summary - it should
// close right away like the old adopt flow did.
expect(onClose).toHaveBeenCalled();
});
it("says the folder already has a lane instead of closing silently (#42)", async () => {
vi.mocked(api.lanes.ensure).mockResolvedValue({
lane: laneFixture({ id: 3, kind: "adopted", status: "idle", title: "SG - Services" }),
created: false,
});
const onAdded = vi.fn();
const onClose = vi.fn();
renderModal({ onAdded, onClose });
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: "Repo" }));
const dirField = screen.getByLabelText("Directory");
await focusField(user, dirField);
await user.type(dirField, "/Users/tester/projects/repo");
await user.click(screen.getByRole("button", { name: "Add workspace" }));
expect(
await screen.findByText(/already has a workspace.*#3.*SG - Services/)
).toBeInTheDocument();
// The existing lane is selected so the user sees it, but nothing claims a new one.
expect(onAdded).toHaveBeenCalledWith(expect.objectContaining({ id: 3 }));
expect(onClose).not.toHaveBeenCalled();
});
});
describe("AddLaneModal — folder browse", () => {
const noNativePicker = () =>
vi
.mocked(api.lanes.pickPath)
.mockRejectedValue(Object.assign(new Error("no picker"), { code: "ENODIALOG" }));
it("fills the path field from the OS's own picker", async () => {
vi.mocked(api.lanes.pickPath).mockResolvedValue({ path: "/Users/tester/app" });
renderModal();
const user = userEvent.setup();
await user.click(screen.getByTitle("Browse for a folder"));
await waitFor(() =>
expect(document.getElementById("add-lane-repo")).toHaveValue("/Users/tester/app")
);
expect(api.lanes.browse).not.toHaveBeenCalled();
});
it("opens the picker on every click into the path field", async () => {
vi.mocked(api.lanes.pickPath).mockResolvedValue({ path: "/Users/tester/app" });
renderModal();
const user = userEvent.setup();
const field = document.getElementById("add-lane-repo")!;
await user.click(field);
await waitFor(() => expect(field).toHaveValue("/Users/tester/app"));
await user.click(field);
await waitFor(() => expect(api.lanes.pickPath).toHaveBeenCalledTimes(2));
expect(api.lanes.pickPath).toHaveBeenLastCalledWith("dir", "/Users/tester/app");
});
it("falls back to the in-app browser when no native picker is available", async () => {
noNativePicker();
vi.mocked(api.lanes.browse).mockResolvedValue({
path: "/Users/tester",
parent: null,
entries: [],
});
const onClose = vi.fn();
renderModal({ onClose });
const user = userEvent.setup();
await user.click(screen.getByTitle("Browse for a folder"));
const browser = await screen.findByRole("dialog", { name: "Browse for a folder" });
// Its backdrop closes only the browser, never the modal behind it.
await user.click(browser.parentElement!);
expect(screen.queryByRole("dialog", { name: "Browse for a folder" })).toBeNull();
expect(onClose).not.toHaveBeenCalled();
});
it("opens the browser, lists subdirectories, and selecting one fills the path field", async () => {
noNativePicker();
vi.mocked(api.lanes.browse).mockResolvedValue({
path: "/Users/tester",
parent: "/Users",
entries: [{ name: "projects", path: "/Users/tester/projects", isGitRepo: false }],
});
renderModal();
const user = userEvent.setup();
await user.click(screen.getByTitle("Browse for a folder"));
expect(await screen.findByText("projects")).toBeInTheDocument();
await user.click(screen.getByText("projects"));
expect(api.lanes.browse).toHaveBeenCalledWith("/Users/tester/projects");
});
it("Escape closes only the folder browser, not the whole modal", async () => {
noNativePicker();
vi.mocked(api.lanes.browse).mockResolvedValue({
path: "/Users/tester",
parent: null,
entries: [],
});
const onClose = vi.fn();
renderModal({ onClose });
const user = userEvent.setup();
await user.click(screen.getByTitle("Browse for a folder"));
await screen.findByRole("dialog", { name: "Browse for a folder" });
await user.keyboard("{Escape}");
expect(screen.queryByRole("dialog", { name: "Browse for a folder" })).toBeNull();
expect(
screen.getByRole("dialog", { name: "Create a workspace from a working directory" })
).toBeInTheDocument();
expect(onClose).not.toHaveBeenCalled();
});
});
describe("AddLaneModal — create/update readme", () => {
it("is not offered here", async () => {
// Creating a lane and writing its docs are separate decisions.
renderModal();
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: "Repo" }));
expect(screen.queryByLabelText("Create/update readme file")).toBeNull();
});
});
describe("AddLaneModal — no proxy here", () => {
it("asks for no AI Proxy: a new session picks its own", async () => {
renderModal({});
await screen.findByRole("button", { name: /Claude Code.*Default/ });
expect(screen.queryByRole("radio", { name: "AI Proxy" })).toBeNull();
expect(api.aiProxies.list).not.toHaveBeenCalled();
});
});
describe("AddLaneModal — tool selection", () => {
it("sends the picked tool when adopting a repo", async () => {
vi.mocked(api.lanes.ensure).mockResolvedValue({
lane: laneFixture({ id: 23, kind: "adopted", status: "idle" }),
created: true,
});
const onAdded = vi.fn();
const onClose = vi.fn();
renderModal({ onAdded, onClose });
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: "Repo" }));
const dirField = screen.getByLabelText("Directory");
await focusField(user, dirField);
await user.type(dirField, "/tmp/demo");
// One button showing the default; it opens every installed + enabled tool.
const picker = await screen.findByRole("button", { name: /Claude Code.*Default/ });
expect(picker).toHaveAttribute("aria-expanded", "false");
await user.click(picker);
const list = screen.getByRole("group", { name: /Which CLI drives this workspace/ });
expect(
within(list)
.getAllByRole("button")
.map((b) => b.textContent)
).toEqual(["Claude Code", "Codex", "Gemini"]);
await user.click(within(list).getByRole("button", { name: "Codex" }));
// The picked tool replaces the default on the button, and the list closes.
expect(screen.getByRole("button", { name: "Codex", expanded: false })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Gemini" })).toBeNull();
await user.click(screen.getByRole("button", { name: /add workspace|create/i }));
await waitFor(() =>
expect(api.lanes.ensure).toHaveBeenCalledWith(expect.objectContaining({ tool: "codex" }))
);
});
it("says so, with a link to Settings, when no tool is installed and enabled", async () => {
vi.mocked(api.aiTools.list).mockResolvedValue({ default: null, tools: [] });
renderModal({});
expect(await screen.findByText(/No AI tool is installed and enabled/)).toBeInTheDocument();
expect(screen.getByRole("link", { name: /AI Tools/ })).toHaveAttribute(
"href",
"/settings/agents"
);
});
it("defaults to claude code", async () => {
vi.mocked(api.lanes.ensure).mockResolvedValue({
lane: laneFixture({ id: 24, kind: "adopted", status: "idle" }),
created: true,
});
const onAdded = vi.fn();
const onClose = vi.fn();
renderModal({ onAdded, onClose });
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: "Repo" }));
const dirField = screen.getByLabelText("Directory");
await focusField(user, dirField);
await user.type(dirField, "/tmp/demo");
await user.click(screen.getByRole("button", { name: /add workspace|create/i }));
await waitFor(() =>
expect(api.lanes.ensure).toHaveBeenCalledWith(expect.objectContaining({ tool: "claude" }))
);
});
});
describe("AddLaneModal — working straight on main (no worktree)", () => {
beforeEach(() => {
vi.mocked(api.lanes.branches).mockResolvedValue({
branches: ["development", "main"],
current: "development",
worktrees: [],
});
});
it("jumps to Repo mode from the branch choice", async () => {
const user = userEvent.setup();
renderWorktree();
const repoField = screen.getByLabelText("Source repository");
await focusField(user, repoField);
await user.type(repoField, "/Users/tester/projects/repo");
await screen.findByTestId("add-lane-branch-section");
await user.click(screen.getByRole("button", { name: "Current branch" }));
// Repo mode adopts the directory as it stands — no branch section left to
// answer, because there is no fork to configure.
expect(screen.queryByTestId("add-lane-branch-section")).toBeNull();
expect(screen.getByRole("button", { name: "Repo" })).toHaveAttribute("aria-pressed", "true");
});
it("prefills the repo and branch it was opened with", async () => {
renderModal({
prefill: { sourceRepo: "/Users/tester/projects/repo", branch: "development" },
});
expect(await screen.findByTestId("add-lane-branch-section")).toBeInTheDocument();
expect(screen.getByLabelText("Source repository")).toHaveValue("/Users/tester/projects/repo");
expect(screen.getByRole("button", { name: "Existing branch" })).toHaveAttribute(
"aria-pressed",
"true"
);
});
});