diff --git a/apps/web/src/components/chat/HeaderActions.test.tsx b/apps/web/src/components/chat/HeaderActions.test.tsx index ee0de270f..07a260422 100644 --- a/apps/web/src/components/chat/HeaderActions.test.tsx +++ b/apps/web/src/components/chat/HeaderActions.test.tsx @@ -120,7 +120,9 @@ vi.mock("@/stores/diffStore", async (importOriginal) => { }); vi.mock("./OpenInAppButton", () => ({ - OpenInAppButton: () =>
, + OpenInAppButton: ({ dirPath }: { dirPath: string | null }) => ( +
+ ), })); vi.mock("./CreatePrDialog", () => ({ @@ -545,6 +547,27 @@ describe("HeaderActions - consolidated header", () => { expect(icon.querySelector('path[d="M12 12H3.75M12 12L19.5 19.5M12 12L19.5 4.5"]')).toBeInTheDocument(); }); + it("does not fall back to the workspace checkout for a worktree thread without a worktree path", () => { + renderHeaderActions(makeThread({ mode: "worktree", worktree_path: null })); + + expect(screen.getByTestId("open-in-app")).toHaveAttribute("data-dir-path", ""); + expect(screen.getByTestId("thread-overview-local-path")).toHaveTextContent("Unavailable"); + expect(screen.getByTestId("thread-overview-local-path")).not.toHaveTextContent("/test"); + }); + + it("opens the workspace checkout for a direct thread", () => { + renderHeaderActions(makeThread({ mode: "direct", worktree_path: null })); + + expect(screen.getByTestId("open-in-app")).toHaveAttribute("data-dir-path", "/test"); + expect(screen.getByTestId("thread-overview-local-path")).toHaveTextContent("/test"); + }); + + it("opens the thread worktree for a worktree thread", () => { + renderHeaderActions(makeThread({ mode: "worktree", worktree_path: "/repo/worktrees/feat-x" })); + + expect(screen.getByTestId("open-in-app")).toHaveAttribute("data-dir-path", "/repo/worktrees/feat-x"); + }); + it("keeps older cached recap visible and exposes coverage times through the affordance", async () => { const messages = [ createMockMessage({ diff --git a/apps/web/src/components/chat/HeaderActions.tsx b/apps/web/src/components/chat/HeaderActions.tsx index 6cb12ca39..dfd37476a 100644 --- a/apps/web/src/components/chat/HeaderActions.tsx +++ b/apps/web/src/components/chat/HeaderActions.tsx @@ -4,6 +4,7 @@ import { OpenInAppButton } from "./OpenInAppButton"; import { ThreadOverview } from "./ThreadOverview"; import { useDiffStore } from "@/stores/diffStore"; import { useWorkspaceStore } from "@/features/projects/state/workspaceStore"; +import { resolveThreadDirPath } from "@/lib/worktree"; import { toggleRightPanelAdaptive } from "@/lib/right-panel-layout"; import { Button } from "@/components/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; @@ -48,7 +49,7 @@ export function HeaderActions({ thread, threadPaneWidth }: HeaderActionsProps) {
diff --git a/apps/web/src/hooks/useThreadGitActions.ts b/apps/web/src/hooks/useThreadGitActions.ts index 9de9fdfd7..5b2baadc2 100644 --- a/apps/web/src/hooks/useThreadGitActions.ts +++ b/apps/web/src/hooks/useThreadGitActions.ts @@ -4,6 +4,7 @@ import { useHasCommitsAhead } from "@/hooks/useHasCommitsAhead"; import { useWorkspaceStore } from "@/features/projects/state/workspaceStore"; import { useComposerDraftStore } from "@/stores/composerDraftStore"; import { isPrable } from "@/lib/is-prable"; +import { resolveThreadDirPath } from "@/lib/worktree"; import { openGitHubUrl } from "@/features/preview/navigation/open-url-in-preview"; import type { Thread } from "@/transport"; @@ -88,7 +89,7 @@ function useThreadPrState(thread: Thread) { export function useThreadGitActions(thread: Thread) { const [createPrOpen, setCreatePrOpen] = useState(false); const { workspace, prable, pr, checks, openPrDetail } = useThreadPrState(thread); - const dirPath = thread.worktree_path ?? workspace?.path ?? null; + const dirPath = resolveThreadDirPath(thread, workspace?.path ?? null); // Whether the branch has commits ahead of base (disable Create PR when it doesn't). const hasCommitsAhead = useHasCommitsAhead( diff --git a/apps/web/src/lib/__tests__/worktree.test.ts b/apps/web/src/lib/__tests__/worktree.test.ts new file mode 100644 index 000000000..e18ac2b89 --- /dev/null +++ b/apps/web/src/lib/__tests__/worktree.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from "vitest"; +import { resolveThreadDirPath } from "../worktree"; + +describe("resolveThreadDirPath", () => { + it("returns the worktree path for a worktree thread", () => { + expect( + resolveThreadDirPath( + { mode: "worktree", worktree_path: "/repo/worktrees/feat-x" }, + "/repo", + ), + ).toBe("/repo/worktrees/feat-x"); + }); + + it("returns null for a worktree thread whose worktree path is not yet provisioned", () => { + expect( + resolveThreadDirPath({ mode: "worktree", worktree_path: null }, "/repo"), + ).toBeNull(); + }); + + it("falls back to the workspace checkout for a direct thread", () => { + expect( + resolveThreadDirPath({ mode: "direct", worktree_path: null }, "/repo"), + ).toBe("/repo"); + }); + + it("prefers a direct thread's worktree path when present", () => { + expect( + resolveThreadDirPath( + { mode: "direct", worktree_path: "/repo/worktrees/feat-x" }, + "/repo", + ), + ).toBe("/repo/worktrees/feat-x"); + }); + + it("returns null when no workspace is available", () => { + expect( + resolveThreadDirPath({ mode: "direct", worktree_path: null }, null), + ).toBeNull(); + }); +}); diff --git a/apps/web/src/lib/worktree.ts b/apps/web/src/lib/worktree.ts index 85bc6b893..62a2e3dc2 100644 --- a/apps/web/src/lib/worktree.ts +++ b/apps/web/src/lib/worktree.ts @@ -1,7 +1,21 @@ +import type { Thread } from "@/transport"; import type { WorktreeInfo } from "@/transport/types"; const DETACHED_WORKTREE_BRANCH = "(detached)"; +/** + * Directory the thread operates in, for open-in and "Worktree path" surfaces. + * A worktree thread whose path has not been persisted yet must not fall back + * to the workspace checkout: the base directory is a different working tree + * than the one the user is in. + */ +export function resolveThreadDirPath( + thread: Pick, + workspacePath: string | null, +): string | null { + return thread.worktree_path ?? (thread.mode === "worktree" ? null : workspacePath); +} + /** Normalizes a worktree path for UI-side matching. */ export function normalizeWorktreePath(path: string): string { return path.replace(/\\/g, "/").replace(/\/$/, "").toLowerCase();