diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 9782f258afe19..df1b0ff200f18 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -242,8 +242,8 @@ export function BranchToolbarBranchSelector({ const branchByName = new Map(refs.map((refName) => [refName.name, refName] as const)); const normalizedBranchQuery = trimmedBranchQuery.toLowerCase(); const prReference = parsePullRequestReference(trimmedBranchQuery); - const isSelectingWorktreeBase = - effectiveEnvMode === "worktree" && !envLocked && !activeWorktreePath; + const needsWorktree = effectiveEnvMode === "worktree" && !activeWorktreePath; + const isSelectingWorktreeBase = needsWorktree && !envLocked; const checkoutPullRequestItemValue = canChangeThreadBranch && prReference && onCheckoutPullRequestRequest ? `__checkout_pull_request__:${prReference}` @@ -738,6 +738,12 @@ export function BranchToolbarBranchSelector({ {displayMode === "panel" && prNumber !== undefined && prUrl !== undefined ? ( ({ status: "success" as PullRequestCheck["status"], + url: "https://github.com/owner/repo/pull/1", + conflicting: false, extraStatus: null as PullRequestCheck["status"] | null, mergeMethods: ["merge"] as Array<"merge" | "squash" | "rebase">, perform: vi.fn(), @@ -29,12 +31,12 @@ vi.mock(import("~/state/entities"), async (importOriginal) => ({ const queryData = () => ({ projectId: ProjectId.make("project"), workspaceRoot: "/workspace", - url: "https://github.com/owner/repo/pull/1", + url: state.url, number: 1, title: "Test PR", - state: "open", + state: "open" as const, isDraft: false, - mergeability: "mergeable", + mergeability: state.conflicting ? "conflicting" : "mergeable", headBranch: "feature", baseBranch: "main", changedFiles: 1, @@ -103,6 +105,10 @@ vi.mock(import("../ui/toast"), async (original) => { import { act, render as renderUi } from "../../../test/render"; import { useUiStateStore } from "~/uiStateStore"; import { ThreadDetailsPrRow } from "./ThreadDetailsPrRow"; +import { + buildFixFindingsHandoff, + buildResolveConflictsPrompt, +} from "../pullRequest/pullRequestDetail.logic"; it("merges with the last selected merge method", async () => { await using screen = await setup(); @@ -177,6 +183,96 @@ it("requires a new merge click after passing checks become pending and pass agai expect(state.perform).not.toHaveBeenCalled(); }); +it.each([ + { matches: true, conflicting: false, draft: false }, + { matches: false, conflicting: false, draft: false }, + { matches: true, conflicting: true, draft: false }, + { matches: true, conflicting: false, draft: true }, + { + matches: true, + conflicting: false, + draft: false, + url: "https://git.cafe/Owner/Repo/pulls/1", + currentUrl: "https://git.cafe/owner/repo/pull/1", + }, + { + matches: true, + conflicting: true, + draft: false, + url: "https://git.cafe/Owner/Repo/pulls/1", + currentUrl: "https://git.cafe/owner/repo/pull/1", + }, + { matches: false, conflicting: false, draft: false, checkoutPending: true }, + { matches: false, conflicting: true, draft: false, checkoutPending: true }, +])("uses the matching checkout for PR tasks: %j", async (scenario) => { + const { matches, conflicting, draft } = scenario; + const checkoutPending = "checkoutPending" in scenario && scenario.checkoutPending; + await using screen = await setup(); + state.status = "failure"; + state.conflicting = conflicting; + if ("url" in scenario) state.url = scenario.url; + const threadRef = { + environmentId: EnvironmentId.make("environment"), + threadId: ThreadId.make("existing-thread"), + }; + const target = draft ? DraftId.make("existing-draft") : threadRef; + useComposerDraftStore.getState().setPrompt(target, "Keep my notes"); + const render = (pending: boolean) => ( + + ); + await screen.rerender(render(checkoutPending)); + const button = screen.getByRole("button", { name: conflicting ? "Resolve" : "Fix", exact: true }); + if (checkoutPending) { + await expect.element(button).toBeDisabled(); + expect(useComposerDraftStore.getState().getComposerDraft(target)?.prompt).toBe("Keep my notes"); + expect(state.newThread).not.toHaveBeenCalled(); + await screen.rerender(render(false)); + } + await button.click(); + if (matches) { + await vi.waitFor(() => { + expect(useComposerDraftStore.getState().getComposerDraft(target)?.prompt).toContain( + state.url, + ); + }); + expect(useComposerDraftStore.getState().getComposerDraft(target)?.prompt).toContain( + "Keep my notes", + ); + const detail = queryData(); + const task = conflicting + ? buildResolveConflictsPrompt(detail) + : buildFixFindingsHandoff({ + ...detail, + reviewThreads: [], + comments: [], + commentsTruncated: false, + }).prompt; + expect(useComposerDraftStore.getState().getComposerDraft(target)?.prompt).toContain(task); + expect(state.newThread).not.toHaveBeenCalled(); + expect(state.prepareThread).not.toHaveBeenCalled(); + } else { + await vi.waitFor(() => expect(state.prepareThread).toHaveBeenCalled()); + expect(useComposerDraftStore.getState().getComposerDraft(target)?.prompt).toBe("Keep my notes"); + } +}); + async function setup() { state.perform.mockReset(); state.checkoutError = null; @@ -184,11 +280,16 @@ async function setup() { draftId: DraftId.make("new-draft"), threadId: ThreadId.make("new-thread"), }); - state.prepareThread.mockReset(); + state.prepareThread.mockReset().mockResolvedValue({ + _tag: "Success", + value: { branch: "feature", worktreePath: "/workspace/pr", isOnPullRequestHead: true }, + }); state.addToast.mockReset().mockReturnValue("checkout-toast"); state.updateToast.mockReset(); state.status = "success"; + state.url = "https://github.com/owner/repo/pull/1"; + state.conflicting = false; state.extraStatus = null; state.mergeMethods = ["merge"]; useUiStateStore.getState().setPullRequestMergeMethod("merge"); diff --git a/apps/web/src/components/chat/ThreadDetailsPrRow.tsx b/apps/web/src/components/chat/ThreadDetailsPrRow.tsx index 23d705a8dae7c..6d7d187ab78a4 100644 --- a/apps/web/src/components/chat/ThreadDetailsPrRow.tsx +++ b/apps/web/src/components/chat/ThreadDetailsPrRow.tsx @@ -53,6 +53,7 @@ import { usePullRequestActionRunner, usePullRequestDefaultMergeMethodResolver, usePullRequestHandoffs, + type PullRequestHandoffThread, } from "../pullRequest/usePullRequestActions"; import { ChangeRequestStatusIcon, @@ -93,7 +94,11 @@ export function ThreadDetailsPrRow({ onOpen, onActed, onStopWatching, + currentThread, + handoffDisabled = false, }: { + currentThread?: PullRequestHandoffThread | undefined; + handoffDisabled?: boolean | undefined; environmentId: EnvironmentId; pr: ThreadPr; number: number; @@ -167,7 +172,11 @@ export function ThreadDetailsPrRow({ onActed?.(); }, }); - const { handoff, startHandoff } = usePullRequestHandoffs({ environmentId, detail }); + const { handoff, startHandoff, handoffInCurrentThread } = usePullRequestHandoffs({ + environmentId, + detail, + currentThread, + }); const lastSelectedMergeMethod = useUiStateStore((state) => state.pullRequestMergeMethod); const resolveProjectDefaultMergeMethod = usePullRequestDefaultMergeMethodResolver( environmentId, @@ -190,7 +199,7 @@ export function ThreadDetailsPrRow({ ); const startResolveConflicts = () => { - if (detail === null) return; + if (detail === null || handoffDisabled) return; void startHandoff("conflicts", { prompt: buildResolveConflictsPrompt({ number: detail.number, @@ -202,7 +211,7 @@ export function ThreadDetailsPrRow({ }; const startFixChecks = () => { - if (detail === null) return; + if (detail === null || handoffDisabled) return; void startHandoff( "findings", buildFixFindingsHandoff({ @@ -325,8 +334,12 @@ export function ThreadDetailsPrRow({ pendingLabel: "Preparing...", pending: handoff === "conflicts", destructive: true, - suffix: , - tooltip: "Check the branch out and resolve the conflicts in a new thread", + suffix: handoffInCurrentThread ? null : ( + + ), + tooltip: handoffInCurrentThread + ? "Resolve the conflicts in this thread" + : "Check the branch out and resolve the conflicts in a new thread", onClick: startResolveConflicts, } : rowAction === "ready" @@ -345,8 +358,12 @@ export function ThreadDetailsPrRow({ pendingLabel: "Preparing...", pending: handoff === "findings", destructive: true, - suffix: , - tooltip: "Fix the failing checks in a new thread", + suffix: handoffInCurrentThread ? null : ( + + ), + tooltip: handoffInCurrentThread + ? "Fix the failing checks in this thread" + : "Fix the failing checks in a new thread", onClick: startFixChecks, } : rowAction === "merge" @@ -461,7 +478,11 @@ export function ThreadDetailsPrRow({ size="sm" part="meta" tone={trailingAction.destructive ? "destructive" : "primary"} - disabled={actionPending || handoff !== null} + disabled={ + actionPending || + handoff !== null || + (handoffDisabled && (rowAction === "fix" || rowAction === "resolve")) + } onClick={trailingAction.onClick} /> } diff --git a/apps/web/src/components/chat/ThreadDetailsPrRows.tsx b/apps/web/src/components/chat/ThreadDetailsPrRows.tsx index 2da6019787f5a..c1781b40da1f7 100644 --- a/apps/web/src/components/chat/ThreadDetailsPrRows.tsx +++ b/apps/web/src/components/chat/ThreadDetailsPrRows.tsx @@ -28,7 +28,11 @@ function ThreadDetailsPrLinkRow({ onOpen, onActed, onStopWatching, + currentThread, + handoffDisabled, }: { + currentThread?: ComponentProps["currentThread"]; + handoffDisabled?: boolean | undefined; environmentId: EnvironmentId; link: ThreadPullRequestLink; onOpen: (event: ReactMouseEvent) => void; @@ -48,6 +52,8 @@ function ThreadDetailsPrLinkRow({ const pr = linked?.pr ?? null; return ( ( onOpenLink(event, link.url)} diff --git a/apps/web/src/components/pullRequest/usePullRequestActions.ts b/apps/web/src/components/pullRequest/usePullRequestActions.ts index b4814dfbb5a4d..204ea66e2f55d 100644 --- a/apps/web/src/components/pullRequest/usePullRequestActions.ts +++ b/apps/web/src/components/pullRequest/usePullRequestActions.ts @@ -31,7 +31,11 @@ import { buildPhysicalToLogicalProjectKeyMap } from "~/sidebarProjectGrouping"; import { useProjects } from "~/state/entities"; import { usePrimaryEnvironmentId } from "~/state/environments"; -import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; +import { + type ComposerThreadTarget, + type DraftId, + useComposerDraftStore, +} from "~/composerDraftStore"; import { useNewThreadHandler } from "~/hooks/useHandleNewThread"; import { usePreparePullRequestThreadAction } from "~/state/sourceControlActions"; import type { ReviewCommentContext } from "~/reviewCommentContext"; @@ -263,12 +267,19 @@ export type PullRequestHandoffDetail = Pick< "projectId" | "workspaceRoot" | "url" >; +export interface PullRequestHandoffThread { + target: ComposerThreadTarget; + pullRequestUrl: string; +} + export function usePullRequestHandoffs({ environmentId, detail, + currentThread, }: { environmentId: EnvironmentId; detail: PullRequestHandoffDetail | null; + currentThread?: PullRequestHandoffThread | undefined; }) { const newThread = useNewThreadHandler(); const prepareThread = usePreparePullRequestThreadAction({ @@ -283,6 +294,11 @@ export function usePullRequestHandoffs({ sourceControlClients.get(undefined) ).changeRequest; + const handoffInCurrentThread = + currentThread !== undefined && + detail !== null && + sourceControlClients.isSameChangeRequest(detail.url, currentThread.pullRequestUrl); + // Questions must not change checkouts or create worktrees. const openThreadWithTask = async ( projectRef: ReturnType, @@ -331,6 +347,15 @@ export function usePullRequestHandoffs({ mode: "worktree" | "local" = "worktree", ) => { if (!detail || handoff !== null) return; + if (handoffInCurrentThread && currentThread !== undefined && task !== null) { + writeHandoffToComposer(currentThread.target, task); + toastManager.add({ + type: "success", + title: "Task ready", + description: "The task is in the composer. Read it over, then send.", + }); + return; + } setHandoff(kind); // Explicit timeouts survive toast updates and would pin the result after loading. const toastId = toastManager.add({ @@ -415,5 +440,5 @@ export function usePullRequestHandoffs({ ); }; - return { handoff, startAsk, startHandoff }; + return { handoff, startAsk, startHandoff, handoffInCurrentThread }; }