feat(FN-7039): drive progress bar + Workflow tab from graph-written results

getUnifiedTaskProgress reads task.workflowStepResults (graph-written in U2) and
no longer needs workflowStepNameLookup — names come from result.workflowStepName.
Removed the lookup prop + fetchWorkflowSteps-name plumbing end-to-end (App,
MainContent, RightDock, Board, Column, Lane, WorktreeGroup, TaskCard). Added the
running state (pending + startedAt) and a distinct advisory_failure (amber, non-
blocking) vs failed (red) treatment; advisory_failure counts as complete.

Plan U3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-25 22:48:50 -07:00
parent c61164853c
commit 7b358b74b5
16 changed files with 292 additions and 152 deletions

View File

@@ -793,11 +793,6 @@ function AppInner() {
};
}, [currentProject?.id]);
const workflowStepNameLookup = useMemo(
() => new Map(workflowSteps.map((step) => [step.id, step.name] as const)),
[workflowSteps],
);
// History-aware modal open handlers — push nav entries for back-navigation.
const openDetailTask = useCallback((task: Task | TaskDetail, tab?: Parameters<typeof modalManager.openDetailTask>[1], opts?: { origin?: DetailTaskOrigin }) => {
modalManager.openDetailTask(task, tab, opts);
@@ -1169,7 +1164,6 @@ function AppInner() {
subscribePluginEvents,
openDetailTask,
openFileInBrowser,
workflowStepNameLookup,
prAuthAvailable,
autoMerge,
settingsLoaded,
@@ -1326,7 +1320,7 @@ function AppInner() {
markGitHubStarPromptShown,
setShowGitHubStarPrompt,
};
const rightDock = useRightDockController({ active: rightDockActive, projectId: currentProject?.id, addToast, settingsLoaded, researchReadinessVersion, goalAnchorId, tasks: isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks, workflowSteps, subscribePluginEvents, openDetailTask, openFileInBrowser, openSettings: (section?: string) => openSettingsWithNav(section as SectionId), onOpenUsage: openUsageWithNav, onOpenActivityLog: openActivityLogWithNav, onOpenGitHubImport: openGitHubImportWithNav, onOpenGitManager: openGitManagerWithNav, onOpenSchedules: openSchedulesWithNav, onSendSelectionToTask: modalManager.openNewTaskWithDescription, onCreateTaskFromInsight: handleInsightTaskCreate, onNavigateToMission: handleOpenMission, onTaskCreated: (task: Task) => ingestCreatedTasks([task]), workflowStepNameLookup, prAuthAvailable, autoMerge, visibilityOptions: { experimentalFeatures: { insights: insightsEnabled, memoryView: memoryEnabled, devServerView: devServerEnabled, researchView: researchEnabled, evalsView: evalsEnabled, goalsView: goalsEnabled }, showSkillsTab: skillsEnabled, todosEnabled, pluginDashboardViews }, footerVisible: executorFooterVisible });
const rightDock = useRightDockController({ active: rightDockActive, projectId: currentProject?.id, addToast, settingsLoaded, researchReadinessVersion, goalAnchorId, tasks: isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks, workflowSteps, subscribePluginEvents, openDetailTask, openFileInBrowser, openSettings: (section?: string) => openSettingsWithNav(section as SectionId), onOpenUsage: openUsageWithNav, onOpenActivityLog: openActivityLogWithNav, onOpenGitHubImport: openGitHubImportWithNav, onOpenGitManager: openGitManagerWithNav, onOpenSchedules: openSchedulesWithNav, onSendSelectionToTask: modalManager.openNewTaskWithDescription, onCreateTaskFromInsight: handleInsightTaskCreate, onNavigateToMission: handleOpenMission, onTaskCreated: (task: Task) => ingestCreatedTasks([task]), prAuthAvailable, autoMerge, visibilityOptions: { experimentalFeatures: { insights: insightsEnabled, memoryView: memoryEnabled, devServerView: devServerEnabled, researchView: researchEnabled, evalsView: evalsEnabled, goalsView: goalsEnabled }, showSkillsTab: skillsEnabled, todosEnabled, pluginDashboardViews }, footerVisible: executorFooterVisible });
return (
<NavigationHistoryProvider value={{ pushNav, replaceCurrent, removeNav }}>

View File

@@ -7,7 +7,7 @@ import "./Board.css";
import type { ToastType } from "../hooks/useToast";
import { useState, useMemo, useEffect, useCallback, useRef } from "react";
import { createPortal } from "react-dom";
import { fetchWorkflowSteps, promoteTask, type ModelInfo, type BoardWorkflowsPayload } from "../api";
import { promoteTask, type ModelInfo, type BoardWorkflowsPayload } from "../api";
import { useBlockerFanout } from "../hooks/useBlockerFanout";
import { MOBILE_MEDIA_QUERY, useViewportMode } from "../hooks/useViewportMode";
import { recordResumeEvent } from "../utils/resumeInstrumentation";
@@ -89,7 +89,6 @@ function areTaskArraysEqual(previous: Task[], next: Task[]): boolean {
return previous.every((task, index) => task === next[index]);
}
const EMPTY_WORKFLOW_STEP_NAME_LOOKUP: ReadonlyMap<string, string> = new Map();
let boardWasPreviouslyInactive = false;
// Real mobile browsers can pan the document horizontally while focusing/clicking
@@ -123,14 +122,6 @@ function scheduleDocumentHorizontalScrollReset() {
setTimeout(run, 0);
}
function areWorkflowNameLookupsEqual(previous: ReadonlyMap<string, string>, next: ReadonlyMap<string, string>): boolean {
if (previous.size !== next.size) return false;
for (const [key, value] of previous) {
if (next.get(key) !== value) return false;
}
return true;
}
function BoardWorkflowSkeleton({ empty = false }: { empty?: boolean }) {
return (
<main className="board board-workflows-skeleton" id="board" aria-busy={!empty} aria-label={empty ? "No workflow lanes available" : "Loading workflow lanes"} data-testid={empty ? "board-workflows-empty" : "board-workflows-skeleton"}>
@@ -148,7 +139,6 @@ function BoardWorkflowSkeleton({ empty = false }: { empty?: boolean }) {
export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask, onOpenDetail, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, onLoadArchivedTasks, searchQuery = "", availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, taskStuckTimeoutMs, onOpenMission, staleHighFanoutBlockerAgeThresholdMs, lastFetchTimeMs, prAuthAvailable, onOpenWorkflowEditor, onCreateWorkflow, workflowColumnsEnabled, settingsLoaded, workflowControlsInHeader = false }: BoardProps) {
const [archivedCollapsed, setArchivedCollapsed] = useState(true);
const archivedLoadedRef = useRef(false);
const [workflowStepNameLookup, setWorkflowStepNameLookup] = useState<ReadonlyMap<string, string>>(EMPTY_WORKFLOW_STEP_NAME_LOOKUP);
const boardRef = useRef<HTMLElement | null>(null);
const [headerWorkflowSlot, setHeaderWorkflowSlot] = useState<HTMLElement | null>(() => {
if (typeof document === "undefined") return null;
@@ -242,28 +232,6 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
return stableGrouped;
}, [tasks]);
useEffect(() => {
let cancelled = false;
fetchWorkflowSteps(projectId)
.then((steps) => {
if (cancelled) return;
const nextLookup = new Map(steps.map((step) => [step.id, step.name] as const));
setWorkflowStepNameLookup((previous) => (
areWorkflowNameLookupsEqual(previous, nextLookup) ? previous : nextLookup
));
})
.catch(() => {
if (cancelled) return;
setWorkflowStepNameLookup((previous) => (previous.size === 0 ? previous : EMPTY_WORKFLOW_STEP_NAME_LOOKUP));
});
return () => {
cancelled = true;
};
}, [projectId]);
// FN-4574 + FN-001 diagnosis: on iOS Safari, the mobile board can occasionally
// snap against stale layout/visualViewport metrics before flex columns resolve,
// both on initial mount and on pageshow/bfcache restore after backgrounding.
@@ -602,7 +570,6 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
taskStuckTimeoutMs={taskStuckTimeoutMs}
onOpenMission={onOpenMission}
lastFetchTimeMs={lastFetchTimeMs}
workflowStepNameLookup={workflowStepNameLookup}
taskCardFieldDefs={taskCardFieldDefs}
blockerFanoutMap={blockerFanoutMap}
prAuthAvailable={prAuthAvailable}
@@ -649,7 +616,6 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
taskStuckTimeoutMs={taskStuckTimeoutMs}
onOpenMission={onOpenMission}
lastFetchTimeMs={lastFetchTimeMs}
workflowStepNameLookup={workflowStepNameLookup}
taskCardFieldDefs={taskCardFieldDefs}
blockerFanoutMap={blockerFanoutMap}
prAuthAvailable={prAuthAvailable}
@@ -695,7 +661,6 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
taskStuckTimeoutMs={taskStuckTimeoutMs}
onOpenMission={onOpenMission}
lastFetchTimeMs={lastFetchTimeMs}
workflowStepNameLookup={workflowStepNameLookup}
taskCardFieldDefs={taskCardFieldDefs}
blockerFanoutMap={blockerFanoutMap}
prAuthAvailable={prAuthAvailable}

View File

@@ -139,8 +139,6 @@ interface ColumnProps {
onOpenMission?: (missionId: string) => void;
/** Timestamp (ms) when task data was last confirmed fresh from the server. Used for freshness-aware stuck detection. */
lastFetchTimeMs?: number;
/** Lookup of workflow step IDs to display names, fetched once at board level. */
workflowStepNameLookup?: ReadonlyMap<string, string>;
/** Per-task card-placed custom field definitions (U13/KTD-14). */
taskCardFieldDefs?: ReadonlyMap<string, import("../api").WorkflowFieldDefinition[]>;
/** Precomputed blocker fanout keyed by blocker task ID. */
@@ -173,7 +171,7 @@ interface ColumnProps {
getDraggingTaskId?: () => string | null;
}
function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask, onPauseTask, onOpenDetail, onOpenGroupModal, 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, taskCardFieldDefs, blockerFanoutMap, prAuthAvailable, workflowMode, workflowId, columnDisplayName, columnFlags, onPromote, canDropTask, getDraggingTaskId }: ColumnProps) {
function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask, onPauseTask, onOpenDetail, onOpenGroupModal, 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, taskCardFieldDefs, blockerFanoutMap, prAuthAvailable, workflowMode, workflowId, columnDisplayName, columnFlags, onPromote, canDropTask, getDraggingTaskId }: ColumnProps) {
const { t } = useTranslation("app");
// Anchor the board.rejection.* catalog keys for the i18next extractor (it
// scopes `t` to the useTranslation binding, so the shared translateRejection
@@ -728,7 +726,6 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
taskStuckTimeoutMs={taskStuckTimeoutMs}
onOpenMission={onOpenMission}
lastFetchTimeMs={lastFetchTimeMs}
workflowStepNameLookup={workflowStepNameLookup}
taskCardFieldDefs={taskCardFieldDefs}
blockerFanoutMap={blockerFanoutMap}
prAuthAvailable={prAuthAvailable}
@@ -762,7 +759,6 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
onPromote={isHoldColumn && onPromote ? handlePromote : undefined}
isPromoting={isHoldColumn && onPromote ? promotingIds.has(task.id) : undefined}
lastFetchTimeMs={lastFetchTimeMs}
workflowStepNameLookup={workflowStepNameLookup}
cardFieldDefs={taskCardFieldDefs?.get(task.id)}
fanout={blockerFanoutMap?.get(task.id)}
prAuthAvailable={prAuthAvailable}

View File

@@ -66,7 +66,6 @@ export interface LaneProps {
taskStuckTimeoutMs?: number;
onOpenMission?: (missionId: string) => void;
lastFetchTimeMs?: number;
workflowStepNameLookup?: ReadonlyMap<string, string>;
/** Per-task card-placed custom field definitions (U13/KTD-14). */
taskCardFieldDefs?: ReadonlyMap<string, import("../api").WorkflowFieldDefinition[]>;
blockerFanoutMap?: ReadonlyMap<string, BlockerFanoutEntry>;
@@ -202,7 +201,6 @@ function LaneComponent(props: LaneProps) {
taskStuckTimeoutMs={props.taskStuckTimeoutMs}
onOpenMission={props.onOpenMission}
lastFetchTimeMs={props.lastFetchTimeMs}
workflowStepNameLookup={props.workflowStepNameLookup}
taskCardFieldDefs={props.taskCardFieldDefs}
blockerFanoutMap={props.blockerFanoutMap}
prAuthAvailable={props.prAuthAvailable}

View File

@@ -1045,14 +1045,24 @@ The execution-time badge is part of the footer's bottom-right chip cluster, so i
background: var(--text-dim);
}
/*
FNXC:WorkflowSteps 2026-06-25-00:00:
`failed` is a blocking gate failure (red/error); `advisory_failure` is a non-blocking REVISE that
returned feedback but does not block merge (amber/warning). `running` is a graph node actively
executing. These map 1:1 to the unified progress status so the dot color encodes blocking-ness.
*/
.card-step-dot--failed {
background: var(--color-error-dark);
}
.card-step-dot--workflow-failed {
.card-step-dot--advisory_failure {
background: var(--ws-warning);
}
.card-step-dot--running {
background: var(--todo);
}
.card-step-name {
flex: 1;
min-width: 0;

View File

@@ -401,8 +401,6 @@ interface TaskCardProps {
isPromoting?: boolean;
/** Timestamp (ms) when task data was last confirmed fresh from the server. Used for freshness-aware stuck detection. */
lastFetchTimeMs?: number;
/** Lookup of workflow step IDs to display names, fetched once at board level. */
workflowStepNameLookup?: ReadonlyMap<string, string>;
/** Disable card drag semantics when embedding in custom draggable containers (e.g. dependency graph). */
disableDrag?: boolean;
/** Downstream fan-out entry for this task, computed at board-level. */
@@ -597,7 +595,6 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo
previous.onMoveTask === next.onMoveTask &&
previous.onPromote === next.onPromote &&
previous.isPromoting === next.isPromoting &&
previous.workflowStepNameLookup === next.workflowStepNameLookup &&
previous.disableDrag === next.disableDrag &&
previous.fanout?.totalCount === next.fanout?.totalCount &&
previous.fanout?.activeTodoCount === next.fanout?.activeTodoCount &&
@@ -707,7 +704,6 @@ function TaskCardComponent({
onPromote,
isPromoting = false,
lastFetchTimeMs,
workflowStepNameLookup,
disableDrag,
fanout,
prAuthAvailable,
@@ -1072,8 +1068,8 @@ function TaskCardComponent({
return providers;
}, [task.modelProvider, task.validatorModelProvider, task.planningModelProvider]);
const unifiedProgress = useMemo(
() => getUnifiedTaskProgress(task, workflowStepNameLookup),
[task.steps, task.enabledWorkflowSteps, task.workflowStepResults, workflowStepNameLookup],
() => getUnifiedTaskProgress(task),
[task.steps, task.enabledWorkflowSteps, task.workflowStepResults],
);
const showProgressSection =
unifiedProgress.total > 0 && (task.status === "executing" || task.column === "in-progress");
@@ -2283,12 +2279,18 @@ function TaskCardComponent({
{showSteps && (
<div className="card-steps-list">
{unifiedProgress.items.map((step) => {
const isWorkflowFailed = step.source === "workflow" && step.status === "failed";
/*
FNXC:WorkflowSteps 2026-06-25-00:00:
The dot color is keyed by the unified status, which now distinguishes the two
workflow-failure modes: `advisory_failure` (non-blocking REVISE → amber/warning) vs
`failed` (blocking gate failure → red/error). `running` shows the in-progress color.
No `card-step-dot--workflow-failed` override is needed — the status class carries the
distinction directly.
*/
return (
<div key={step.id} className="card-step-item">
<span
className={`card-step-dot card-step-dot--${step.status}${isWorkflowFailed ? " card-step-dot--workflow-failed" : ""}`}
className={`card-step-dot card-step-dot--${step.status}`}
aria-hidden="true"
/>
<span className={`card-step-name${step.status === "done" ? " completed" : ""}`}>

View File

@@ -28,8 +28,6 @@ interface WorktreeGroupProps {
onOpenMission?: (missionId: string) => void;
/** Timestamp (ms) when task data was last confirmed fresh from the server. Used for freshness-aware stuck detection. */
lastFetchTimeMs?: number;
/** Lookup of workflow step IDs to display names, fetched once at board level. */
workflowStepNameLookup?: ReadonlyMap<string, string>;
/** Per-task card-placed custom field definitions (U13/KTD-14). */
taskCardFieldDefs?: ReadonlyMap<string, import("../api").WorkflowFieldDefinition[]>;
/** Precomputed blocker fanout keyed by blocker task ID. */
@@ -55,7 +53,6 @@ function WorktreeGroupComponent({
taskStuckTimeoutMs,
onOpenMission,
lastFetchTimeMs,
workflowStepNameLookup,
taskCardFieldDefs,
blockerFanoutMap,
prAuthAvailable,
@@ -92,7 +89,6 @@ function WorktreeGroupComponent({
taskStuckTimeoutMs={taskStuckTimeoutMs}
onOpenMission={onOpenMission}
lastFetchTimeMs={lastFetchTimeMs}
workflowStepNameLookup={workflowStepNameLookup}
cardFieldDefs={taskCardFieldDefs?.get(task.id)}
fanout={blockerFanoutMap?.get(task.id)}
prAuthAvailable={prAuthAvailable}
@@ -115,7 +111,6 @@ function WorktreeGroupComponent({
taskStuckTimeoutMs={taskStuckTimeoutMs}
onOpenMission={onOpenMission}
lastFetchTimeMs={lastFetchTimeMs}
workflowStepNameLookup={workflowStepNameLookup}
cardFieldDefs={taskCardFieldDefs?.get(task.id)}
fanout={blockerFanoutMap?.get(task.id)}
prAuthAvailable={prAuthAvailable}

View File

@@ -48,10 +48,10 @@ const columnRenderCounts: Record<string, number> = {};
// Mock child components so we only test Board's own rendering
vi.mock("../Column", () => ({
Column: React.memo(({ column, tasks, collapsed, onToggleCollapse, onQuickCreate, onNewTask, onToggleAutoMerge, onArchiveAllDone, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, workflowStepNameLookup }: { column: string; tasks: Task[]; collapsed?: boolean; onToggleCollapse?: () => void; onQuickCreate?: unknown; onNewTask?: unknown; onToggleAutoMerge?: () => void; onArchiveAllDone?: unknown; favoriteProviders?: string[]; favoriteModels?: string[]; onToggleFavorite?: (provider: string) => void; onToggleModelFavorite?: (modelId: string) => void; isSearchActive?: boolean; workflowStepNameLookup?: ReadonlyMap<string, string> }) => {
Column: React.memo(({ column, tasks, collapsed, onToggleCollapse, onQuickCreate, onNewTask, onToggleAutoMerge, onArchiveAllDone, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive }: { column: string; tasks: Task[]; collapsed?: boolean; onToggleCollapse?: () => void; onQuickCreate?: unknown; onNewTask?: unknown; onToggleAutoMerge?: () => void; onArchiveAllDone?: unknown; favoriteProviders?: string[]; favoriteModels?: string[]; onToggleFavorite?: (provider: string) => void; onToggleModelFavorite?: (modelId: string) => void; isSearchActive?: boolean }) => {
columnRenderCounts[column] = (columnRenderCounts[column] ?? 0) + 1;
return (
<div data-testid={`column-${column}`} data-tasks={JSON.stringify(tasks)} data-collapsed={collapsed ? "true" : "false"} data-has-quick-create={onQuickCreate ? "yes" : "no"} data-has-new-task={onNewTask ? "yes" : "no"} data-has-auto-merge-toggle={onToggleAutoMerge ? "yes" : "no"} data-has-archive-all={onArchiveAllDone ? "yes" : "no"} data-favorite-providers={JSON.stringify(favoriteProviders ?? [])} data-favorite-models={JSON.stringify(favoriteModels ?? [])} data-has-toggle-favorite={onToggleFavorite ? "yes" : "no"} data-has-toggle-model-favorite={onToggleModelFavorite ? "yes" : "no"} data-is-search-active={isSearchActive ? "true" : "false"} data-workflow-lookup-size={String(workflowStepNameLookup?.size ?? 0)}>
<div data-testid={`column-${column}`} data-tasks={JSON.stringify(tasks)} data-collapsed={collapsed ? "true" : "false"} data-has-quick-create={onQuickCreate ? "yes" : "no"} data-has-new-task={onNewTask ? "yes" : "no"} data-has-auto-merge-toggle={onToggleAutoMerge ? "yes" : "no"} data-has-archive-all={onArchiveAllDone ? "yes" : "no"} data-favorite-providers={JSON.stringify(favoriteProviders ?? [])} data-favorite-models={JSON.stringify(favoriteModels ?? [])} data-has-toggle-favorite={onToggleFavorite ? "yes" : "no"} data-has-toggle-model-favorite={onToggleModelFavorite ? "yes" : "no"} data-is-search-active={isSearchActive ? "true" : "false"}>
{onToggleCollapse && <button onClick={onToggleCollapse}>toggle-{column}</button>}
</div>
);
@@ -230,20 +230,6 @@ describe("Board", () => {
expect(triageTasks[0]?.id).toBe("FN-404");
});
it("forwards board-level workflow name lookup to columns", async () => {
fetchWorkflowStepsMock.mockResolvedValue([
{ id: "WS-003", name: "Accessibility Audit", enabled: true },
]);
renderBoard();
await waitFor(() => {
for (const col of COLUMNS) {
const columnEl = screen.getByTestId(`column-${col}`);
expect(columnEl.getAttribute("data-workflow-lookup-size")).toBe("1");
}
});
});
it("renders all 6 columns as direct children of .board (CSS selector target)", () => {
renderBoard();
const board = screen.getByRole("main");

View File

@@ -9,10 +9,10 @@ import type { Task, Column as ColumnType } from "@fusion/core";
const taskCardRenderSpy = vi.fn();
vi.mock("../TaskCard", () => ({
TaskCard: React.memo(({ task, workflowStepNameLookup, onPromote, isPromoting }: { task: Task; workflowStepNameLookup?: ReadonlyMap<string, string>; onPromote?: (taskId: string) => Promise<void>; isPromoting?: boolean }) => {
TaskCard: React.memo(({ task, onPromote, isPromoting }: { task: Task; onPromote?: (taskId: string) => Promise<void>; isPromoting?: boolean }) => {
taskCardRenderSpy(task.id);
return (
<div data-testid={`task-${task.id}`} data-workflow-lookup-size={String(workflowStepNameLookup?.size ?? 0)}>
<div data-testid={`task-${task.id}`}>
{onPromote && (
<button type="button" data-testid={`card-promote-${task.id}`} disabled={isPromoting} onClick={() => void onPromote(task.id)}>
{isPromoting ? "Promoting…" : "Promote"}
@@ -23,8 +23,8 @@ vi.mock("../TaskCard", () => ({
}),
}));
vi.mock("../WorktreeGroup", () => ({
WorktreeGroup: ({ workflowStepNameLookup }: { workflowStepNameLookup?: ReadonlyMap<string, string> }) => (
<div data-testid="worktree-group" data-workflow-lookup-size={String(workflowStepNameLookup?.size ?? 0)} />
WorktreeGroup: () => (
<div data-testid="worktree-group" />
),
}));
vi.mock("../QuickEntryBox", () => ({
@@ -255,31 +255,6 @@ describe("Column memoization", () => {
expect(taskCardRenderSpy).toHaveBeenCalledTimes(1);
});
it("forwards workflowStepNameLookup to task cards", () => {
render(
<Column
{...defaultProps}
column="todo"
tasks={[{ ...makeTask("FN-001"), column: "todo" as ColumnType }]}
workflowStepNameLookup={new Map([["WS-003", "Accessibility Audit"]])}
/>,
);
expect(screen.getByTestId("task-FN-001").getAttribute("data-workflow-lookup-size")).toBe("1");
});
it("forwards workflowStepNameLookup to in-progress worktree groups", () => {
render(
<Column
{...defaultProps}
column="in-progress"
tasks={[{ ...makeTask("FN-001"), column: "in-progress" as ColumnType, worktree: "wt-1" }]}
workflowStepNameLookup={new Map([["WS-003", "Accessibility Audit"]])}
/>,
);
expect(screen.getByTestId("worktree-group").getAttribute("data-workflow-lookup-size")).toBe("1");
});
});
describe("Column pagination", () => {

View File

@@ -387,7 +387,6 @@ describe("RightDock", () => {
onCreateTaskFromInsight: vi.fn(),
onNavigateToMission: vi.fn(),
onTaskCreated: vi.fn(),
workflowStepNameLookup: new Map<string, string>(),
prAuthAvailable: false,
autoMerge: false,
visibilityOptions: {},
@@ -445,7 +444,6 @@ describe("RightDock", () => {
onCreateTaskFromInsight: vi.fn(),
onNavigateToMission: vi.fn(),
onTaskCreated: vi.fn(),
workflowStepNameLookup: new Map<string, string>(),
prAuthAvailable: false,
autoMerge: false,
visibilityOptions: {},

View File

@@ -1909,7 +1909,10 @@ describe("TaskCard", () => {
expect(screen.queryByText("1 steps")).toBeNull();
});
it("renders workflow checks after normal steps with mapped statuses and phase badges", () => {
// FNXC:WorkflowSteps 2026-06-25 — graph-written results drive the card progress; names come from
// result.workflowStepName (with raw-id fallback), and advisory_failure (amber) is visually distinct
// from failed (red). No board-level name lookup is involved.
it("renders workflow checks after normal steps with graph-written statuses and phase badges", () => {
const { container } = render(
<TaskCard
task={makeTask({
@@ -1917,7 +1920,7 @@ describe("TaskCard", () => {
{ name: "Step 0", status: "done" },
{ name: "Step 1", status: "failed" as any },
],
enabledWorkflowSteps: ["WS-001", "WS-002", "WS-003"],
enabledWorkflowSteps: ["WS-001", "WS-002", "WS-003", "WS-004"],
workflowStepResults: [
{
workflowStepId: "WS-001",
@@ -1927,53 +1930,90 @@ describe("TaskCard", () => {
{
workflowStepId: "WS-002",
workflowStepName: "Frontend UX Design",
status: "failed",
status: "advisory_failure",
phase: "post-merge",
},
{
workflowStepId: "WS-004",
workflowStepName: "Code Review Gate",
status: "failed",
},
],
})}
workflowStepNameLookup={new Map([["WS-003", "Accessibility Audit"]])}
onOpenDetail={noop}
addToast={noop}
/>,
);
const stepNames = Array.from(container.querySelectorAll(".card-step-name")).map((el) => el.textContent);
// WS-003 has no result → name falls back to the raw id; all others resolve from result.workflowStepName.
expect(stepNames).toEqual([
"Step 0",
"Step 1",
"Browser Verification",
"Frontend UX Design",
"Accessibility Audit",
"WS-003",
"Code Review Gate",
]);
const dots = container.querySelectorAll(".card-step-dot");
// Impl step failure → blocking red.
expect(dots[1]?.className).toContain("card-step-dot--failed");
expect(dots[1]?.className).not.toContain("card-step-dot--workflow-failed");
expect(dots[1]?.className).not.toContain("card-step-dot--advisory_failure");
// Passed workflow step → done.
expect(dots[2]?.className).toContain("card-step-dot--done");
expect(dots[2]?.className).not.toContain("card-step-dot--workflow-failed");
expect(dots[3]?.className).toContain("card-step-dot--failed");
expect(dots[3]?.className).toContain("card-step-dot--workflow-failed");
// advisory_failure → amber, NOT the blocking red failed class.
expect(dots[3]?.className).toContain("card-step-dot--advisory_failure");
expect(dots[3]?.className).not.toContain("card-step-dot--failed");
// Enabled-but-not-run → pending.
expect(dots[4]?.className).toContain("card-step-dot--pending");
expect(dots[4]?.className).not.toContain("card-step-dot--workflow-failed");
// Gate failure → blocking red, NOT amber advisory.
expect(dots[5]?.className).toContain("card-step-dot--failed");
expect(dots[5]?.className).not.toContain("card-step-dot--advisory_failure");
const workflowBadgeElements = container.querySelectorAll(".card-step-workflow-badge");
const workflowBadges = Array.from(workflowBadgeElements).map((el) => el.textContent);
expect(workflowBadges).toEqual(["workflow", "workflow", "workflow"]);
expect(workflowBadges).toEqual(["workflow", "workflow", "workflow", "workflow"]);
expect(workflowBadgeElements[0]?.className).toContain("card-step-workflow-badge--pre-merge");
expect(workflowBadgeElements[1]?.className).toContain("card-step-workflow-badge--post-merge");
expect(workflowBadgeElements[2]?.className).toContain("card-step-workflow-badge--pre-merge");
expect(workflowBadgeElements[3]?.className).toContain("card-step-workflow-badge--pre-merge");
workflowBadgeElements.forEach((badge) => {
expect(badge.getAttribute("title")).toBe("Workflow check");
});
});
it("falls back to workflow result name, then raw ID when lookup names are unavailable", () => {
it("renders the running state for a started-but-not-completed workflow step", () => {
const { container } = render(
<TaskCard
task={makeTask({
enabledWorkflowSteps: ["WS-001"],
workflowStepResults: [
{
workflowStepId: "WS-001",
workflowStepName: "Browser Verification",
status: "pending",
startedAt: "2026-06-25T00:00:00.000Z",
},
],
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
const dots = container.querySelectorAll(".card-step-dot");
expect(dots[0]?.className).toContain("card-step-dot--running");
expect(dots[0]?.className).not.toContain("card-step-dot--pending");
});
it("falls back to the raw workflow step ID when the result name is blank", () => {
const { container } = render(
<TaskCard
task={makeTask({
@@ -1981,19 +2021,19 @@ describe("TaskCard", () => {
workflowStepResults: [
{
workflowStepId: "WS-002",
workflowStepName: "Fallback from result",
workflowStepName: " ",
status: "passed",
},
],
})}
workflowStepNameLookup={new Map([["WS-002", " "]])}
onOpenDetail={noop}
addToast={noop}
/>,
);
const stepNames = Array.from(container.querySelectorAll(".card-step-name")).map((el) => el.textContent);
expect(stepNames).toEqual(["Fallback from result", "WS-003"]);
// Blank result name → fall back to id; WS-003 (no result) → id.
expect(stepNames).toEqual(["WS-002", "WS-003"]);
});
it("shows drop indicator on file dragover and removes on dragleave", () => {

View File

@@ -68,7 +68,6 @@ export function MainContent({
subscribePluginEvents,
openDetailTask,
openFileInBrowser,
workflowStepNameLookup,
prAuthAvailable,
autoMerge,
settingsLoaded,
@@ -279,7 +278,6 @@ export function MainContent({
projectId={currentProject?.id}
onOpenDetail={(value: Task | TaskDetail) => openDetailTask(value)}
addToast={addToast}
workflowStepNameLookup={workflowStepNameLookup}
disableDrag={true}
prAuthAvailable={prAuthAvailable}
autoMergeEnabled={autoMerge}

View File

@@ -108,7 +108,6 @@ export interface MainContentProps {
options?: { origin?: DetailTaskOrigin },
) => void;
openFileInBrowser: (path: string, opts?: { workspace?: string; line?: number; col?: number }) => void;
workflowStepNameLookup: Map<string, string>;
prAuthAvailable: boolean;
autoMerge: boolean;
settingsLoaded: boolean;

View File

@@ -33,7 +33,6 @@ export interface RightDockControllerInput {
onCreateTaskFromInsight: (payload: { insightId: string; title: string; description: string }) => Promise<void> | void;
onNavigateToMission: (missionId: string) => void;
onTaskCreated: (task: Task) => void;
workflowStepNameLookup: Map<string, string>;
prAuthAvailable: boolean;
autoMerge: boolean;
visibilityOptions: OverflowViewVisibilityOptions;
@@ -104,7 +103,6 @@ export function useRightDockController(input: RightDockControllerInput): RightDo
projectId={input.projectId}
onOpenDetail={(value: Task | TaskDetail) => input.openDetailTask(value)}
addToast={input.addToast}
workflowStepNameLookup={input.workflowStepNameLookup}
disableDrag={true}
prAuthAvailable={input.prAuthAvailable}
autoMergeEnabled={input.autoMerge}

View File

@@ -0,0 +1,173 @@
import { describe, expect, it } from "vitest";
import type { Task } from "@fusion/core";
import { getUnifiedTaskProgress } from "../taskProgress";
/*
FNXC:WorkflowSteps 2026-06-25 — graph-native progress model (plan U3).
These tests pin the render-state contract that the progress bar / Workflow tab rely on:
- names resolve from result.workflowStepName (no DB-row name lookup), with a raw-id fallback;
- a "pending" result with a startedAt and no completedAt is the `running` state, vs bare `pending`;
- advisory_failure (non-blocking) is distinct from failed (blocking) and counts as completed;
- disabled optional steps (absent from enabledWorkflowSteps) never appear in the counter/bar.
*/
function makeTask(overrides: Partial<Pick<Task, "steps" | "enabledWorkflowSteps" | "workflowStepResults">>) {
return {
steps: [],
enabledWorkflowSteps: [],
workflowStepResults: [],
...overrides,
} as Pick<Task, "steps" | "enabledWorkflowSteps" | "workflowStepResults">;
}
describe("getUnifiedTaskProgress", () => {
it("resolves workflow step names from result.workflowStepName without a lookup", () => {
const progress = getUnifiedTaskProgress(
makeTask({
enabledWorkflowSteps: ["browser-verification"],
workflowStepResults: [
{
workflowStepId: "browser-verification",
workflowStepName: "Browser Verification",
status: "passed",
},
],
}),
);
const item = progress.items.find((i) => i.id === "workflow-browser-verification");
expect(item?.name).toBe("Browser Verification");
expect(item?.status).toBe("done");
});
it("falls back to the workflow step id when no result name is available", () => {
const progress = getUnifiedTaskProgress(
makeTask({
enabledWorkflowSteps: ["code-review"],
workflowStepResults: [],
}),
);
const item = progress.items.find((i) => i.id === "workflow-code-review");
expect(item?.name).toBe("code-review");
// Enabled but never run → pending.
expect(item?.status).toBe("pending");
});
it("distinguishes running (started, not completed) from pending (not started)", () => {
const progress = getUnifiedTaskProgress(
makeTask({
enabledWorkflowSteps: ["running-step", "not-started-step"],
workflowStepResults: [
{
workflowStepId: "running-step",
workflowStepName: "Running Step",
status: "pending",
startedAt: "2026-06-25T00:00:00.000Z",
},
],
}),
);
expect(progress.items.find((i) => i.id === "workflow-running-step")?.status).toBe("running");
expect(progress.items.find((i) => i.id === "workflow-not-started-step")?.status).toBe("pending");
});
it("treats a completed pending entry (startedAt + completedAt) as pending, not running", () => {
// Defensive: a terminal entry should carry a non-pending status, but if a pending entry has
// both timestamps it is not actively running.
const progress = getUnifiedTaskProgress(
makeTask({
enabledWorkflowSteps: ["edge"],
workflowStepResults: [
{
workflowStepId: "edge",
workflowStepName: "Edge",
status: "pending",
startedAt: "2026-06-25T00:00:00.000Z",
completedAt: "2026-06-25T00:01:00.000Z",
},
],
}),
);
expect(progress.items.find((i) => i.id === "workflow-edge")?.status).toBe("pending");
});
it("maps advisory_failure (non-blocking) distinctly from failed (blocking)", () => {
const progress = getUnifiedTaskProgress(
makeTask({
enabledWorkflowSteps: ["advisory", "gate"],
workflowStepResults: [
{ workflowStepId: "advisory", workflowStepName: "Advisory", status: "advisory_failure" },
{ workflowStepId: "gate", workflowStepName: "Gate", status: "failed" },
],
}),
);
expect(progress.items.find((i) => i.id === "workflow-advisory")?.status).toBe("advisory_failure");
expect(progress.items.find((i) => i.id === "workflow-gate")?.status).toBe("failed");
// advisory_failure counts as completed (non-blocking); failed does not.
expect(progress.completed).toBe(1);
});
it("excludes a disabled step (absent from enabledWorkflowSteps) from the count", () => {
const progress = getUnifiedTaskProgress(
makeTask({
// "disabled-step" is toggled off → not in enabledWorkflowSteps, even though a stale result exists.
enabledWorkflowSteps: ["code-review"],
workflowStepResults: [
{ workflowStepId: "code-review", workflowStepName: "Code Review", status: "passed" },
{ workflowStepId: "disabled-step", workflowStepName: "Disabled", status: "passed" },
],
}),
);
expect(progress.total).toBe(1);
expect(progress.items.some((i) => i.id === "workflow-disabled-step")).toBe(false);
});
it("produces 8 items with the correct completed count for 6 impl steps + 2 workflow steps", () => {
const progress = getUnifiedTaskProgress(
makeTask({
steps: [
{ name: "Step 1", status: "done" },
{ name: "Step 2", status: "done" },
{ name: "Step 3", status: "done" },
{ name: "Step 4", status: "done" },
{ name: "Step 5", status: "done" },
{ name: "Step 6", status: "done" },
],
enabledWorkflowSteps: ["browser-verification", "code-review"],
workflowStepResults: [
{ workflowStepId: "browser-verification", workflowStepName: "Browser Verification", status: "passed" },
{ workflowStepId: "code-review", workflowStepName: "Code Review", status: "advisory_failure" },
],
}),
);
expect(progress.total).toBe(8);
expect(progress.items).toHaveLength(8);
// 6 done impl steps + 1 passed (done) + 1 advisory_failure (non-blocking → completed) = 8.
expect(progress.completed).toBe(8);
expect(progress.items.filter((i) => i.source === "step")).toHaveLength(6);
expect(progress.items.filter((i) => i.source === "workflow")).toHaveLength(2);
});
it("maps impl step statuses straight through and skipped as completed", () => {
const progress = getUnifiedTaskProgress(
makeTask({
steps: [
{ name: "Done", status: "done" },
{ name: "Skipped", status: "skipped" },
{ name: "In progress", status: "in-progress" },
{ name: "Pending", status: "pending" },
],
}),
);
expect(progress.total).toBe(4);
// done + skipped count as completed; in-progress + pending do not.
expect(progress.completed).toBe(2);
});
});

View File

@@ -1,6 +1,24 @@
import type { Task, WorkflowStepResult, WorkflowStepPhase, StepStatus } from "@fusion/core";
export type UnifiedTaskProgressStatus = StepStatus | "failed";
/*
FNXC:WorkflowSteps 2026-06-25-00:00:
Graph-native workflow steps (plan U3). Workflow step status now comes entirely from the graph-written
`task.workflowStepResults` entries (keyed by node id === enabledWorkflowSteps[i]); the legacy
`/api/workflow-steps` DB-row name lookup was dropped, so step names resolve from `result.workflowStepName`
with a fallback to the raw id.
Render states (design-lens): the progress model distinguishes
- `pending` (enabled, never started — no `startedAt`)
- `running` (graph node active — `pending` status with a `startedAt` and no `completedAt`)
- `done` (passed)
- `advisory_failure` (non-blocking REVISE — amber, counts as completed; does not block merge)
- `failed` (blocking gate failure — red)
- `skipped`
Disabled optional steps are simply absent from `enabledWorkflowSteps`, so they never appear in the
counter/bar.
*/
export type UnifiedTaskProgressStatus = StepStatus | "failed" | "advisory_failure" | "running";
export interface UnifiedTaskProgressItem {
id: string;
@@ -16,46 +34,41 @@ export interface UnifiedTaskProgress {
items: UnifiedTaskProgressItem[];
}
function mapWorkflowStatus(status: WorkflowStepResult["status"]): UnifiedTaskProgressStatus {
switch (status) {
function mapWorkflowStatus(result: WorkflowStepResult): UnifiedTaskProgressStatus {
switch (result.status) {
case "passed":
return "done";
case "failed":
case "advisory_failure":
return "failed";
case "advisory_failure":
return "advisory_failure";
case "skipped":
return "skipped";
case "pending":
default:
return "pending";
// The graph upserts a `pending` entry when a step starts running. A started-but-not-completed
// entry is the in-progress/`running` display state; a bare `pending` (no `startedAt`) is an
// enabled step that has not begun yet.
return result.startedAt && !result.completedAt ? "running" : "pending";
}
}
function isCompleted(status: UnifiedTaskProgressStatus): boolean {
return status === "done" || status === "skipped";
// advisory_failure is non-blocking: the step ran and returned feedback, so it counts as completed
// (overall progress reads complete when only advisory steps returned REVISE).
return status === "done" || status === "skipped" || status === "advisory_failure";
}
function resolveWorkflowStepName(
workflowStepId: string,
result: WorkflowStepResult | undefined,
workflowStepNameLookup?: ReadonlyMap<string, string>,
): string {
const lookupName = workflowStepNameLookup?.get(workflowStepId)?.trim();
if (lookupName) {
return lookupName;
}
function resolveWorkflowStepName(workflowStepId: string, result: WorkflowStepResult | undefined): string {
const resultName = result?.workflowStepName?.trim();
if (resultName) {
return resultName;
}
return workflowStepId;
}
export function getUnifiedTaskProgress(
task: Pick<Task, "steps" | "enabledWorkflowSteps" | "workflowStepResults">,
workflowStepNameLookup?: ReadonlyMap<string, string>,
): UnifiedTaskProgress {
const stepItems: UnifiedTaskProgressItem[] = (task.steps ?? []).map((step, index) => ({
id: `step-${index}`,
@@ -73,8 +86,8 @@ export function getUnifiedTaskProgress(
const result = workflowResultsById.get(workflowStepId);
return {
id: `workflow-${workflowStepId}`,
name: resolveWorkflowStepName(workflowStepId, result, workflowStepNameLookup),
status: result ? mapWorkflowStatus(result.status) : "pending",
name: resolveWorkflowStepName(workflowStepId, result),
status: result ? mapWorkflowStatus(result) : "pending",
source: "workflow",
phase: result?.phase ?? "pre-merge",
};