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) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8526-workflow-column-descriptions.md
Normal file
7
.changeset/fn-8526-workflow-column-descriptions.md
Normal file
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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)", () => {
|
||||
|
||||
@@ -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:<pluginId>:<extensionId>`. */
|
||||
extensions?: Record<string, Record<string, unknown>>;
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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] ?? []}
|
||||
|
||||
@@ -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<ReadonlySet<string>>(() => new Set());
|
||||
const [inlineFeedback, setInlineFeedback] = useState<string | null>(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<HTMLDivElement | null>(null);
|
||||
const countFlashing = useFlashOnIncrease(tasks.length);
|
||||
const { confirm } = useConfirm();
|
||||
@@ -855,8 +863,8 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktree
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!isCollapsed && COLUMN_DESCRIPTIONS[column] !== undefined && (
|
||||
<p className="column-desc">{COLUMN_DESCRIPTIONS[column]}</p>
|
||||
{!isCollapsed && resolvedColumnDescription && (
|
||||
<p className="column-desc">{resolvedColumnDescription}</p>
|
||||
)}
|
||||
{!isCollapsed && inlineFeedback && (
|
||||
<p className="column-inline-feedback" role="status" data-testid="column-inline-feedback">
|
||||
|
||||
@@ -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({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="wf-column-description-field">
|
||||
<span>{t("workflowColumns.description", "Description")}</span>
|
||||
<textarea
|
||||
className="wf-column-description"
|
||||
aria-label={t("workflowColumns.descriptionLabel", "Column description")}
|
||||
value={col.description ?? ""}
|
||||
disabled={readOnly}
|
||||
placeholder={t("workflowColumns.descriptionPlaceholder", "Optional explanation for this column")}
|
||||
onChange={(event) => setColumnDescription(col.id, event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{colViolations.map((v, i) => (
|
||||
<p key={`${v.code}-${i}`} className="wf-column-violation" role="alert">
|
||||
<AlertTriangle size={12} aria-hidden /> {v.message}
|
||||
|
||||
@@ -2071,6 +2071,46 @@ Fusion tokens so light/dark themes never show browser-default white controls.
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.wf-column-description-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.wf-column-description {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: calc(var(--wf-editor-touch-target) * 2);
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 0.78rem;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.wf-column-description:hover:not(:disabled) {
|
||||
border-color: var(--accent);
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
|
||||
.wf-column-description:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
|
||||
.wf-column-description:disabled {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-dim);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.wf-column-item-actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
@@ -2544,6 +2584,7 @@ Column trait toggles are left-sidebar workflow controls; keep their enabled and
|
||||
.wf-field textarea,
|
||||
.wf-field select,
|
||||
.wf-column-name,
|
||||
.wf-column-description,
|
||||
.wf-column-agent-select,
|
||||
.wf-templates-filter,
|
||||
.wf-ai-prompt {
|
||||
|
||||
@@ -290,6 +290,25 @@ describe("Column legacy descriptions", () => {
|
||||
});
|
||||
|
||||
describe("Column workflow mode (U9)", () => {
|
||||
it("preserves multiline workflow descriptions and uses overflow-safe board styling", () => {
|
||||
const description = `Send work to this lane.\nhttps://example.test/${"unbroken-token-".repeat(24)}`;
|
||||
render(
|
||||
<Column
|
||||
{...defaultProps}
|
||||
column={"custom-col" as ColumnType}
|
||||
workflowMode
|
||||
columnDisplayName="Custom lane"
|
||||
columnDescription={description}
|
||||
tasks={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const descriptionElement = document.querySelector(".column-desc");
|
||||
expect(descriptionElement?.textContent).toBe(description);
|
||||
const css = readFileSync(resolve(__dirname, "../../styles.css"), "utf8");
|
||||
expect(css).toMatch(/\.column-desc\s*\{[\s\S]*white-space:\s*pre-wrap;[\s\S]*overflow-wrap:\s*anywhere;/);
|
||||
});
|
||||
|
||||
it("uses the workflow column display name instead of the legacy label", () => {
|
||||
render(
|
||||
<Column
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { cleanup, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Agent, TraitCatalogEntry } from "../../api";
|
||||
@@ -109,6 +109,40 @@ describe("WorkflowColumnPanel", () => {
|
||||
expect(screen.getByTestId("wf-column-agent-select-triage")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("edits populated descriptions, omits cleared values, and leaves new columns description-free", () => {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<WorkflowColumnPanel
|
||||
columns={[{ id: "triage", name: "Triage", description: "Plan work", traits: [{ trait: "intake" }] }]}
|
||||
onChange={onChange}
|
||||
violations={[]}
|
||||
readOnly={false}
|
||||
addToast={vi.fn()}
|
||||
columnAgentsEnabled
|
||||
/>,
|
||||
);
|
||||
|
||||
const description = screen.getByRole("textbox", { name: /Column description/i });
|
||||
expect(description).toHaveValue("Plan work");
|
||||
fireEvent.change(description, { target: { value: "Refined planning guidance" } });
|
||||
expect(onChange).toHaveBeenLastCalledWith([expect.objectContaining({ description: "Refined planning guidance" })]);
|
||||
|
||||
fireEvent.change(description, { target: { value: "" } });
|
||||
expect(onChange).toHaveBeenLastCalledWith([expect.not.objectContaining({ description: expect.anything() })]);
|
||||
|
||||
fireEvent.change(description, { target: { value: " \n\t " } });
|
||||
expect(onChange).toHaveBeenLastCalledWith([expect.not.objectContaining({ description: expect.anything() })]);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Add column/i }));
|
||||
const added = onChange.mock.calls.at(-1)?.[0][1];
|
||||
expect(added).not.toHaveProperty("description");
|
||||
});
|
||||
|
||||
it("keeps the description control visible and disabled for read-only workflows", () => {
|
||||
renderPanel({ readOnly: true, columns: [{ id: "triage", name: "Triage", description: "Built-in guidance", traits: [] }] });
|
||||
expect(screen.getByRole("textbox", { name: /Column description/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("defines tokenized CSS rules for every column-panel selector themed by FN-6400", () => {
|
||||
const css = readFileSync(resolve(__dirname, "../WorkflowNodeEditor.css"), "utf8");
|
||||
const selectors = [
|
||||
@@ -118,6 +152,8 @@ describe("WorkflowColumnPanel", () => {
|
||||
".wf-column-panel-empty",
|
||||
".wf-column-panel-errors",
|
||||
".wf-column-name",
|
||||
".wf-column-description-field",
|
||||
".wf-column-description",
|
||||
".wf-column-traits",
|
||||
".wf-column-agent",
|
||||
".wf-column-agent-label",
|
||||
|
||||
@@ -1943,6 +1943,25 @@ describe("WorkflowNodeEditor — U10 columns/traits/holds", () => {
|
||||
expect(ir.nodes.every((node) => node.column === undefined || columnIds.has(node.column))).toBe(true);
|
||||
});
|
||||
|
||||
it("serializes populated column descriptions on save", async () => {
|
||||
const definition = v2Def();
|
||||
if (definition.ir.version === "v2") definition.ir.columns[0] = {
|
||||
...definition.ir.columns[0],
|
||||
description: "Initial planning guidance",
|
||||
};
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([definition]);
|
||||
vi.mocked(updateWorkflow).mockImplementation(async (_id, updates) => ({ ...definition, ...(updates as object) }));
|
||||
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
const [description] = await screen.findAllByRole("textbox", { name: /Column description/i });
|
||||
fireEvent.change(description, { target: { value: "Saved planning guidance" } });
|
||||
fireEvent.click(screen.getByText("Save").closest("button")!);
|
||||
await waitFor(() => expect(updateWorkflow).toHaveBeenCalled());
|
||||
const saved = vi.mocked(updateWorkflow).mock.calls.at(-1)?.[1] as { ir: { columns: { description?: string }[] } };
|
||||
expect(saved.ir.columns[0].description).toBe("Saved planning guidance");
|
||||
|
||||
});
|
||||
|
||||
it("saves a valid v2 workflow round-tripping columns to the API", async () => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]);
|
||||
vi.mocked(updateWorkflow).mockImplementation(async (_id, updates) => ({
|
||||
|
||||
@@ -865,6 +865,7 @@ export function flowToIr(
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
traits: c.traits,
|
||||
...(c.description ? { description: c.description } : {}),
|
||||
...(c.agent ? { agent: c.agent } : {}),
|
||||
}))
|
||||
: [],
|
||||
|
||||
@@ -1240,10 +1240,18 @@ Map it to the established triage token so the decorative header indicator remain
|
||||
animation: count-flash-bg 1400ms ease-out;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:WorkflowColumnDescriptions 2026-07-22-12:35:
|
||||
Custom workflow descriptions are author-entered explanatory text shared by selected,
|
||||
aggregate, and archived board columns. Preserve explicit line breaks and break long
|
||||
unspaced values so narrow desktop and mobile lanes do not clip the copy.
|
||||
*/
|
||||
.column-desc {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-dim);
|
||||
padding: var(--space-xs) calc(var(--space-lg) - 2px) calc(var(--space-md) - 2px);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.column-body {
|
||||
|
||||
59
packages/dashboard/src/__tests__/board-workflows.test.ts
Normal file
59
packages/dashboard/src/__tests__/board-workflows.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { BUILTIN_CODING_WORKFLOW_IR, type WorkflowIr } from "@fusion/core";
|
||||
import { buildBoardWorkflowsPayload } from "../routes/board-workflows.js";
|
||||
|
||||
const CUSTOM_WORKFLOW_ID = "WF-DESCRIPTIONS";
|
||||
|
||||
function customWorkflowIr(columns: WorkflowIr["columns"]): WorkflowIr {
|
||||
return {
|
||||
...BUILTIN_CODING_WORKFLOW_IR,
|
||||
name: "Description workflow",
|
||||
columns,
|
||||
};
|
||||
}
|
||||
|
||||
function makeStore(ir: WorkflowIr) {
|
||||
return {
|
||||
getSettings: vi.fn(),
|
||||
getTaskWorkflowSelection: vi.fn((taskId: string) => taskId === "FN-CUSTOM" ? { workflowId: CUSTOM_WORKFLOW_ID } : null),
|
||||
getWorkflowDefinition: vi.fn(async (id: string) => id === CUSTOM_WORKFLOW_ID ? {
|
||||
id: CUSTOM_WORKFLOW_ID,
|
||||
name: "Description workflow",
|
||||
description: "",
|
||||
kind: "workflow",
|
||||
ir,
|
||||
layout: {},
|
||||
createdAt: "2026-07-22T00:00:00.000Z",
|
||||
updatedAt: "2026-07-22T00:00:00.000Z",
|
||||
} : undefined),
|
||||
listWorkflowDefinitions: vi.fn(async () => []),
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:WorkflowColumnDescriptions 2026-07-22-12:35:
|
||||
The board-workflows bridge must preserve author-defined column copy without
|
||||
inventing empty values; Column applies the lifecycle fallback only after this
|
||||
projection keeps an omitted description absent.
|
||||
*/
|
||||
describe("buildBoardWorkflowsPayload column descriptions", () => {
|
||||
it("projects populated descriptions and omits legacy columns without custom copy", async () => {
|
||||
const columns = BUILTIN_CODING_WORKFLOW_IR.columns.map((column, index) => (
|
||||
index === 0
|
||||
? { ...column, description: "Plan work\nwith the team" }
|
||||
: { ...column }
|
||||
));
|
||||
const payload = await buildBoardWorkflowsPayload(
|
||||
makeStore(customWorkflowIr(columns)) as never,
|
||||
["FN-CUSTOM"],
|
||||
{ experimentalFeatures: { workflowColumns: true } },
|
||||
);
|
||||
|
||||
const workflow = payload.workflows.find(({ id }) => id === CUSTOM_WORKFLOW_ID);
|
||||
expect(workflow?.columns[0]).toMatchObject({
|
||||
id: BUILTIN_CODING_WORKFLOW_IR.columns[0].id,
|
||||
description: "Plan work\nwith the team",
|
||||
});
|
||||
expect(workflow?.columns[1]).not.toHaveProperty("description");
|
||||
});
|
||||
});
|
||||
@@ -43,6 +43,8 @@ export const DEFAULT_WORKFLOW_LANE_ID = "builtin:coding";
|
||||
export interface BoardWorkflowColumn {
|
||||
id: string;
|
||||
name: string;
|
||||
/** Optional author-defined explanatory copy; omitted keeps client lifecycle fallback. */
|
||||
description?: string;
|
||||
flags: TraitFlags;
|
||||
}
|
||||
|
||||
@@ -103,6 +105,7 @@ function describeColumns(ir: WorkflowIr, canonicalizeLifecycle = false): BoardWo
|
||||
return v2.columns.map((col) => ({
|
||||
id: col.id,
|
||||
name: displayColumnName(col.id, col.name, canonicalizeLifecycle),
|
||||
...(col.description ? { description: col.description } : {}),
|
||||
flags: resolveColumnFlags(col),
|
||||
}));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user