feat(FN-5589): merge fusion/fn-5589

This commit is contained in:
gsxdsm
2026-05-26 07:29:28 -07:00
parent 4b5a2e5c68
commit aa64643cf1
10 changed files with 260 additions and 141 deletions

View File

@@ -561,6 +561,7 @@ Inspect task definition, logs, review feedback, comments, documents, workflow ou
- The GitHub tracking section now defaults to a compact summary row; use the disclosure arrow to expand linked-issue details plus tracking edit controls. - The GitHub tracking section now defaults to a compact summary row; use the disclosure arrow to expand linked-issue details plus tracking edit controls.
- In shared task edit/create forms, GitHub Tracking appears at the bottom of **More options**, after **Workflow Steps**. - In shared task edit/create forms, GitHub Tracking appears at the bottom of **More options**, after **Workflow Steps**.
- From this section you can explicitly enable/disable tracking and manage a per-task repo override (`owner/repo`). Clearing the override saves `null` and falls back to project/global defaults. - From this section you can explicitly enable/disable tracking and manage a per-task repo override (`owner/repo`). Clearing the override saves `null` and falls back to project/global defaults.
- In `in-review`, pull-request controls/status (including stall badges) are in a dedicated **Pull Request** tab instead of the Definition tab.
- The **Review** tab is separate from **Comments**: Review shows actionable PR/reviewer feedback and same-task revision controls, while Comments remains the general collaboration thread. - The **Review** tab is separate from **Comments**: Review shows actionable PR/reviewer feedback and same-task revision controls, while Comments remains the general collaboration thread.
- **Request revision** in Review resumes work on the same task ID (no refinement task): `in-progress` tasks get steering injection, while `in-review` tasks are moved back to `in-progress` for the same branch/worktree revision pass. - **Request revision** in Review resumes work on the same task ID (no refinement task): `in-progress` tasks get steering injection, while `in-review` tasks are moved back to `in-progress` for the same branch/worktree revision pass.
- Review supports a manual **Refresh** action in-place: PR mode pulls latest GitHub review state/decision, while direct mode rehydrates reviewer-agent feedback from persisted task data (no GitHub call). - Review supports a manual **Refresh** action in-place: PR mode pulls latest GitHub review state/decision, while direct mode rehydrates reviewer-agent feedback from persisted task data (no GitHub call).

View File

@@ -360,8 +360,20 @@
margin-top: var(--space-lg); margin-top: var(--space-lg);
} }
.detail-pr-tab {
display: grid;
gap: var(--space-lg);
}
.detail-pr-tab .detail-section,
.detail-pr-section { .detail-pr-section {
margin-top: var(--space-lg); margin-top: 0;
}
@media (max-width: 768px) {
.detail-pr-tab {
gap: var(--space-md);
}
} }
.detail-provider-icons { .detail-provider-icons {

View File

@@ -275,7 +275,7 @@ function formatDurationCompact(ageMs: number): string {
return `${minutes}m`; return `${minutes}m`;
} }
type TabId = "definition" | "logs" | "changes" | "review" | "comments" | "model" | "workflow" | "documents" | "stats" | "routing" | "retries" | `plugin-${string}`; type TabId = "definition" | "logs" | "changes" | "review" | "pr" | "comments" | "model" | "workflow" | "documents" | "stats" | "routing" | "retries" | `plugin-${string}`;
export interface TaskDetailModalProps { export interface TaskDetailModalProps {
task: Task | TaskDetail; task: Task | TaskDetail;
@@ -556,6 +556,12 @@ export function TaskDetailContent({
} }
}, [initialTab]); }, [initialTab]);
useEffect(() => {
if (activeTab === "pr" && task.column !== "in-review") {
setActiveTab("definition");
}
}, [activeTab, task.column]);
// Reset description expanded state when task changes // Reset description expanded state when task changes
useEffect(() => { useEffect(() => {
setDescriptionExpanded(false); setDescriptionExpanded(false);
@@ -2577,6 +2583,14 @@ export function TaskDetailContent({
> >
Review Review
</button> </button>
{task.column === "in-review" && (
<button
className={`detail-tab${activeTab === "pr" ? " detail-tab-active" : ""}`}
onClick={() => setActiveTab("pr")}
>
Pull Request
</button>
)}
<button <button
className={`detail-tab${activeTab === "comments" ? " detail-tab-active" : ""}`} className={`detail-tab${activeTab === "comments" ? " detail-tab-active" : ""}`}
onClick={() => setActiveTab("comments")} onClick={() => setActiveTab("comments")}
@@ -2730,6 +2744,135 @@ export function TaskDetailContent({
autoMergeEnabled={autoMergeEnabled} autoMergeEnabled={autoMergeEnabled}
onRequestCreatePr={() => setPrCreateOpen(true)} onRequestCreatePr={() => setPrCreateOpen(true)}
/> />
) : activeTab === "pr" ? (
<div className="detail-section detail-pr-tab">
{task.column === "in-review" && (
<>
{shouldShowInReviewStallBadge(workingTask) && workingTask.inReviewStall && (() => {
const copy = getInReviewStallCopy(workingTask.inReviewStall, {
mergeRetries: workingTask.mergeRetries,
maxAutoMergeRetries: MAX_AUTO_MERGE_RETRIES,
});
const logMatch = findInReviewStallLogEntry(workingTask, workingTask.inReviewStall.code);
return (
<div
className={`detail-section detail-in-review-stall detail-in-review-stall--${copy.code}`}
data-stall-code={copy.code}
>
<div className="detail-in-review-stall-header">
<span className="card-status-badge card-status-badge--in-review in-review-stall">
{copy.badgeLabel}{copy.counter ? ` ${copy.counter}` : ""}
</span>
<span className="detail-in-review-stall-headline">{copy.headline}</span>
</div>
<div className="detail-in-review-stall-reason">{workingTask.inReviewStall.reason}</div>
<div className="detail-in-review-stall-description">{copy.description}</div>
<div className="detail-in-review-stall-action">{copy.suggestedAction}</div>
<div className="detail-in-review-stall-meta">
<span>Observed {formatTimestamp(workingTask.inReviewStall.observedAt)}</span>
{logMatch ? (
<button
type="button"
className="btn btn-sm detail-in-review-stall-jump"
onClick={() => {
setActiveTab("logs");
setLogSubview("activity");
setHighlightStallCode(workingTask.inReviewStall?.code ?? null);
}}
>
View activity log
</button>
) : (
<span
className="detail-in-review-stall-no-log"
title="No 'In-review stall surfaced' entry on this task yet — self-healing may not have logged one within its rate-limit window."
>
No log entry yet
</span>
)}
</div>
</div>
);
})()}
{shouldShowStalePausedReviewBadge(workingTask) && workingTask.stalePausedReview && (() => {
const copy = getStalePausedReviewCopy(workingTask.stalePausedReview);
const logMatch = [...(workingTask.log ?? [])].reverse().find((entry) => {
const match = entry.action.match(STALE_PAUSED_REVIEW_LOG_REGEX);
return match?.[1] === workingTask.stalePausedReview?.code;
});
return (
<div
className={`detail-section detail-in-review-stall detail-in-review-stall--${copy.code}`}
data-stall-code={copy.code}
>
<div className="detail-in-review-stall-header">
<span className="card-status-badge card-status-badge--in-review stale-paused-review">
{copy.badgeLabel}
</span>
<span className="detail-in-review-stall-headline">{copy.headline}</span>
</div>
<div className="detail-in-review-stall-reason">{workingTask.stalePausedReview.reason}</div>
<div className="detail-in-review-stall-description">{copy.description}</div>
<div className="detail-in-review-stall-action">{copy.suggestedAction}</div>
<div className="detail-in-review-stall-meta">
<span>Age {formatDurationCompact(workingTask.stalePausedReview.ageMs)}</span>
<span>Threshold {formatDurationCompact(workingTask.stalePausedReview.thresholdMs)}</span>
<span>Observed {formatTimestamp(workingTask.stalePausedReview.observedAt)}</span>
{logMatch ? (
<button
type="button"
className="btn btn-sm detail-in-review-stall-jump"
onClick={() => {
setActiveTab("logs");
setLogSubview("activity");
setHighlightStallCode(workingTask.stalePausedReview?.code ?? null);
}}
>
View activity log
</button>
) : (
<span className="detail-in-review-stall-no-log">No log entry yet</span>
)}
</div>
</div>
);
})()}
<div className="detail-section detail-pr-section">
<PrPanel
taskId={task.id}
projectId={projectId}
prInfo={task.prInfo}
prInfos={task.prInfos}
automationStatus={task.status ?? null}
taskColumn={task.column}
autoMerge={settings?.autoMerge ?? false}
isManualPrFlow={isManualPrFlow}
directMergeCommitStrategy={settings?.directMergeCommitStrategy}
prAuthAvailable={prAuthAvailable ?? false}
onRequestCreatePr={() => setPrCreateOpen(true)}
onPrUpdated={(prInfo) => {
const existing = task.prInfos ?? (task.prInfo ? [task.prInfo] : []);
const nextPrInfos = existing.some((entry) => entry.number === prInfo.number)
? existing.map((entry) => (entry.number === prInfo.number ? prInfo : entry))
: [...existing, prInfo];
(task as TaskDetail).prInfos = nextPrInfos;
(task as TaskDetail).prInfo = nextPrInfos[0] ?? prInfo;
}}
onPrsRefreshed={(prInfos) => {
(task as TaskDetail).prInfos = prInfos;
(task as TaskDetail).prInfo = prInfos[0];
}}
onPrUnlinked={(prNumber) => {
const nextPrInfos = (task.prInfos ?? (task.prInfo ? [task.prInfo] : [])).filter((entry) => entry.number !== prNumber);
(task as TaskDetail).prInfos = nextPrInfos;
(task as TaskDetail).prInfo = nextPrInfos[0];
}}
addToast={addToast}
/>
</div>
</>
)}
</div>
) : activeTab === "comments" ? ( ) : activeTab === "comments" ? (
<TaskComments task={task} addToast={addToast} projectId={projectId} onTaskUpdated={onTaskUpdated} /> <TaskComments task={task} addToast={addToast} projectId={projectId} onTaskUpdated={onTaskUpdated} />
) : activeTab === "documents" ? ( ) : activeTab === "documents" ? (
@@ -3424,133 +3567,6 @@ export function TaskDetailContent({
</div> </div>
); );
})()} })()}
{/* PR Section - only for in-review tasks */}
{task.column === "in-review" && (
<>
{shouldShowInReviewStallBadge(workingTask) && workingTask.inReviewStall && (() => {
const copy = getInReviewStallCopy(workingTask.inReviewStall, {
mergeRetries: workingTask.mergeRetries,
maxAutoMergeRetries: MAX_AUTO_MERGE_RETRIES,
});
const logMatch = findInReviewStallLogEntry(workingTask, workingTask.inReviewStall.code);
return (
<div
className={`detail-section detail-in-review-stall detail-in-review-stall--${copy.code}`}
data-stall-code={copy.code}
>
<div className="detail-in-review-stall-header">
<span className="card-status-badge card-status-badge--in-review in-review-stall">
{copy.badgeLabel}{copy.counter ? ` ${copy.counter}` : ""}
</span>
<span className="detail-in-review-stall-headline">{copy.headline}</span>
</div>
<div className="detail-in-review-stall-reason">{workingTask.inReviewStall.reason}</div>
<div className="detail-in-review-stall-description">{copy.description}</div>
<div className="detail-in-review-stall-action">{copy.suggestedAction}</div>
<div className="detail-in-review-stall-meta">
<span>Observed {formatTimestamp(workingTask.inReviewStall.observedAt)}</span>
{logMatch ? (
<button
type="button"
className="btn btn-sm detail-in-review-stall-jump"
onClick={() => {
setActiveTab("logs");
setLogSubview("activity");
setHighlightStallCode(workingTask.inReviewStall?.code ?? null);
}}
>
View activity log
</button>
) : (
<span
className="detail-in-review-stall-no-log"
title="No 'In-review stall surfaced' entry on this task yet — self-healing may not have logged one within its rate-limit window."
>
No log entry yet
</span>
)}
</div>
</div>
);
})()}
{shouldShowStalePausedReviewBadge(workingTask) && workingTask.stalePausedReview && (() => {
const copy = getStalePausedReviewCopy(workingTask.stalePausedReview);
const logMatch = [...(workingTask.log ?? [])].reverse().find((entry) => {
const match = entry.action.match(STALE_PAUSED_REVIEW_LOG_REGEX);
return match?.[1] === workingTask.stalePausedReview?.code;
});
return (
<div
className={`detail-section detail-in-review-stall detail-in-review-stall--${copy.code}`}
data-stall-code={copy.code}
>
<div className="detail-in-review-stall-header">
<span className="card-status-badge card-status-badge--in-review stale-paused-review">
{copy.badgeLabel}
</span>
<span className="detail-in-review-stall-headline">{copy.headline}</span>
</div>
<div className="detail-in-review-stall-reason">{workingTask.stalePausedReview.reason}</div>
<div className="detail-in-review-stall-description">{copy.description}</div>
<div className="detail-in-review-stall-action">{copy.suggestedAction}</div>
<div className="detail-in-review-stall-meta">
<span>Age {formatDurationCompact(workingTask.stalePausedReview.ageMs)}</span>
<span>Threshold {formatDurationCompact(workingTask.stalePausedReview.thresholdMs)}</span>
<span>Observed {formatTimestamp(workingTask.stalePausedReview.observedAt)}</span>
{logMatch ? (
<button
type="button"
className="btn btn-sm detail-in-review-stall-jump"
onClick={() => {
setActiveTab("logs");
setLogSubview("activity");
setHighlightStallCode(workingTask.stalePausedReview?.code ?? null);
}}
>
View activity log
</button>
) : (
<span className="detail-in-review-stall-no-log">No log entry yet</span>
)}
</div>
</div>
);
})()}
<div className="detail-section detail-pr-section">
<PrPanel
taskId={task.id}
projectId={projectId}
prInfo={task.prInfo}
prInfos={task.prInfos}
automationStatus={task.status ?? null}
taskColumn={task.column}
autoMerge={settings?.autoMerge ?? false}
isManualPrFlow={isManualPrFlow}
directMergeCommitStrategy={settings?.directMergeCommitStrategy}
prAuthAvailable={prAuthAvailable ?? false}
onRequestCreatePr={() => setPrCreateOpen(true)}
onPrUpdated={(prInfo) => {
const existing = task.prInfos ?? (task.prInfo ? [task.prInfo] : []);
const nextPrInfos = existing.some((entry) => entry.number === prInfo.number)
? existing.map((entry) => (entry.number === prInfo.number ? prInfo : entry))
: [...existing, prInfo];
(task as TaskDetail).prInfos = nextPrInfos;
(task as TaskDetail).prInfo = nextPrInfos[0] ?? prInfo;
}}
onPrsRefreshed={(prInfos) => {
(task as TaskDetail).prInfos = prInfos;
(task as TaskDetail).prInfo = prInfos[0];
}}
onPrUnlinked={(prNumber) => {
const nextPrInfos = (task.prInfos ?? (task.prInfo ? [task.prInfo] : [])).filter((entry) => entry.number !== prNumber);
(task as TaskDetail).prInfos = nextPrInfos;
(task as TaskDetail).prInfo = nextPrInfos[0];
}}
addToast={addToast}
/>
</div>
</>
)}
</> </>
)} )}
</> </>

View File

@@ -305,7 +305,7 @@ describe("TaskDetailModal", () => {
}); });
}); });
it("wraps in-review PR content in a spaced detail section after dependencies", () => { it("renders in-review PR content only in the Pull Request tab body", () => {
const { container } = render( const { container } = render(
<TaskDetailModal <TaskDetailModal
task={makeTask({ column: "in-review", status: "creating-pr", dependencies: ["FN-001"] })} task={makeTask({ column: "in-review", status: "creating-pr", dependencies: ["FN-001"] })}
@@ -318,20 +318,19 @@ describe("TaskDetailModal", () => {
/>, />,
); );
const depsSection = container.querySelector(".detail-deps"); expect(container.querySelector(".detail-pr-section")).toBeNull();
const blockingSection = container.querySelector(".detail-deps.detail-blocking");
const prSection = container.querySelector(".detail-pr-section");
expect(depsSection).toBeTruthy(); fireEvent.click(screen.getByRole("button", { name: "Pull Request" }));
expect(blockingSection).toBeTruthy();
const prSection = container.querySelector(".detail-pr-section");
expect(prSection).toBeTruthy(); expect(prSection).toBeTruthy();
expect(blockingSection?.nextElementSibling).toBe(prSection);
expect(prSection?.querySelector(".pr-section")).toBeTruthy(); expect(prSection?.querySelector(".pr-section")).toBeTruthy();
}); });
it("defines tokenized margin on detail-pr-section spacing contract", () => { it("defines tokenized spacing rules for detail-pr-tab layout", () => {
const css = readDashboardStylesSource(); const css = readDashboardStylesSource();
expectBaseRule(css, ".detail-pr-section", "margin-top: var(--space-lg);"); expectBaseRule(css, ".detail-pr-tab", "gap: var(--space-lg);");
expectBaseRule(css, ".detail-pr-tab .detail-section,\n.detail-pr-section", "margin-top: 0;");
}); });
it("activity list does not have nested scroll constraints", () => { it("activity list does not have nested scroll constraints", () => {

View File

@@ -181,6 +181,7 @@ describe("TaskDetailModal create-PR integration wiring", () => {
/>, />,
); );
fireEvent.click(screen.getByRole("button", { name: "Pull Request" }));
const panelCreateButton = await screen.findByTestId("pr-panel-create-pr"); const panelCreateButton = await screen.findByTestId("pr-panel-create-pr");
expect(screen.queryByRole("dialog", { name: "Create Pull Request" })).toBeNull(); expect(screen.queryByRole("dialog", { name: "Create Pull Request" })).toBeNull();

View File

@@ -111,6 +111,7 @@ describe("TaskDetailModal create-PR wiring", () => {
/>, />,
); );
fireEvent.click(screen.getByRole("button", { name: "Pull Request" }));
await waitFor(() => { await waitFor(() => {
expect(screen.getByRole("button", { name: "Create PR" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Create PR" })).toBeInTheDocument();
}); });

View File

@@ -0,0 +1,82 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
vi.mock("../PrPanel", () => ({
PrPanel: () => <div data-testid="pr-panel-stub">PR Panel</div>,
}));
import {
makeTask,
noop,
noopDelete,
noopMerge,
noopMove,
noopOpenDetail,
setupTaskDetailModalHooks,
} from "./TaskDetailModal.test-helpers";
import { TaskDetailModal } from "../TaskDetailModal";
setupTaskDetailModalHooks();
describe("TaskDetailModal Pull Request tab", () => {
it("shows Pull Request tab only for in-review tasks", () => {
const { rerender } = render(
<TaskDetailModal
task={makeTask({ column: "todo" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.queryByRole("button", { name: "Pull Request" })).toBeNull();
rerender(
<TaskDetailModal
task={makeTask({ column: "in-review" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.getByRole("button", { name: "Pull Request" })).toBeInTheDocument();
});
it("renders PrPanel and in-review stall badge in Pull Request tab, not Definition tab", () => {
const inReviewStall = {
code: "merge-failed" as const,
reason: "merge failed",
observedAt: "2026-01-01T00:00:00Z",
consecutiveFailures: 2,
mergeRetries: 2,
maxAutoMergeRetries: 3,
};
const { container } = render(
<TaskDetailModal
task={makeTask({ column: "in-review", inReviewStall })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.queryByTestId("pr-panel-stub")).toBeNull();
expect(container.querySelector(".detail-in-review-stall")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Pull Request" }));
expect(screen.getByTestId("pr-panel-stub")).toBeInTheDocument();
expect(container.querySelector(".detail-in-review-stall")).toBeTruthy();
});
});

View File

@@ -146,6 +146,8 @@ describe("TaskDetailModal in-review stall diagnostics", () => {
/>, />,
); );
await user.click(screen.getByRole("button", { name: "Pull Request" }));
expect(screen.getByText("Merge blocked by a pre-merge check")).toBeInTheDocument(); expect(screen.getByText("Merge blocked by a pre-merge check")).toBeInTheDocument();
expect(screen.getByText("Workflow pre-merge check failed")).toBeInTheDocument(); expect(screen.getByText("Workflow pre-merge check failed")).toBeInTheDocument();
expect(screen.getByText("Open the Review tab to see which step is blocking, then fix the failure or override the step.")).toBeInTheDocument(); expect(screen.getByText("Open the Review tab to see which step is blocking, then fix the failure or override the step.")).toBeInTheDocument();
@@ -157,7 +159,8 @@ describe("TaskDetailModal in-review stall diagnostics", () => {
expect(highlighted?.textContent).toContain("In-review stall surfaced [merge-blocker]"); expect(highlighted?.textContent).toContain("In-review stall surfaced [merge-blocker]");
}); });
it("renders retry-exhausted badge label with counter", () => { it("renders retry-exhausted badge label with counter", async () => {
const user = userEvent.setup();
render( render(
<TaskDetailModal <TaskDetailModal
task={makeTask({ task={makeTask({
@@ -178,10 +181,12 @@ describe("TaskDetailModal in-review stall diagnostics", () => {
/>, />,
); );
await user.click(screen.getByRole("button", { name: "Pull Request" }));
expect(screen.getByText("Retries exhausted 3/3")).toBeInTheDocument(); expect(screen.getByText("Retries exhausted 3/3")).toBeInTheDocument();
}); });
it("shows no-log copy when no matching stall entry exists", () => { it("shows no-log copy when no matching stall entry exists", async () => {
const user = userEvent.setup();
render( render(
<TaskDetailModal <TaskDetailModal
task={makeTask({ task={makeTask({
@@ -202,6 +207,7 @@ describe("TaskDetailModal in-review stall diagnostics", () => {
/>, />,
); );
await user.click(screen.getByRole("button", { name: "Pull Request" }));
expect(screen.getByText("No log entry yet")).toBeInTheDocument(); expect(screen.getByText("No log entry yet")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "View activity log" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "View activity log" })).not.toBeInTheDocument();
}); });

View File

@@ -10,6 +10,7 @@ export type DetailTaskTab =
| "comments" | "comments"
| "model" | "model"
| "workflow" | "workflow"
| "pr"
| "retries"; | "retries";
export type DetailTaskOrigin = "list-mobile"; export type DetailTaskOrigin = "list-mobile";

View File

@@ -12,7 +12,7 @@ import type { ReactNode } from "react";
import type { Task, TaskDetail, WorkflowStep } from "@fusion/core"; import type { Task, TaskDetail, WorkflowStep } from "@fusion/core";
/** Tab identifiers for the task detail modal. Mirrors the dashboard's local enum. */ /** Tab identifiers for the task detail modal. Mirrors the dashboard's local enum. */
export type DetailTaskTab = "definition" | "logs" | "changes" | "comments" | "model" | "workflow" | "retries"; export type DetailTaskTab = "definition" | "logs" | "changes" | "comments" | "model" | "workflow" | "pr" | "retries";
export type PluginToastType = "success" | "error" | "warning" | "info"; export type PluginToastType = "success" | "error" | "warning" | "info";