diff --git a/.changeset/fn-7242-all-workflows-board.md b/.changeset/fn-7242-all-workflows-board.md new file mode 100644 index 0000000000..e41a9e1666 --- /dev/null +++ b/.changeset/fn-7242-all-workflows-board.md @@ -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. diff --git a/.changeset/fn-7242-task-detail-workflow-badge.md b/.changeset/fn-7242-task-detail-workflow-badge.md new file mode 100644 index 0000000000..c88441a54d --- /dev/null +++ b/.changeset/fn-7242-task-detail-workflow-badge.md @@ -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. diff --git a/.changeset/fn-7242-workflow-badges.md b/.changeset/fn-7242-workflow-badges.md new file mode 100644 index 0000000000..d1edc19bcc --- /dev/null +++ b/.changeset/fn-7242-workflow-badges.md @@ -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. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index bce226953f..f33506c172 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -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. -- 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`. + + + +- 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. + +- 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. diff --git a/packages/dashboard/app/__tests__/workflow-selection-cross-surface.test.tsx b/packages/dashboard/app/__tests__/workflow-selection-cross-surface.test.tsx index 3466a5c44c..1b8c830ca6 100644 --- a/packages/dashboard/app/__tests__/workflow-selection-cross-surface.test.tsx +++ b/packages/dashboard/app/__tests__/workflow-selection-cross-surface.test.tsx @@ -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()); }); }); diff --git a/packages/dashboard/app/components/Board.tsx b/packages/dashboard/app/components/Board.tsx index 69d1f48e02..de0144e3cc 100644 --- a/packages/dashboard/app/components/Board.tsx +++ b/packages/dashboard/app/components/Board.tsx @@ -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 (
@@ -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("completion-date-desc"); + const [isAllWorkflowsViewSelected, setIsAllWorkflowsViewSelected] = useState(false); const archivedLoadedRef = useRef(false); const boardRef = useRef(null); const [headerWorkflowSlot, setHeaderWorkflowSlot] = useState(() => { @@ -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>(); + 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(); 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(); + 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(); + 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(() => { + const byId = new Map(); + 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(() => { + 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 = {}; + 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 ? (
+ {renderedWorkflowToolbar} +
+ {aggregateRenderedBoardColumns.map((columnDef) => { + const isCreateColumn = aggregateQuickCreateTarget?.columnId === columnDef.id; + const isDoneLikeColumn = columnDef.flags.complete === true && columnDef.flags.archived !== true; + return ( + + ); + })} +
+
+ ); + } return (
- {workflowControlsInHeader && headerWorkflowSlot ? relocatedWorkflowToolbar : workflowToolbar} + {renderedWorkflowToolbar}
; + /** Trusted aggregate-board workflow badges keyed by task id; omitted in per-workflow and non-board surfaces. */ + taskWorkflowBadges?: ReadonlyMap; /** Precomputed blocker fanout keyed by blocker task ID. */ blockerFanoutMap?: ReadonlyMap; /** 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)} diff --git a/packages/dashboard/app/components/TaskCard.css b/packages/dashboard/app/components/TaskCard.css index 429aedac7c..ef035ab3a1 100644 --- a/packages/dashboard/app/components/TaskCard.css +++ b/packages/dashboard/app/components/TaskCard.css @@ -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); diff --git a/packages/dashboard/app/components/TaskCard.tsx b/packages/dashboard/app/components/TaskCard.tsx index b9e081cfe7..a88e76b6bf 100644 --- a/packages/dashboard/app/components/TaskCard.tsx +++ b/packages/dashboard/app/components/TaskCard.tsx @@ -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 && (
+ {hasWorkflowBadge && ( + + {workflowBadge.workflowName} + + )} {showPriorityBadge && ( {normalizedPriority} diff --git a/packages/dashboard/app/components/TaskDetailModal.css b/packages/dashboard/app/components/TaskDetailModal.css index e344a9d709..8621ac01ae 100644 --- a/packages/dashboard/app/components/TaskDetailModal.css +++ b/packages/dashboard/app/components/TaskDetailModal.css @@ -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); diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx index 3017412795..f09b0ef559 100644 --- a/packages/dashboard/app/components/TaskDetailModal.tsx +++ b/packages/dashboard/app/components/TaskDetailModal.tsx @@ -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(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({ {columnLabel(task.column)} + {taskWorkflowName && ( + + {taskWorkflowName} + + )}
{!isEditing && canEdit && ( diff --git a/packages/dashboard/app/components/WorkflowSwitcher.tsx b/packages/dashboard/app/components/WorkflowSwitcher.tsx index d4c89b579e..50c0ef57a4 100644 --- a/packages/dashboard/app/components/WorkflowSwitcher.tsx +++ b/packages/dashboard/app/components/WorkflowSwitcher.tsx @@ -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; + /** 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, 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(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 }} >
- {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 (
- {onEditWorkflow ? ( + {onEditWorkflow && !isAggregateOption ? ( } {onDoneSortModeChange && }
@@ -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 () => { diff --git a/packages/dashboard/app/components/__tests__/TaskCard.badge-wrap.test.tsx b/packages/dashboard/app/components/__tests__/TaskCard.badge-wrap.test.tsx index 2f2d1aedf8..c104b066f5 100644 --- a/packages/dashboard/app/components/__tests__/TaskCard.badge-wrap.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskCard.badge-wrap.test.tsx @@ -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(); diff --git a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx index 37e94fa35c..f55f38c22b 100644 --- a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx @@ -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( + , + ); + + 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( + , + ); + expect(screen.queryByTestId("card-workflow-badge")).toBeNull(); + }); }); describe("TaskCard mission badge", () => { diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.rendering.test.tsx b/packages/dashboard/app/components/__tests__/TaskDetailModal.rendering.test.tsx index f7ff29ffd8..87c50c0741 100644 --- a/packages/dashboard/app/components/__tests__/TaskDetailModal.rendering.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.rendering.test.tsx @@ -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( + , + ); + } + + 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(); + expect(await screen.findByTestId("task-detail-workflow-badge")).toHaveTextContent("Docs"); + + rerender(); + 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(); + expect(await screen.findByTestId("task-detail-workflow-badge")).toHaveTextContent("Docs"); + + rerender(); + 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( + , + ); + + 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(); }); diff --git a/packages/dashboard/app/components/__tests__/WorkflowSwitcher.test.tsx b/packages/dashboard/app/components/__tests__/WorkflowSwitcher.test.tsx index 327e8460ac..b0ee454e6e 100644 --- a/packages/dashboard/app/components/__tests__/WorkflowSwitcher.test.tsx +++ b/packages/dashboard/app/components/__tests__/WorkflowSwitcher.test.tsx @@ -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( + , + ); + + 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( + , + ); + + 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(); diff --git a/packages/dashboard/app/components/__tests__/workflowStatusCounts.test.ts b/packages/dashboard/app/components/__tests__/workflowStatusCounts.test.ts index 91cf86be3e..a7c929db73 100644 --- a/packages/dashboard/app/components/__tests__/workflowStatusCounts.test.ts +++ b/packages/dashboard/app/components/__tests__/workflowStatusCounts.test.ts @@ -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( [ diff --git a/packages/dashboard/app/components/workflowStatusCounts.ts b/packages/dashboard/app/components/workflowStatusCounts.ts index 6d75ff3494..a60f4177b1 100644 --- a/packages/dashboard/app/components/workflowStatusCounts.ts +++ b/packages/dashboard/app/components/workflowStatusCounts.ts @@ -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 @@ -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;