Skip to content
Open
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
10 changes: 8 additions & 2 deletions apps/web/src/components/BranchToolbarBranchSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}`
Expand Down Expand Up @@ -738,6 +738,12 @@ export function BranchToolbarBranchSelector({
{displayMode === "panel" && prNumber !== undefined && prUrl !== undefined ? (
<ThreadDetailsPrRows
threadRef={threadRef}
handoffDisabled={needsWorktree && envLocked}
currentThread={
branchPr && !needsWorktree
? { target: draftId ?? threadRef, pullRequestUrl: branchPr.url }
: undefined
}
links={serverThread?.pullRequests ?? []}
currentLink={currentLinkedPr}
onOpenLink={openPrLink}
Expand Down
111 changes: 106 additions & 5 deletions apps/web/src/components/chat/ThreadDetailsPrRow.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { EnvironmentId, ProjectId, ThreadId, type PullRequestCheck } from "@t3tools/contracts";
import { DraftId } from "~/composerDraftStore";
import { DraftId, useComposerDraftStore } from "~/composerDraftStore";
import { AsyncResult } from "effect/reactivity";
import { expect, it, vi } from "vite-plus/test";

const state = vi.hoisted(() => ({
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(),
Expand All @@ -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,
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -177,18 +183,113 @@ 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) => (
<ThreadDetailsPrRow
handoffDisabled={pending}
environmentId={threadRef.environmentId}
pr={null}
number={1}
status={null}
project={null}
label="Test PR"
openAriaLabel="Open PR"
onOpen={vi.fn()}
currentThread={{
target,
pullRequestUrl:
"currentUrl" in scenario
? scenario.currentUrl
: `https://github.com/owner/repo/pull/${matches ? 1 : 2}`,
}}
/>
);
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;
state.newThread.mockReset().mockResolvedValue({
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");
Expand Down
37 changes: 29 additions & 8 deletions apps/web/src/components/chat/ThreadDetailsPrRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import {
usePullRequestActionRunner,
usePullRequestDefaultMergeMethodResolver,
usePullRequestHandoffs,
type PullRequestHandoffThread,
} from "../pullRequest/usePullRequestActions";
import {
ChangeRequestStatusIcon,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -202,7 +211,7 @@ export function ThreadDetailsPrRow({
};

const startFixChecks = () => {
if (detail === null) return;
if (detail === null || handoffDisabled) return;
void startHandoff(
"findings",
buildFixFindingsHandoff({
Expand Down Expand Up @@ -325,8 +334,12 @@ export function ThreadDetailsPrRow({
pendingLabel: "Preparing...",
pending: handoff === "conflicts",
destructive: true,
suffix: <ArrowUpRightIcon aria-hidden className="size-3 shrink-0" />,
tooltip: "Check the branch out and resolve the conflicts in a new thread",
suffix: handoffInCurrentThread ? null : (
<ArrowUpRightIcon aria-hidden className="size-3 shrink-0" />
),
tooltip: handoffInCurrentThread
? "Resolve the conflicts in this thread"
: "Check the branch out and resolve the conflicts in a new thread",
onClick: startResolveConflicts,
}
: rowAction === "ready"
Expand All @@ -345,8 +358,12 @@ export function ThreadDetailsPrRow({
pendingLabel: "Preparing...",
pending: handoff === "findings",
destructive: true,
suffix: <ArrowUpRightIcon aria-hidden className="size-3 shrink-0" />,
tooltip: "Fix the failing checks in a new thread",
suffix: handoffInCurrentThread ? null : (
<ArrowUpRightIcon aria-hidden className="size-3 shrink-0" />
),
tooltip: handoffInCurrentThread
? "Fix the failing checks in this thread"
: "Fix the failing checks in a new thread",
onClick: startFixChecks,
}
: rowAction === "merge"
Expand Down Expand Up @@ -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}
/>
}
Expand Down
8 changes: 8 additions & 0 deletions apps/web/src/components/chat/ThreadDetailsPrRows.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,11 @@ function ThreadDetailsPrLinkRow({
onOpen,
onActed,
onStopWatching,
currentThread,
handoffDisabled,
}: {
currentThread?: ComponentProps<typeof ThreadDetailsPrRow>["currentThread"];
handoffDisabled?: boolean | undefined;
environmentId: EnvironmentId;
link: ThreadPullRequestLink;
onOpen: (event: ReactMouseEvent<HTMLElement>) => void;
Expand All @@ -48,6 +52,8 @@ function ThreadDetailsPrLinkRow({
const pr = linked?.pr ?? null;
return (
<ThreadDetailsPrRow
handoffDisabled={handoffDisabled}
currentThread={currentThread}
environmentId={environmentId}
pr={pr}
number={link.number}
Expand Down Expand Up @@ -108,6 +114,8 @@ export function ThreadDetailsPrRows({
{rest.map((link) => (
<ThreadDetailsPrLinkRow
key={threadPullRequestKeyOf(link)}
handoffDisabled={row.handoffDisabled}
currentThread={row.currentThread}
environmentId={row.environmentId}
link={link}
onOpen={(event) => onOpenLink(event, link.url)}
Expand Down
29 changes: 27 additions & 2 deletions apps/web/src/components/pullRequest/usePullRequestActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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({
Expand All @@ -283,6 +294,11 @@ export function usePullRequestHandoffs({
sourceControlClients.get(undefined)
).changeRequest;

const handoffInCurrentThread =
currentThread !== undefined &&
detail !== null &&
sourceControlClients.isSameChangeRequest(detail.url, currentThread.pullRequestUrl);

Comment thread
extoci marked this conversation as resolved.
Resolved
// Questions must not change checkouts or create worktrees.
const openThreadWithTask = async (
projectRef: ReturnType<typeof scopeProjectRef>,
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -415,5 +440,5 @@ export function usePullRequestHandoffs({
);
};

return { handoff, startAsk, startHandoff };
return { handoff, startAsk, startHandoff, handoffInCurrentThread };
}
Loading