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