Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,12 @@ export function PullRequestStatusMenu(props: RowProps) {
onPressAction={({ nativeEvent }) => {
const selected = actions.find((id) => id === nativeEvent.event);
if (!selected) return;
if (selected === "close") action.confirmClose();
if (selected === "close")
action.confirm(
selected,
pullRequestActionLabel("close", terminology),
`Close this ${terminology.singular} without merging its changes?`,
);
else if (selected === "revert")
action.confirm(
selected,
Expand Down
136 changes: 135 additions & 1 deletion apps/web/src/components/chat/ThreadDetailsPrRow.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,26 @@ const state = vi.hoisted(() => ({
status: "success" as PullRequestCheck["status"],
extraStatus: null as PullRequestCheck["status"] | null,
mergeMethods: ["merge"] as Array<"merge" | "squash" | "rebase">,
reviewThreads: [] as Array<{
id: string;
path: string;
line: number;
side: "right";
isResolved: boolean;
isOutdated: boolean;
comments: Array<{
id: string;
author: { login: string; name: null; avatarUrl: null };
body: string;
createdAt: string;
url: null;
}>;
}>,
activityPending: false,
activitySuccess: true,
activityError: null as string | null,
perform: vi.fn(),
startHandoff: vi.fn(),
newThread: vi.fn(),
prepareThread: vi.fn(),
checkoutError: null as Error | null,
Expand Down Expand Up @@ -53,6 +72,9 @@ const queryData = () => ({
squash: state.mergeMethods.includes("squash"),
rebase: state.mergeMethods.includes("rebase"),
},
reviewThreads: state.reviewThreads,
comments: [],
commentsTruncated: false,
});
vi.mock(import("~/state/query"), async (importOriginal) => ({
...(await importOriginal()),
Expand All @@ -62,18 +84,29 @@ vi.mock(import("~/state/query"), async (importOriginal) => ({
useEnvironmentQueryRefresh: () => ({ refresh: vi.fn(), isPending: () => false }),
useEnvironmentQuery: (() => ({
data: {
...queryData(),
code: { additions: 1, deletions: 0 },
tests: { additions: 0, deletions: 0 },
mocks: { additions: 0, deletions: 0 },
generated: { additions: 0, deletions: 0 },
docs: { additions: 7, deletions: 3 },
},
isPending: false,
dataUpdatedAt: 1,
error: state.activityError,
failure: null,
isPending: state.activityPending,
isSuccess: state.activitySuccess,
refresh: vi.fn(),
})) as never,
}));
vi.mock(import("../pullRequest/usePullRequestActions"), async (importOriginal) => ({
...(await importOriginal()),
usePullRequestActionRunner: () => ({ actionPending: false, perform: state.perform }),
usePullRequestHandoffs: () => ({
handoff: null,
startAsk: vi.fn(),
startHandoff: state.startHandoff,
}),
}));

vi.mock(import("~/hooks/useHandleNewThread"), async (original) => ({
Expand Down Expand Up @@ -136,6 +169,102 @@ it("merges with the last selected merge method", async () => {
expect(state.perform).toHaveBeenCalledWith("merge", "squash");
});

it("adds unresolved review feedback to the composer as chips", async () => {
await using screen = await setup();
const { container } = screen;
state.reviewThreads = [
{
id: "t1",
path: "src/a.ts",
line: 4,
side: "right",
isResolved: false,
isOutdated: false,
comments: [
{
id: "c1",
author: { login: "reviewer", name: null, avatarUrl: null },
body: "rename this helper",
createdAt: "2026-07-03T00:00:00Z",
url: null,
},
],
},
];
await act(async () => {
await screen.rerender(
<ThreadDetailsPrRow
environmentId={EnvironmentId.make("environment")}
pr={null}
number={1}
status={null}
project={null}
label="Test PR"
openAriaLabel="Open PR"
onOpen={vi.fn()}
/>,
);
});
const fix = [...container.querySelectorAll("button")].find(
(button) => button.textContent?.trim() === "Fix",
);
expect(fix).toBeTruthy();
await act(async () => {
fix!.click();
});
expect(state.startHandoff).toHaveBeenCalledWith(
"findings",
expect.objectContaining({
prompt: "",
reviewComments: expect.arrayContaining([
expect.objectContaining({
filePath: "Review feedback on #1",
}),
expect.objectContaining({
text: expect.stringContaining("rename this helper"),
}),
]),
}),
);
});

it("hides Merge while review activity is still fetching or failed", async () => {
await using screen = await setup();
const { container } = screen;
const render = () => (
<ThreadDetailsPrRow
environmentId={EnvironmentId.make("environment")}
pr={null}
number={1}
status={null}
project={null}
label="Test PR"
openAriaLabel="Open PR"
onOpen={vi.fn()}
/>
);
const mergeButton = () =>
[...container.querySelectorAll("button")].find(
(button) => button.textContent?.trim() === "Merge",
);

state.activityPending = true;
state.activitySuccess = true;
await act(async () => await screen.rerender(render()));
expect(mergeButton()).toBeUndefined();

state.activityPending = false;
state.activitySuccess = false;
state.activityError = "The environment request failed.";
await act(async () => await screen.rerender(render()));
expect(mergeButton()).toBeUndefined();

state.activitySuccess = true;
state.activityError = null;
await act(async () => await screen.rerender(render()));
expect(mergeButton()).toBeTruthy();
});

it("requires a new merge click after passing checks become pending and pass again", async () => {
await using screen = await setup();
const { container } = screen;
Expand Down Expand Up @@ -179,6 +308,7 @@ it("requires a new merge click after passing checks become pending and pass agai

async function setup() {
state.perform.mockReset();
state.startHandoff.mockReset();
state.checkoutError = null;
state.newThread.mockReset().mockResolvedValue({
draftId: DraftId.make("new-draft"),
Expand All @@ -191,6 +321,10 @@ async function setup() {
state.status = "success";
state.extraStatus = null;
state.mergeMethods = ["merge"];
state.reviewThreads = [];
state.activityPending = false;
state.activitySuccess = true;
state.activityError = null;
useUiStateStore.getState().setPullRequestMergeMethod("merge");
return renderUi(null);
}
97 changes: 74 additions & 23 deletions apps/web/src/components/chat/ThreadDetailsPrRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,16 +27,21 @@ import { useServerConfigs } from "~/state/entities";
import { pullRequestEnvironment } from "~/state/pullRequests";
import { useUiStateStore } from "~/uiStateStore";
import {
useEnvironmentQuery,
useEnvironmentQueryIsNewer,
useEnvironmentQueryRefresh,
useEnvironmentQuerySelector,
} from "~/state/query";

import {
buildFixFindingsHandoff,
buildFixChecksHandoff,
buildFixFeedbackHandoff,
buildResolveConflictsPrompt,
classifyPullRequestChecks,
describePullRequestChecks,
describeThreadPanelFixAction,
gateThreadPanelMergeUntilFeedbackKnown,
hasActionablePullRequestFeedback,
isPullRequestConflicting,
resolvePullRequestMergeMethod,
allowedPullRequestMergeMethods,
Expand Down Expand Up @@ -138,6 +143,18 @@ export function ThreadDetailsPrRow({
const checksQuery = useEnvironmentQueryRefresh(checksAtom);
const checksAreNewer = useEnvironmentQueryIsNewer(checksAtom, detailAtom);
const detail = resolvePullRequestDetailChecks(detailData, checksData, checksAreNewer);
const activityAtom =
reference !== null && detailData !== null
? pullRequestEnvironment.activity({ environmentId, input: reference })
: null;
const activityQuery = useEnvironmentQuery(activityAtom);
const activity = activityQuery.data;
const activityError = activity === null ? activityQuery.error : null;
const feedback = {
reviewThreads: activity?.reviewThreads ?? [],
comments: activity?.comments ?? [],
};
const hasFeedback = hasActionablePullRequestFeedback(feedback);
const terminology = sourceControlClients.get(
detail?.provider ?? project?.repositoryIdentity?.provider,
).changeRequest;
Expand All @@ -146,6 +163,7 @@ export function ThreadDetailsPrRow({
useLiveRefresh(
() => {
if (!detailQuery.isPending()) detailQuery.refresh();
if (!activityQuery.isPending) activityQuery.refresh();
},
{ enabled: open, key: `workspace-pr:${refreshKey}`, intervalMs: 10 * 60_000 },
);
Expand All @@ -164,6 +182,7 @@ export function ThreadDetailsPrRow({
terminology,
onSuccess: () => {
checksQuery.refresh();
activityQuery.refresh();
onActed?.();
},
});
Expand All @@ -175,13 +194,24 @@ export function ThreadDetailsPrRow({
);
const [confirmingMerge, setConfirmingMerge] = useState(false);

const rowAction = resolveThreadPanelPullRequestAction(detail);
const rowAction = gateThreadPanelMergeUntilFeedbackKnown(
resolveThreadPanelPullRequestAction(detail, feedback),
activityQuery.isSuccess && !activityQuery.isPending,
);
if (confirmingMerge && rowAction !== "merge") {
setConfirmingMerge(false);
}
const conflicting = isPullRequestConflicting(detail);
const checksState = detail === null ? "none" : classifyPullRequestChecks(detail.checks);
const checksRollup = detail === null ? null : pullRequestChecksState(detail.checks);
const fixAction =
detail === null
? null
: describeThreadPanelFixAction({
failingChecks: checksState === "failing",
hasFeedback,
inThisThread: false,
});
const selectedMergeMethod = resolvePullRequestMergeMethod(
allowedPullRequestMergeMethods(detail),
null,
Expand All @@ -201,24 +231,37 @@ export function ThreadDetailsPrRow({
});
};

const startFixChecks = () => {
const startFixFindings = () => {
if (detail === null) return;
void startHandoff(
"findings",
buildFixFindingsHandoff({
number: detail.number,
title: detail.title,
url: detail.url,
headBranch: detail.headBranch,
baseBranch: detail.baseBranch,
state: detail.state,
isDraft: detail.isDraft,
reviewThreads: [],
comments: [],
checks: detail.checks,
commentsTruncated: false,
}),
);
const reviewComments = [
...(hasFeedback
? buildFixFeedbackHandoff({
number: detail.number,
title: detail.title,
url: detail.url,
headBranch: detail.headBranch,
baseBranch: detail.baseBranch,
state: detail.state,
isDraft: detail.isDraft,
reviewThreads: feedback.reviewThreads,
comments: feedback.comments,
commentsTruncated: activity?.commentsTruncated ?? false,
}).reviewComments
: []),
...(checksState === "failing"
? buildFixChecksHandoff({
number: detail.number,
title: detail.title,
url: detail.url,
headBranch: detail.headBranch,
baseBranch: detail.baseBranch,
state: detail.state,
isDraft: detail.isDraft,
checks: detail.checks,
}).reviewComments
: []),
];
void startHandoff("findings", { prompt: "", reviewComments });
};

const statePresentation =
Expand Down Expand Up @@ -298,6 +341,14 @@ export function ThreadDetailsPrRow({
</div>
</div>
) : null}
{detail.state === "open" && activityError ? (
<div className="flex min-w-0 items-start gap-2 text-destructive">
<TriangleAlertIcon aria-hidden className="mt-0.5 size-3 shrink-0 stroke-current" />
<div className="min-w-0 flex-1 wrap-break-word">
Couldn't load review activity. Open the {terminology.singular} for details.
</div>
</div>
) : null}
<div className="flex min-w-0 items-center gap-2">
<FileDiffIcon className="size-3 shrink-0 stroke-muted-foreground" />
<div className="min-w-0 flex items-baseline gap-1 truncate text-foreground/75">
Expand Down Expand Up @@ -339,15 +390,15 @@ export function ThreadDetailsPrRow({
tooltip: `Mark this ${terminology.singular} as ready for review`,
onClick: () => void perform("ready"),
}
: rowAction === "fix"
: rowAction === "fix" && fixAction !== null
? {
label: "Fix",
label: fixAction.label,
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",
onClick: startFixChecks,
tooltip: fixAction.tooltip,
onClick: startFixFindings,
}
: rowAction === "merge"
? {
Expand Down
Loading
Loading