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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user