feat(ui): call lanes "workspaces" in the UI, CLI output and docs

Copy-only rename (workspace redesign, phase 0). The word a user reads
changes — "Lane" → "Workspace", "Add lane" → "Add workspace" — in both
locales, in agenthub CLI prose and help, in the server's error messages
and in the docs. Every identifier stays: /api/lanes, `agenthub lanes`,
--lane, AGENTHUB_LANE_ID, error codes, tmux names, DB tables, file,
component and i18n key names. docs/LANES.md keeps its name, retitled
"Workspaces (lanes)".
This commit is contained in:
2026-10-01 16:03:24 +07:00
parent e01cb26bde
commit 5b3884b309
43 changed files with 928 additions and 891 deletions
@@ -243,7 +243,7 @@ describe("AddLaneModal — picking an existing branch (issue #34)", () => {
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 lane" }));
await user.click(screen.getByRole("button", { name: "Add workspace" }));
await waitFor(() =>
expect(api.lanes.worktree).toHaveBeenCalledWith(
@@ -287,7 +287,7 @@ describe("AddLaneModal — picking an existing branch (issue #34)", () => {
"Automatic — named from the title"
);
await user.click(screen.getByRole("button", { name: "Add lane" }));
await user.click(screen.getByRole("button", { name: "Add workspace" }));
await waitFor(() =>
expect(api.lanes.worktree).toHaveBeenCalledWith(
expect.objectContaining({ branch: undefined, base: "main" })
@@ -301,7 +301,7 @@ describe("AddLaneModal — worktree mode (default)", () => {
// the server name it" (issue #34).
it("disables confirm until a repo, a title and a resolved branch list are all present", () => {
renderModal();
expect(screen.getByRole("button", { name: "Add lane" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Add workspace" })).toBeDisabled();
});
it("looks up branches once the repo path settles, and shows them as a picker", async () => {
@@ -337,7 +337,7 @@ describe("AddLaneModal — worktree mode (default)", () => {
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 lane" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Add workspace" })).toBeDisabled();
});
it("refuses a worktree of a repo with no commits and steers to Repo mode", async () => {
@@ -355,11 +355,11 @@ describe("AddLaneModal — worktree mode (default)", () => {
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 lane" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Add workspace" })).toBeDisabled();
await user.click(screen.getByRole("button", { name: "Use as a Repo lane" }));
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 lane" })).toBeEnabled();
expect(screen.getByRole("button", { name: "Add workspace" })).toBeEnabled();
});
it("submits through the worktree provisioning endpoint, with the typed branch name", async () => {
@@ -375,7 +375,7 @@ describe("AddLaneModal — worktree mode (default)", () => {
const user = userEvent.setup();
await fillWorktreeForm(user, { title: "New feature", branch: "feat/new-feature" });
await user.click(screen.getByRole("button", { name: "Add lane" }));
await user.click(screen.getByRole("button", { name: "Add workspace" }));
await waitFor(() => {
expect(api.lanes.worktree).toHaveBeenCalledWith({
@@ -407,7 +407,7 @@ describe("AddLaneModal — worktree mode (default)", () => {
const user = userEvent.setup();
await fillWorktreeForm(user, { title: "New feature" });
await user.click(screen.getByRole("button", { name: "Add lane" }));
await user.click(screen.getByRole("button", { name: "Add workspace" }));
expect(await screen.findByText("EWORKTREEDIRCOLLISION")).toBeInTheDocument();
expect(onClose).not.toHaveBeenCalled();
@@ -432,7 +432,7 @@ describe("AddLaneModal — worktree mode (default)", () => {
const user = userEvent.setup();
await fillWorktreeForm(user);
await user.click(screen.getByRole("button", { name: "Add lane" }));
await user.click(screen.getByRole("button", { name: "Add workspace" }));
await waitFor(() => expect(api.lanes.worktree).toHaveBeenCalled());
await waitFor(() => expect(api.lanes.profileInit).toHaveBeenCalledWith(42));
@@ -457,7 +457,7 @@ describe("AddLaneModal — worktree mode (default)", () => {
const user = userEvent.setup();
await fillWorktreeForm(user, { title: "demo2" });
await user.click(screen.getByRole("button", { name: "Add lane" }));
await user.click(screen.getByRole("button", { name: "Add workspace" }));
await waitFor(() => expect(onAdded).toHaveBeenCalled());
expect(onClose).not.toHaveBeenCalled();
@@ -478,14 +478,14 @@ describe("AddLaneModal — worktree mode (default)", () => {
const user = userEvent.setup();
await fillWorktreeForm(user, { title: "demo3" });
await user.click(screen.getByRole("button", { name: "Add lane" }));
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 lane" })).toBeNull();
expect(screen.queryByRole("button", { name: "Add workspace" })).toBeNull();
await user.click(screen.getByRole("button", { name: "Close" }));
expect(onClose).toHaveBeenCalled();
});
@@ -510,7 +510,7 @@ describe("AddLaneModal — worktree mode (default)", () => {
const user = userEvent.setup();
await fillWorktreeForm(user, { title: "demo4" });
await user.click(screen.getByRole("button", { name: "Add lane" }));
await user.click(screen.getByRole("button", { name: "Add workspace" }));
await waitFor(() => expect(onAdded).toHaveBeenCalled());
expect(await screen.findByText(/auto-setup was skipped/)).toBeInTheDocument();
@@ -525,12 +525,12 @@ describe("AddLaneModal — repo mode (adopt)", () => {
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: "Repo" }));
expect(screen.getByRole("button", { name: "Add lane" })).toBeDisabled();
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 lane" })).toBeEnabled();
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();
@@ -551,7 +551,7 @@ describe("AddLaneModal — repo mode (adopt)", () => {
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 lane" }));
await user.click(screen.getByRole("button", { name: "Add workspace" }));
await waitFor(() =>
expect(api.lanes.ensure).toHaveBeenCalledWith({
@@ -582,9 +582,11 @@ describe("AddLaneModal — repo mode (adopt)", () => {
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 lane" }));
await user.click(screen.getByRole("button", { name: "Add workspace" }));
expect(await screen.findByText(/already has a lane.*#3.*SG - Services/)).toBeInTheDocument();
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();
@@ -625,7 +627,7 @@ describe("AddLaneModal — folder browse", () => {
expect(screen.queryByRole("dialog", { name: "Browse for a folder" })).toBeNull();
expect(
screen.getByRole("dialog", { name: "Create a lane from a working directory" })
screen.getByRole("dialog", { name: "Create a workspace from a working directory" })
).toBeInTheDocument();
expect(onClose).not.toHaveBeenCalled();
});
@@ -655,7 +657,7 @@ describe("AddLaneModal — AI Router", () => {
const repoField = screen.getByLabelText("Directory");
await focusField(user, repoField);
await user.type(repoField, "/Users/tester/projects/repo");
await user.click(screen.getByRole("button", { name: "Add lane" }));
await user.click(screen.getByRole("button", { name: "Add workspace" }));
await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalledWith(9, "native"));
});
@@ -674,7 +676,7 @@ describe("AddLaneModal — AI Router", () => {
await focusField(user, repoField);
await user.type(repoField, "/Users/tester/projects/repo");
await user.click(await screen.findByRole("radio", { name: "AI Router" }));
await user.click(screen.getByRole("button", { name: "Add lane" }));
await user.click(screen.getByRole("button", { name: "Add workspace" }));
await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalledWith(9, "9router"));
});
@@ -703,7 +705,7 @@ describe("AddLaneModal — AI Router", () => {
const repoField = screen.getByLabelText("Directory");
await focusField(user, repoField);
await user.type(repoField, "/Users/tester/projects/repo");
await user.click(screen.getByRole("button", { name: "Add lane" }));
await user.click(screen.getByRole("button", { name: "Add workspace" }));
await waitFor(() => expect(api.lanes.ensure).toHaveBeenCalled());
});
@@ -726,7 +728,7 @@ describe("AddLaneModal — a created lane is never lost to a setup failure", ()
const repoField = screen.getByLabelText("Directory");
await focusField(user, repoField);
await user.type(repoField, "/Users/tester/projects/repo");
await user.click(screen.getByRole("button", { name: "Add lane" }));
await user.click(screen.getByRole("button", { name: "Add workspace" }));
await waitFor(() => expect(onAdded).toHaveBeenCalled());
// …and the failure is still surfaced, on a modal left open.
@@ -792,7 +794,7 @@ describe("AddLaneModal — tool selection", () => {
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 lane/ });
const list = screen.getByRole("group", { name: /Which CLI drives this workspace/ });
expect(
within(list)
.getAllByRole("button")
@@ -802,7 +804,7 @@ describe("AddLaneModal — tool selection", () => {
// 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 lane|create/i }));
await user.click(screen.getByRole("button", { name: /add workspace|create/i }));
await waitFor(() =>
expect(api.lanes.ensure).toHaveBeenCalledWith(expect.objectContaining({ tool: "codex" }))
@@ -842,7 +844,7 @@ describe("AddLaneModal — tool selection", () => {
const dirField = screen.getByLabelText("Directory");
await focusField(user, dirField);
await user.type(dirField, "/tmp/demo");
await user.click(screen.getByRole("button", { name: /add lane|create/i }));
await user.click(screen.getByRole("button", { name: /add workspace|create/i }));
await waitFor(() =>
expect(api.lanes.ensure).toHaveBeenCalledWith(expect.objectContaining({ tool: "claude" }))
@@ -135,7 +135,7 @@ describe("DestructiveLaneModal", () => {
onConfirm={onConfirm}
/>
);
const confirmButton = await screen.findByRole("button", { name: "Delete lane record" });
const confirmButton = await screen.findByRole("button", { name: "Delete workspace record" });
await waitFor(() => expect(confirmButton).not.toBeDisabled());
// "adopted" is shown as context, not as a refusal.
expect(screen.getByText(/Only the dashboard's record of it is dropped/)).toBeInTheDocument();
@@ -167,7 +167,7 @@ describe("DestructiveLaneModal", () => {
onConfirm={onConfirm}
/>
);
const confirmButton = await screen.findByRole("button", { name: "Delete lane record" });
const confirmButton = await screen.findByRole("button", { name: "Delete workspace record" });
await waitFor(() => expect(confirmButton).not.toBeDisabled());
expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
fireEvent.click(confirmButton);
@@ -212,9 +212,9 @@ describe("DestructiveLaneModal", () => {
onConfirm={onConfirm}
/>
);
const confirmButton = await screen.findByRole("button", { name: "Delete lane record" });
const confirmButton = await screen.findByRole("button", { name: "Delete workspace record" });
await waitFor(() => expect(confirmButton).not.toBeDisabled());
expect(screen.getByText(/The lane directory is already gone/)).toBeInTheDocument();
expect(screen.getByText(/The workspace directory is already gone/)).toBeInTheDocument();
fireEvent.click(confirmButton);
expect(onConfirm).toHaveBeenCalledWith({
expect: { head: null, dirty: 0, untracked: 0, unpushed: 0 },
@@ -259,7 +259,7 @@ describe("DestructiveLaneModal", () => {
onConfirm={onConfirm}
/>
);
const confirmButton = await screen.findByRole("button", { name: "Delete lane record" });
const confirmButton = await screen.findByRole("button", { name: "Delete workspace record" });
await waitFor(() => expect(confirmButton).not.toBeDisabled());
expect(screen.getByText(/cannot be read as a Git worktree/)).toBeInTheDocument();
fireEvent.click(confirmButton);
@@ -289,7 +289,7 @@ describe("DestructiveLaneModal", () => {
onConfirm={onConfirm}
/>
);
const confirmButton = await screen.findByRole("button", { name: "Delete lane record" });
const confirmButton = await screen.findByRole("button", { name: "Delete workspace record" });
await waitFor(() => expect(confirmButton).not.toBeDisabled());
expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
fireEvent.click(confirmButton);
@@ -207,7 +207,7 @@ describe("LaneChangesPanel", () => {
});
render(<LaneChangesPanel laneId={7} />);
expect(
await screen.findByText("This lane's directory is not a readable git repository.")
await screen.findByText("This workspace's directory is not a readable git repository.")
).toBeInTheDocument();
});
@@ -197,7 +197,7 @@ describe("LaneGitBar", () => {
);
await user.selectOptions(screen.getByLabelText("Switch branch…"), "development");
await user.click(screen.getByRole("button", { name: "New lane here" }));
await user.click(screen.getByRole("button", { name: "New workspace here" }));
expect(onNewLane).toHaveBeenCalledWith("development");
});
@@ -215,7 +215,7 @@ describe("LaneGitBar", () => {
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} onNewLane={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /Branch/ }));
const select = await screen.findByLabelText("Switch branch…");
const newLane = () => screen.getByRole("button", { name: "New lane here" });
const newLane = () => screen.getByRole("button", { name: "New workspace here" });
// This lane's own branch: its worktree is this lane.
await user.selectOptions(select, "feat/thing");
@@ -287,7 +287,7 @@ describe("LaneGitBar", () => {
worktrees: [{ path: "/repo/dev", branch: "development" }],
});
await user.selectOptions(select, "development");
const newLane = screen.getByRole("button", { name: "New lane here" });
const newLane = screen.getByRole("button", { name: "New workspace here" });
expect(newLane).toBeEnabled();
expect(newLane).not.toHaveAttribute("title", expect.stringContaining("/repo/dev"));
});
@@ -425,7 +425,7 @@ export function LaneConsolePane({
const startedId = laneStartResult.run_id ?? laneStartResult.lane?.run_id;
if (!startedId) {
throw new Error("No run_id returned from lane start");
throw new Error(t("errors.noRunIdReturned"));
}
fetched = await api.run.get(startedId);