diff --git a/.changeset/fn-8526-workflow-column-descriptions.md b/.changeset/fn-8526-workflow-column-descriptions.md new file mode 100644 index 0000000000..c927b8fd3a --- /dev/null +++ b/.changeset/fn-8526-workflow-column-descriptions.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add optional explanatory descriptions to custom workflow board columns. +category: feature +dev: Workflow IR column descriptions are projected to selected, aggregate, and archived boards. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index feac50cb25..da054af655 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -2177,3 +2177,7 @@ Chat can queue `fn_task_request_verification` for an **in-progress** task that h Productivity duration uses total agent-active time: planning (`cumulativePlanningMs`) plus execution (`cumulativeActiveMs`); queued column dwell is not included. + +### Custom workflow column descriptions + +Custom workflow authors can add optional explanatory copy beneath each column name in the workflow editor. The description appears on selected, aggregate, and archived workflow board columns. Clearing it removes the custom metadata; columns then continue to use the standard lifecycle description when one exists. diff --git a/packages/core/src/__tests__/workflow-ir-validation.test.ts b/packages/core/src/__tests__/workflow-ir-validation.test.ts index 7ca87e93fa..41158e8c8f 100644 --- a/packages/core/src/__tests__/workflow-ir-validation.test.ts +++ b/packages/core/src/__tests__/workflow-ir-validation.test.ts @@ -22,6 +22,36 @@ import { planReviewOptionalGroupNode } from "../builtin-plan-review-group.js"; import { completionSummaryNode } from "../builtin-completion-summary-node.js"; import { computeRemovedOccupiedColumns } from "../workflow-reconciliation.js"; +// ── Column descriptions ─────────────────────────────────────────────────────── + +describe("workflow IR validation — column descriptions", () => { + it("preserves omitted and populated descriptions through parsing", () => { + const parsed = parseWorkflowIr({ + ...BUILTIN_CODING_WORKFLOW_IR, + columns: BUILTIN_CODING_WORKFLOW_IR.columns.map((column, index) => ( + index === 0 ? { ...column, description: "Explain this workflow stage" } : { ...column } + )), + }); + + expect(parsed.columns[0]).toMatchObject({ description: "Explain this workflow stage" }); + expect(parsed.columns.slice(1).every((column) => column.description === undefined)).toBe(true); + }); + + it("rejects a non-string description without weakening duplicate-column validation", () => { + expect(() => parseWorkflowIr({ + ...BUILTIN_CODING_WORKFLOW_IR, + columns: BUILTIN_CODING_WORKFLOW_IR.columns.map((column, index) => ( + index === 0 ? { ...column, description: 42 } : { ...column } + )), + } as unknown as WorkflowIr)).toThrow("Workflow IR column 'triage' description must be a string"); + + expect(() => parseWorkflowIr({ + ...BUILTIN_CODING_WORKFLOW_IR, + columns: [...BUILTIN_CODING_WORKFLOW_IR.columns, { ...BUILTIN_CODING_WORKFLOW_IR.columns[0] }], + })).toThrow("Workflow IR has duplicate column id 'triage'"); + }); +}); + // ── Save-time hard errors ───────────────────────────────────────────────────── describe("workflow IR validation — capacity release topology (hard error)", () => { diff --git a/packages/core/src/workflow-ir-types.ts b/packages/core/src/workflow-ir-types.ts index fd7e9ac61f..922f70de53 100644 --- a/packages/core/src/workflow-ir-types.ts +++ b/packages/core/src/workflow-ir-types.ts @@ -364,6 +364,10 @@ export interface WorkflowColumnAgent { export interface WorkflowIrColumn { id: string; name: string; + /** Optional author-defined explanatory copy. Omission is the compatible default + * for columns without custom copy, allowing board renderers to use lifecycle + * descriptions where available. */ + description?: string; traits: WorkflowIrColumnTrait[]; /** Plugin-namespaced extension metadata keyed as `plugin::`. */ extensions?: Record>; diff --git a/packages/core/src/workflow-ir.ts b/packages/core/src/workflow-ir.ts index 4a7e8a1c2f..8e3c6e453f 100644 --- a/packages/core/src/workflow-ir.ts +++ b/packages/core/src/workflow-ir.ts @@ -1435,6 +1435,15 @@ function validateColumns(ir: WorkflowIrV2): void { throw new WorkflowIrError(`Workflow IR has duplicate column id '${column.id}'`); } seen.add(column.id); + /* + FNXC:WorkflowColumnDescriptions 2026-07-22-12:00: + FN-8526 makes column explanatory copy first-class workflow metadata. Keep + its absent form as omission (not null) so existing definitions retain board + lifecycle-description fallback while arbitrary author string content round-trips. + */ + if (column.description !== undefined && typeof column.description !== "string") { + throw new WorkflowIrError(`Workflow IR column '${column.id}' description must be a string`); + } if (!Array.isArray(column.traits)) { throw new WorkflowIrError(`Workflow IR column '${column.id}' traits must be an array`); } diff --git a/packages/dashboard/app/api/board-workflows.ts b/packages/dashboard/app/api/board-workflows.ts index e92a2f910c..addd54f75c 100644 --- a/packages/dashboard/app/api/board-workflows.ts +++ b/packages/dashboard/app/api/board-workflows.ts @@ -51,6 +51,8 @@ export interface BoardWorkflowColumnFlags { export interface BoardWorkflowColumn { id: string; name: string; + /** Optional author-defined explanatory copy from the workflow IR. */ + description?: string; flags: BoardWorkflowColumnFlags; } diff --git a/packages/dashboard/app/components/Board.tsx b/packages/dashboard/app/components/Board.tsx index 985ed42c50..33933e0ab1 100644 --- a/packages/dashboard/app/components/Board.tsx +++ b/packages/dashboard/app/components/Board.tsx @@ -950,6 +950,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o column={columnDef.id as ColumnType} workflowMode columnDisplayName={columnDef.name} + columnDescription={columnDef.description} columnFlags={columnDef.flags} taskContextMenuColumnsByTaskId={taskContextMenuColumnsByTaskId} tasks={aggregateTasksByColumn[columnDef.id] ?? []} @@ -1031,6 +1032,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o workflowMode workflowId={selectedWorkflow.id} columnDisplayName={columnDef.name} + columnDescription={columnDef.description} columnFlags={columnDef.flags} workflowContextMenuColumns={selectedWorkflowContextMenuColumns} tasks={selectedWorkflowTasksByColumn[columnDef.id] ?? []} @@ -1090,6 +1092,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o workflowMode workflowId={selectedWorkflow.id} columnDisplayName={selectedWorkflowArchivedColumn.name} + columnDescription={selectedWorkflowArchivedColumn.description} columnFlags={selectedWorkflowArchivedColumn.flags} workflowContextMenuColumns={selectedWorkflowContextMenuColumns} tasks={selectedWorkflowTasksByColumn[selectedWorkflowArchivedColumn.id] ?? []} diff --git a/packages/dashboard/app/components/Column.tsx b/packages/dashboard/app/components/Column.tsx index b7a3fdabc2..101f3fcbe8 100644 --- a/packages/dashboard/app/components/Column.tsx +++ b/packages/dashboard/app/components/Column.tsx @@ -187,6 +187,8 @@ interface ColumnProps { defaultWorkflowId?: string | null; /** Display name for this column, from the workflow definition. */ columnDisplayName?: string; + /** Optional explanatory copy from the workflow definition. */ + columnDescription?: string; /** Resolved trait flags for this column (workflow mode). */ columnFlags?: BoardWorkflowColumnFlags; /** Ordered workflow columns for deriving context-menu move targets in workflow mode. */ @@ -208,7 +210,7 @@ interface ColumnProps { getDraggingTaskId?: () => string | null; } -function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktreeGrouping, onMoveTask, onPauseTask, onUnpauseTask, onResetTask, onDuplicateTask, onMergeTask, onOpenDetail, onOpenRefine, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, mergeStrategy = "direct", onToggleAutoMerge, planAutoApproveEnabled, onTogglePlanAutoApprove, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onRevertTask, onDeleteTask, onArchiveAllDone, doneSortMode, onDoneSortModeChange, collapsed, onToggleCollapse, archivedHasMore, archivedLoadingMore, onLoadMoreArchived, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, taskStuckTimeoutMs, onOpenMission, lastFetchTimeMs, taskCardFieldDefs, taskWorkflowBadges, blockerFanoutMap, prAuthAvailable, workflowMode, workflowId, workflowOptions, defaultWorkflowId, columnDisplayName, columnFlags, workflowContextMenuColumns, taskContextMenuColumnsByTaskId, onPromote, canDropTask, getDraggingTaskId }: ColumnProps) { +function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktreeGrouping, onMoveTask, onPauseTask, onUnpauseTask, onResetTask, onDuplicateTask, onMergeTask, onOpenDetail, onOpenRefine, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, mergeStrategy = "direct", onToggleAutoMerge, planAutoApproveEnabled, onTogglePlanAutoApprove, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onRevertTask, onDeleteTask, onArchiveAllDone, doneSortMode, onDoneSortModeChange, collapsed, onToggleCollapse, archivedHasMore, archivedLoadingMore, onLoadMoreArchived, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, taskStuckTimeoutMs, onOpenMission, lastFetchTimeMs, taskCardFieldDefs, taskWorkflowBadges, blockerFanoutMap, prAuthAvailable, workflowMode, workflowId, workflowOptions, defaultWorkflowId, columnDisplayName, columnDescription, columnFlags, workflowContextMenuColumns, taskContextMenuColumnsByTaskId, 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 @@ -231,6 +233,12 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktree // Workflow mode: per-card promote in-flight ids + inline capacity feedback. const [promotingIds, setPromotingIds] = useState>(() => new Set()); const [inlineFeedback, setInlineFeedback] = useState(null); + /* + FNXC:WorkflowColumnDescriptions 2026-07-22-12:30: + Whitespace-only values can exist in pre-editor/custom IR. Treat them as + absent so they retain lifecycle fallback rather than creating a blank shell. + */ + const resolvedColumnDescription = columnDescription?.trim() ? columnDescription : COLUMN_DESCRIPTIONS[column]; const menuRef = useRef(null); const countFlashing = useFlashOnIncrease(tasks.length); const { confirm } = useConfirm(); @@ -855,8 +863,8 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktree )} - {!isCollapsed && COLUMN_DESCRIPTIONS[column] !== undefined && ( -

{COLUMN_DESCRIPTIONS[column]}

+ {!isCollapsed && resolvedColumnDescription && ( +

{resolvedColumnDescription}

)} {!isCollapsed && inlineFeedback && (

diff --git a/packages/dashboard/app/components/WorkflowColumnPanel.tsx b/packages/dashboard/app/components/WorkflowColumnPanel.tsx index 9f60911e87..b8d15edc92 100644 --- a/packages/dashboard/app/components/WorkflowColumnPanel.tsx +++ b/packages/dashboard/app/components/WorkflowColumnPanel.tsx @@ -163,6 +163,25 @@ export function WorkflowColumnPanel({ [columns, onChange], ); + const setColumnDescription = useCallback( + (id: string, description: string) => { + onChange(columns.map((column) => { + if (column.id !== id) return column; + /* + FNXC:WorkflowColumnDescriptions 2026-07-22-12:30: + A whitespace-only editor value has no explanatory content. Omit it so + board lifecycle fallback remains available instead of rendering a blank shell. + */ + if (!description.trim()) { + const { description: _omit, ...withoutDescription } = column; + return withoutDescription; + } + return { ...column, description }; + })); + }, + [columns, onChange], + ); + const removeColumn = useCallback( (id: string) => { onChange(columns.filter((c) => c.id !== id)); @@ -295,6 +314,18 @@ export function WorkflowColumnPanel({ +