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:
gsxdsm
2026-06-29 18:54:28 -07:00
parent 9fd286b1d3
commit 797b30ce5d
20 changed files with 887 additions and 73 deletions

View 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.

View 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.

View 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.

View File

@@ -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.
![Board view](./screenshots/dashboard-overview.png)
@@ -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.

View File

@@ -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());
});
});

View File

@@ -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"

View File

@@ -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)}

View File

@@ -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);

View File

@@ -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}

View File

@@ -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);

View File

@@ -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 && (

View File

@@ -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"

View File

@@ -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}

View File

@@ -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 () => {

View File

@@ -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();

View File

@@ -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", () => {

View File

@@ -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();
});

View File

@@ -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()} />);

View File

@@ -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(
[

View File

@@ -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;