feat(FN-5589): merge fusion/fn-5589
This commit is contained in:
@@ -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).
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user