feat(FN-5018): wire create-pr review surface in TaskReviewTab

Wires the PR-create review surface into the task review flow, adding the UI surface to `TaskReviewTab`, `TaskDetailModal`, and `PrPanel` for reviewing PR creation as part of task review. Includes tests covering the create-PR review flow in both `TaskDetailModal` and `TaskReviewTab`.

Fusion-Task-Id: FN-5018
This commit is contained in:
Fusion (runfusion.ai)
2026-05-18 06:31:55 -07:00
committed by gsxdsm
parent 0607ca518f
commit 5a1794fc55
6 changed files with 151 additions and 2 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
Wire the redesigned Create-PR modal into the task detail modal and the merge/review tab so
PrPanel's Create button and a new review-tab Create-PR action open the same flow. Fixes the
FN-4758 follow-up where PrPanel was mounted with `onRequestCreatePr={undefined}`.

View File

@@ -164,6 +164,7 @@ export function PrPanel({
className="btn btn-primary btn-sm"
onClick={onRequestCreatePr}
disabled={createDisabled}
data-testid="pr-panel-create-pr"
title={prAuthAvailable ? "Create a PR for this task" : "PR auth unavailable — run 'gh auth login'"}
>
<Plus />

View File

@@ -2534,7 +2534,14 @@ export function TaskDetailContent({
) : activeTab === "changes" ? (
<TaskChangesTab taskId={task.id} worktree={task.worktree} projectId={projectId} column={task.column} mergeDetails={task.mergeDetails} modifiedFiles={task.modifiedFiles} />
) : activeTab === "review" ? (
<TaskReviewTab task={task} addToast={addToast} projectId={projectId} onTaskUpdated={onTaskUpdated} />
<TaskReviewTab
task={task}
addToast={addToast}
projectId={projectId}
onTaskUpdated={onTaskUpdated}
prAuthAvailable={prAuthAvailable}
onRequestCreatePr={() => setPrCreateOpen(true)}
/>
) : activeTab === "comments" ? (
<TaskComments task={task} addToast={addToast} projectId={projectId} onTaskUpdated={onTaskUpdated} />
) : activeTab === "documents" ? (
@@ -3332,9 +3339,11 @@ export function TaskDetailContent({
open={prCreateOpen}
taskId={task.id}
projectId={projectId}
defaultBaseBranch={undefined}
onClose={() => setPrCreateOpen(false)}
onCreated={(prInfo) => {
(task as TaskDetail).prInfo = prInfo;
onTaskUpdated?.({ ...workingTask, prInfo } as Task);
setPrCreateOpen(false);
}}
addToast={addToast}

View File

@@ -1,5 +1,6 @@
import "./TaskReviewTab.css";
import type { Task, TaskDetail } from "@fusion/core";
import { GitPullRequest } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
@@ -13,6 +14,8 @@ interface Props {
task: Task | TaskDetail;
projectId?: string;
onTaskUpdated?: (task: Task) => void;
onRequestCreatePr?: () => void;
prAuthAvailable?: boolean;
addToast: (message: string, type?: ToastType) => void;
}
@@ -129,7 +132,14 @@ function getDisplayReviewItems(review: ReviewState): DisplayReviewItem[] {
return [...items, ...snapshots];
}
export function TaskReviewTab({ task, projectId, onTaskUpdated, addToast }: Props) {
export function TaskReviewTab({
task,
projectId,
onTaskUpdated,
onRequestCreatePr,
prAuthAvailable,
addToast,
}: Props) {
const [selected, setSelected] = useState<string[]>([]);
const [refreshing, setRefreshing] = useState(false);
const [revising, setRevising] = useState(false);
@@ -275,6 +285,12 @@ export function TaskReviewTab({ task, projectId, onTaskUpdated, addToast }: Prop
{decisionLabel ? <span className={`task-review-tab__decision task-review-tab__decision--${decisionLabel}`}>{decisionLabel}</span> : null}
</div>
<div className="task-review-tab__actions">
{task.column === "in-review" && !task.prInfo && prAuthAvailable === true && typeof onRequestCreatePr === "function" ? (
<button className="btn btn-sm" onClick={() => onRequestCreatePr?.()} data-testid="task-review-create-pr">
<GitPullRequest />
Create PR
</button>
) : null}
<button
className="btn btn-sm"
onClick={() => setRenderMarkdown((prev) => !prev)}

View File

@@ -10,6 +10,10 @@ const prCreateModalState = vi.hoisted(() => ({
latestProps: null as any,
}));
const taskReviewTabState = vi.hoisted(() => ({
latestProps: null as any,
}));
vi.mock("../PrPanel", () => ({
PrPanel: (props: any) => {
prPanelState.latestPrInfo = props.prInfo;
@@ -48,11 +52,25 @@ vi.mock("../PrCreateModal", () => ({
>
Stub create
</button>
<button type="button" onClick={() => props.onClose()}>
Stub close
</button>
</div>
);
},
}));
vi.mock("../TaskReviewTab", () => ({
TaskReviewTab: (props: any) => {
taskReviewTabState.latestProps = props;
return (
<button type="button" data-testid="task-review-create-pr" onClick={() => props.onRequestCreatePr?.()}>
Review create PR
</button>
);
},
}));
import {
makeTask,
noop,
@@ -71,10 +89,12 @@ describe("TaskDetailModal create-PR wiring", () => {
vi.clearAllMocks();
prPanelState.latestPrInfo = undefined;
prCreateModalState.latestProps = null;
taskReviewTabState.latestProps = null;
});
it("opens PrCreateModal from PrPanel and updates prInfo on create", async () => {
const addToast = vi.fn();
const onTaskUpdated = vi.fn();
const task = makeTask({ id: "FN-5020", prInfo: undefined, column: "in-review" });
render(
@@ -87,6 +107,7 @@ describe("TaskDetailModal create-PR wiring", () => {
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={addToast}
onTaskUpdated={onTaskUpdated}
/>,
);
@@ -101,6 +122,7 @@ describe("TaskDetailModal create-PR wiring", () => {
expect(prCreateModalState.latestProps?.open).toBe(true);
expect(prCreateModalState.latestProps?.taskId).toBe("FN-5020");
expect(prCreateModalState.latestProps?.projectId).toBe("project-123");
expect(prCreateModalState.latestProps?.defaultBaseBranch).toBeUndefined();
expect(prCreateModalState.latestProps?.addToast).toBe(addToast);
fireEvent.click(screen.getByRole("button", { name: "Stub create" }));
@@ -109,5 +131,41 @@ describe("TaskDetailModal create-PR wiring", () => {
expect(prCreateModalState.latestProps?.open).toBe(false);
expect(screen.getByTestId("pr-panel-pr-number")).toHaveTextContent("321");
expect(prPanelState.latestPrInfo?.number).toBe(321);
expect(onTaskUpdated).toHaveBeenCalledWith(expect.objectContaining({
id: "FN-5020",
prInfo: expect.objectContaining({ number: 321 }),
}));
});
it("opens the same PrCreateModal from TaskReviewTab and closes via onClose", async () => {
const task = makeTask({ id: "FN-5021", prInfo: undefined, column: "in-review" });
render(
<TaskDetailModal
task={task}
projectId="project-123"
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={vi.fn()}
prAuthAvailable
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Review" }));
await waitFor(() => expect(screen.getByTestId("task-review-create-pr")).toBeInTheDocument());
fireEvent.click(screen.getByTestId("task-review-create-pr"));
expect(screen.getByTestId("pr-create-modal-stub")).toBeInTheDocument();
expect(prCreateModalState.latestProps?.taskId).toBe("FN-5021");
expect(prCreateModalState.latestProps?.projectId).toBe("project-123");
expect(prCreateModalState.latestProps?.defaultBaseBranch).toBeUndefined();
expect(taskReviewTabState.latestProps?.prAuthAvailable).toBe(true);
fireEvent.click(screen.getByRole("button", { name: "Stub close" }));
expect(screen.queryByTestId("pr-create-modal-stub")).toBeNull();
expect(prCreateModalState.latestProps?.open).toBe(false);
});
});

View File

@@ -493,6 +493,64 @@ describe("TaskReviewTab", () => {
expect(mobileCss).not.toMatch(/\.task-review-tab__actions\s+\.btn\s*\{[^}]*flex\s*:\s*1\s*;[^}]*\}/);
});
it("shows create PR action when in-review without prInfo and auth is available", async () => {
const onRequestCreatePr = vi.fn();
const task = makeTask({ column: "in-review", prInfo: undefined });
apiMocks.fetchTaskReview.mockResolvedValue({ reviewState: task.reviewState, automationStatus: null, emptyMessage: null });
render(
<TaskReviewTab
task={task}
addToast={vi.fn()}
prAuthAvailable
onRequestCreatePr={onRequestCreatePr}
/>,
);
fireEvent.click(await screen.findByTestId("task-review-create-pr"));
expect(onRequestCreatePr).toHaveBeenCalledTimes(1);
});
it("hides create PR action outside in-review column", async () => {
const task = makeTask({ column: "todo", prInfo: undefined });
apiMocks.fetchTaskReview.mockResolvedValue({ reviewState: task.reviewState, automationStatus: null, emptyMessage: null });
render(<TaskReviewTab task={task} addToast={vi.fn()} prAuthAvailable onRequestCreatePr={vi.fn()} />);
await screen.findByRole("button", { name: "Refresh" });
expect(screen.queryByTestId("task-review-create-pr")).toBeNull();
});
it("hides create PR action when prInfo already exists", async () => {
const task = makeTask({
column: "in-review",
prInfo: {
number: 1,
title: "Existing PR",
url: "https://example.com/pr/1",
status: "open",
headBranch: "fusion/FN-1",
baseBranch: "main",
},
});
apiMocks.fetchTaskReview.mockResolvedValue({ reviewState: task.reviewState, automationStatus: null, emptyMessage: null });
render(<TaskReviewTab task={task} addToast={vi.fn()} prAuthAvailable onRequestCreatePr={vi.fn()} />);
await screen.findByRole("button", { name: "Refresh" });
expect(screen.queryByTestId("task-review-create-pr")).toBeNull();
});
it("hides create PR action when auth is unavailable", async () => {
const task = makeTask({ column: "in-review", prInfo: undefined });
apiMocks.fetchTaskReview.mockResolvedValue({ reviewState: task.reviewState, automationStatus: null, emptyMessage: null });
render(<TaskReviewTab task={task} addToast={vi.fn()} prAuthAvailable={false} onRequestCreatePr={vi.fn()} />);
await screen.findByRole("button", { name: "Refresh" });
expect(screen.queryByTestId("task-review-create-pr")).toBeNull();
});
it("submits reviewer-agent selections through same revision action", async () => {
const task = makeTask();
apiMocks.fetchTaskReview.mockResolvedValue({