feat(workspace): a session in its own worktree, Claude's way
Desktop-parity phase 6, following Claude Desktop: - the workspace home's Worktree checkbox starts the pane with `claude --worktree=<name>`, so Claude Code makes, populates (.worktreeinclude, WorktreeCreate hook) and cleans up <repo>/.claude/worktrees/<name>; AgentHub only excludes /.claude/worktrees/ at the repo root (Claude does not) - server/lib/session-worktree.js resolves a session's worktree from the session row only (owned by the workspace, registered by git worktree list); every lane git route takes ?session= and answers ENOWORKTREE instead of falling back to the main folder - the session view's Changes panel works in the opened run's worktree (dashboard_runs.worktree, GitTarget in the client api) - Resume/Fork start in the session's worktree; a gone one is ENOWORKTREE - Archive of a worktree session removes the worktree after asking: git worktree remove without --force, git branch -d (unmerged work keeps its branch), a lock held by a live pid refused, a stale one cleared - listWorktrees reads `locked <reason>` locks - tests scrub GIT_* env so the pre-commit suite cannot act on this repo
This commit is contained in:
@@ -19,6 +19,7 @@ import { api } from "../../../lib/api";
|
||||
import type { LaneChanges } from "../../../lib/types";
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
gitTarget: (lane: number, session?: string | null) => (session ? { lane, session } : lane),
|
||||
api: {
|
||||
lanes: {
|
||||
git: vi.fn(),
|
||||
|
||||
@@ -14,6 +14,7 @@ import { LaneConflictView } from "../LaneConflictView";
|
||||
import { api } from "../../../lib/api";
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
gitTarget: (lane: number, session?: string | null) => (session ? { lane, session } : lane),
|
||||
api: { lanes: { gitFile: vi.fn(), gitSaveFile: vi.fn(), gitAction: vi.fn() } },
|
||||
}));
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import { api } from "../../../lib/api";
|
||||
import type { LaneGitFacts } from "../../../lib/types";
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
gitTarget: (lane: number, session?: string | null) => (session ? { lane, session } : lane),
|
||||
api: { lanes: { gitAction: vi.fn(), gitBranches: vi.fn() } },
|
||||
}));
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import { LaneGitHeader, useLaneGitFacts } from "../LaneGitHeader";
|
||||
import { api } from "../../../lib/api";
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
gitTarget: (lane: number, session?: string | null) => (session ? { lane, session } : lane),
|
||||
api: { lanes: { git: vi.fn(), gitAction: vi.fn(), gitBranches: vi.fn() } },
|
||||
}));
|
||||
|
||||
|
||||
@@ -43,7 +43,12 @@ function Where() {
|
||||
return <span data-testid="where">{loc.pathname + loc.search}</span>;
|
||||
}
|
||||
|
||||
function renderButton(session: { id: string; cwd: string | null; tool?: string }) {
|
||||
function renderButton(session: {
|
||||
id: string;
|
||||
cwd: string | null;
|
||||
tool?: string;
|
||||
lane_id?: number;
|
||||
}) {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={["/sessions/x"]}>
|
||||
<Routes>
|
||||
@@ -136,6 +141,17 @@ describe("SessionResumeButton", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("resumes a session in its own worktree through the workspace that owns it", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderButton({ id: "s-wt", cwd: "/r/.claude/worktrees/ah-3-x", tool: "claude", lane_id: 3 });
|
||||
await user.click(await screen.findByRole("button", { name: "Resume" }));
|
||||
await user.click(screen.getByRole("button", { name: "Resume session" }));
|
||||
expect(api.lanes.action).toHaveBeenCalledWith(3, "start", {
|
||||
slot: 1,
|
||||
resumeSessionId: "s-wt",
|
||||
});
|
||||
});
|
||||
|
||||
it("offers no Resume for a session started in a subfolder, but opens its live pane", async () => {
|
||||
const { unmount } = renderButton({ id: "s-9", cwd: "/r/lib", tool: "claude" });
|
||||
await waitFor(() => expect(api.run.list).toHaveBeenCalled());
|
||||
|
||||
@@ -193,6 +193,21 @@ describe("WorkspaceHome", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("starts the session in its own worktree when Worktree is ticked (a repo only)", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderHome();
|
||||
await screen.findByTestId("workspace-recent-s-live");
|
||||
await user.click(await screen.findByRole("checkbox", { name: "Worktree" }));
|
||||
await user.type(screen.getByLabelText("How can I help you today?"), "try it");
|
||||
await user.click(screen.getByTestId("workspace-home-start"));
|
||||
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", {
|
||||
slot: 1,
|
||||
prompt: "try it",
|
||||
permissionMode: "auto",
|
||||
worktree: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses to start when all four panes are busy", async () => {
|
||||
runs = [0, 1, 2, 3].map(
|
||||
(slot) => ({ id: `r${slot}`, laneId: 1, slot, status: "running" }) as RunHandle
|
||||
|
||||
Reference in New Issue
Block a user