diff --git a/apps/mobile/src/features/pull-requests/PullRequestStatusRows.tsx b/apps/mobile/src/features/pull-requests/PullRequestStatusRows.tsx
index 52d5505c45604..185d555cc776b 100644
--- a/apps/mobile/src/features/pull-requests/PullRequestStatusRows.tsx
+++ b/apps/mobile/src/features/pull-requests/PullRequestStatusRows.tsx
@@ -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,
diff --git a/apps/web/src/components/chat/ThreadDetailsPrRow.browser.test.tsx b/apps/web/src/components/chat/ThreadDetailsPrRow.browser.test.tsx
index 7d1c3199b8ec8..12212a7d7f786 100644
--- a/apps/web/src/components/chat/ThreadDetailsPrRow.browser.test.tsx
+++ b/apps/web/src/components/chat/ThreadDetailsPrRow.browser.test.tsx
@@ -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,
@@ -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()),
@@ -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) => ({
@@ -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(
+ ,
+ );
+ });
+ 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 = () => (
+
+ );
+ 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;
@@ -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"),
@@ -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);
}
diff --git a/apps/web/src/components/chat/ThreadDetailsPrRow.tsx b/apps/web/src/components/chat/ThreadDetailsPrRow.tsx
index 23d705a8dae7c..55d4bf9f9f5e9 100644
--- a/apps/web/src/components/chat/ThreadDetailsPrRow.tsx
+++ b/apps/web/src/components/chat/ThreadDetailsPrRow.tsx
@@ -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,
@@ -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;
@@ -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 },
);
@@ -164,6 +182,7 @@ export function ThreadDetailsPrRow({
terminology,
onSuccess: () => {
checksQuery.refresh();
+ activityQuery.refresh();
onActed?.();
},
});
@@ -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,
@@ -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 =
@@ -298,6 +341,14 @@ export function ThreadDetailsPrRow({
) : null}
+ {detail.state === "open" && activityError ? (
+
+
+
+ Couldn't load review activity. Open the {terminology.singular} for details.
+
+
+ ) : null}
@@ -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:
,
- tooltip: "Fix the failing checks in a new thread",
- onClick: startFixChecks,
+ tooltip: fixAction.tooltip,
+ onClick: startFixFindings,
}
: rowAction === "merge"
? {
diff --git a/apps/web/src/components/chat/UserMessageContextChips.tsx b/apps/web/src/components/chat/UserMessageContextChips.tsx
index 2638692caab26..2247256e68dbf 100644
--- a/apps/web/src/components/chat/UserMessageContextChips.tsx
+++ b/apps/web/src/components/chat/UserMessageContextChips.tsx
@@ -24,7 +24,12 @@ import { PREFERRED_HIGHLIGHTER } from "../../lib/syntaxHighlighting";
import ChatMarkdown from "../ChatMarkdown";
import { EnvironmentContextChip } from "../EnvironmentContextChip";
import { ThreadContextChip } from "../ThreadContextChip";
-import { MessageCircleIcon, MousePointerClickIcon, PaintbrushIcon } from "lucide-react";
+import {
+ ListChecksIcon,
+ MessageCircleIcon,
+ MousePointerClickIcon,
+ PaintbrushIcon,
+} from "lucide-react";
import type { ComposerContextId, KnownComposerContextRecord } from "@t3tools/contracts";
import { buildExpandedImagePreview } from "./ExpandedImagePreview";
import { WorkspaceEntryIcon, WorkspaceEntryTooltip } from "./WorkspaceEntryIcon";
@@ -39,6 +44,8 @@ import {
UnresolvedChip,
} from "../contextChipParts";
import {
+ isPullRequestCheckContext,
+ isPullRequestFeedbackContext,
isPullRequestSummaryContext,
pullRequestContextDisplayState,
pullRequestContextKindLabel,
@@ -446,7 +453,15 @@ const userMessageContextPresentationRegistry = createContextPresentationRegistry
}
const isPullRequest = isPullRequestSummaryContext(record);
const label = reviewCommentContextLabel(record);
- const kindLabel = isPullRequest ? pullRequestContextKindLabel(record) : "Review comment";
+ const isCheck = isPullRequestCheckContext(record);
+ const isFeedback = isPullRequestFeedbackContext(record);
+ const kindLabel = isPullRequest
+ ? pullRequestContextKindLabel(record)
+ : isCheck
+ ? "Check"
+ : isFeedback
+ ? "Feedback"
+ : "Review comment";
const pullRequestState = pullRequestContextDisplayState(record) ?? "unknown";
if (isPullRequest && record.pullRequest !== undefined) {
return (
@@ -462,7 +477,15 @@ const userMessageContextPresentationRegistry = createContextPresentationRegistry
copyMarkdown={context.copyMarkdown}
accessibleLabel={`${kindLabel}, ${label}${record.pullRequest ? `, ${record.pullRequest.title}` : ""}`}
kind={isPullRequest ? PULL_REQUEST_CHIP_KINDS[pullRequestState] : "review-comment"}
- icon={isPullRequest ?
:
}
+ icon={
+ isPullRequest ? (
+
+ ) : isCheck ? (
+
+ ) : (
+
+ )
+ }
label={label}
>
- {formatWorkspaceRelativePath(comment.filePath, ctx.workspaceRoot)}
+ {isPullRequestCheckContext(comment) || isPullRequestFeedbackContext(comment)
+ ? comment.filePath
+ : formatWorkspaceRelativePath(comment.filePath, ctx.workspaceRoot)}
{comment.sectionTitle} ยท {comment.rangeLabel}
diff --git a/apps/web/src/components/composerContextPresentation.tsx b/apps/web/src/components/composerContextPresentation.tsx
index 845e921725b55..43425e3ff91ca 100644
--- a/apps/web/src/components/composerContextPresentation.tsx
+++ b/apps/web/src/components/composerContextPresentation.tsx
@@ -8,7 +8,7 @@ import type {
} from "@t3tools/contracts";
import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments";
import { videoMimeType } from "@t3tools/shared/video";
-import { MessageCircleIcon, MousePointerClickIcon } from "lucide-react";
+import { ListChecksIcon, MessageCircleIcon, MousePointerClickIcon } from "lucide-react";
import { createContext, type ReactElement, type ReactNode, use } from "react";
import { ComposerContextActionsContext } from "./composerContextActions";
@@ -23,6 +23,8 @@ import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons";
import {
fileContextReference,
imageContextReference,
+ isPullRequestCheckContext,
+ isPullRequestFeedbackContext,
isPullRequestSummaryContext,
pullRequestContextDisplayState,
pullRequestContextKindLabel,
@@ -425,9 +427,25 @@ const composerContextPresentationRegistry = createContextPresentationRegistry<
}
return (
:
}
+ icon={
+ isPullRequest ? (
+
+ ) : isPullRequestCheckContext(entry.record) ? (
+
+ ) : (
+
+ )
+ }
label={reviewCommentContextLabel(entry.record)}
- kindLabel={isPullRequest ? pullRequestContextKindLabel(entry.record) : "Review comment"}
+ kindLabel={
+ isPullRequest
+ ? pullRequestContextKindLabel(entry.record)
+ : isPullRequestCheckContext(entry.record)
+ ? "Check"
+ : isPullRequestFeedbackContext(entry.record)
+ ? "Feedback"
+ : "Review comment"
+ }
details={
}
detailsMode={definition.capabilities.details}
kind={isPullRequest ? PULL_REQUEST_CHIP_KINDS[pullRequestState] : "review-comment"}
diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx
index 9d0f6193f7e3f..d08f404a8399b 100644
--- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx
+++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx
@@ -115,6 +115,8 @@ import {
buildAskAboutGuideStopHandoff,
buildAskAboutPullRequestHandoff,
buildExplainPullRequestHandoff,
+ buildFixChecksHandoff,
+ buildFixFeedbackHandoff,
buildFixFindingHandoff,
buildFixFindingsHandoff,
buildPullRequestCommentContext,
@@ -127,6 +129,8 @@ import {
pullRequestActionNeedsHostRefresh,
pullRequestCheckoutCommand,
pullRequestFindingKey,
+ PULL_REQUEST_FIX_CHECKS_KEY,
+ PULL_REQUEST_FIX_FEEDBACK_KEY,
pullRequestHandoffLabels,
PULL_REQUEST_MERGE_METHOD_LABELS,
checkoutFailureDescription,
@@ -826,7 +830,7 @@ export function PullRequestDetailPanel({
const startHandoff = async (
kind: string,
- task: { prompt: string; reviewComments?: ReadonlyArray
} | null,
+ task: ThreadTask | null,
mode: "worktree" | "local" = "worktree",
) => {
if (!handoffSummary || handoff !== null) return;
@@ -1075,6 +1079,42 @@ export function PullRequestDetailPanel({
);
};
+ const startFixChecks = () => {
+ if (!detail) return;
+ void startHandoff(
+ PULL_REQUEST_FIX_CHECKS_KEY,
+ 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,
+ }),
+ );
+ };
+
+ const startFixFeedback = () => {
+ if (!detail) return;
+ void startHandoff(
+ PULL_REQUEST_FIX_FEEDBACK_KEY,
+ buildFixFeedbackHandoff({
+ number: detail.number,
+ title: detail.title,
+ url: detail.url,
+ headBranch: detail.headBranch,
+ baseBranch: detail.baseBranch,
+ state: detail.state,
+ isDraft: detail.isDraft,
+ reviewThreads: detail.reviewThreads,
+ comments: detail.comments,
+ commentsTruncated: detail.commentsTruncated,
+ }),
+ );
+ };
+
const startFixFindings = () => {
if (!detail) return;
void startHandoff(
@@ -1756,7 +1796,15 @@ export function PullRequestDetailPanel({
pendingFinding={handoff}
fixFindingLabel={handoffLabels.fixFinding}
fixCheckLabel={handoffLabels.fixCheck}
- {...(canFixFindings ? { onFixFinding: startFixFinding } : {})}
+ fixChecksLabel={handoffLabels.fixChecks}
+ fixFeedbackLabel={handoffLabels.fixFeedback}
+ {...(canFixFindings
+ ? {
+ onFixFinding: startFixFinding,
+ onFixChecks: startFixChecks,
+ onFixFeedback: startFixFeedback,
+ }
+ : {})}
{...(attachTarget === null ? {} : { onAddToChat: addCommentToChat })}
onRefresh={refreshDetail}
onRefreshChecks={refreshFromHost}
diff --git a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx
index b98a48a817b0e..902acaf809395 100644
--- a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx
+++ b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx
@@ -66,6 +66,10 @@ import {
pagePullRequestConversationItems,
pullRequestConversationItemComments,
pullRequestConversationItems,
+ PULL_REQUEST_FIX_CHECKS_KEY,
+ PULL_REQUEST_FIX_FEEDBACK_KEY,
+ classifyPullRequestChecks,
+ hasActionablePullRequestFeedback,
pullRequestFindingKey,
pullRequestReviewOutcome,
visibleBody,
@@ -433,7 +437,11 @@ export function PullRequestSummaryTab({
pendingFinding,
fixFindingLabel = "Fix in a thread",
fixCheckLabel = "Fix",
+ fixChecksLabel = "Fix all",
+ fixFeedbackLabel = "Fix all",
onFixFinding,
+ onFixChecks,
+ onFixFeedback,
onAddToChat,
onRefresh,
onRefreshChecks = onRefresh,
@@ -449,7 +457,11 @@ export function PullRequestSummaryTab({
pendingFinding?: string | null;
fixFindingLabel?: string;
fixCheckLabel?: string;
+ fixChecksLabel?: string;
+ fixFeedbackLabel?: string;
onFixFinding?: (finding: PullRequestFinding) => void;
+ onFixChecks?: () => void;
+ onFixFeedback?: () => void;
/** Absent where there is no composer beside the panel to add a remark to. */
onAddToChat?: (subject: PullRequestChatSubject) => void;
onRefresh: () => void;
@@ -634,7 +646,8 @@ export function PullRequestSummaryTab({
const finding: PullRequestFinding | null =
reply ||
(comment.kind !== "review" && comment.kind !== "review-comment") ||
- outcome === "approved"
+ outcome === "approved" ||
+ outcome === "dismissed"
? null
: thread === undefined
? body === null
@@ -875,7 +888,25 @@ export function PullRequestSummaryTab({
-
+
+
+ {pendingFinding === PULL_REQUEST_FIX_CHECKS_KEY ? "Preparing..." : fixChecksLabel}
+
+ ) : undefined
+ }
+ >
{checksStale ? (
Check details are out of date.
@@ -938,6 +969,24 @@ export function PullRequestSummaryTab({
title={`Comments (${detail.commentCount})`}
actions={
+ {onFixFeedback &&
+ hasActionablePullRequestFeedback({
+ reviewThreads: detail.reviewThreads,
+ comments: detail.comments,
+ }) ? (
+
+ ) : null}