fa156d6a59
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.
782 lines
30 KiB
TypeScript
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"
|
|
);
|
|
});
|
|
});
|