feat(FN-2466): prefer workflow lookup names in progress labels

- Thread workflow name lookup data from Board into Column, TaskCard, and WorktreeGroup components
- Update unified task progress resolution to prefer workflow lookup names over raw workflow IDs
- Keep fallback behavior for missing lookup entries so progress labels remain stable
- Expand Board, Column, and TaskCard tests to cover lookup-based workflow name rendering
This commit is contained in:
Fusion
2026-04-24 09:54:00 -07:00
committed by gsxdsm
parent a4044e2b02
commit 34a47b2c95
9 changed files with 160 additions and 14 deletions

View File

@@ -4,7 +4,7 @@ import { Column } from "./Column";
import type { ToastType } from "../hooks/useToast";
import { useState, useMemo, useEffect, useCallback, useRef } from "react";
import { useBatchBadgeFetch } from "../hooks/useBatchBadgeFetch";
import type { ModelInfo } from "../api";
import { fetchWorkflowSteps, type ModelInfo } from "../api";
interface BoardProps {
tasks: Task[];
@@ -67,11 +67,22 @@ 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();
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;
}
export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, onLoadArchivedTasks, searchQuery = "", availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, taskStuckTimeoutMs, onOpenMission, lastFetchTimeMs }: BoardProps) {
const [archivedCollapsed, setArchivedCollapsed] = useState(true);
const archivedLoadedRef = useRef(false);
const { fetchBatch } = useBatchBadgeFetch(projectId);
const debounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [workflowStepNameLookup, setWorkflowStepNameLookup] = useState<ReadonlyMap<string, string>>(EMPTY_WORKFLOW_STEP_NAME_LOOKUP);
// Normalized search-active signal: trimmed and non-empty
const isSearchActive = searchQuery.trim() !== "";
const tasksByColumnCacheRef = useRef<Record<ColumnType, Task[]>>({
@@ -121,6 +132,28 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onOpenDetai
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]);
// Collect task IDs with GitHub badge info for batch fetching
const taskIdsWithBadges = useMemo(() => {
return tasks
@@ -186,6 +219,7 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onOpenDetai
taskStuckTimeoutMs={taskStuckTimeoutMs}
onOpenMission={onOpenMission}
lastFetchTimeMs={lastFetchTimeMs}
workflowStepNameLookup={workflowStepNameLookup}
{...(col === "triage" ? { onQuickCreate, onNewTask, onPlanningMode, onSubtaskBreakdown } : {})}
{...(col === "in-review" ? { autoMerge, onToggleAutoMerge } : {})}
{...(col === "done" ? { onArchiveAllDone } : {})}

View File

@@ -61,9 +61,11 @@ 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>;
}
function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, collapsed, onToggleCollapse, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, taskStuckTimeoutMs, onOpenMission, lastFetchTimeMs }: ColumnProps) {
function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, collapsed, onToggleCollapse, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, taskStuckTimeoutMs, onOpenMission, lastFetchTimeMs, workflowStepNameLookup }: ColumnProps) {
const [dragOver, setDragOver] = useState(false);
const [visibleTaskCount, setVisibleTaskCount] = useState(VISIBLE_TASKS_INITIAL);
const countFlashing = useFlashOnIncrease(tasks.length);
@@ -237,6 +239,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
taskStuckTimeoutMs={taskStuckTimeoutMs}
onOpenMission={onOpenMission}
lastFetchTimeMs={lastFetchTimeMs}
workflowStepNameLookup={workflowStepNameLookup}
/>
))
)
@@ -261,6 +264,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
onOpenMission={onOpenMission}
onMoveTask={onMoveTask}
lastFetchTimeMs={lastFetchTimeMs}
workflowStepNameLookup={workflowStepNameLookup}
/>
))}
{shouldPaginate && hiddenTaskCount > 0 && (

View File

@@ -150,6 +150,7 @@ describe("TaskCard", () => {
},
],
})}
workflowStepNameLookup={new Map([["WS-003", "Accessibility Audit"]])}
onOpenDetail={noop}
addToast={noop}
/>,
@@ -161,7 +162,7 @@ describe("TaskCard", () => {
"Step 1",
"Browser Verification",
"Frontend UX Design",
"WS-003",
"Accessibility Audit",
]);
const dots = container.querySelectorAll(".card-step-dot");
@@ -177,6 +178,23 @@ describe("TaskCard", () => {
]);
});
it("falls back to raw workflow step ID when lookup is unavailable", () => {
const { container } = render(
<TaskCard
task={makeTask({
enabledWorkflowSteps: ["WS-003"],
workflowStepResults: [],
})}
workflowStepNameLookup={new Map()}
onOpenDetail={noop}
addToast={noop}
/>,
);
const stepNames = Array.from(container.querySelectorAll(".card-step-name")).map((el) => el.textContent);
expect(stepNames).toEqual(["WS-003"]);
});
it("shows drop indicator on file dragover and removes on dragleave", () => {
const { container } = render(
<TaskCard task={makeTask()} onOpenDetail={noop} addToast={noop} />,

View File

@@ -107,6 +107,8 @@ interface TaskCardProps {
onMoveTask?: (id: string, column: Column) => Promise<Task>;
/** 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>;
}
function areTaskBadgeInfosEqual(
@@ -241,6 +243,7 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo
previous.onOpenDetailWithTab === next.onOpenDetailWithTab &&
previous.onOpenMission === next.onOpenMission &&
previous.onMoveTask === next.onMoveTask &&
previous.workflowStepNameLookup === next.workflowStepNameLookup &&
previousTask.id === nextTask.id &&
previousTask.title === nextTask.title &&
previousTask.description === nextTask.description &&
@@ -292,6 +295,7 @@ function TaskCardComponent({
onOpenMission,
onMoveTask,
lastFetchTimeMs,
workflowStepNameLookup,
}: TaskCardProps) {
const [dragging, setDragging] = useState(false);
const [fileDragOver, setFileDragOver] = useState(false);
@@ -543,8 +547,8 @@ function TaskCardComponent({
const hasGitHubBadge = Boolean(task.prInfo || task.issueInfo);
const isAgentNameLoading = Boolean(task.assignedAgentId && agentName === null);
const unifiedProgress = useMemo(
() => getUnifiedTaskProgress(task),
[task.steps, task.enabledWorkflowSteps, task.workflowStepResults],
() => getUnifiedTaskProgress(task, workflowStepNameLookup),
[task.steps, task.enabledWorkflowSteps, task.workflowStepResults, workflowStepNameLookup],
);
useEffect(() => {

View File

@@ -23,6 +23,8 @@ 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>;
}
function WorktreeGroupComponent({
@@ -38,6 +40,7 @@ function WorktreeGroupComponent({
taskStuckTimeoutMs,
onOpenMission,
lastFetchTimeMs,
workflowStepNameLookup,
}: WorktreeGroupProps) {
return (
<div className="worktree-group">
@@ -48,7 +51,7 @@ function WorktreeGroupComponent({
<span className="worktree-label">{label}</span>
</div>
{activeTasks.map((task) => (
<TaskCard key={task.id} task={task} projectId={projectId} onOpenDetail={onOpenDetail} addToast={addToast} globalPaused={globalPaused} onUpdateTask={onUpdateTask} onOpenDetailWithTab={onOpenDetailWithTab} taskStuckTimeoutMs={taskStuckTimeoutMs} onOpenMission={onOpenMission} lastFetchTimeMs={lastFetchTimeMs} />
<TaskCard key={task.id} task={task} projectId={projectId} onOpenDetail={onOpenDetail} addToast={addToast} globalPaused={globalPaused} onUpdateTask={onUpdateTask} onOpenDetailWithTab={onOpenDetailWithTab} taskStuckTimeoutMs={taskStuckTimeoutMs} onOpenMission={onOpenMission} lastFetchTimeMs={lastFetchTimeMs} workflowStepNameLookup={workflowStepNameLookup} />
))}
{queuedTasks.map((task) => (
<TaskCard
@@ -64,6 +67,7 @@ function WorktreeGroupComponent({
taskStuckTimeoutMs={taskStuckTimeoutMs}
onOpenMission={onOpenMission}
lastFetchTimeMs={lastFetchTimeMs}
workflowStepNameLookup={workflowStepNameLookup}
/>
))}
</div>

View File

@@ -1,19 +1,25 @@
import React from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { Board } from "../Board";
import { COLUMNS } from "@fusion/core";
import type { Task } from "@fusion/core";
vi.mock("../../api", () => ({
fetchWorkflowSteps: vi.fn().mockResolvedValue([
{ id: "WS-003", name: "Accessibility Audit", enabled: true },
]),
}));
const columnRenderCounts: Record<string, number> = {};
// Mock child components so we only test Board's own rendering
vi.mock("../Column", () => ({
Column: React.memo(({ column, tasks, onToggleCollapse, availableModels, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive }: { column: string; tasks: Task[]; onToggleCollapse?: () => void; availableModels?: unknown; favoriteProviders?: string[]; favoriteModels?: string[]; onToggleFavorite?: (provider: string) => void; onToggleModelFavorite?: (modelId: string) => void; isSearchActive?: boolean }) => {
Column: React.memo(({ column, tasks, onToggleCollapse, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, workflowStepNameLookup }: { column: string; tasks: Task[]; onToggleCollapse?: () => void; favoriteProviders?: string[]; favoriteModels?: string[]; onToggleFavorite?: (provider: string) => void; onToggleModelFavorite?: (modelId: string) => void; isSearchActive?: boolean; workflowStepNameLookup?: ReadonlyMap<string, string> }) => {
columnRenderCounts[column] = (columnRenderCounts[column] ?? 0) + 1;
return (
<div data-testid={`column-${column}`} data-tasks={JSON.stringify(tasks)} 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"}>
<div data-testid={`column-${column}`} data-tasks={JSON.stringify(tasks)} 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)}>
{onToggleCollapse && <button onClick={onToggleCollapse}>toggle-{column}</button>}
</div>
);
@@ -73,6 +79,17 @@ describe("Board", () => {
}
});
it("forwards board-level workflow name lookup to columns", async () => {
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,13 +9,15 @@ import type { Task, Column as ColumnType } from "@fusion/core";
const taskCardRenderSpy = vi.fn();
vi.mock("../TaskCard", () => ({
TaskCard: React.memo(({ task }: { task: Task }) => {
TaskCard: React.memo(({ task, workflowStepNameLookup }: { task: Task; workflowStepNameLookup?: ReadonlyMap<string, string> }) => {
taskCardRenderSpy(task.id);
return <div data-testid={`task-${task.id}`} />;
return <div data-testid={`task-${task.id}`} data-workflow-lookup-size={String(workflowStepNameLookup?.size ?? 0)} />;
}),
}));
vi.mock("../WorktreeGroup", () => ({
WorktreeGroup: () => <div />,
WorktreeGroup: ({ workflowStepNameLookup }: { workflowStepNameLookup?: ReadonlyMap<string, string> }) => (
<div data-testid="worktree-group" data-workflow-lookup-size={String(workflowStepNameLookup?.size ?? 0)} />
),
}));
vi.mock("../QuickEntryBox", () => ({
QuickEntryBox: ({ favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, autoExpand }: { favoriteProviders?: string[]; favoriteModels?: string[]; onToggleFavorite?: (provider: string) => void; onToggleModelFavorite?: (modelId: string) => void; autoExpand?: boolean }) => (
@@ -119,6 +121,32 @@ 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

@@ -262,6 +262,39 @@ describe("TaskCard memoization", () => {
expect(screen.getByText("1/1")).toBeDefined();
});
it("re-renders workflow labels when workflowStepNameLookup prop changes", () => {
const task = createTask({
enabledWorkflowSteps: ["WS-003"],
workflowStepResults: [],
steps: [],
});
const onOpenDetail = vi.fn();
const addToast = vi.fn();
const { rerender } = render(
<TaskCard
task={task}
workflowStepNameLookup={new Map()}
onOpenDetail={onOpenDetail}
addToast={addToast}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /show steps/i }));
expect(screen.getByText("WS-003")).toBeDefined();
rerender(
<TaskCard
task={task}
workflowStepNameLookup={new Map([["WS-003", "Accessibility Audit"]])}
onOpenDetail={onOpenDetail}
addToast={addToast}
/>,
);
expect(screen.getByText("Accessibility Audit")).toBeDefined();
});
it("re-renders when blockedBy changes", () => {
const onOpenDetail = vi.fn();
const addToast = vi.fn();

View File

@@ -34,7 +34,10 @@ function isCompleted(status: UnifiedTaskProgressStatus): boolean {
return status === "done" || status === "skipped";
}
export function getUnifiedTaskProgress(task: Pick<Task, "steps" | "enabledWorkflowSteps" | "workflowStepResults">): UnifiedTaskProgress {
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}`,
name: step.name,
@@ -49,9 +52,10 @@ export function getUnifiedTaskProgress(task: Pick<Task, "steps" | "enabledWorkfl
const workflowItems: UnifiedTaskProgressItem[] = (task.enabledWorkflowSteps ?? []).map((workflowStepId) => {
const result = workflowResultsById.get(workflowStepId);
const lookupName = workflowStepNameLookup?.get(workflowStepId);
return {
id: `workflow-${workflowStepId}`,
name: result?.workflowStepName || workflowStepId,
name: lookupName ?? result?.workflowStepName ?? workflowStepId,
status: result ? mapWorkflowStatus(result.status) : "pending",
source: "workflow",
phase: result?.phase ?? "pre-merge",