Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion apps/web/src/components/chat/HeaderActions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,9 @@ vi.mock("@/stores/diffStore", async (importOriginal) => {
});

vi.mock("./OpenInAppButton", () => ({
OpenInAppButton: () => <div data-testid="open-in-app" />,
OpenInAppButton: ({ dirPath }: { dirPath: string | null }) => (
<div data-testid="open-in-app" data-dir-path={dirPath ?? ""} />
),
}));

vi.mock("./CreatePrDialog", () => ({
Expand Down Expand Up @@ -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({
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/components/chat/HeaderActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -48,7 +49,7 @@ export function HeaderActions({ thread, threadPaneWidth }: HeaderActionsProps) {
<div className="flex items-center justify-end gap-1">
<div className="flex items-center gap-0.5 bg-muted/20 rounded-md px-1 py-0.5">
<OpenInAppButton
dirPath={thread.worktree_path ?? workspacePath}
dirPath={resolveThreadDirPath(thread, workspacePath)}
threadId={thread.id}
threadOverride={thread.default_open_in_app ?? null}
/>
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/hooks/useThreadGitActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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(
Expand Down
40 changes: 40 additions & 0 deletions apps/web/src/lib/__tests__/worktree.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
14 changes: 14 additions & 0 deletions apps/web/src/lib/worktree.ts
Original file line number Diff line number Diff line change
@@ -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<Thread, "mode" | "worktree_path">,
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();
Expand Down
Loading