feat(FN-4288): complete Step 4 — add stall diagnostics in task detail

Fusion-Task-Id: FN-4288
Fusion-Task-Lineage: 09c6efc0-ea0b-443a-ab45-a89188fcf888
This commit is contained in:
Fusion
2026-05-13 15:53:23 -07:00
committed by gsxdsm
parent 8d8171b9cd
commit 56b5f28458
3 changed files with 251 additions and 16 deletions

View File

@@ -1809,3 +1809,56 @@
}
.detail-in-review-stall {
display: flex;
flex-direction: column;
gap: var(--space-sm);
padding: var(--space-lg);
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--color-warning) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--color-warning) 30%, transparent);
}
.detail-in-review-stall-header {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.detail-in-review-stall-headline {
font-weight: 600;
color: var(--text);
}
.detail-in-review-stall-reason {
font-family: var(--font-mono);
color: var(--text-muted);
font-size: 0.75rem;
}
.detail-in-review-stall-description,
.detail-in-review-stall-action {
color: var(--text);
}
.detail-in-review-stall-meta {
display: flex;
align-items: center;
gap: var(--space-md);
color: var(--text-muted);
}
.detail-log-entry--stall-highlight {
outline: 2px solid color-mix(in srgb, var(--color-warning) 60%, transparent);
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--color-warning) 12%, transparent);
transition: outline-color var(--transition-slow);
}
@media (max-width: 768px) {
.detail-in-review-stall-meta {
flex-direction: column;
align-items: flex-start;
}
}

View File

@@ -7,7 +7,7 @@ import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
import ReactMarkdown from "react-markdown";
import type { Components } from "react-markdown";
import remarkGfm from "remark-gfm";
import type { Task, TaskDetail, TaskAttachment, Column, MergeResult, Settings, GlobalSettings, AgentLogEntry, Agent, TaskPriority, TaskSourceIssue, WorkflowStepResult, GithubIssueAction } from "@fusion/core";
import type { Task, TaskDetail, TaskAttachment, Column, MergeResult, Settings, GlobalSettings, AgentLogEntry, Agent, TaskPriority, TaskSourceIssue, WorkflowStepResult, GithubIssueAction, InReviewStallCode } from "@fusion/core";
import {
COLUMN_LABELS,
DEFAULT_TASK_PRIORITY,
@@ -45,6 +45,8 @@ import { extractDependencyDeleteConflict } from "../utils/taskDelete";
import { computeBlockerFanoutMap } from "../hooks/useBlockerFanout";
import { resolveEffectiveGithubRepoDefault } from "./githubTracking";
import { linkifyFilePaths, linkifyReactChildren } from "../utils/filePathLinkify";
import { getInReviewStallCopy, shouldShowInReviewStallBadge } from "../utils/inReviewStallCopy";
import { findInReviewStallLogEntry, IN_REVIEW_STALL_LOG_REGEX } from "../utils/findInReviewStallLogEntry";
interface ModelSelection {
provider?: string;
@@ -535,6 +537,7 @@ export function TaskDetailContent({
}, [task.id]);
const [logSubview, setLogSubview] = useState<"activity" | "agent-log">("activity");
const [highlightStallCode, setHighlightStallCode] = useState<InReviewStallCode | null>(null);
const [descriptionExpanded, setDescriptionExpanded] = useState(false);
const [attachments, setAttachments] = useState<TaskAttachment[]>(task.attachments || []);
const [uploading, setUploading] = useState(false);
@@ -551,6 +554,22 @@ export function TaskDetailContent({
const [specEditContent, setSpecEditContent] = useState(workingTask.prompt || "");
const [specFeedback, setSpecFeedback] = useState("");
const [showRefineModal, setShowRefineModal] = useState(false);
useEffect(() => {
if (activeTab !== "logs" || logSubview !== "activity") {
setHighlightStallCode(null);
return;
}
if (!highlightStallCode) {
return;
}
const highlighted = activityListRef.current?.querySelector<HTMLElement>("[data-stall-highlight=\"true\"]");
if (highlighted && typeof highlighted.scrollIntoView === "function") {
highlighted.scrollIntoView({ block: "nearest", behavior: "smooth" });
}
}, [activeTab, logSubview, highlightStallCode]);
const [refineFeedback, setRefineFeedback] = useState("");
const [isRefining, setIsRefining] = useState(false);
@@ -594,6 +613,7 @@ export function TaskDetailContent({
const [githubRepoOverrideError, setGithubRepoOverrideError] = useState<string | null>(null);
const [isSavingGithubTracking, setIsSavingGithubTracking] = useState(false);
const moveMenuRef = useRef<HTMLDivElement>(null);
const activityListRef = useRef<HTMLDivElement>(null);
const moveButtonRef = useRef<HTMLButtonElement>(null);
const actionsMenuRef = useRef<HTMLDivElement>(null);
@@ -2357,20 +2377,36 @@ export function TaskDetailContent({
</div>
) : null}
{workingTask.log && workingTask.log.length > 0 ? (
<div className="detail-activity-list">
{[...workingTask.log].reverse().map((entry, i) => (
<div key={i} className="detail-log-entry">
<div className="detail-log-header">
<span className="detail-log-timestamp">
{formatTimestamp(entry.timestamp)}
</span>
<span className="detail-log-action">{entry.action}</span>
</div>
{entry.outcome && (
<div className="detail-log-outcome">{entry.outcome}</div>
)}
</div>
))}
<div className="detail-activity-list" ref={activityListRef}>
{(() => {
let highlightedOnce = false;
return [...workingTask.log].reverse().map((entry, i) => {
const stallMatch = entry.action.match(IN_REVIEW_STALL_LOG_REGEX);
const isHighlighted = !highlightedOnce
&& highlightStallCode != null
&& stallMatch?.[1] === highlightStallCode;
if (isHighlighted) {
highlightedOnce = true;
}
return (
<div
key={i}
className={`detail-log-entry${isHighlighted ? " detail-log-entry--stall-highlight" : ""}`}
data-stall-highlight={isHighlighted ? "true" : undefined}
>
<div className="detail-log-header">
<span className="detail-log-timestamp">
{formatTimestamp(entry.timestamp)}
</span>
<span className="detail-log-action">{entry.action}</span>
</div>
{entry.outcome && (
<div className="detail-log-outcome">{entry.outcome}</div>
)}
</div>
);
});
})()}
</div>
) : (
<div className="detail-log-empty">(no activity)</div>
@@ -2989,7 +3025,51 @@ export function TaskDetailContent({
</div>
{/* PR Section - only for in-review tasks */}
{task.column === "in-review" && (
<div className="detail-section detail-pr-section">
<>
{shouldShowInReviewStallBadge(workingTask) && workingTask.inReviewStall && (() => {
const copy = getInReviewStallCopy(workingTask.inReviewStall);
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}
</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>
);
})()}
<div className="detail-section detail-pr-section">
<PrSection
taskId={task.id}
projectId={projectId}
@@ -3008,6 +3088,7 @@ export function TaskDetailContent({
addToast={addToast}
/>
</div>
</>
)}
</>
)}

View File

@@ -86,3 +86,104 @@ describe("TaskDetailModal GitHub tracking CTA", () => {
expect(screen.queryByText("Tracking issue will be created once this task has a title or description to summarize.")).not.toBeInTheDocument();
});
});
describe("TaskDetailModal in-review stall diagnostics", () => {
it("renders diagnostic row and jumps to highlighted activity entry", async () => {
const user = userEvent.setup();
render(
<TaskDetailModal
task={makeTask({
column: "in-review",
inReviewStall: {
code: "merge-blocker",
reason: "Workflow pre-merge check failed",
observedAt: "2026-05-13T00:00:00.000Z",
},
log: [
{ timestamp: "2026-05-13T00:01:00.000Z", action: "In-review stall surfaced [merge-blocker]: Workflow pre-merge check failed" },
],
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.getByText("Merge blocked by a pre-merge check")).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();
await user.click(screen.getByRole("button", { name: "View activity log" }));
expect(screen.getByRole("button", { name: "Activity" })).toHaveClass("log-subview-btn-active");
const highlighted = document.querySelector(".detail-log-entry--stall-highlight .detail-log-action");
expect(highlighted?.textContent).toContain("In-review stall surfaced [merge-blocker]");
});
it("shows no-log copy when no matching stall entry exists", () => {
render(
<TaskDetailModal
task={makeTask({
column: "in-review",
inReviewStall: {
code: "merge-blocker",
reason: "Workflow pre-merge check failed",
observedAt: "2026-05-13T00:00:00.000Z",
},
log: [{ timestamp: "2026-05-13T00:01:00.000Z", action: "Something else" }],
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.getByText("No log entry yet")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "View activity log" })).not.toBeInTheDocument();
});
it.each([
{
label: "paused in-review task",
task: makeTask({
column: "in-review",
paused: true,
inReviewStall: {
code: "merge-blocker",
reason: "Workflow pre-merge check failed",
observedAt: "2026-05-13T00:00:00.000Z",
},
}),
},
{
label: "non in-review task",
task: makeTask({
column: "in-progress",
inReviewStall: {
code: "merge-blocker",
reason: "Workflow pre-merge check failed",
observedAt: "2026-05-13T00:00:00.000Z",
},
}),
},
])("does not render diagnostic row for $label", ({ task }) => {
render(
<TaskDetailModal
task={task}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.queryByText("Merge blocked by a pre-merge check")).not.toBeInTheDocument();
});
});