feat(FN-3897): add blocker fan-out hook, badge, and detail modal section
Implemented the blocker fan-out feature across the dashboard: added a `useBlockerFanout` hook to track blocking relationships, rendered a fan-out badge on task cards, and added a blocking section to the task detail modal, with tests covering the hook and responsive modal behavior. Fusion-Task-Id: FN-3897
This commit is contained in:
5
.changeset/fn-3897-blocker-fanout.md
Normal file
5
.changeset/fn-3897-blocker-fanout.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Show downstream blocker fan-out count on the board so high-impact blockers are visible at a glance.
|
||||||
@@ -381,6 +381,18 @@ Inspect task definition, logs, review feedback, comments, documents, workflow ou
|
|||||||
- 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).
|
||||||
- In direct/non-PR auto-merge mode, Review renders normalized reviewer-agent feedback (verdict/step/timestamp/detail) with dedicated loading/error/empty states; it does not require users to read raw agent logs.
|
- In direct/non-PR auto-merge mode, Review renders normalized reviewer-agent feedback (verdict/step/timestamp/detail) with dedicated loading/error/empty states; it does not require users to read raw agent logs.
|
||||||
|
|
||||||
|
### Identifying high-impact blockers
|
||||||
|
|
||||||
|
Use the `Blocks N` badge on task cards to spot blockers with high downstream impact:
|
||||||
|
|
||||||
|
- `Blocks N` counts active downstream dependents in `triage`, `todo`, `in-progress`, or `in-review`.
|
||||||
|
- The badge tooltip shows total active dependents plus how many are currently waiting in `todo`.
|
||||||
|
- Open a task to the **Blocking** section in Task Detail to view each downstream dependent and click through quickly.
|
||||||
|
- `(stale)` markers mean the dependent is blocked through `blockedBy` and matches stale conditions that `clearStaleBlockedBy` self-healing should clear automatically.
|
||||||
|
- Stale `dependencies[]` links are shown for awareness but are not auto-cleared by `clearStaleBlockedBy`.
|
||||||
|
|
||||||
|
Recommended workflow: when a blocker has high fan-out, prioritize unblocking first (reassign, split, or resolve immediately) before lower-impact tasks.
|
||||||
|
|
||||||
### Logs → Agent Log view
|
### Logs → Agent Log view
|
||||||
|
|
||||||
The **Logs** tab includes an **Agent Log** subview designed for debugging long-running and tool-heavy sessions:
|
The **Logs** tab includes an **Agent Log** subview designed for debugging long-running and tool-heavy sessions:
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { ToastType } from "../hooks/useToast";
|
|||||||
import { useState, useMemo, useEffect, useCallback, useRef } from "react";
|
import { useState, useMemo, useEffect, useCallback, useRef } from "react";
|
||||||
import { useBatchBadgeFetch } from "../hooks/useBatchBadgeFetch";
|
import { useBatchBadgeFetch } from "../hooks/useBatchBadgeFetch";
|
||||||
import { fetchWorkflowSteps, type ModelInfo } from "../api";
|
import { fetchWorkflowSteps, type ModelInfo } from "../api";
|
||||||
|
import { useBlockerFanout } from "../hooks/useBlockerFanout";
|
||||||
|
|
||||||
interface BoardProps {
|
interface BoardProps {
|
||||||
tasks: Task[];
|
tasks: Task[];
|
||||||
@@ -76,6 +77,7 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
|
|||||||
const { fetchBatch } = useBatchBadgeFetch(projectId);
|
const { fetchBatch } = useBatchBadgeFetch(projectId);
|
||||||
const debounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const debounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const [workflowStepNameLookup, setWorkflowStepNameLookup] = useState<ReadonlyMap<string, string>>(EMPTY_WORKFLOW_STEP_NAME_LOOKUP);
|
const [workflowStepNameLookup, setWorkflowStepNameLookup] = useState<ReadonlyMap<string, string>>(EMPTY_WORKFLOW_STEP_NAME_LOOKUP);
|
||||||
|
const blockerFanoutMap = useBlockerFanout(tasks);
|
||||||
// Normalized search-active signal: trimmed and non-empty
|
// Normalized search-active signal: trimmed and non-empty
|
||||||
const isSearchActive = searchQuery.trim() !== "";
|
const isSearchActive = searchQuery.trim() !== "";
|
||||||
const tasksByColumnCacheRef = useRef<Record<ColumnType, Task[]>>({
|
const tasksByColumnCacheRef = useRef<Record<ColumnType, Task[]>>({
|
||||||
@@ -222,6 +224,7 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
|
|||||||
onOpenMission={onOpenMission}
|
onOpenMission={onOpenMission}
|
||||||
lastFetchTimeMs={lastFetchTimeMs}
|
lastFetchTimeMs={lastFetchTimeMs}
|
||||||
workflowStepNameLookup={workflowStepNameLookup}
|
workflowStepNameLookup={workflowStepNameLookup}
|
||||||
|
blockerFanoutMap={blockerFanoutMap}
|
||||||
{...(col === "triage" ? { onQuickCreate, onNewTask, onPlanningMode, onSubtaskBreakdown } : {})}
|
{...(col === "triage" ? { onQuickCreate, onNewTask, onPlanningMode, onSubtaskBreakdown } : {})}
|
||||||
{...(col === "in-review" ? { autoMerge, onToggleAutoMerge } : {})}
|
{...(col === "in-review" ? { autoMerge, onToggleAutoMerge } : {})}
|
||||||
{...(col === "done" ? { onArchiveAllDone } : {})}
|
{...(col === "done" ? { onArchiveAllDone } : {})}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { groupByWorktree } from "../utils/worktreeGrouping";
|
|||||||
import type { ToastType } from "../hooks/useToast";
|
import type { ToastType } from "../hooks/useToast";
|
||||||
import { ChevronDown, ChevronUp, Archive, MoreVertical } from "lucide-react";
|
import { ChevronDown, ChevronUp, Archive, MoreVertical } from "lucide-react";
|
||||||
import type { ModelInfo } from "../api";
|
import type { ModelInfo } from "../api";
|
||||||
|
import type { BlockerFanoutEntry } from "../hooks/useBlockerFanout";
|
||||||
|
|
||||||
const PAGINATED_COLUMN_THRESHOLD = 100;
|
const PAGINATED_COLUMN_THRESHOLD = 100;
|
||||||
const VISIBLE_TASKS_INITIAL = 50;
|
const VISIBLE_TASKS_INITIAL = 50;
|
||||||
@@ -66,9 +67,11 @@ interface ColumnProps {
|
|||||||
lastFetchTimeMs?: number;
|
lastFetchTimeMs?: number;
|
||||||
/** Lookup of workflow step IDs to display names, fetched once at board level. */
|
/** Lookup of workflow step IDs to display names, fetched once at board level. */
|
||||||
workflowStepNameLookup?: ReadonlyMap<string, string>;
|
workflowStepNameLookup?: ReadonlyMap<string, string>;
|
||||||
|
/** Precomputed blocker fanout keyed by blocker task ID. */
|
||||||
|
blockerFanoutMap?: ReadonlyMap<string, BlockerFanoutEntry>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask, onPauseTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, collapsed, onToggleCollapse, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, taskStuckTimeoutMs, onOpenMission, lastFetchTimeMs, workflowStepNameLookup }: ColumnProps) {
|
function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask, onPauseTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, collapsed, onToggleCollapse, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, taskStuckTimeoutMs, onOpenMission, lastFetchTimeMs, workflowStepNameLookup, blockerFanoutMap }: ColumnProps) {
|
||||||
const [dragOver, setDragOver] = useState(false);
|
const [dragOver, setDragOver] = useState(false);
|
||||||
const [visibleTaskCount, setVisibleTaskCount] = useState(VISIBLE_TASKS_INITIAL);
|
const [visibleTaskCount, setVisibleTaskCount] = useState(VISIBLE_TASKS_INITIAL);
|
||||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||||
@@ -488,6 +491,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
|
|||||||
onOpenMission={onOpenMission}
|
onOpenMission={onOpenMission}
|
||||||
lastFetchTimeMs={lastFetchTimeMs}
|
lastFetchTimeMs={lastFetchTimeMs}
|
||||||
workflowStepNameLookup={workflowStepNameLookup}
|
workflowStepNameLookup={workflowStepNameLookup}
|
||||||
|
blockerFanoutMap={blockerFanoutMap}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
)
|
)
|
||||||
@@ -514,6 +518,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
|
|||||||
onMoveTask={onMoveTask}
|
onMoveTask={onMoveTask}
|
||||||
lastFetchTimeMs={lastFetchTimeMs}
|
lastFetchTimeMs={lastFetchTimeMs}
|
||||||
workflowStepNameLookup={workflowStepNameLookup}
|
workflowStepNameLookup={workflowStepNameLookup}
|
||||||
|
fanout={blockerFanoutMap?.get(task.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{shouldPaginate && hiddenTaskCount > 0 && (
|
{shouldPaginate && hiddenTaskCount > 0 && (
|
||||||
|
|||||||
@@ -411,7 +411,29 @@
|
|||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-scope-badge[data-tooltip]:hover::after {
|
.card-fanout-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: calc(var(--space-xs) / 2);
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: var(--color-info);
|
||||||
|
position: relative;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-fanout-count {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-fanout-badge--stale {
|
||||||
|
color: var(--color-warning);
|
||||||
|
background: color-mix(in srgb, var(--color-warning) 12%, transparent);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
padding-inline: calc(var(--space-xs) / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-scope-badge[data-tooltip]:hover::after,
|
||||||
|
.card-fanout-badge[data-tooltip]:hover::after {
|
||||||
content: attr(data-tooltip);
|
content: attr(data-tooltip);
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 100%;
|
bottom: 100%;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import "./TaskCard.css";
|
import "./TaskCard.css";
|
||||||
import { memo, useCallback, useState, useRef, useEffect, useMemo } from "react";
|
import { memo, useCallback, useState, useRef, useEffect, useMemo } from "react";
|
||||||
import { Link, Clock, Layers, Pencil, ChevronDown, Folder, Target, Bot, Trash2, RotateCw, Zap } from "lucide-react";
|
import { Link, Clock, Layers, Pencil, ChevronDown, Folder, Target, Bot, Trash2, RotateCw, Zap, GitBranch } from "lucide-react";
|
||||||
import type { Task, TaskDetail, Column, PrInfo, IssueInfo, TaskPriority } from "@fusion/core";
|
import type { Task, TaskDetail, Column, PrInfo, IssueInfo, TaskPriority } from "@fusion/core";
|
||||||
import { COLUMN_LABELS, DEFAULT_TASK_PRIORITY, TASK_PRIORITIES, VALID_TRANSITIONS, getErrorMessage } from "@fusion/core";
|
import { COLUMN_LABELS, DEFAULT_TASK_PRIORITY, TASK_PRIORITIES, VALID_TRANSITIONS, getErrorMessage } from "@fusion/core";
|
||||||
import { fetchTaskDetail, uploadAttachment, fetchMission, fetchAgent } from "../api";
|
import { fetchTaskDetail, uploadAttachment, fetchMission, fetchAgent } from "../api";
|
||||||
@@ -17,6 +17,7 @@ import { getEndToEndDurationMs, getTimedDurationMs, getWorkflowRuntimeMs, parseT
|
|||||||
import type { ToastType } from "../hooks/useToast";
|
import type { ToastType } from "../hooks/useToast";
|
||||||
import { useConfirm } from "../hooks/useConfirm";
|
import { useConfirm } from "../hooks/useConfirm";
|
||||||
import { extractDependencyDeleteConflict } from "../utils/taskDelete";
|
import { extractDependencyDeleteConflict } from "../utils/taskDelete";
|
||||||
|
import type { BlockerFanoutEntry } from "../hooks/useBlockerFanout";
|
||||||
|
|
||||||
// ── Mission title caching ───────────────────────────────────────────────────
|
// ── Mission title caching ───────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -277,6 +278,8 @@ interface TaskCardProps {
|
|||||||
workflowStepNameLookup?: ReadonlyMap<string, string>;
|
workflowStepNameLookup?: ReadonlyMap<string, string>;
|
||||||
/** Disable card drag semantics when embedding in custom draggable containers (e.g. dependency graph). */
|
/** Disable card drag semantics when embedding in custom draggable containers (e.g. dependency graph). */
|
||||||
disableDrag?: boolean;
|
disableDrag?: boolean;
|
||||||
|
/** Downstream fan-out entry for this task, computed at board-level. */
|
||||||
|
fanout?: BlockerFanoutEntry;
|
||||||
}
|
}
|
||||||
|
|
||||||
function areTaskBadgeInfosEqual(
|
function areTaskBadgeInfosEqual(
|
||||||
@@ -401,6 +404,10 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo
|
|||||||
previous.onMoveTask === next.onMoveTask &&
|
previous.onMoveTask === next.onMoveTask &&
|
||||||
previous.workflowStepNameLookup === next.workflowStepNameLookup &&
|
previous.workflowStepNameLookup === next.workflowStepNameLookup &&
|
||||||
previous.disableDrag === next.disableDrag &&
|
previous.disableDrag === next.disableDrag &&
|
||||||
|
previous.fanout?.totalCount === next.fanout?.totalCount &&
|
||||||
|
previous.fanout?.activeTodoCount === next.fanout?.activeTodoCount &&
|
||||||
|
areTaskDependenciesEqual(previous.fanout?.dependentIds ?? [], next.fanout?.dependentIds ?? []) &&
|
||||||
|
areTaskDependenciesEqual(previous.fanout?.staleBlockedByDependentIds ?? [], next.fanout?.staleBlockedByDependentIds ?? []) &&
|
||||||
previousTask.id === nextTask.id &&
|
previousTask.id === nextTask.id &&
|
||||||
previousTask.title === nextTask.title &&
|
previousTask.title === nextTask.title &&
|
||||||
previousTask.description === nextTask.description &&
|
previousTask.description === nextTask.description &&
|
||||||
@@ -465,6 +472,7 @@ function TaskCardComponent({
|
|||||||
lastFetchTimeMs,
|
lastFetchTimeMs,
|
||||||
workflowStepNameLookup,
|
workflowStepNameLookup,
|
||||||
disableDrag,
|
disableDrag,
|
||||||
|
fanout,
|
||||||
}: TaskCardProps) {
|
}: TaskCardProps) {
|
||||||
const [dragging, setDragging] = useState(false);
|
const [dragging, setDragging] = useState(false);
|
||||||
const [fileDragOver, setFileDragOver] = useState(false);
|
const [fileDragOver, setFileDragOver] = useState(false);
|
||||||
@@ -1609,7 +1617,7 @@ function TaskCardComponent({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{((task.dependencies && task.dependencies.length > 0) || queued || task.status === "queued" || task.blockedBy) && (
|
{((task.dependencies && task.dependencies.length > 0) || queued || task.status === "queued" || task.blockedBy || (fanout && fanout.totalCount > 0)) && (
|
||||||
<div className="card-meta">
|
<div className="card-meta">
|
||||||
{task.dependencies && task.dependencies.length > 0 && (
|
{task.dependencies && task.dependencies.length > 0 && (
|
||||||
<div className="card-dep-list">
|
<div className="card-dep-list">
|
||||||
@@ -1630,6 +1638,18 @@ function TaskCardComponent({
|
|||||||
<Layers size={12} style={{ verticalAlign: "middle" }} /> {task.blockedBy}
|
<Layers size={12} style={{ verticalAlign: "middle" }} /> {task.blockedBy}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{fanout && fanout.totalCount > 0 && (
|
||||||
|
<span
|
||||||
|
className={`card-fanout-badge${fanout.staleBlockedByDependentIds.length > 0 ? " card-fanout-badge--stale" : ""}`}
|
||||||
|
data-tooltip={`Blocking ${fanout.totalCount} task(s); ${fanout.activeTodoCount} waiting in todo`}
|
||||||
|
>
|
||||||
|
<GitBranch size={12} style={{ verticalAlign: "middle" }} />
|
||||||
|
<span>
|
||||||
|
Blocks <span className="card-fanout-count">{fanout.totalCount}</span>
|
||||||
|
{fanout.staleBlockedByDependentIds.length > 0 ? ` (${fanout.staleBlockedByDependentIds.length} stale)` : ""}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{(queued || task.status === "queued") && task.column !== "in-progress" && <span className="queued-badge"><Clock size={12} style={{ verticalAlign: "middle" }} /> Queued</span>}
|
{(queued || task.status === "queued") && task.column !== "in-progress" && <span className="queued-badge"><Clock size={12} style={{ verticalAlign: "middle" }} /> Queued</span>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -522,6 +522,11 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.detail-blocking-item--stale {
|
||||||
|
color: var(--color-warning);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
.modal-actions-spacer {
|
.modal-actions-spacer {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import { subscribeSse } from "../sse-bus";
|
|||||||
import { usePluginUiSlots } from "../hooks/usePluginUiSlots";
|
import { usePluginUiSlots } from "../hooks/usePluginUiSlots";
|
||||||
import { appendTokenQuery } from "../auth";
|
import { appendTokenQuery } from "../auth";
|
||||||
import { extractDependencyDeleteConflict } from "../utils/taskDelete";
|
import { extractDependencyDeleteConflict } from "../utils/taskDelete";
|
||||||
|
import { computeBlockerFanoutMap } from "../hooks/useBlockerFanout";
|
||||||
|
|
||||||
interface ModelSelection {
|
interface ModelSelection {
|
||||||
provider?: string;
|
provider?: string;
|
||||||
@@ -1579,6 +1580,21 @@ export function TaskDetailContent({
|
|||||||
return bNum - aNum;
|
return bNum - aNum;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const blockerFanoutMap = useMemo(() => computeBlockerFanoutMap(tasks), [tasks]);
|
||||||
|
const blockingEntry = blockerFanoutMap.get(task.id);
|
||||||
|
const blockingDependents = useMemo(() => {
|
||||||
|
if (!blockingEntry) return [] as Array<{ id: string; label: string; stale: boolean }>;
|
||||||
|
const staleSet = new Set(blockingEntry.staleBlockedByDependentIds);
|
||||||
|
return blockingEntry.dependentIds.map((dependentId) => {
|
||||||
|
const dependentTask = tasks.find((candidate) => candidate.id === dependentId);
|
||||||
|
return {
|
||||||
|
id: dependentId,
|
||||||
|
label: dependentTask?.title || dependentTask?.description || dependentId,
|
||||||
|
stale: staleSet.has(dependentId),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [blockingEntry, tasks]);
|
||||||
|
|
||||||
const assignedAgentLabel = assignedAgent?.name ?? task.assignedAgentId ?? null;
|
const assignedAgentLabel = assignedAgent?.name ?? task.assignedAgentId ?? null;
|
||||||
const detailProviders = useMemo(() => {
|
const detailProviders = useMemo(() => {
|
||||||
const providers: string[] = [];
|
const providers: string[] = [];
|
||||||
@@ -2538,6 +2554,43 @@ export function TaskDetailContent({
|
|||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="detail-deps detail-blocking">
|
||||||
|
<h4>Blocking</h4>
|
||||||
|
{blockingDependents.length > 0 ? (
|
||||||
|
<ul className="detail-dep-list">
|
||||||
|
{blockingDependents.map((dependent) => (
|
||||||
|
<li key={dependent.id} className="detail-dep-item">
|
||||||
|
<span
|
||||||
|
className="detail-dep-link"
|
||||||
|
onClick={() => handleDepClick(dependent.id)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
handleDepClick(dependent.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
role="link"
|
||||||
|
tabIndex={0}
|
||||||
|
title={`Click to view ${dependent.id}`}
|
||||||
|
>
|
||||||
|
<span className="detail-dep-id">{dependent.id}</span>
|
||||||
|
<span className="detail-dep-label">{truncate(dependent.label, 40)}</span>
|
||||||
|
</span>
|
||||||
|
{dependent.stale && (
|
||||||
|
<span
|
||||||
|
className="detail-blocking-item--stale"
|
||||||
|
title="Stale blockedBy edge: self-healing clearStaleBlockedBy should clear this automatically"
|
||||||
|
>
|
||||||
|
(stale)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<div className="detail-empty-inline">(no downstream tasks blocked)</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{/* PR Section - only for in-review tasks */}
|
{/* PR Section - only for in-review tasks */}
|
||||||
{task.column === "in-review" && (
|
{task.column === "in-review" && (
|
||||||
<div className="detail-section detail-pr-section">
|
<div className="detail-section detail-pr-section">
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { Task, TaskDetail } from "@fusion/core";
|
|||||||
import { ClipboardList, GitBranch } from "lucide-react";
|
import { ClipboardList, GitBranch } from "lucide-react";
|
||||||
import { TaskCard } from "./TaskCard";
|
import { TaskCard } from "./TaskCard";
|
||||||
import type { ToastType } from "../hooks/useToast";
|
import type { ToastType } from "../hooks/useToast";
|
||||||
|
import type { BlockerFanoutEntry } from "../hooks/useBlockerFanout";
|
||||||
|
|
||||||
interface WorktreeGroupProps {
|
interface WorktreeGroupProps {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -26,6 +27,8 @@ interface WorktreeGroupProps {
|
|||||||
lastFetchTimeMs?: number;
|
lastFetchTimeMs?: number;
|
||||||
/** Lookup of workflow step IDs to display names, fetched once at board level. */
|
/** Lookup of workflow step IDs to display names, fetched once at board level. */
|
||||||
workflowStepNameLookup?: ReadonlyMap<string, string>;
|
workflowStepNameLookup?: ReadonlyMap<string, string>;
|
||||||
|
/** Precomputed blocker fanout keyed by blocker task ID. */
|
||||||
|
blockerFanoutMap?: ReadonlyMap<string, BlockerFanoutEntry>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function WorktreeGroupComponent({
|
function WorktreeGroupComponent({
|
||||||
@@ -43,6 +46,7 @@ function WorktreeGroupComponent({
|
|||||||
onOpenMission,
|
onOpenMission,
|
||||||
lastFetchTimeMs,
|
lastFetchTimeMs,
|
||||||
workflowStepNameLookup,
|
workflowStepNameLookup,
|
||||||
|
blockerFanoutMap,
|
||||||
}: WorktreeGroupProps) {
|
}: WorktreeGroupProps) {
|
||||||
return (
|
return (
|
||||||
<div className="worktree-group">
|
<div className="worktree-group">
|
||||||
@@ -53,7 +57,7 @@ function WorktreeGroupComponent({
|
|||||||
<span className="worktree-label">{label}</span>
|
<span className="worktree-label">{label}</span>
|
||||||
</div>
|
</div>
|
||||||
{activeTasks.map((task) => (
|
{activeTasks.map((task) => (
|
||||||
<TaskCard key={task.id} task={task} projectId={projectId} onOpenDetail={onOpenDetail} addToast={addToast} globalPaused={globalPaused} onUpdateTask={onUpdateTask} onRetryTask={onRetryTask} onOpenDetailWithTab={onOpenDetailWithTab} taskStuckTimeoutMs={taskStuckTimeoutMs} onOpenMission={onOpenMission} lastFetchTimeMs={lastFetchTimeMs} workflowStepNameLookup={workflowStepNameLookup} />
|
<TaskCard key={task.id} task={task} projectId={projectId} onOpenDetail={onOpenDetail} addToast={addToast} globalPaused={globalPaused} onUpdateTask={onUpdateTask} onRetryTask={onRetryTask} onOpenDetailWithTab={onOpenDetailWithTab} taskStuckTimeoutMs={taskStuckTimeoutMs} onOpenMission={onOpenMission} lastFetchTimeMs={lastFetchTimeMs} workflowStepNameLookup={workflowStepNameLookup} fanout={blockerFanoutMap?.get(task.id)} />
|
||||||
))}
|
))}
|
||||||
{queuedTasks.map((task) => (
|
{queuedTasks.map((task) => (
|
||||||
<TaskCard
|
<TaskCard
|
||||||
@@ -71,6 +75,7 @@ function WorktreeGroupComponent({
|
|||||||
onOpenMission={onOpenMission}
|
onOpenMission={onOpenMission}
|
||||||
lastFetchTimeMs={lastFetchTimeMs}
|
lastFetchTimeMs={lastFetchTimeMs}
|
||||||
workflowStepNameLookup={workflowStepNameLookup}
|
workflowStepNameLookup={workflowStepNameLookup}
|
||||||
|
fanout={blockerFanoutMap?.get(task.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { Task } from "@fusion/core";
|
|||||||
// Mock lucide-react to avoid SVG rendering issues in test env
|
// Mock lucide-react to avoid SVG rendering issues in test env
|
||||||
vi.mock("lucide-react", () => ({
|
vi.mock("lucide-react", () => ({
|
||||||
Link: () => null,
|
Link: () => null,
|
||||||
|
GitBranch: () => null,
|
||||||
Clock: () => null,
|
Clock: () => null,
|
||||||
Pencil: () => null,
|
Pencil: () => null,
|
||||||
Layers: () => null,
|
Layers: () => null,
|
||||||
@@ -183,6 +184,65 @@ describe("TaskCard", () => {
|
|||||||
expect(screen.queryByText("paused by agent")).toBeNull();
|
expect(screen.queryByText("paused by agent")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not render fan-out badge when fanout is missing or zero", () => {
|
||||||
|
const { container, rerender } = render(
|
||||||
|
<TaskCard task={makeTask({ column: "todo" })} onOpenDetail={noop} addToast={noop} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container.querySelector(".card-fanout-badge")).toBeNull();
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<TaskCard
|
||||||
|
task={makeTask({ column: "todo" })}
|
||||||
|
fanout={{ totalCount: 0, activeTodoCount: 0, dependentIds: [], staleBlockedByDependentIds: [] }}
|
||||||
|
onOpenDetail={noop}
|
||||||
|
addToast={noop}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container.querySelector(".card-fanout-badge")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders fan-out badge with downstream count and tooltip", () => {
|
||||||
|
render(
|
||||||
|
<TaskCard
|
||||||
|
task={makeTask({ column: "in-progress" })}
|
||||||
|
fanout={{ totalCount: 7, activeTodoCount: 4, dependentIds: ["FN-002"], staleBlockedByDependentIds: [] }}
|
||||||
|
onOpenDetail={noop}
|
||||||
|
addToast={noop}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const badge = screen.getByText("Blocks").closest(".card-fanout-badge") as HTMLElement;
|
||||||
|
expect(badge).not.toBeNull();
|
||||||
|
expect(badge.textContent).toContain("Blocks 7");
|
||||||
|
expect(badge.getAttribute("data-tooltip")).toBe("Blocking 7 task(s); 4 waiting in todo");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies stale fan-out modifier when stale blockedBy dependents exist", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<TaskCard
|
||||||
|
task={makeTask({ column: "in-progress" })}
|
||||||
|
fanout={{ totalCount: 3, activeTodoCount: 1, dependentIds: ["FN-003"], staleBlockedByDependentIds: ["FN-003"] }}
|
||||||
|
onOpenDetail={noop}
|
||||||
|
addToast={noop}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const badge = container.querySelector(".card-fanout-badge") as HTMLElement;
|
||||||
|
expect(badge.className).toContain("card-fanout-badge--stale");
|
||||||
|
expect(badge.textContent).toContain("(1 stale)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows plain paused label when pausedByAgentId is not set", () => {
|
||||||
|
render(
|
||||||
|
<TaskCard task={makeTask({ paused: true })} onOpenDetail={noop} addToast={noop} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("paused")).toBeDefined();
|
||||||
|
expect(screen.queryByText("paused by agent")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("hides default working branch and default base branch metadata", () => {
|
it("hides default working branch and default base branch metadata", () => {
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
<TaskCard
|
<TaskCard
|
||||||
|
|||||||
@@ -725,5 +725,32 @@ describe("TaskDetailModal", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("blocking section", () => {
|
||||||
|
it("renders downstream dependents and stale annotations", () => {
|
||||||
|
const tasks = [
|
||||||
|
makeTask({ id: "FN-099", title: "Blocker", column: "done" as Column }),
|
||||||
|
makeTask({ id: "FN-100", title: "Todo dependent", column: "todo" as Column, dependencies: ["FN-099"] }),
|
||||||
|
makeTask({ id: "FN-101", title: "Stale blockedBy dependent", column: "todo" as Column, blockedBy: "FN-099" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<TaskDetailModal
|
||||||
|
task={tasks[0]}
|
||||||
|
tasks={tasks}
|
||||||
|
onOpenDetail={noopOpenDetail}
|
||||||
|
onClose={noop}
|
||||||
|
onMoveTask={noopMove}
|
||||||
|
onDeleteTask={noopDelete}
|
||||||
|
onMergeTask={noopMerge}
|
||||||
|
addToast={noop}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("Blocking")).toBeTruthy();
|
||||||
|
expect(container.textContent).toContain("FN-100");
|
||||||
|
expect(container.textContent).toContain("FN-101");
|
||||||
|
expect(container.querySelector(".detail-blocking-item--stale")?.textContent).toBe("(stale)");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
138
packages/dashboard/app/hooks/__tests__/useBlockerFanout.test.ts
Normal file
138
packages/dashboard/app/hooks/__tests__/useBlockerFanout.test.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { Task } from "@fusion/core";
|
||||||
|
import { computeBlockerFanoutMap, MAX_AUTO_MERGE_RETRIES } from "../useBlockerFanout";
|
||||||
|
|
||||||
|
function createTask(id: string, column: Task["column"], overrides: Partial<Task> = {}): Task {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
description: `Task ${id}`,
|
||||||
|
column,
|
||||||
|
dependencies: [],
|
||||||
|
steps: [],
|
||||||
|
currentStep: 0,
|
||||||
|
log: [],
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("computeBlockerFanoutMap", () => {
|
||||||
|
it("returns an empty map for an empty task list", () => {
|
||||||
|
expect(computeBlockerFanoutMap([]).size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty map when no downstream dependencies exist", () => {
|
||||||
|
const tasks = [createTask("FN-1", "todo"), createTask("FN-2", "done")];
|
||||||
|
expect(computeBlockerFanoutMap(tasks).size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tracks a single dependent via dependencies[]", () => {
|
||||||
|
const tasks = [
|
||||||
|
createTask("FN-1", "in-progress"),
|
||||||
|
createTask("FN-2", "todo", { dependencies: ["FN-1"] }),
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(computeBlockerFanoutMap(tasks).get("FN-1")).toEqual({
|
||||||
|
totalCount: 1,
|
||||||
|
activeTodoCount: 1,
|
||||||
|
dependentIds: ["FN-2"],
|
||||||
|
staleBlockedByDependentIds: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tracks mixed dependencies[] and blockedBy edges", () => {
|
||||||
|
const tasks = [
|
||||||
|
createTask("FN-1", "in-progress"),
|
||||||
|
createTask("FN-2", "todo", { dependencies: ["FN-1"] }),
|
||||||
|
createTask("FN-3", "in-review", { blockedBy: "FN-1" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(computeBlockerFanoutMap(tasks).get("FN-1")).toEqual({
|
||||||
|
totalCount: 2,
|
||||||
|
activeTodoCount: 1,
|
||||||
|
dependentIds: ["FN-2", "FN-3"],
|
||||||
|
staleBlockedByDependentIds: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes done/archived dependents from totalCount but keeps dependentIds", () => {
|
||||||
|
const tasks = [
|
||||||
|
createTask("FN-1", "in-progress"),
|
||||||
|
createTask("FN-2", "done", { dependencies: ["FN-1"] }),
|
||||||
|
createTask("FN-3", "archived", { blockedBy: "FN-1" }),
|
||||||
|
createTask("FN-4", "todo", { dependencies: ["FN-1"] }),
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(computeBlockerFanoutMap(tasks).get("FN-1")).toEqual({
|
||||||
|
totalCount: 1,
|
||||||
|
activeTodoCount: 1,
|
||||||
|
dependentIds: ["FN-2", "FN-3", "FN-4"],
|
||||||
|
staleBlockedByDependentIds: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks stale blockedBy dependents only for blockedBy edges, not dependencies[]", () => {
|
||||||
|
const tasks = [
|
||||||
|
createTask("FN-2", "todo", { dependencies: ["MISSING"] }),
|
||||||
|
createTask("FN-3", "todo", { blockedBy: "MISSING" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(computeBlockerFanoutMap(tasks).get("MISSING")).toEqual({
|
||||||
|
totalCount: 2,
|
||||||
|
activeTodoCount: 2,
|
||||||
|
dependentIds: ["FN-2", "FN-3"],
|
||||||
|
staleBlockedByDependentIds: ["FN-3"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks blockedBy edges stale when blocker is done", () => {
|
||||||
|
const tasks = [createTask("B", "done"), createTask("D", "todo", { blockedBy: "B" })];
|
||||||
|
expect(computeBlockerFanoutMap(tasks).get("B")?.staleBlockedByDependentIds).toEqual(["D"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks blockedBy edges stale when blocker is archived", () => {
|
||||||
|
const tasks = [createTask("B", "archived"), createTask("D", "todo", { blockedBy: "B" })];
|
||||||
|
expect(computeBlockerFanoutMap(tasks).get("B")?.staleBlockedByDependentIds).toEqual(["D"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks blockedBy edges stale when blocker is in-review and paused", () => {
|
||||||
|
const tasks = [createTask("B", "in-review", { paused: true }), createTask("D", "todo", { blockedBy: "B" })];
|
||||||
|
expect(computeBlockerFanoutMap(tasks).get("B")?.staleBlockedByDependentIds).toEqual(["D"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks blockedBy edges stale when blocker failed in-review at max retries", () => {
|
||||||
|
const tasks = [
|
||||||
|
createTask("B", "in-review", { status: "failed", mergeRetries: MAX_AUTO_MERGE_RETRIES }),
|
||||||
|
createTask("D", "todo", { blockedBy: "B" }),
|
||||||
|
];
|
||||||
|
expect(computeBlockerFanoutMap(tasks).get("B")?.staleBlockedByDependentIds).toEqual(["D"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("FN-3897 regression: reports active and todo downstream counts for high fan-out blockers", () => {
|
||||||
|
const tasks = [
|
||||||
|
createTask("B", "in-progress"),
|
||||||
|
createTask("D1", "todo", { dependencies: ["B"] }),
|
||||||
|
createTask("D2", "todo", { blockedBy: "B" }),
|
||||||
|
createTask("D3", "in-review", { dependencies: ["B"] }),
|
||||||
|
createTask("D4", "done", { dependencies: ["B"] }),
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(computeBlockerFanoutMap(tasks).get("B")).toEqual({
|
||||||
|
totalCount: 3,
|
||||||
|
activeTodoCount: 2,
|
||||||
|
dependentIds: ["D1", "D2", "D3", "D4"],
|
||||||
|
staleBlockedByDependentIds: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps MAX_AUTO_MERGE_RETRIES aligned with engine self-healing source", () => {
|
||||||
|
const testDir = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const source = readFileSync(resolve(testDir, "../../../../engine/src/self-healing.ts"), "utf8");
|
||||||
|
const match = source.match(/const MAX_AUTO_MERGE_RETRIES = (\d+);/);
|
||||||
|
expect(match?.[1]).toBe(String(MAX_AUTO_MERGE_RETRIES));
|
||||||
|
});
|
||||||
|
});
|
||||||
102
packages/dashboard/app/hooks/useBlockerFanout.ts
Normal file
102
packages/dashboard/app/hooks/useBlockerFanout.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import type { Task } from "@fusion/core";
|
||||||
|
|
||||||
|
export interface BlockerFanoutEntry {
|
||||||
|
totalCount: number;
|
||||||
|
activeTodoCount: number;
|
||||||
|
dependentIds: string[];
|
||||||
|
staleBlockedByDependentIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep in sync with packages/engine/src/self-healing.ts
|
||||||
|
export const MAX_AUTO_MERGE_RETRIES = 3;
|
||||||
|
|
||||||
|
const ACTIVE_COLUMNS = new Set<Task["column"]>(["triage", "todo", "in-progress", "in-review"]);
|
||||||
|
|
||||||
|
function isStaleBlockedByBlocker(blocker: Task | undefined): boolean {
|
||||||
|
if (!blocker) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (blocker.column === "done" || blocker.column === "archived") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (blocker.column === "in-review" && blocker.paused === true) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
blocker.column === "in-review" &&
|
||||||
|
blocker.status === "failed" &&
|
||||||
|
(blocker.mergeRetries ?? 0) >= MAX_AUTO_MERGE_RETRIES
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MutableEntry {
|
||||||
|
dependentIds: string[];
|
||||||
|
blockedByDependentIds: string[];
|
||||||
|
activeCount: number;
|
||||||
|
activeTodoCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeBlockerFanoutMap(tasks: Task[]): Map<string, BlockerFanoutEntry> {
|
||||||
|
const taskById = new Map(tasks.map((task) => [task.id, task]));
|
||||||
|
const fanout = new Map<string, MutableEntry>();
|
||||||
|
|
||||||
|
const ensureEntry = (blockerId: string): MutableEntry => {
|
||||||
|
let entry = fanout.get(blockerId);
|
||||||
|
if (!entry) {
|
||||||
|
entry = { dependentIds: [], blockedByDependentIds: [], activeCount: 0, activeTodoCount: 0 };
|
||||||
|
fanout.set(blockerId, entry);
|
||||||
|
}
|
||||||
|
return entry;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const task of tasks) {
|
||||||
|
const active = ACTIVE_COLUMNS.has(task.column);
|
||||||
|
const isTodo = task.column === "todo";
|
||||||
|
|
||||||
|
const dependencyIds = task.dependencies ?? [];
|
||||||
|
for (const depId of dependencyIds) {
|
||||||
|
if (!depId) continue;
|
||||||
|
const entry = ensureEntry(depId);
|
||||||
|
entry.dependentIds.push(task.id);
|
||||||
|
if (active) entry.activeCount += 1;
|
||||||
|
if (isTodo) entry.activeTodoCount += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (task.blockedBy) {
|
||||||
|
const entry = ensureEntry(task.blockedBy);
|
||||||
|
entry.dependentIds.push(task.id);
|
||||||
|
entry.blockedByDependentIds.push(task.id);
|
||||||
|
if (active) entry.activeCount += 1;
|
||||||
|
if (isTodo) entry.activeTodoCount += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = new Map<string, BlockerFanoutEntry>();
|
||||||
|
for (const [blockerId, entry] of fanout) {
|
||||||
|
const blocker = taskById.get(blockerId);
|
||||||
|
const staleBlockedByDependentIds = isStaleBlockedByBlocker(blocker)
|
||||||
|
? [...entry.blockedByDependentIds]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
result.set(blockerId, {
|
||||||
|
totalCount: entry.activeCount,
|
||||||
|
activeTodoCount: entry.activeTodoCount,
|
||||||
|
dependentIds: entry.dependentIds,
|
||||||
|
staleBlockedByDependentIds,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBlockerFanout(tasks: Task[]): Map<string, BlockerFanoutEntry> {
|
||||||
|
return useMemo(() => computeBlockerFanoutMap(tasks), [tasks]);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user