From de5d446b5c3f4da8b241b0f4202c83b166cdf7c2 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 22 Jul 2026 22:37:51 -0700 Subject: [PATCH] FN-8526: add custom workflow column descriptions Enable custom workflow authors to define and display descriptions for board columns. - Persist optional descriptions in workflow column definitions and API responses. - Add editor controls and board tooltips for column descriptions. - Cover validation, API, and dashboard behavior with tests and documentation. Files changed: .changeset/fn-8526-workflow-column-descriptions.md | 7 +++ docs/dashboard-guide.md | 4 ++ .../src/__tests__/workflow-ir-validation.test.ts | 30 +++++++++++ packages/core/src/workflow-ir-types.ts | 4 ++ packages/core/src/workflow-ir.ts | 9 ++++ packages/dashboard/app/api/board-workflows.ts | 2 + packages/dashboard/app/components/Board.tsx | 3 ++ packages/dashboard/app/components/Column.tsx | 14 +++-- .../app/components/WorkflowColumnPanel.tsx | 31 ++++++++++++ .../app/components/WorkflowNodeEditor.css | 41 +++++++++++++++ .../app/components/__tests__/Column.test.tsx | 19 +++++++ .../__tests__/WorkflowColumnPanel.test.tsx | 38 ++++++++++++++- .../app/components/__tests__/WorkflowNodeEditor.test.tsx | 19 +++++++ .../app/components/workflow-flow-mapping.ts | 1 + packages/dashboard/app/styles.css | 8 +++ .../src/__tests__/board-workflows.test.ts | 59 ++++++++++++++++++++++ packages/dashboard/src/routes/board-workflows.ts | 3 ++ 17 files changed, 288 insertions(+), 4 deletions(-) Fusion-Task-Id: FN-8526 Fusion-Task-Lineage: df26b7c0-301c-4875-b100-d63d0cfecf85 Co-authored-by: Fusion (runfusion.ai) --- .../fn-8526-workflow-column-descriptions.md | 7 +++ docs/dashboard-guide.md | 4 ++ .../__tests__/workflow-ir-validation.test.ts | 30 ++++++++++ packages/core/src/workflow-ir-types.ts | 4 ++ packages/core/src/workflow-ir.ts | 9 +++ packages/dashboard/app/api/board-workflows.ts | 2 + packages/dashboard/app/components/Board.tsx | 3 + packages/dashboard/app/components/Column.tsx | 14 ++++- .../app/components/WorkflowColumnPanel.tsx | 31 ++++++++++ .../app/components/WorkflowNodeEditor.css | 41 +++++++++++++ .../app/components/__tests__/Column.test.tsx | 19 ++++++ .../__tests__/WorkflowColumnPanel.test.tsx | 38 +++++++++++- .../__tests__/WorkflowNodeEditor.test.tsx | 19 ++++++ .../app/components/workflow-flow-mapping.ts | 1 + packages/dashboard/app/styles.css | 8 +++ .../src/__tests__/board-workflows.test.ts | 59 +++++++++++++++++++ .../dashboard/src/routes/board-workflows.ts | 3 + 17 files changed, 288 insertions(+), 4 deletions(-) create mode 100644 .changeset/fn-8526-workflow-column-descriptions.md create mode 100644 packages/dashboard/src/__tests__/board-workflows.test.ts 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({ +