FN-7242: add all-workflows board view and workflow badges
Adds an aggregate workflow board mode with workflow-name context on mixed-workflow task surfaces. - Add a dashboard-only All workflows switcher option that unions visible workflow columns without persisting the sentinel as a real workflow selection. - Thread workflow metadata into board cards, worktree groups, and task detail headers so mixed workflow views show the task workflow name. - Update workflow status counts, quick-create targeting, docs, changesets, and regression coverage for aggregate board and badge behavior. Files changed: .changeset/fn-7242-all-workflows-board.md | 7 + .changeset/fn-7242-task-detail-workflow-badge.md | 7 + .changeset/fn-7242-workflow-badges.md | 7 + docs/dashboard-guide.md | 8 +- .../workflow-selection-cross-surface.test.tsx | 6 +- packages/dashboard/app/components/Board.tsx | 295 +++++++++++++++++++-- packages/dashboard/app/components/Column.tsx | 6 +- packages/dashboard/app/components/TaskCard.css | 16 +- packages/dashboard/app/components/TaskCard.tsx | 23 +- .../dashboard/app/components/TaskDetailModal.css | 14 +- .../dashboard/app/components/TaskDetailModal.tsx | 43 ++- .../dashboard/app/components/WorkflowSwitcher.tsx | 38 ++- .../dashboard/app/components/WorktreeGroup.tsx | 5 + .../app/components/__tests__/Board.test.tsx | 192 +++++++++++++- .../__tests__/TaskCard.badge-wrap.test.tsx | 2 + .../app/components/__tests__/TaskCard.test.tsx | 38 +++ .../__tests__/TaskDetailModal.rendering.test.tsx | 161 ++++++++++- .../components/__tests__/WorkflowSwitcher.test.tsx | 50 ++++ .../__tests__/workflowStatusCounts.test.ts | 26 +- .../app/components/workflowStatusCounts.ts | 16 +- 20 files changed, 887 insertions(+), 73 deletions(-) Fusion-Task-Id: FN-7242 Fusion-Task-Lineage: 7ff9d35a-83ef-406c-88f4-a8846edad381 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7242-all-workflows-board.md
Normal file
7
.changeset/fn-7242-all-workflows-board.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: Add a Board dropdown option that shows tasks across all workflows.
|
||||
category: feature
|
||||
dev: Uses a dashboard-only aggregate workflow sentinel that is not sent to workflow APIs or durable selection.
|
||||
7
.changeset/fn-7242-task-detail-workflow-badge.md
Normal file
7
.changeset/fn-7242-task-detail-workflow-badge.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Show workflow names on aggregate board cards and task detail headers when available.
|
||||
category: feature
|
||||
dev: Reuses the board-workflows payload for detail custom fields and workflow-name badges.
|
||||
7
.changeset/fn-7242-workflow-badges.md
Normal file
7
.changeset/fn-7242-workflow-badges.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: Show workflow-name badges on Board cards in the All workflows view.
|
||||
category: feature
|
||||
dev: Adds aggregate Board task-card workflow metadata threading through Column and WorktreeGroup.
|
||||
@@ -139,7 +139,10 @@ Features:
|
||||
- Done-column sorting has two descending modes: **Completion date (newest first)** keeps the default completion-time order, while **Task ID (newest first)** places the highest numeric task IDs first. The selector is only shown on Done/complete columns, including custom workflow completion lanes.
|
||||
- On mobile, both default and workflow-mode boards fill the project viewport while the column strip remains the internal horizontal scroller with contained edge overscroll.
|
||||
<!-- FNXC:WorkflowSelection 2026-06-29-13:34: Board, List, Header, and Graph workflow selectors now share a durable per-project selection so operators return to the same lane after remounts, task refreshes, or respecification flows; stale saved workflow ids must fall back to a valid default/first workflow instead of hiding all tasks. -->
|
||||
- Board and List workflow switchers use a themed dropdown instead of a native select. The closed trigger shows the workflow name and chevron only; compact Todo / In Progress / Done counts derived from workflow column flags (excluding archived columns) refresh each time the dropdown opens and appear while the dropdown is expanded, including on each workflow option. Built-in lanes with synthesized trait-less lifecycle columns fall back to canonical column ids (`todo`, `in-progress`, `done`, and `archived`) for those counts. Each option row also exposes an inline edit action, and a persistent **New workflow** footer stays visible below the scrollable option list. The open listbox grows from the longest workflow name plus its count/edit decorations while remaining viewport-bounded; the closed trigger stays narrow and ellipsized. Those inline count badges intentionally use the same board column color tokens as cards: `--todo`, `--in-progress`, and `--done`.
|
||||
<!-- FNXC:WorkflowSelection 2026-06-29-18:37: The Board-only All workflows option renders an aggregate column set across workflows while keeping workflow-specific creates, edits, and durable selection scoped to real workflow ids. -->
|
||||
<!-- FNXC:WorkflowSelection 2026-06-29-23:58: All workflows quick-create must use a real workflow intake/default column rather than a synthesized lifecycle column, so custom-default boards do not create tasks into invalid or disappearing columns. -->
|
||||
<!-- FNXC:WorkflowSelection 2026-06-29-23:59: Workflow counts and All workflows grouping resolve each task's effective workflow before evaluating column visibility, so a shared column id hidden in one workflow does not leak that workflow's hidden tasks into another workflow's visible aggregate lane. -->
|
||||
- Board and List workflow switchers use a themed dropdown instead of a native select. The closed trigger shows the workflow name and chevron only; compact Todo / In Progress / Done counts derived from workflow column flags (excluding archived and board-hidden columns) refresh each time the dropdown opens and appear while the dropdown is expanded, including on each workflow option. Built-in lanes with synthesized trait-less lifecycle columns fall back to canonical column ids (`todo`, `in-progress`, `done`, and `archived`) for those counts. Board also shows **All workflows** before real workflows as a dashboard-only aggregate view with combined counts and a deterministic union of visible workflow columns; shared column ids use the default workflow label/flags when available, otherwise the first workflow definition that declares the column. That option is not editable and is not saved as the durable selected workflow, and its quick-create affordance appears only on the chosen real workflow intake/default column so task creation still sends a real workflow id and column. Each real workflow option row also exposes an inline edit action, and a persistent **New workflow** footer stays visible below the scrollable option list. The open listbox grows from the longest workflow name plus its count/edit decorations while remaining viewport-bounded; the closed trigger stays narrow and ellipsized. Those inline count badges intentionally use the same board column color tokens as cards: `--todo`, `--in-progress`, and `--done`.
|
||||
- When workflow columns are enabled, Board and List hydrate the last successful workflow-lane payload from a per-project session cache; cold loads show a neutral skeleton until settings and workflow metadata are known, avoiding a legacy single-lane flash. The selected workflow is remembered per project in durable browser storage and restored when returning to Board/List after task refreshes, route changes, or respecification flows; if that saved workflow is later deleted, Fusion falls back to a valid default/first workflow so tasks remain visible.
|
||||
|
||||

|
||||
@@ -998,7 +1001,8 @@ Inspect task definition, logs, review feedback, comments, artifacts, workflow ou
|
||||
- The priority chip in task metadata is an inline picker: you can change priority directly without entering full edit mode.
|
||||
- Execution mode has a read-mode inline lightning-bolt toggle for Fast mode on/off without opening the full edit form.
|
||||
- These two metadata controls share matched sizing/alignment in read mode (including mobile wrapping) so they behave like a single polished control group.
|
||||
- Task metadata keeps priority, execution mode, provenance, optional PR context, and compact `Created` / `Updated` timestamps in one wrapping row across desktop and mobile widths; recent timestamps render as relative time (`just now`, `Xm`, `Xh`, `Xd`) and older values switch to short month/day dates.
|
||||
<!-- FNXC:TaskDetailWorkflowBadge 2026-06-29-18:45: Task Detail header metadata shows the resolved workflow name when board-workflows metadata is available, but omits the chip entirely for missing or stale workflow payloads so embedded, modal, and mobile headers do not render empty badge shells. -->
|
||||
- Task metadata keeps priority, execution mode, provenance, optional workflow name, optional PR context, and compact `Created` / `Updated` timestamps in one wrapping row across desktop and mobile widths; recent timestamps render as relative time (`just now`, `Xm`, `Xh`, `Xd`) and older values switch to short month/day dates.
|
||||
- The **Actions** menu exposes **Pause** / **Unpause** for eligible non-terminal tasks, including tasks assigned to agents. If a task was paused by an agent, the **Paused by agent** note is informational; users can still unpause it manually from the same menu.
|
||||
- After delete confirmations are complete, Task Detail closes immediately while the delete request finishes in the background; success and error outcomes still appear as toasts.
|
||||
- Eligible existing tasks (triage, todo, in-progress, in-review) expose a **GitHub tracking** section directly in Task Detail, even when tracking is currently disabled.
|
||||
|
||||
@@ -202,7 +202,7 @@ describe("workflow selection across dashboard surfaces", () => {
|
||||
await waitFor(() => expect(fetchBoardWorkflowsMock).toHaveBeenCalledWith("project-disabled"));
|
||||
expect(screen.queryByTestId("workflow-switcher")).toBeNull();
|
||||
expect(screen.getByTestId("header-workflow-slot")).toBeEmptyDOMElement();
|
||||
expect(localStorage.getItem("kb:project-disabled:kb-dashboard-board-workflow-selection")).toBeNull();
|
||||
await waitFor(() => expect(localStorage.getItem("kb:project-disabled:kb-dashboard-board-workflow-selection")).toBeNull());
|
||||
for (const task of TASKS) {
|
||||
expect(screen.getByTestId(`graph-task-${task.id}`)).toBeInTheDocument();
|
||||
}
|
||||
@@ -215,7 +215,7 @@ describe("workflow selection across dashboard surfaces", () => {
|
||||
await waitFor(() => expect(fetchBoardWorkflowsMock).toHaveBeenCalledWith("project-empty"));
|
||||
expect(screen.queryByTestId("workflow-switcher")).toBeNull();
|
||||
expect(screen.getByTestId("header-workflow-slot")).toBeEmptyDOMElement();
|
||||
expect(localStorage.getItem("kb:project-empty:kb-dashboard-board-workflow-selection")).toBeNull();
|
||||
await waitFor(() => expect(localStorage.getItem("kb:project-empty:kb-dashboard-board-workflow-selection")).toBeNull());
|
||||
empty.unmount();
|
||||
|
||||
sessionStorage.clear();
|
||||
@@ -225,6 +225,6 @@ describe("workflow selection across dashboard surfaces", () => {
|
||||
await waitFor(() => expect(fetchBoardWorkflowsMock).toHaveBeenCalledWith("project-single"));
|
||||
expect(screen.queryByTestId("workflow-switcher")).toBeNull();
|
||||
expect(screen.getByTestId("header-workflow-slot")).toBeEmptyDOMElement();
|
||||
expect(localStorage.getItem("kb:project-single:kb-dashboard-board-workflow-selection")).toBeNull();
|
||||
await waitFor(() => expect(localStorage.getItem("kb:project-single:kb-dashboard-board-workflow-selection")).toBeNull());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,13 +7,13 @@ import "./Board.css";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { useState, useMemo, useEffect, useCallback, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { promoteTask, type ModelInfo, type BoardWorkflowsPayload } from "../api";
|
||||
import { promoteTask, type ModelInfo, type BoardWorkflowsPayload, type BoardWorkflowColumn } from "../api";
|
||||
import { useBlockerFanout } from "../hooks/useBlockerFanout";
|
||||
import { MOBILE_MEDIA_QUERY, useViewportMode } from "../hooks/useViewportMode";
|
||||
import { recordResumeEvent } from "../utils/resumeInstrumentation";
|
||||
import { getBoardCanDropTaskRejection } from "./boardCanDropTask";
|
||||
import { WorkflowSwitcher } from "./WorkflowSwitcher";
|
||||
import { computeWorkflowStatusCounts } from "./workflowStatusCounts";
|
||||
import { computeWorkflowStatusCounts, type WorkflowStatusCounts } from "./workflowStatusCounts";
|
||||
import { writeBoardWorkflowsCache } from "../utils/boardWorkflowsCache";
|
||||
import { useBoardWorkflows } from "../hooks/useBoardWorkflows";
|
||||
|
||||
@@ -123,6 +123,11 @@ function scheduleDocumentHorizontalScrollReset() {
|
||||
setTimeout(run, 0);
|
||||
}
|
||||
|
||||
export const ALL_WORKFLOWS_BOARD_VIEW_ID = "__all_workflows__";
|
||||
|
||||
type AggregateBoardColumn = BoardWorkflowColumn & { sourceWorkflowIds: string[] };
|
||||
type AggregateQuickCreateTarget = { columnId: string; workflowId: string };
|
||||
|
||||
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"}>
|
||||
@@ -144,6 +149,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
|
||||
Board owns one Done sort mode so legacy and built-in workflow Done surfaces stay in sync; the default remains completion-date descending to preserve existing first-load ordering.
|
||||
*/
|
||||
const [doneSortMode, setDoneSortMode] = useState<DoneColumnSortMode>("completion-date-desc");
|
||||
const [isAllWorkflowsViewSelected, setIsAllWorkflowsViewSelected] = useState(false);
|
||||
const archivedLoadedRef = useRef(false);
|
||||
const boardRef = useRef<HTMLElement | null>(null);
|
||||
const [headerWorkflowSlot, setHeaderWorkflowSlot] = useState<HTMLElement | null>(() => {
|
||||
@@ -352,6 +358,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
|
||||
workflowMode,
|
||||
workflowOptions,
|
||||
selectedWorkflow,
|
||||
selectedWorkflowId,
|
||||
setSelectedWorkflowId,
|
||||
refreshBoardWorkflows,
|
||||
setBoardWorkflowsState,
|
||||
@@ -371,18 +378,61 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
|
||||
|
||||
const getDraggingTaskId = useCallback(() => draggingTaskIdRef.current, []);
|
||||
|
||||
const workflowStatusCounts = useMemo(
|
||||
() => computeWorkflowStatusCounts(tasks, boardWorkflows),
|
||||
[boardWorkflows, tasks],
|
||||
);
|
||||
const workflowStatusCounts = useMemo(() => {
|
||||
const counts = computeWorkflowStatusCounts(tasks, boardWorkflows);
|
||||
const aggregateCounts: WorkflowStatusCounts = { todo: 0, inProgress: 0, done: 0, merging: 0 };
|
||||
for (const workflowCounts of counts.values()) {
|
||||
aggregateCounts.todo += workflowCounts.todo;
|
||||
aggregateCounts.inProgress += workflowCounts.inProgress;
|
||||
aggregateCounts.done += workflowCounts.done;
|
||||
aggregateCounts.merging += workflowCounts.merging;
|
||||
}
|
||||
counts.set(ALL_WORKFLOWS_BOARD_VIEW_ID, aggregateCounts);
|
||||
return counts;
|
||||
}, [boardWorkflows, tasks]);
|
||||
|
||||
const handleWorkflowSwitcherChange = useCallback((workflowId: string) => {
|
||||
/*
|
||||
FNXC:WorkflowBoard 2026-06-29-16:00:
|
||||
"All workflows" is a Board-only aggregate filter sentinel. Selecting it must not write to the durable workflow-selection store or flow into task creation, workflow editing, or workflow settings APIs that expect a real workflow id.
|
||||
Real workflow selections still use useBoardWorkflows so FN-7234 project-scoped persistence remains the only durable path.
|
||||
*/
|
||||
if (workflowId === ALL_WORKFLOWS_BOARD_VIEW_ID) {
|
||||
setIsAllWorkflowsViewSelected(true);
|
||||
return;
|
||||
}
|
||||
setIsAllWorkflowsViewSelected(false);
|
||||
setSelectedWorkflowId(workflowId);
|
||||
}, [setSelectedWorkflowId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!workflowMode) {
|
||||
setIsAllWorkflowsViewSelected(false);
|
||||
}
|
||||
}, [workflowMode]);
|
||||
|
||||
const knownWorkflowIds = useMemo(() => new Set(boardWorkflows?.workflows.map((workflow) => workflow.id) ?? []), [boardWorkflows]);
|
||||
|
||||
const workflowColumnsByWorkflowId = useMemo(() => {
|
||||
const byWorkflow = new Map<string, Map<string, BoardWorkflowColumn>>();
|
||||
for (const workflow of boardWorkflows?.workflows ?? []) {
|
||||
byWorkflow.set(workflow.id, new Map(workflow.columns.map((column) => [column.id, column])));
|
||||
}
|
||||
return byWorkflow;
|
||||
}, [boardWorkflows]);
|
||||
|
||||
const getEffectiveTaskWorkflowId = useCallback((task: Task) => {
|
||||
if (!boardWorkflows) return null;
|
||||
const assignedWorkflowId = boardWorkflows.taskWorkflowIds[task.id];
|
||||
return assignedWorkflowId && knownWorkflowIds.has(assignedWorkflowId)
|
||||
? assignedWorkflowId
|
||||
: boardWorkflows.defaultWorkflowId;
|
||||
}, [boardWorkflows, knownWorkflowIds]);
|
||||
|
||||
const selectedWorkflowTasks = useMemo(() => {
|
||||
if (!workflowMode || !boardWorkflows || !selectedWorkflow) return [];
|
||||
return tasks.filter((task) => {
|
||||
const workflowId = boardWorkflows.taskWorkflowIds[task.id] ?? boardWorkflows.defaultWorkflowId;
|
||||
return workflowId === selectedWorkflow.id;
|
||||
});
|
||||
}, [boardWorkflows, selectedWorkflow, tasks, workflowMode]);
|
||||
return tasks.filter((task) => getEffectiveTaskWorkflowId(task) === selectedWorkflow.id);
|
||||
}, [boardWorkflows, getEffectiveTaskWorkflowId, selectedWorkflow, tasks, workflowMode]);
|
||||
|
||||
const applyOptimisticTaskWorkflow = useCallback((taskId: string, workflowId: string) => {
|
||||
setBoardWorkflowsState((previous) => {
|
||||
@@ -416,6 +466,22 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
|
||||
return created;
|
||||
}, [applyOptimisticTaskWorkflow, onQuickCreate, refreshBoardWorkflows, selectedWorkflow]);
|
||||
|
||||
/**
|
||||
* FNXC:WorkflowBoard 2026-06-29-23:58:
|
||||
* The aggregate All workflows board is a read-side union, not a real workflow. Quick create must attach to one real workflow intake/default column so custom-default projects never submit synthetic `triage` or an empty workflow id to the backend.
|
||||
*/
|
||||
const handleAggregateWorkflowQuickCreate = useCallback(async (input: TaskCreateInput) => {
|
||||
if (!onQuickCreate) return undefined;
|
||||
const created = await onQuickCreate(input);
|
||||
const targetWorkflowId = typeof input.workflowId === "string" ? input.workflowId : undefined;
|
||||
if (created?.id && targetWorkflowId) {
|
||||
const createdWorkflowId = (created as Task & { workflowId?: string }).workflowId ?? targetWorkflowId;
|
||||
applyOptimisticTaskWorkflow(created.id, createdWorkflowId);
|
||||
refreshBoardWorkflows();
|
||||
}
|
||||
return created;
|
||||
}, [applyOptimisticTaskWorkflow, onQuickCreate, refreshBoardWorkflows]);
|
||||
|
||||
const selectedWorkflowArchivedColumn = useMemo(() => {
|
||||
if (!selectedWorkflow) return null;
|
||||
return selectedWorkflow.columns.find((column) => column.flags.archived) ?? null;
|
||||
@@ -469,14 +535,141 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
|
||||
const map = new Map<string, import("../api").WorkflowFieldDefinition[]>();
|
||||
if (cardDefsByWorkflow.size === 0) return map;
|
||||
if (!boardWorkflows) return map;
|
||||
const { taskWorkflowIds, defaultWorkflowId } = boardWorkflows;
|
||||
for (const task of tasks) {
|
||||
const workflowId = taskWorkflowIds[task.id] ?? defaultWorkflowId;
|
||||
const defs = cardDefsByWorkflow.get(workflowId);
|
||||
const workflowId = getEffectiveTaskWorkflowId(task);
|
||||
const defs = workflowId ? cardDefsByWorkflow.get(workflowId) : undefined;
|
||||
if (defs) map.set(task.id, defs);
|
||||
}
|
||||
return map;
|
||||
}, [cardDefsByWorkflow, tasks, boardWorkflows]);
|
||||
}, [cardDefsByWorkflow, getEffectiveTaskWorkflowId, tasks, boardWorkflows]);
|
||||
|
||||
const workflowNameById = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
if (!boardWorkflows) return map;
|
||||
for (const workflow of boardWorkflows.workflows) {
|
||||
map.set(workflow.id, workflow.name);
|
||||
}
|
||||
return map;
|
||||
}, [boardWorkflows]);
|
||||
|
||||
/*
|
||||
FNXC:WorkflowBoard 2026-06-29-00:00:
|
||||
All-workflows Board cards need trustworthy workflow-name badges, but per-workflow Board views and other TaskCard callers must not render empty shells. Derive badges only from board-workflows metadata, falling stale or missing task assignments back to the default workflow without persisting the aggregate sentinel.
|
||||
*/
|
||||
const aggregateTaskWorkflowBadges = useMemo(() => {
|
||||
const map = new Map<string, { workflowId: string; workflowName: string }>();
|
||||
if (!boardWorkflows) return map;
|
||||
for (const task of tasks) {
|
||||
const assignedWorkflowId = boardWorkflows.taskWorkflowIds[task.id] ?? boardWorkflows.defaultWorkflowId;
|
||||
const workflowId = workflowNameById.has(assignedWorkflowId) ? assignedWorkflowId : boardWorkflows.defaultWorkflowId;
|
||||
const workflowName = workflowNameById.get(workflowId);
|
||||
if (workflowName) {
|
||||
map.set(task.id, { workflowId, workflowName });
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [boardWorkflows, tasks, workflowNameById]);
|
||||
|
||||
/*
|
||||
FNXC:WorkflowBoard 2026-06-29-16:00:
|
||||
The aggregate Board view must not hide cards from custom workflow columns. Build a non-persisted union of visible workflow column ids and append canonical lifecycle columns so all task columns have a rendered destination without inventing a backend workflow id.
|
||||
|
||||
FNXC:WorkflowBoard 2026-06-29-18:37:
|
||||
Shared aggregate column ids must use the default workflow's label and trait flags when that workflow declares them; otherwise preserve the first workflow definition that introduced the id. This keeps "All workflows" deterministic for duplicate column names without OR-merging incompatible workflow traits.
|
||||
|
||||
FNXC:WorkflowBoard 2026-06-29-23:54:
|
||||
Aggregate Board rendering separates active columns from archived columns after the deterministic union is built. This preserves the existing collapsed archived-column behavior while the main All workflows lane set stays limited to non-hidden, non-archived destinations.
|
||||
*/
|
||||
const aggregateBoardColumns = useMemo<AggregateBoardColumn[]>(() => {
|
||||
const byId = new Map<string, AggregateBoardColumn>();
|
||||
if (boardWorkflows) {
|
||||
const defaultWorkflow = boardWorkflows.workflows.find((workflow) => workflow.id === boardWorkflows.defaultWorkflowId);
|
||||
const orderedWorkflows = [
|
||||
...(defaultWorkflow ? [defaultWorkflow] : []),
|
||||
...boardWorkflows.workflows.filter((workflow) => workflow.id !== boardWorkflows.defaultWorkflowId),
|
||||
];
|
||||
for (const workflow of orderedWorkflows) {
|
||||
for (const column of workflow.columns) {
|
||||
if (column.flags.hiddenFromBoard) continue;
|
||||
const existing = byId.get(column.id);
|
||||
if (existing) {
|
||||
existing.sourceWorkflowIds.push(workflow.id);
|
||||
continue;
|
||||
}
|
||||
byId.set(column.id, { ...column, flags: { ...column.flags }, sourceWorkflowIds: [workflow.id] });
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const column of COLUMNS) {
|
||||
if (!byId.has(column)) {
|
||||
byId.set(column, {
|
||||
id: column,
|
||||
name: column,
|
||||
flags: { archived: column === "archived", complete: column === "done", intake: column === "triage", countsTowardWip: column === "in-progress", mergeBlocker: column === "in-review" },
|
||||
sourceWorkflowIds: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
const order = new Map(COLUMNS.map((column, index) => [column, index]));
|
||||
return [...byId.values()].sort((a, b) => {
|
||||
const aOrder = order.get(a.id as ColumnType) ?? (a.flags.archived ? 10_000 : 1_000);
|
||||
const bOrder = order.get(b.id as ColumnType) ?? (b.flags.archived ? 10_000 : 1_000);
|
||||
if (aOrder !== bOrder) return aOrder - bOrder;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}, [boardWorkflows]);
|
||||
|
||||
const aggregateQuickCreateTarget = useMemo<AggregateQuickCreateTarget | null>(() => {
|
||||
if (!boardWorkflows) return null;
|
||||
const defaultWorkflow = boardWorkflows.workflows.find((workflow) => workflow.id === boardWorkflows.defaultWorkflowId);
|
||||
const orderedWorkflows = [
|
||||
...(defaultWorkflow ? [defaultWorkflow] : []),
|
||||
...boardWorkflows.workflows.filter((workflow) => workflow.id !== boardWorkflows.defaultWorkflowId),
|
||||
];
|
||||
for (const workflow of orderedWorkflows) {
|
||||
const column = workflow.columns.find((candidate) => candidate.flags.intake && !candidate.flags.archived && !candidate.flags.hiddenFromBoard)
|
||||
?? workflow.columns.find((candidate) => !candidate.flags.archived && !candidate.flags.hiddenFromBoard);
|
||||
if (column) return { columnId: column.id, workflowId: workflow.id };
|
||||
}
|
||||
return null;
|
||||
}, [boardWorkflows]);
|
||||
|
||||
const aggregateVisibleBoardColumns = useMemo(
|
||||
() => aggregateBoardColumns.filter((column) => column.flags.archived !== true),
|
||||
[aggregateBoardColumns],
|
||||
);
|
||||
|
||||
const aggregateArchivedBoardColumns = useMemo(
|
||||
() => aggregateBoardColumns.filter((column) => column.flags.archived === true),
|
||||
[aggregateBoardColumns],
|
||||
);
|
||||
|
||||
const aggregateRenderedBoardColumns = useMemo(
|
||||
() => [...aggregateVisibleBoardColumns, ...aggregateArchivedBoardColumns],
|
||||
[aggregateArchivedBoardColumns, aggregateVisibleBoardColumns],
|
||||
);
|
||||
|
||||
const aggregateTasksByColumn = useMemo(() => {
|
||||
const grouped: Record<string, Task[]> = {};
|
||||
for (const column of aggregateBoardColumns) grouped[column.id] = [];
|
||||
for (const task of tasks) {
|
||||
const workflowId = getEffectiveTaskWorkflowId(task);
|
||||
const workflowColumn = workflowId ? workflowColumnsByWorkflowId.get(workflowId)?.get(task.column) : null;
|
||||
/*
|
||||
FNXC:WorkflowBoard 2026-06-29-23:59:
|
||||
Aggregate Board grouping must resolve the task's effective workflow before using a shared column id. If one workflow hides `qa` while another shows it, tasks assigned to the hidden `qa` column stay hidden instead of leaking into the visible aggregate lane.
|
||||
*/
|
||||
if (!workflowColumn || workflowColumn.flags.hiddenFromBoard) continue;
|
||||
(grouped[task.column] ??= []).push(task);
|
||||
}
|
||||
for (const column of aggregateBoardColumns) {
|
||||
const isDoneLikeColumn = column.flags.complete === true && column.flags.archived !== true;
|
||||
grouped[column.id] = isDoneLikeColumn
|
||||
? sortTasksForDisplayColumn(grouped[column.id] ?? [], "done", doneSortMode)
|
||||
: sortTasksForDisplayColumn(grouped[column.id] ?? [], column.id as ColumnType);
|
||||
}
|
||||
return grouped;
|
||||
}, [aggregateBoardColumns, doneSortMode, getEffectiveTaskWorkflowId, tasks, workflowColumnsByWorkflowId]);
|
||||
|
||||
// Drag pre-check (R17): adjacency + capacity from the lane's column metadata.
|
||||
// Cross-lane drag → workflow-mismatch. Deterministic rejections return a
|
||||
@@ -505,15 +698,17 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
|
||||
}
|
||||
|
||||
if (workflowMode && selectedWorkflow) {
|
||||
const shouldRenderWorkflowControls = workflowOptions.length > 1 || Boolean(onCreateWorkflow || onOpenWorkflowEditor);
|
||||
const workflowToolbar = shouldRenderWorkflowControls && workflowOptions.length > 0 ? (
|
||||
const shouldRenderWorkflowControls = workflowOptions.length > 0;
|
||||
const workflowSwitcherValue = isAllWorkflowsViewSelected ? ALL_WORKFLOWS_BOARD_VIEW_ID : (selectedWorkflowId ?? selectedWorkflow.id);
|
||||
const workflowToolbar = shouldRenderWorkflowControls ? (
|
||||
<div className="board-workflow-toolbar">
|
||||
<div className="board-workflow-selector">
|
||||
<WorkflowSwitcher
|
||||
workflows={workflowOptions}
|
||||
value={selectedWorkflow.id}
|
||||
onChange={setSelectedWorkflowId}
|
||||
value={workflowSwitcherValue}
|
||||
onChange={handleWorkflowSwitcherChange}
|
||||
counts={workflowStatusCounts}
|
||||
aggregateOption={{ id: ALL_WORKFLOWS_BOARD_VIEW_ID, name: "All workflows" }}
|
||||
onOpen={refreshBoardWorkflows}
|
||||
onEditWorkflow={onOpenWorkflowEditor}
|
||||
onCreateWorkflow={onCreateWorkflow}
|
||||
@@ -531,10 +726,70 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
|
||||
const relocatedWorkflowToolbar = workflowControlsInHeader && headerWorkflowSlot && workflowToolbar
|
||||
? createPortal(workflowToolbar, headerWorkflowSlot)
|
||||
: null;
|
||||
const renderedWorkflowToolbar = workflowControlsInHeader && headerWorkflowSlot ? relocatedWorkflowToolbar : workflowToolbar;
|
||||
|
||||
if (isAllWorkflowsViewSelected) {
|
||||
return (
|
||||
<div className="board-workflow-view">
|
||||
{renderedWorkflowToolbar}
|
||||
<main className="board board-workflow-columns" id="board" ref={boardRef}>
|
||||
{aggregateRenderedBoardColumns.map((columnDef) => {
|
||||
const isCreateColumn = aggregateQuickCreateTarget?.columnId === columnDef.id;
|
||||
const isDoneLikeColumn = columnDef.flags.complete === true && columnDef.flags.archived !== true;
|
||||
return (
|
||||
<Column
|
||||
key={columnDef.id}
|
||||
column={columnDef.id as ColumnType}
|
||||
workflowMode
|
||||
columnDisplayName={columnDef.name}
|
||||
columnFlags={columnDef.flags}
|
||||
tasks={aggregateTasksByColumn[columnDef.id] ?? []}
|
||||
projectId={projectId}
|
||||
maxConcurrent={maxConcurrent}
|
||||
showWorktreeGrouping={showWorktreeGrouping}
|
||||
onMoveTask={onMoveTask}
|
||||
onPauseTask={onPauseTask}
|
||||
onOpenDetail={onOpenDetail}
|
||||
onOpenGroupModal={onOpenGroupModal}
|
||||
addToast={addToast}
|
||||
globalPaused={globalPaused}
|
||||
onUpdateTask={onUpdateTask}
|
||||
onRetryTask={onRetryTask}
|
||||
onArchiveTask={onArchiveTask}
|
||||
onUnarchiveTask={onUnarchiveTask}
|
||||
onDeleteTask={onDeleteTask}
|
||||
allTasks={tasks}
|
||||
availableModels={availableModels}
|
||||
onOpenDetailWithTab={onOpenDetailWithTab}
|
||||
favoriteProviders={favoriteProviders}
|
||||
favoriteModels={favoriteModels}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
onToggleModelFavorite={onToggleModelFavorite}
|
||||
isSearchActive={isSearchActive}
|
||||
taskStuckTimeoutMs={taskStuckTimeoutMs}
|
||||
onOpenMission={onOpenMission}
|
||||
lastFetchTimeMs={lastFetchTimeMs}
|
||||
taskCardFieldDefs={taskCardFieldDefs}
|
||||
taskWorkflowBadges={aggregateTaskWorkflowBadges}
|
||||
blockerFanoutMap={blockerFanoutMap}
|
||||
prAuthAvailable={prAuthAvailable}
|
||||
autoMerge={autoMerge}
|
||||
{...(isCreateColumn && aggregateQuickCreateTarget ? { workflowId: aggregateQuickCreateTarget.workflowId, onQuickCreate: handleAggregateWorkflowQuickCreate, onNewTask, onPlanningMode, onSubtaskBreakdown } : {})}
|
||||
{...(columnDef.flags.mergeBlocker || columnDef.flags.humanReview ? { onToggleAutoMerge: handleToggleAutoMerge } : {})}
|
||||
{...(columnDef.id === "done" ? { onArchiveAllDone } : {})}
|
||||
{...(isDoneLikeColumn ? { doneSortMode, onDoneSortModeChange: setDoneSortMode } : {})}
|
||||
{...(columnDef.flags.archived ? { collapsed: archivedCollapsed, onToggleCollapse: handleToggleArchivedCollapse } : {})}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="board-workflow-view">
|
||||
{workflowControlsInHeader && headerWorkflowSlot ? relocatedWorkflowToolbar : workflowToolbar}
|
||||
{renderedWorkflowToolbar}
|
||||
<main
|
||||
className="board board-workflow-columns"
|
||||
id="board"
|
||||
|
||||
@@ -147,6 +147,8 @@ interface ColumnProps {
|
||||
lastFetchTimeMs?: number;
|
||||
/** Per-task card-placed custom field definitions (U13/KTD-14). */
|
||||
taskCardFieldDefs?: ReadonlyMap<string, import("../api").WorkflowFieldDefinition[]>;
|
||||
/** Trusted aggregate-board workflow badges keyed by task id; omitted in per-workflow and non-board surfaces. */
|
||||
taskWorkflowBadges?: ReadonlyMap<string, { workflowId: string; workflowName: string }>;
|
||||
/** Precomputed blocker fanout keyed by blocker task ID. */
|
||||
blockerFanoutMap?: ReadonlyMap<string, BlockerFanoutEntry>;
|
||||
/** Whether GitHub CLI auth is available for creating PRs from task cards. */
|
||||
@@ -177,7 +179,7 @@ interface ColumnProps {
|
||||
getDraggingTaskId?: () => string | null;
|
||||
}
|
||||
|
||||
function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktreeGrouping, onMoveTask, onPauseTask, onOpenDetail, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, doneSortMode, onDoneSortModeChange, 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) {
|
||||
function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktreeGrouping, onMoveTask, onPauseTask, onOpenDetail, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, doneSortMode, onDoneSortModeChange, collapsed, onToggleCollapse, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, taskStuckTimeoutMs, onOpenMission, lastFetchTimeMs, taskCardFieldDefs, taskWorkflowBadges, 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
|
||||
@@ -760,6 +762,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktree
|
||||
onOpenMission={onOpenMission}
|
||||
lastFetchTimeMs={lastFetchTimeMs}
|
||||
taskCardFieldDefs={taskCardFieldDefs}
|
||||
taskWorkflowBadges={taskWorkflowBadges}
|
||||
blockerFanoutMap={blockerFanoutMap}
|
||||
prAuthAvailable={prAuthAvailable}
|
||||
autoMergeEnabled={Boolean(autoMerge)}
|
||||
@@ -793,6 +796,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktree
|
||||
isPromoting={isHoldColumn && onPromote ? promotingIds.has(task.id) : undefined}
|
||||
lastFetchTimeMs={lastFetchTimeMs}
|
||||
cardFieldDefs={taskCardFieldDefs?.get(task.id)}
|
||||
workflowBadge={taskWorkflowBadges?.get(task.id)}
|
||||
fanout={blockerFanoutMap?.get(task.id)}
|
||||
prAuthAvailable={prAuthAvailable}
|
||||
autoMergeEnabled={Boolean(autoMerge)}
|
||||
|
||||
@@ -179,7 +179,8 @@ The global mobile touch-action reset applies to descendants, and browsers inters
|
||||
.card-status-badge,
|
||||
.card-priority-badge,
|
||||
.card-size-badge,
|
||||
.card-no-commits-expected-badge {
|
||||
.card-no-commits-expected-badge,
|
||||
.card-workflow-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: 100%;
|
||||
@@ -199,6 +200,19 @@ The global mobile touch-action reset applies to descendants, and browsers inters
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:WorkflowBoard 2026-06-29-00:00:
|
||||
Aggregate Board cards need a compact workflow-name badge so operators can identify each card's source workflow while shared columns combine multiple workflows. The badge is opt-in metadata from Board and uses neutral card tokens so non-board TaskCard callers do not render empty workflow shells.
|
||||
*/
|
||||
.card-workflow-badge {
|
||||
max-width: 18ch;
|
||||
background: var(--surface-2);
|
||||
border-color: var(--border);
|
||||
color: var(--text-muted);
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.card-no-commits-expected-badge {
|
||||
background: color-mix(in srgb, var(--text-muted) 18%, transparent);
|
||||
border-color: color-mix(in srgb, var(--text-muted) 35%, transparent);
|
||||
|
||||
@@ -414,6 +414,8 @@ interface TaskCardProps {
|
||||
/** Card-placed custom field definitions for this task's workflow (U13/KTD-14).
|
||||
* Empty/undefined → no field badges render (card byte-identical to today). */
|
||||
cardFieldDefs?: WorkflowFieldDefinition[];
|
||||
/** Board aggregate-view workflow metadata. Absent outside trusted board callers so empty workflow badges never render. */
|
||||
workflowBadge?: { workflowId: string; workflowName: string };
|
||||
/** Unified PR entity node-state for this task's work, surfaced on the card (R12).
|
||||
* When present, the card shows a node-state badge linking to the PR view. The
|
||||
* `failed` state renders a DISTINCT error badge (not the open-PR badge). */
|
||||
@@ -576,6 +578,8 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo
|
||||
previous.prNode?.prNumber === next.prNode?.prNumber &&
|
||||
previous.cliSessionState?.agentState === next.cliSessionState?.agentState &&
|
||||
previous.nearDuplicateCanonicalInactive === next.nearDuplicateCanonicalInactive &&
|
||||
previous.workflowBadge?.workflowId === next.workflowBadge?.workflowId &&
|
||||
previous.workflowBadge?.workflowName === next.workflowBadge?.workflowName &&
|
||||
previous.cardFieldDefs === next.cardFieldDefs &&
|
||||
(previous.cardFieldDefs == null && next.cardFieldDefs == null
|
||||
? true
|
||||
@@ -711,6 +715,7 @@ function TaskCardComponent({
|
||||
prAuthAvailable,
|
||||
autoMergeEnabled = false,
|
||||
cardFieldDefs,
|
||||
workflowBadge,
|
||||
prNode,
|
||||
onOpenPullRequest,
|
||||
cliSessionState,
|
||||
@@ -1838,9 +1843,14 @@ function TaskCardComponent({
|
||||
&& filesChangedButton == null
|
||||
&& showTrackingIndicator
|
||||
&& Boolean(githubTrackedIssue);
|
||||
const hasWorkflowBadge = typeof workflowBadge?.workflowId === "string"
|
||||
&& workflowBadge.workflowId.trim().length > 0
|
||||
&& typeof workflowBadge.workflowName === "string"
|
||||
&& workflowBadge.workflowName.trim().length > 0;
|
||||
const hasCardMetaBadges = showPriorityBadge
|
||||
|| task.executionMode === "fast"
|
||||
|| isAgentCreated;
|
||||
|| isAgentCreated
|
||||
|| hasWorkflowBadge;
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
@@ -2036,6 +2046,17 @@ function TaskCardComponent({
|
||||
)}
|
||||
{hasCardMetaBadges && (
|
||||
<div className="card-meta-badges" data-testid="card-meta-badges">
|
||||
{hasWorkflowBadge && (
|
||||
<span
|
||||
className="card-workflow-badge"
|
||||
title={t("tasks.workflowBadgeTitle", "Workflow: {{name}}", { name: workflowBadge.workflowName })}
|
||||
aria-label={t("tasks.workflowBadgeAriaLabel", "Workflow {{name}}", { name: workflowBadge.workflowName })}
|
||||
data-testid="card-workflow-badge"
|
||||
data-workflow-id={workflowBadge.workflowId}
|
||||
>
|
||||
{workflowBadge.workflowName}
|
||||
</span>
|
||||
)}
|
||||
{showPriorityBadge && (
|
||||
<span className={`card-priority-badge card-priority-badge--${normalizedPriority}`}>
|
||||
{normalizedPriority}
|
||||
|
||||
@@ -39,14 +39,24 @@ The gray top header band (task id + column badge) was over-padded. Trim its vert
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.detail-column-badge {
|
||||
.detail-column-badge,
|
||||
.detail-workflow-badge {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-pill);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.detail-column-badge {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.detail-workflow-badge {
|
||||
color: var(--text-muted);
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.badge-triage {
|
||||
background: var(--status-triage-bg);
|
||||
color: var(--triage);
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
import { isNearDuplicateCanonicalInactive } from "../../../core/src/near-duplicate-canonical";
|
||||
import { resolveEffectiveAutoMerge } from "../../../core/src/task-merge";
|
||||
import { uploadAttachment, deleteAttachment, updateTask, repairOverlapBlocker, pauseTask, unpauseTask, fetchTaskDetail, fetchSettings, fetchTaskEffectiveSettings, fetchGlobalSettings, requestSpecRevision, rebuildTaskSpec, approvePlan, rejectPlan, refineTask, fetchWorkflowResults, assignTask, fetchAgents, fetchAgent, refreshPrStatus, fetchBoardWorkflows, updateTaskCustomFields, summarizeTitle, api } from "../api";
|
||||
import type { WorkflowFieldDefinition, CustomFieldRejection } from "../api";
|
||||
import type { BoardWorkflowsPayload, WorkflowFieldDefinition, CustomFieldRejection } from "../api";
|
||||
import { ApiRequestError } from "../api";
|
||||
import { TaskFieldsSection } from "./TaskFieldsSection";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
@@ -358,6 +358,15 @@ function normalizeTaskPriorityValue(priority: Task["priority"]): TaskPriority {
|
||||
: DEFAULT_TASK_PRIORITY;
|
||||
}
|
||||
|
||||
function resolveTaskWorkflowMetadata(payload: BoardWorkflowsPayload, taskId: string): { name: string; fields: WorkflowFieldDefinition[] | null } | null {
|
||||
if (payload.flagEnabled !== true) return null;
|
||||
const workflowId = payload.taskWorkflowIds[taskId] ?? payload.defaultWorkflowId;
|
||||
const workflow = payload.workflows.find((candidate) => candidate.id === workflowId);
|
||||
const name = workflow?.name?.trim();
|
||||
if (!workflow || !name) return null;
|
||||
return { name, fields: workflow.fields ?? null };
|
||||
}
|
||||
|
||||
function normalizeExecutionModeValue(executionMode: Task["executionMode"]): "standard" | "fast" {
|
||||
return executionMode === "fast" ? "fast" : "standard";
|
||||
}
|
||||
@@ -714,6 +723,11 @@ export function TaskDetailContent({
|
||||
};
|
||||
}, [descriptionExpanded, displayTitleText, task.id]);
|
||||
|
||||
/*
|
||||
FNXC:WorkflowBadges 2026-06-29-00:00:
|
||||
Task details need a stable workflow-name badge because aggregate Board cards can mix tasks from multiple workflows. Resolve the badge name and custom field definitions from the same board-workflows payload so detail headers do not issue duplicate workflow-metadata fetches.
|
||||
*/
|
||||
const [taskWorkflowName, setTaskWorkflowName] = useState<string | null>(null);
|
||||
// Custom field definitions (U13/KTD-14). Resolved for this task's workflow
|
||||
// from the board-workflows payload; absent when the workflow declares none,
|
||||
// in which case the fields section renders nothing (today's UI byte-identical).
|
||||
@@ -730,25 +744,35 @@ export function TaskDetailContent({
|
||||
setCustomFieldValues(task.customFields ?? {});
|
||||
}, [task.id, task.customFields]);
|
||||
|
||||
// Resolve this task's workflow field definitions once per task. Skipped when
|
||||
// Resolve this task's workflow field definitions and display name once per task. Skipped when
|
||||
// the caller supplies `workflowFieldDefs` directly (Board context). Best-effort:
|
||||
// a failed fetch (or flag-OFF empty payload) leaves defs null → no section.
|
||||
// a failed fetch (or flag-OFF empty payload) leaves defs/name null → no section/badge.
|
||||
useEffect(() => {
|
||||
if (workflowFieldDefsProp !== undefined) {
|
||||
// Prop-driven path: keep in sync if the prop changes (task switch etc.).
|
||||
setCustomFieldDefs(workflowFieldDefsProp ?? null);
|
||||
setTaskWorkflowName(null);
|
||||
return;
|
||||
}
|
||||
/*
|
||||
FNXC:WorkflowBadges 2026-06-29-16:48:
|
||||
Mounted task-detail hosts can swap from one task to another (List split-pane, right dock, floating windows). Clear the previous workflow badge before the shared board-workflows lookup resolves so aggregate-board context never shows a stale cross-workflow label.
|
||||
*/
|
||||
setCustomFieldDefs(null);
|
||||
setTaskWorkflowName(null);
|
||||
let cancelled = false;
|
||||
void fetchBoardWorkflows(projectId)
|
||||
.then((payload) => {
|
||||
if (cancelled) return;
|
||||
const workflowId = payload.taskWorkflowIds[task.id] ?? payload.defaultWorkflowId;
|
||||
const workflow = payload.workflows.find((w) => w.id === workflowId);
|
||||
setCustomFieldDefs(workflow?.fields ?? null);
|
||||
const metadata = resolveTaskWorkflowMetadata(payload, task.id);
|
||||
setCustomFieldDefs(metadata?.fields ?? null);
|
||||
setTaskWorkflowName(metadata?.name ?? null);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setCustomFieldDefs(null);
|
||||
if (!cancelled) {
|
||||
setCustomFieldDefs(null);
|
||||
setTaskWorkflowName(null);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
@@ -2669,6 +2693,11 @@ export function TaskDetailContent({
|
||||
<span className={`detail-column-badge badge-${task.column}`}>
|
||||
{columnLabel(task.column)}
|
||||
</span>
|
||||
{taskWorkflowName && (
|
||||
<span className="detail-workflow-badge" data-testid="task-detail-workflow-badge">
|
||||
{taskWorkflowName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="modal-header-actions">
|
||||
{!isEditing && canEdit && (
|
||||
|
||||
@@ -7,11 +7,18 @@ import { useTranslation } from "react-i18next";
|
||||
import type { BoardWorkflowDefinition } from "../api";
|
||||
import type { WorkflowStatusCounts } from "./workflowStatusCounts";
|
||||
|
||||
export interface WorkflowSwitcherAggregateOption {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface WorkflowSwitcherProps {
|
||||
workflows: BoardWorkflowDefinition[];
|
||||
value: string;
|
||||
onChange: (id: string) => void;
|
||||
counts: Map<string, WorkflowStatusCounts>;
|
||||
/** Optional dashboard-only aggregate view. It is rendered before real workflows and is never editable. */
|
||||
aggregateOption?: WorkflowSwitcherAggregateOption;
|
||||
/** Fired each time the dropdown transitions from closed to open so consumers can refresh count data. */
|
||||
onOpen?: () => void;
|
||||
label?: string;
|
||||
@@ -79,7 +86,7 @@ function getCounts(counts: Map<string, WorkflowStatusCounts>, workflowId: string
|
||||
* Opening the dropdown must refresh workflow count data because task-to-workflow assignments do not emit board-workflows invalidation events.
|
||||
* Fire onOpen only on closed-to-open transitions so consumers can refetch without close-time calls or render loops.
|
||||
*/
|
||||
export function WorkflowSwitcher({ workflows, value, onChange, counts, onOpen, label: labelProp, onEditWorkflow, onCreateWorkflow }: WorkflowSwitcherProps) {
|
||||
export function WorkflowSwitcher({ workflows, value, onChange, counts, aggregateOption, onOpen, label: labelProp, onEditWorkflow, onCreateWorkflow }: WorkflowSwitcherProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const label = labelProp ?? t("workflowSwitcher.label", "Workflow");
|
||||
const todoLabel = t("workflowSwitcher.todo", "Todo");
|
||||
@@ -102,8 +109,16 @@ export function WorkflowSwitcher({ workflows, value, onChange, counts, onOpen, l
|
||||
const measurementCanvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const onOpenRef = useRef(onOpen);
|
||||
|
||||
const selectedIndex = useMemo(() => Math.max(0, workflows.findIndex((workflow) => workflow.id === value)), [value, workflows]);
|
||||
const selectedWorkflow = workflows[selectedIndex] ?? workflows[0] ?? null;
|
||||
const switcherOptions = useMemo(() => {
|
||||
/*
|
||||
FNXC:WorkflowSwitcher 2026-06-29-16:00:
|
||||
The Board can expose a dashboard-only "All workflows" filter before real workflows, but that sentinel is not a backend workflow id and must never receive workflow edit affordances.
|
||||
Keep the aggregate option in this presentation layer so real workflow sorting, counts, create/edit actions, and durable selection semantics remain owned by the existing Board/useBoardWorkflows path.
|
||||
*/
|
||||
return aggregateOption ? [aggregateOption, ...workflows] : workflows;
|
||||
}, [aggregateOption, workflows]);
|
||||
const selectedIndex = useMemo(() => Math.max(0, switcherOptions.findIndex((workflow) => workflow.id === value)), [value, switcherOptions]);
|
||||
const selectedWorkflow = switcherOptions[selectedIndex] ?? switcherOptions[0] ?? null;
|
||||
const selectedCounts = selectedWorkflow ? getCounts(counts, selectedWorkflow.id) : ZERO_COUNTS;
|
||||
|
||||
const measureLongestOptionNameWidth = useCallback((names: string[]) => {
|
||||
@@ -137,7 +152,7 @@ export function WorkflowSwitcher({ workflows, value, onChange, counts, onOpen, l
|
||||
const openUpward = spaceBelow < preferredHeight && spaceAbove > spaceBelow;
|
||||
const availableHeight = Math.max((openUpward ? spaceAbove : spaceBelow) - verticalPadding - gap, 160);
|
||||
const maxHeight = Math.max(Math.min(availableHeight, preferredHeight), 160);
|
||||
const longestNameWidth = measureLongestOptionNameWidth(workflows.map((workflow) => workflow.name));
|
||||
const longestNameWidth = measureLongestOptionNameWidth(switcherOptions.map((workflow) => workflow.name));
|
||||
const width = computeMenuWidth({ longestNameWidth, triggerWidth: rect.width, viewportWidth, horizontalPadding });
|
||||
const left = Math.min(Math.max(triggerLeft, horizontalPadding), viewportWidth - horizontalPadding - width) + offsetLeft;
|
||||
const top = openUpward
|
||||
@@ -145,7 +160,7 @@ export function WorkflowSwitcher({ workflows, value, onChange, counts, onOpen, l
|
||||
: Math.min(triggerBottom + gap + offsetTop, viewportHeight + offsetTop - verticalPadding - maxHeight);
|
||||
|
||||
setDropdownPosition({ top, left, width, maxHeight });
|
||||
}, [measureLongestOptionNameWidth, workflows]);
|
||||
}, [measureLongestOptionNameWidth, switcherOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
onOpenRef.current = onOpen;
|
||||
@@ -236,7 +251,7 @@ export function WorkflowSwitcher({ workflows, value, onChange, counts, onOpen, l
|
||||
if (!isOpen) {
|
||||
openDropdown();
|
||||
} else {
|
||||
setHighlightedIndex((current) => (workflows.length ? (current + 1) % workflows.length : 0));
|
||||
setHighlightedIndex((current) => (switcherOptions.length ? (current + 1) % switcherOptions.length : 0));
|
||||
}
|
||||
break;
|
||||
case "ArrowUp":
|
||||
@@ -244,14 +259,14 @@ export function WorkflowSwitcher({ workflows, value, onChange, counts, onOpen, l
|
||||
if (!isOpen) {
|
||||
openDropdown();
|
||||
} else {
|
||||
setHighlightedIndex((current) => (workflows.length ? (current - 1 + workflows.length) % workflows.length : 0));
|
||||
setHighlightedIndex((current) => (switcherOptions.length ? (current - 1 + switcherOptions.length) % switcherOptions.length : 0));
|
||||
}
|
||||
break;
|
||||
case "Enter":
|
||||
case " ":
|
||||
event.preventDefault();
|
||||
if (isOpen) {
|
||||
const workflow = workflows[highlightedIndex];
|
||||
const workflow = switcherOptions[highlightedIndex];
|
||||
if (workflow) selectWorkflow(workflow.id);
|
||||
} else {
|
||||
openDropdown();
|
||||
@@ -265,7 +280,7 @@ export function WorkflowSwitcher({ workflows, value, onChange, counts, onOpen, l
|
||||
setIsOpen(false);
|
||||
break;
|
||||
}
|
||||
}, [highlightedIndex, isOpen, openDropdown, selectWorkflow, workflows]);
|
||||
}, [highlightedIndex, isOpen, openDropdown, selectWorkflow, switcherOptions]);
|
||||
|
||||
if (!selectedWorkflow) return null;
|
||||
|
||||
@@ -315,10 +330,11 @@ export function WorkflowSwitcher({ workflows, value, onChange, counts, onOpen, l
|
||||
}}
|
||||
>
|
||||
<div ref={listRef} className="workflow-switcher-options">
|
||||
{workflows.map((workflow, index) => {
|
||||
{switcherOptions.map((workflow, index) => {
|
||||
const workflowCounts = getCounts(counts, workflow.id);
|
||||
const isSelected = workflow.id === selectedWorkflow.id;
|
||||
const isHighlighted = index === highlightedIndex;
|
||||
const isAggregateOption = aggregateOption?.id === workflow.id;
|
||||
return (
|
||||
<div
|
||||
key={workflow.id}
|
||||
@@ -338,7 +354,7 @@ export function WorkflowSwitcher({ workflows, value, onChange, counts, onOpen, l
|
||||
{renderCountBadges(workflowCounts, "option")}
|
||||
{renderAccessibleCounts(workflowCounts)}
|
||||
</button>
|
||||
{onEditWorkflow ? (
|
||||
{onEditWorkflow && !isAggregateOption ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-icon btn-sm workflow-switcher-edit"
|
||||
|
||||
@@ -30,6 +30,8 @@ interface WorktreeGroupProps {
|
||||
lastFetchTimeMs?: number;
|
||||
/** Per-task card-placed custom field definitions (U13/KTD-14). */
|
||||
taskCardFieldDefs?: ReadonlyMap<string, import("../api").WorkflowFieldDefinition[]>;
|
||||
/** Trusted aggregate-board workflow badges keyed by task id; omitted in per-workflow and non-board surfaces. */
|
||||
taskWorkflowBadges?: ReadonlyMap<string, { workflowId: string; workflowName: string }>;
|
||||
/** Precomputed blocker fanout keyed by blocker task ID. */
|
||||
blockerFanoutMap?: ReadonlyMap<string, BlockerFanoutEntry>;
|
||||
/** Whether GitHub CLI auth is available for creating PRs from task cards. */
|
||||
@@ -54,6 +56,7 @@ function WorktreeGroupComponent({
|
||||
onOpenMission,
|
||||
lastFetchTimeMs,
|
||||
taskCardFieldDefs,
|
||||
taskWorkflowBadges,
|
||||
blockerFanoutMap,
|
||||
prAuthAvailable,
|
||||
autoMergeEnabled,
|
||||
@@ -90,6 +93,7 @@ function WorktreeGroupComponent({
|
||||
onOpenMission={onOpenMission}
|
||||
lastFetchTimeMs={lastFetchTimeMs}
|
||||
cardFieldDefs={taskCardFieldDefs?.get(task.id)}
|
||||
workflowBadge={taskWorkflowBadges?.get(task.id)}
|
||||
fanout={blockerFanoutMap?.get(task.id)}
|
||||
prAuthAvailable={prAuthAvailable}
|
||||
autoMergeEnabled={autoMergeEnabled}
|
||||
@@ -112,6 +116,7 @@ function WorktreeGroupComponent({
|
||||
onOpenMission={onOpenMission}
|
||||
lastFetchTimeMs={lastFetchTimeMs}
|
||||
cardFieldDefs={taskCardFieldDefs?.get(task.id)}
|
||||
workflowBadge={taskWorkflowBadges?.get(task.id)}
|
||||
fanout={blockerFanoutMap?.get(task.id)}
|
||||
prAuthAvailable={prAuthAvailable}
|
||||
autoMergeEnabled={autoMergeEnabled}
|
||||
|
||||
@@ -53,6 +53,7 @@ vi.mock("../Column", () => ({
|
||||
Column: React.memo(({
|
||||
column,
|
||||
tasks,
|
||||
columnDisplayName,
|
||||
collapsed,
|
||||
onToggleCollapse,
|
||||
onQuickCreate,
|
||||
@@ -66,9 +67,15 @@ vi.mock("../Column", () => ({
|
||||
isSearchActive,
|
||||
doneSortMode,
|
||||
onDoneSortModeChange,
|
||||
workflowId,
|
||||
canDropTask,
|
||||
onPlanningMode,
|
||||
onSubtaskBreakdown,
|
||||
taskWorkflowBadges,
|
||||
}: {
|
||||
column: string;
|
||||
tasks: Task[];
|
||||
columnDisplayName?: string;
|
||||
collapsed?: boolean;
|
||||
onToggleCollapse?: () => void;
|
||||
onQuickCreate?: unknown;
|
||||
@@ -82,10 +89,15 @@ vi.mock("../Column", () => ({
|
||||
isSearchActive?: boolean;
|
||||
doneSortMode?: string;
|
||||
onDoneSortModeChange?: (mode: "completion-date-desc" | "task-id-desc") => void;
|
||||
workflowId?: string;
|
||||
canDropTask?: unknown;
|
||||
onPlanningMode?: unknown;
|
||||
onSubtaskBreakdown?: unknown;
|
||||
taskWorkflowBadges?: ReadonlyMap<string, { workflowId: string; workflowName: string }>;
|
||||
}) => {
|
||||
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-done-sort-mode={doneSortMode ?? ""} data-has-done-sort-handler={onDoneSortModeChange ? "yes" : "no"}>
|
||||
<div data-testid={`column-${column}`} data-tasks={JSON.stringify(tasks)} data-workflow-badges={JSON.stringify(Object.fromEntries(taskWorkflowBadges ?? new Map()))} 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-done-sort-mode={doneSortMode ?? ""} data-has-done-sort-handler={onDoneSortModeChange ? "yes" : "no"} data-workflow-id={workflowId ?? ""} data-column-display-name={columnDisplayName ?? ""} data-has-can-drop={canDropTask ? "yes" : "no"} data-has-planning={onPlanningMode ? "yes" : "no"} data-has-subtask={onSubtaskBreakdown ? "yes" : "no"}>
|
||||
{onToggleCollapse && <button onClick={onToggleCollapse}>toggle-{column}</button>}
|
||||
{onDoneSortModeChange && <button type="button" onClick={() => onDoneSortModeChange("task-id-desc")}>sort-{column}-by-id</button>}
|
||||
</div>
|
||||
@@ -1080,7 +1092,7 @@ describe("Board", () => {
|
||||
await waitFor(() => expect(screen.getByTestId("column-todo")).toBeDefined());
|
||||
expect(JSON.parse(screen.getByTestId("column-todo").getAttribute("data-tasks") || "[]").map((task: Task) => task.id)).toEqual(["FN-1"]);
|
||||
expect(JSON.parse(screen.getByTestId("column-in-progress").getAttribute("data-tasks") || "[]").map((task: Task) => task.id)).toEqual(["FN-2"]);
|
||||
expect(screen.queryByTestId("workflow-switcher")).toBeNull();
|
||||
expect(screen.getByTestId("workflow-switcher")).toHaveTextContent("Coding");
|
||||
});
|
||||
|
||||
it("puts create controls on the workflow intake column instead of the first visible column", async () => {
|
||||
@@ -1257,7 +1269,7 @@ describe("Board", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("does not leave a board workflow shell when header relocation has no controls", async () => {
|
||||
it("relocates the aggregate selector without empty action shells", async () => {
|
||||
const headerSlot = document.createElement("div");
|
||||
headerSlot.id = "header-workflow-slot";
|
||||
document.body.appendChild(headerSlot);
|
||||
@@ -1269,9 +1281,12 @@ describe("Board", () => {
|
||||
});
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId("column-todo")).toBeDefined());
|
||||
expect(screen.queryByTestId("workflow-switcher")).toBeNull();
|
||||
expect(document.querySelector(".board-workflow-toolbar")).toBeNull();
|
||||
expect(headerSlot.childElementCount).toBe(0);
|
||||
await waitFor(() => expect(headerSlot.querySelector(".board-workflow-toolbar")).not.toBeNull());
|
||||
expect(headerSlot.querySelector(".board-workflow-edit-btn")).toBeNull();
|
||||
expect(headerSlot.querySelector(".board-workflow-create-btn")).toBeNull();
|
||||
fireEvent.click(screen.getByTestId("workflow-switcher"));
|
||||
expect(screen.getByTestId("workflow-switcher-option-__all_workflows__")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("workflow-switcher-edit-__all_workflows__")).toBeNull();
|
||||
} finally {
|
||||
headerSlot.remove();
|
||||
}
|
||||
@@ -1309,6 +1324,7 @@ describe("Board", () => {
|
||||
expect(onCreateWorkflow).toHaveBeenCalledTimes(1);
|
||||
expect(onOpenWorkflowEditor).toHaveBeenCalledTimes(1);
|
||||
expect(JSON.parse(screen.getByTestId("column-todo").getAttribute("data-tasks") || "[]").map((task: Task) => task.id)).toEqual(["FN-1"]);
|
||||
expect(screen.getByTestId("column-todo")).toHaveAttribute("data-workflow-badges", "{}");
|
||||
expect(screen.queryByTestId("column-intake")).toBeNull();
|
||||
|
||||
await selectWorkflow("wf-custom");
|
||||
@@ -1321,6 +1337,166 @@ describe("Board", () => {
|
||||
expect(onOpenWorkflowEditor).toHaveBeenCalledWith("wf-custom");
|
||||
});
|
||||
|
||||
it("selects the all-workflows aggregate view without persisting the sentinel", async () => {
|
||||
const projectId = "project-board-all-workflows";
|
||||
enableFlag(
|
||||
{ "FN-1": "builtin:coding", "FN-2": "wf-custom" },
|
||||
[DEFAULT_WORKFLOW, CUSTOM_WORKFLOW],
|
||||
);
|
||||
renderBoard({
|
||||
projectId,
|
||||
tasks: [mkTask({ id: "FN-1", column: "todo" }), mkTask({ id: "FN-2", column: "intake" })],
|
||||
onPlanningMode: vi.fn(),
|
||||
onSubtaskBreakdown: vi.fn(),
|
||||
});
|
||||
|
||||
await selectWorkflow("__all_workflows__");
|
||||
|
||||
expect(screen.getByTestId("workflow-switcher")).toHaveTextContent("All workflows");
|
||||
expect(screen.getByTestId("column-todo")).toHaveAttribute("data-tasks", expect.stringContaining("FN-1"));
|
||||
expect(screen.getByTestId("column-intake")).toHaveAttribute("data-tasks", expect.stringContaining("FN-2"));
|
||||
expect(JSON.parse(screen.getByTestId("column-todo").getAttribute("data-workflow-badges") || "{}")).toMatchObject({
|
||||
"FN-1": { workflowId: "builtin:coding", workflowName: "Coding (built-in)" },
|
||||
});
|
||||
expect(JSON.parse(screen.getByTestId("column-intake").getAttribute("data-workflow-badges") || "{}")).toMatchObject({
|
||||
"FN-2": { workflowId: "wf-custom", workflowName: "Custom Flow" },
|
||||
});
|
||||
expect(screen.getByTestId("column-triage")).toHaveAttribute("data-workflow-id", "builtin:coding");
|
||||
expect(screen.getByTestId("column-triage")).toHaveAttribute("data-has-can-drop", "no");
|
||||
expect(screen.getByTestId("column-triage")).toHaveAttribute("data-has-planning", "yes");
|
||||
expect(screen.getByTestId("column-triage")).toHaveAttribute("data-has-subtask", "yes");
|
||||
expect(window.localStorage.getItem(scopedKey(BOARD_WORKFLOW_SELECTION_STORAGE_KEY, projectId))).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByTestId("workflow-switcher"));
|
||||
expect(screen.queryByTestId("workflow-switcher-edit-__all_workflows__")).toBeNull();
|
||||
});
|
||||
|
||||
it("falls stale and missing task workflow ids back to the default workflow", async () => {
|
||||
enableFlag(
|
||||
{ "FN-default": "builtin:coding", "FN-stale": "wf-deleted", "FN-custom": "wf-custom" },
|
||||
[DEFAULT_WORKFLOW, CUSTOM_WORKFLOW],
|
||||
);
|
||||
renderBoard({
|
||||
tasks: [
|
||||
mkTask({ id: "FN-default", column: "todo" }),
|
||||
mkTask({ id: "FN-stale", column: "todo" }),
|
||||
mkTask({ id: "FN-missing", column: "todo" }),
|
||||
mkTask({ id: "FN-custom", column: "intake" }),
|
||||
],
|
||||
});
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId("column-todo")).toBeDefined());
|
||||
const defaultWorkflowIds = JSON.parse(screen.getByTestId("column-todo").getAttribute("data-tasks") || "[]").map((task: Task) => task.id).sort();
|
||||
expect(defaultWorkflowIds).toEqual(["FN-default", "FN-missing", "FN-stale"]);
|
||||
|
||||
await openWorkflowSwitcher();
|
||||
expect(screen.getByTestId("workflow-switcher-option-builtin:coding")).toHaveTextContent("3");
|
||||
fireEvent.click(screen.getByTestId("workflow-switcher-option-__all_workflows__"));
|
||||
const aggregateTodoIds = JSON.parse(screen.getByTestId("column-todo").getAttribute("data-tasks") || "[]").map((task: Task) => task.id).sort();
|
||||
expect(aggregateTodoIds).toEqual(["FN-default", "FN-missing", "FN-stale"]);
|
||||
expect(JSON.parse(screen.getByTestId("column-todo").getAttribute("data-workflow-badges") || "{}")).toMatchObject({
|
||||
"FN-default": { workflowId: "builtin:coding", workflowName: "Coding (built-in)" },
|
||||
"FN-missing": { workflowId: "builtin:coding", workflowName: "Coding (built-in)" },
|
||||
"FN-stale": { workflowId: "builtin:coding", workflowName: "Coding (built-in)" },
|
||||
});
|
||||
expect(screen.getByTestId("column-intake")).toHaveAttribute("data-tasks", expect.stringContaining("FN-custom"));
|
||||
});
|
||||
|
||||
it("keeps hidden workflow tasks out of shared aggregate columns and archived columns collapsed", async () => {
|
||||
const customWorkflow = {
|
||||
id: "wf-archive-hidden",
|
||||
name: "Archive + Hidden Flow",
|
||||
columns: [
|
||||
{ id: "ready", name: "Ready", flags: { intake: true } },
|
||||
{ id: "quiet", name: "Quiet", flags: { hiddenFromBoard: true } },
|
||||
{ id: "cold-storage", name: "Cold storage", flags: { archived: true } },
|
||||
],
|
||||
};
|
||||
const visibleQuietWorkflow = {
|
||||
id: "wf-visible-quiet",
|
||||
name: "Visible Quiet Flow",
|
||||
columns: [
|
||||
{ id: "quiet", name: "Visible quiet", flags: { intake: true } },
|
||||
],
|
||||
};
|
||||
enableFlag(
|
||||
{ "FN-ready": "wf-archive-hidden", "FN-quiet-hidden": "wf-archive-hidden", "FN-quiet-visible": "wf-visible-quiet", "FN-cold": "wf-archive-hidden" },
|
||||
[DEFAULT_WORKFLOW, customWorkflow, visibleQuietWorkflow],
|
||||
);
|
||||
renderBoard({
|
||||
tasks: [
|
||||
mkTask({ id: "FN-ready", column: "ready" }),
|
||||
mkTask({ id: "FN-quiet-hidden", column: "quiet" }),
|
||||
mkTask({ id: "FN-quiet-visible", column: "quiet" }),
|
||||
mkTask({ id: "FN-cold", column: "cold-storage" }),
|
||||
],
|
||||
});
|
||||
|
||||
await selectWorkflow("__all_workflows__");
|
||||
|
||||
expect(screen.getByTestId("column-ready")).toHaveAttribute("data-tasks", expect.stringContaining("FN-ready"));
|
||||
expect(screen.getByTestId("column-quiet")).toHaveAttribute("data-tasks", expect.stringContaining("FN-quiet-visible"));
|
||||
expect(screen.getByTestId("column-quiet")).not.toHaveAttribute("data-tasks", expect.stringContaining("FN-quiet-hidden"));
|
||||
expect(screen.getByTestId("column-cold-storage")).toHaveAttribute("data-tasks", expect.stringContaining("FN-cold"));
|
||||
expect(screen.getByTestId("column-cold-storage")).toHaveAttribute("data-collapsed", "true");
|
||||
expect(screen.getByRole("main").lastElementChild).toBe(screen.getByTestId("column-cold-storage"));
|
||||
});
|
||||
|
||||
it("creates aggregate tasks only from a real workflow intake column", async () => {
|
||||
const customDefaultWorkflow = {
|
||||
id: "wf-custom-default",
|
||||
name: "Custom Default",
|
||||
columns: [
|
||||
{ id: "inbox", name: "Inbox", flags: { intake: true } },
|
||||
{ id: "active", name: "Active", flags: { countsTowardWip: true } },
|
||||
{ id: "finished", name: "Finished", flags: { complete: true } },
|
||||
],
|
||||
};
|
||||
fetchBoardWorkflowsMock.mockResolvedValue({
|
||||
flagEnabled: true,
|
||||
defaultWorkflowId: "wf-custom-default",
|
||||
workflows: [customDefaultWorkflow, DEFAULT_WORKFLOW],
|
||||
taskWorkflowIds: { "FN-custom-default": "wf-custom-default" },
|
||||
});
|
||||
renderBoard({
|
||||
tasks: [mkTask({ id: "FN-custom-default", column: "inbox" })],
|
||||
onPlanningMode: vi.fn(),
|
||||
onSubtaskBreakdown: vi.fn(),
|
||||
});
|
||||
|
||||
await selectWorkflow("__all_workflows__");
|
||||
|
||||
expect(screen.getByTestId("column-inbox")).toHaveAttribute("data-has-quick-create", "yes");
|
||||
expect(screen.getByTestId("column-inbox")).toHaveAttribute("data-workflow-id", "wf-custom-default");
|
||||
expect(screen.getByTestId("column-inbox")).toHaveAttribute("data-has-planning", "yes");
|
||||
expect(screen.getByTestId("column-triage")).toHaveAttribute("data-has-quick-create", "no");
|
||||
expect(screen.getByTestId("column-triage")).toHaveAttribute("data-workflow-id", "");
|
||||
});
|
||||
|
||||
it("uses default workflow column labels and flags for duplicate aggregate column ids", async () => {
|
||||
const duplicateNameWorkflow = {
|
||||
id: "wf-duplicate",
|
||||
name: "Coding (built-in)",
|
||||
columns: [
|
||||
{ id: "todo", name: "Queue from duplicate", flags: { intake: true } },
|
||||
{ id: "done", name: "Complete from duplicate", flags: { complete: true } },
|
||||
],
|
||||
};
|
||||
enableFlag(
|
||||
{ "FN-1": "builtin:coding", "FN-2": "wf-duplicate" },
|
||||
[duplicateNameWorkflow, DEFAULT_WORKFLOW],
|
||||
);
|
||||
renderBoard({ tasks: [mkTask({ id: "FN-1", column: "todo" }), mkTask({ id: "FN-2", column: "todo" })] });
|
||||
|
||||
await selectWorkflow("__all_workflows__");
|
||||
|
||||
expect(screen.getByTestId("column-todo")).toHaveAttribute("data-column-display-name", "Todo");
|
||||
expect(screen.getByTestId("column-todo")).toHaveAttribute("data-has-quick-create", "no");
|
||||
expect(screen.getByTestId("column-triage")).toHaveAttribute("data-has-quick-create", "yes");
|
||||
await openWorkflowSwitcher();
|
||||
expect(workflowSwitcherOptionIds()).toEqual(["__all_workflows__", "builtin:coding", "wf-duplicate"]);
|
||||
});
|
||||
|
||||
it("keeps the default workflow first in the dropdown even when another workflow has cards", async () => {
|
||||
enableFlag(
|
||||
{ "FN-2": "wf-custom" },
|
||||
@@ -1328,7 +1504,7 @@ describe("Board", () => {
|
||||
);
|
||||
renderBoard({ tasks: [mkTask({ id: "FN-2", column: "intake" })] });
|
||||
const selector = await openWorkflowSwitcher();
|
||||
expect(workflowSwitcherOptionIds()).toEqual(["builtin:coding", "wf-custom"]);
|
||||
expect(workflowSwitcherOptionIds()).toEqual(["__all_workflows__", "builtin:coding", "wf-custom"]);
|
||||
expect(selector).toHaveTextContent("Coding");
|
||||
expect(screen.queryByTestId("column-intake")).toBeNull();
|
||||
});
|
||||
@@ -1340,7 +1516,7 @@ describe("Board", () => {
|
||||
);
|
||||
renderBoard({ tasks: [mkTask({ id: "FN-1" }), mkTask({ id: "FN-2", column: "intake" })] });
|
||||
await openWorkflowSwitcher();
|
||||
expect(workflowSwitcherOptionIds()).toEqual(["builtin:coding", "wf-custom"]);
|
||||
expect(workflowSwitcherOptionIds()).toEqual(["__all_workflows__", "builtin:coding", "wf-custom"]);
|
||||
});
|
||||
|
||||
it("renders archived cards in the selected workflow archived column", async () => {
|
||||
|
||||
@@ -80,6 +80,7 @@ describe("TaskCard badge wrapping (FN-5162)", () => {
|
||||
})}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
workflowBadge={{ workflowId: "wf-badge-wrap", workflowName: "Long workflow badge label" }}
|
||||
/>,
|
||||
).container;
|
||||
});
|
||||
@@ -104,6 +105,7 @@ describe("TaskCard badge wrapping (FN-5162)", () => {
|
||||
".card-agent-created-badge",
|
||||
".card-no-commits-expected-badge",
|
||||
".card-github-badge",
|
||||
".card-workflow-badge",
|
||||
])("applies truncation constraints to %s when rendered", (selector) => {
|
||||
const badge = container.querySelector(selector);
|
||||
expect(badge, `${selector} should render for the fixture`).toBeTruthy();
|
||||
|
||||
@@ -4801,6 +4801,44 @@ describe("TaskCard memo comparator provenance behavior", () => {
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("detects workflow badge metadata changes", () => {
|
||||
const task = makeTask();
|
||||
expect(
|
||||
__test_areTaskCardPropsEqual(
|
||||
{ task, workflowBadge: { workflowId: "builtin:coding", workflowName: "Coding" }, onOpenDetail: noop, addToast: noop } as any,
|
||||
{ task, workflowBadge: { workflowId: "wf-custom", workflowName: "Custom Flow" }, onOpenDetail: noop, addToast: noop } as any,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskCard workflow badges", () => {
|
||||
it("renders a compact accessible workflow badge only when metadata is present", () => {
|
||||
const { rerender } = render(
|
||||
<TaskCard
|
||||
task={makeTask()}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
workflowBadge={{ workflowId: "wf-custom", workflowName: "Custom Flow" }}
|
||||
/>,
|
||||
);
|
||||
|
||||
const badge = screen.getByTestId("card-workflow-badge");
|
||||
expect(badge).toHaveTextContent("Custom Flow");
|
||||
expect(badge).toHaveAttribute("data-workflow-id", "wf-custom");
|
||||
expect(badge).toHaveAccessibleName("Workflow Custom Flow");
|
||||
|
||||
rerender(
|
||||
<TaskCard
|
||||
task={makeTask()}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
workflowBadge={{ workflowId: "", workflowName: "" }}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByTestId("card-workflow-badge")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskCard mission badge", () => {
|
||||
|
||||
@@ -35,6 +35,163 @@ import { FileBrowserProvider } from "../../context/FileBrowserContext";
|
||||
setupTaskDetailModalHooks();
|
||||
|
||||
describe("TaskDetailModal", () => {
|
||||
describe("workflow header badge", () => {
|
||||
const workflowPayload = {
|
||||
flagEnabled: true,
|
||||
defaultWorkflowId: "builtin:coding",
|
||||
workflows: [
|
||||
{ id: "builtin:coding", name: "Coding", columns: [], fields: [{ id: "risk", name: "Risk", type: "text" }] },
|
||||
{ id: "wf-docs", name: "Docs", columns: [] },
|
||||
],
|
||||
taskWorkflowIds: { "FN-101": "wf-docs" },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(dashboardApi.fetchBoardWorkflows).mockReset();
|
||||
vi.mocked(dashboardApi.fetchBoardWorkflows).mockResolvedValue({
|
||||
flagEnabled: false,
|
||||
defaultWorkflowId: "",
|
||||
workflows: [],
|
||||
taskWorkflowIds: {},
|
||||
});
|
||||
});
|
||||
|
||||
function renderDetail(task = makeTask({ id: "FN-101", column: "todo", title: "Docs task" })) {
|
||||
return render(
|
||||
<TaskDetailModal
|
||||
initialTab="definition"
|
||||
task={task}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
it("renders the resolved workflow name beside the task id and column badge", async () => {
|
||||
vi.mocked(dashboardApi.fetchBoardWorkflows).mockResolvedValueOnce(workflowPayload);
|
||||
|
||||
renderDetail();
|
||||
|
||||
const badge = await screen.findByTestId("task-detail-workflow-badge");
|
||||
expect(badge).toHaveTextContent("Docs");
|
||||
expect(badge.parentElement).toHaveClass("detail-title-row");
|
||||
expect(screen.getByText("FN-101")).toBeInTheDocument();
|
||||
expect(screen.getByText("Todo")).toBeInTheDocument();
|
||||
expect(dashboardApi.fetchBoardWorkflows).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("uses the default workflow for tasks without explicit workflow assignment", async () => {
|
||||
vi.mocked(dashboardApi.fetchBoardWorkflows).mockResolvedValueOnce(workflowPayload);
|
||||
|
||||
renderDetail(makeTask({ id: "FN-default", column: "todo", title: "Default workflow task" }));
|
||||
|
||||
expect(await screen.findByTestId("task-detail-workflow-badge")).toHaveTextContent("Coding");
|
||||
});
|
||||
|
||||
it("hides the badge when an explicit workflow assignment cannot be resolved", async () => {
|
||||
vi.mocked(dashboardApi.fetchBoardWorkflows).mockResolvedValueOnce({
|
||||
...workflowPayload,
|
||||
taskWorkflowIds: { "FN-stale": "wf-deleted" },
|
||||
});
|
||||
|
||||
renderDetail(makeTask({ id: "FN-stale", column: "todo", title: "Stale workflow task" }));
|
||||
|
||||
await waitFor(() => expect(dashboardApi.fetchBoardWorkflows).toHaveBeenCalledTimes(1));
|
||||
expect(screen.queryByTestId("task-detail-workflow-badge")).toBeNull();
|
||||
});
|
||||
|
||||
it("updates the badge when a mounted task detail switches tasks", async () => {
|
||||
vi.mocked(dashboardApi.fetchBoardWorkflows)
|
||||
.mockResolvedValueOnce(workflowPayload)
|
||||
.mockResolvedValueOnce(workflowPayload);
|
||||
const props = {
|
||||
initialTab: "definition" as const,
|
||||
task: makeTask({ id: "FN-101", column: "todo", title: "Docs task" }),
|
||||
onMoveTask: noopMove,
|
||||
onDeleteTask: noopDelete,
|
||||
onMergeTask: noopMerge,
|
||||
onOpenDetail: noopOpenDetail,
|
||||
addToast: noop,
|
||||
};
|
||||
|
||||
const { rerender } = render(<TaskDetailContent {...props} embedded onRequestClose={noop} />);
|
||||
expect(await screen.findByTestId("task-detail-workflow-badge")).toHaveTextContent("Docs");
|
||||
|
||||
rerender(<TaskDetailContent {...props} task={makeTask({ id: "FN-default", column: "in-review", title: "Coding task" })} embedded onRequestClose={noop} />);
|
||||
await waitFor(() => expect(screen.getByTestId("task-detail-workflow-badge")).toHaveTextContent("Coding"));
|
||||
});
|
||||
|
||||
it("clears the previous workflow badge while a mounted task switch reloads metadata", async () => {
|
||||
let resolveNextPayload: (payload: typeof workflowPayload) => void = () => undefined;
|
||||
vi.mocked(dashboardApi.fetchBoardWorkflows)
|
||||
.mockResolvedValueOnce(workflowPayload)
|
||||
.mockImplementationOnce(() => new Promise((resolve) => {
|
||||
resolveNextPayload = resolve;
|
||||
}));
|
||||
const props = {
|
||||
initialTab: "definition" as const,
|
||||
task: makeTask({ id: "FN-101", column: "todo", title: "Docs task" }),
|
||||
onMoveTask: noopMove,
|
||||
onDeleteTask: noopDelete,
|
||||
onMergeTask: noopMerge,
|
||||
onOpenDetail: noopOpenDetail,
|
||||
addToast: noop,
|
||||
};
|
||||
|
||||
const { rerender } = render(<TaskDetailContent {...props} embedded onRequestClose={noop} />);
|
||||
expect(await screen.findByTestId("task-detail-workflow-badge")).toHaveTextContent("Docs");
|
||||
|
||||
rerender(<TaskDetailContent {...props} task={makeTask({ id: "FN-default", column: "in-review", title: "Coding task" })} embedded onRequestClose={noop} />);
|
||||
await waitFor(() => expect(dashboardApi.fetchBoardWorkflows).toHaveBeenCalledTimes(2));
|
||||
expect(screen.queryByTestId("task-detail-workflow-badge")).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
resolveNextPayload(workflowPayload);
|
||||
});
|
||||
expect(await screen.findByTestId("task-detail-workflow-badge")).toHaveTextContent("Coding");
|
||||
});
|
||||
|
||||
it("hides the badge without workflow metadata and leaves no empty shell", async () => {
|
||||
vi.mocked(dashboardApi.fetchBoardWorkflows).mockResolvedValueOnce({
|
||||
flagEnabled: false,
|
||||
defaultWorkflowId: "",
|
||||
workflows: [],
|
||||
taskWorkflowIds: {},
|
||||
});
|
||||
|
||||
const { container } = renderDetail();
|
||||
|
||||
await waitFor(() => expect(dashboardApi.fetchBoardWorkflows).toHaveBeenCalledTimes(1));
|
||||
expect(screen.queryByTestId("task-detail-workflow-badge")).toBeNull();
|
||||
expect(container.querySelector(".detail-workflow-badge")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders in the mobile back-header variant", async () => {
|
||||
vi.mocked(dashboardApi.fetchBoardWorkflows).mockResolvedValueOnce(workflowPayload);
|
||||
|
||||
render(
|
||||
<TaskDetailModal
|
||||
initialTab="definition"
|
||||
mobileHeaderMode="back"
|
||||
task={makeTask({ id: "FN-101", column: "todo", title: "Docs task" })}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByTestId("task-detail-workflow-badge")).toHaveTextContent("Docs");
|
||||
expect(screen.getByRole("button", { name: "Back to task list" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders clickable file links in markdown inline code while preserving code wrappers", async () => {
|
||||
const openFile = vi.fn();
|
||||
render(
|
||||
@@ -1002,7 +1159,7 @@ describe("TaskDetailModal", () => {
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Review" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Refresh" }));
|
||||
expect(await screen.findByText("APPROVED")).toBeTruthy();
|
||||
expect((await screen.findAllByText("APPROVED")).length).toBeGreaterThan(0);
|
||||
|
||||
const commentsTab = screen.getByRole("button", { name: "Comments" });
|
||||
expect(commentsTab).toBeInTheDocument();
|
||||
@@ -1040,7 +1197,7 @@ describe("TaskDetailModal", () => {
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Review" }));
|
||||
expect(await screen.findByText("CHANGES_REQUESTED")).toBeTruthy();
|
||||
expect((await screen.findAllByText("CHANGES_REQUESTED")).length).toBeGreaterThan(0);
|
||||
expect(screen.getByText(/No review items yet\./i)).toBeTruthy();
|
||||
});
|
||||
|
||||
|
||||
@@ -242,6 +242,56 @@ describe("WorkflowSwitcher", () => {
|
||||
expect(screen.queryByRole("listbox", { name: "Workflow" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders an accessible aggregate option before workflows without an edit shell", () => {
|
||||
const onChange = vi.fn();
|
||||
const onEditWorkflow = vi.fn();
|
||||
render(
|
||||
<WorkflowSwitcher
|
||||
workflows={workflows}
|
||||
value="__all_workflows__"
|
||||
onChange={onChange}
|
||||
counts={countMap([["__all_workflows__", { todo: 4, inProgress: 3, done: 2, merging: 1 }]])}
|
||||
aggregateOption={{ id: "__all_workflows__", name: "All workflows" }}
|
||||
onEditWorkflow={onEditWorkflow}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("workflow-switcher")).toHaveAccessibleName("Select workflow. Current workflow: All workflows");
|
||||
fireEvent.click(screen.getByTestId("workflow-switcher"));
|
||||
|
||||
const options = screen.getAllByRole("option");
|
||||
expect(options.map((option) => option.textContent)).toEqual(expect.arrayContaining([expect.stringContaining("All workflows")]));
|
||||
expect(options[0]).toHaveAttribute("data-testid", "workflow-switcher-option-__all_workflows__");
|
||||
expect(screen.getByTestId("workflow-switcher-option-__all_workflows__")).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.queryByTestId("workflow-switcher-edit-__all_workflows__")).toBeNull();
|
||||
expect(screen.getByTestId("workflow-switcher-edit-coding")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("workflow-switcher-option-__all_workflows__")).toHaveTextContent("4");
|
||||
|
||||
fireEvent.click(screen.getByTestId("workflow-switcher-option-design"));
|
||||
expect(onChange).toHaveBeenCalledWith("design");
|
||||
expect(onEditWorkflow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("supports keyboard selection of the aggregate option", () => {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<WorkflowSwitcher
|
||||
workflows={workflows}
|
||||
value="coding"
|
||||
onChange={onChange}
|
||||
counts={countMap()}
|
||||
aggregateOption={{ id: "__all_workflows__", name: "All workflows" }}
|
||||
/>,
|
||||
);
|
||||
|
||||
const trigger = screen.getByTestId("workflow-switcher");
|
||||
fireEvent.keyDown(trigger, { key: "ArrowDown" });
|
||||
fireEvent.keyDown(trigger, { key: "ArrowUp" });
|
||||
fireEvent.keyDown(trigger, { key: "Enter" });
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith("__all_workflows__");
|
||||
});
|
||||
|
||||
it("supports keyboard navigation and escape dismissal", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<WorkflowSwitcher workflows={workflows} value="coding" onChange={onChange} counts={countMap()} />);
|
||||
|
||||
@@ -217,29 +217,38 @@ describe("computeWorkflowStatusCounts", () => {
|
||||
expect(counts.get("design")).toEqual({ todo: 0, inProgress: 2, done: 0, merging: 1 });
|
||||
});
|
||||
|
||||
it("excludes archived-column tasks and ignores unknown workflows or columns", () => {
|
||||
it("excludes archived and board-hidden column tasks while stale workflows fall back to default", () => {
|
||||
const counts = computeWorkflowStatusCounts(
|
||||
[
|
||||
task("FN-archived", "archived"),
|
||||
task("FN-hidden", "quiet"),
|
||||
task("FN-unknown-column", "missing"),
|
||||
task("FN-unknown-workflow", "todo"),
|
||||
],
|
||||
{
|
||||
...boardWorkflows,
|
||||
workflows: [
|
||||
{
|
||||
...boardWorkflows.workflows[0],
|
||||
columns: [
|
||||
...boardWorkflows.workflows[0].columns,
|
||||
{ id: "quiet", name: "Quiet", flags: { hiddenFromBoard: true } },
|
||||
],
|
||||
},
|
||||
boardWorkflows.workflows[1],
|
||||
boardWorkflows.workflows[2],
|
||||
],
|
||||
taskWorkflowIds: {
|
||||
"FN-unknown-workflow": "missing-workflow",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
expect(counts.get("default")).toEqual({ todo: 0, inProgress: 0, done: 0, merging: 0 });
|
||||
expect(counts.get("default")).toEqual({ todo: 1, inProgress: 0, done: 0, merging: 0 });
|
||||
});
|
||||
|
||||
it("uses real quick-fix empty-trait columns to count the reported two done and zero in-progress state", () => {
|
||||
it("uses real quick-fix columns to count the reported two done and zero in-progress state", () => {
|
||||
const columns = builtinWorkflowColumns("builtin:quick-fix");
|
||||
expect(
|
||||
columns.every((column) => Object.keys(column.flags).length === 0)
|
||||
).toBe(true);
|
||||
|
||||
const counts = computeWorkflowStatusCounts(
|
||||
[task("FN-done-1", "done"), task("FN-done-2", "done")],
|
||||
@@ -254,16 +263,13 @@ describe("computeWorkflowStatusCounts", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to canonical lifecycle ids for every linear built-in with synthesized empty traits", () => {
|
||||
it("classifies every linear built-in's canonical lifecycle columns", () => {
|
||||
for (const workflowId of [
|
||||
"builtin:quick-fix",
|
||||
"builtin:review-heavy",
|
||||
"builtin:compound-engineering",
|
||||
]) {
|
||||
const columns = builtinWorkflowColumns(workflowId);
|
||||
expect(
|
||||
columns.every((column) => Object.keys(column.flags).length === 0)
|
||||
).toBe(true);
|
||||
|
||||
const counts = computeWorkflowStatusCounts(
|
||||
[
|
||||
|
||||
@@ -22,7 +22,7 @@ const MERGING_STATUSES = new Set(["merging", "merging-pr", "merging-fix"]);
|
||||
/**
|
||||
* FNXC:WorkflowSwitcher 2026-06-20-00:09:
|
||||
* The board/list workflow dropdown must show compact Todo, In Progress, and Done task counts for every selectable workflow without duplicating logic across render surfaces.
|
||||
* Use workflow column flags as the source of truth: archived columns are excluded, complete columns count as Done, active non-intake WIP columns count as In Progress, and all remaining visible work counts as Todo/not-yet-started.
|
||||
* Use workflow column flags as the source of truth: archived or board-hidden columns are excluded, complete columns count as Done, active non-intake WIP columns count as In Progress, and all remaining visible work counts as Todo/not-yet-started.
|
||||
*
|
||||
* FNXC:WorkflowSwitcher 2026-06-21-00:00:
|
||||
* Built-in linear workflows synthesize canonical lifecycle columns with empty traits, so their resolved flags cannot identify Done, In Progress, or Archived buckets.
|
||||
@@ -31,7 +31,7 @@ const MERGING_STATUSES = new Set(["merging", "merging-pr", "merging-fix"]);
|
||||
function classifyWorkflowStatusColumn(
|
||||
column: BoardWorkflowColumn
|
||||
): WorkflowStatusBucket {
|
||||
if (column.flags.archived) return "excluded";
|
||||
if (column.flags.archived || column.flags.hiddenFromBoard) return "excluded";
|
||||
if (column.flags.complete) return "done";
|
||||
if (column.flags.countsTowardWip && !column.flags.intake) return "inProgress";
|
||||
|
||||
@@ -57,6 +57,7 @@ export function computeWorkflowStatusCounts(
|
||||
const workflowsById = new Map(
|
||||
boardWorkflows.workflows.map((workflow) => [workflow.id, workflow])
|
||||
);
|
||||
const knownWorkflowIds = new Set(workflowsById.keys());
|
||||
const columnsByWorkflowId = new Map<
|
||||
string,
|
||||
Map<string, BoardWorkflowColumn>
|
||||
@@ -73,9 +74,14 @@ export function computeWorkflowStatusCounts(
|
||||
if (!tasks?.length) return countsByWorkflow;
|
||||
|
||||
for (const task of tasks) {
|
||||
const workflowId =
|
||||
boardWorkflows.taskWorkflowIds[task.id] ??
|
||||
boardWorkflows.defaultWorkflowId;
|
||||
const assignedWorkflowId = boardWorkflows.taskWorkflowIds[task.id];
|
||||
/*
|
||||
FNXC:WorkflowSwitcher 2026-06-29-18:37:
|
||||
Workflow counts must follow the same stale-assignment repair semantics as Board rendering: missing or unknown task-workflow ids fall back to the default workflow so cards and selector counts do not diverge while taskWorkflowIds is stale.
|
||||
*/
|
||||
const workflowId = assignedWorkflowId && knownWorkflowIds.has(assignedWorkflowId)
|
||||
? assignedWorkflowId
|
||||
: boardWorkflows.defaultWorkflowId;
|
||||
const workflow = workflowsById.get(workflowId);
|
||||
if (!workflow) continue;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user