diff --git a/.changeset/fn-7285-workflow-icons.md b/.changeset/fn-7285-workflow-icons.md new file mode 100644 index 0000000000..118d2e68e2 --- /dev/null +++ b/.changeset/fn-7285-workflow-icons.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Show workflow identity with icons instead of built-in text suffixes. +category: feature +dev: Adds optional custom workflow icon metadata and renders Fusion icons for built-in workflows. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index bd13e6739a..787931b18e 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -137,8 +137,8 @@ Features: - The quick-entry GitHub icon is a per-task tracking override: leave it untouched to use the project default, turn it on to opt the next task into tracking when the default is off, or turn it off to opt the next task out when the default is on. - PR/issue badges with live updates - GitHub provenance marker on task cards imported from GitHub (`sourceType: github_import`), shown in the footer with other external-source metadata -- Task card header meta badges group priority, fast mode, agent-created provenance, workflow name, and elapsed/created-time chips into one wrapping row; agent labels prefer `sourceMetadata.agentName` over raw agent IDs -- Task detail surfaces show the selected/effective workflow name near the task's workflow controls so individual cards remain understandable when Board is in **All workflows** or another aggregate/mixed context. +- Task card header meta badges group priority, fast mode, agent-created provenance, workflow identity, and elapsed/created-time chips into one wrapping row; agent labels prefer `sourceMetadata.agentName` over raw agent IDs +- Task detail surfaces show the selected/effective workflow identity near the task's workflow controls so individual cards remain understandable when Board is in **All workflows** or another aggregate/mixed context. - Board task cards support a context menu from right-click, keyboard context menu / Shift+F10, or touch long-press for detail-aligned lifecycle actions without changing normal card clicks. Actions that require additional detail-only UI, such as opening the refinement feedback modal, remain available from task detail. @@ -155,7 +155,7 @@ FNXC:WorkflowSelection 2026-06-30-00:00: The Board view preference persists eith -- 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. Hidden columns stay workflow-scoped in the aggregate: a task whose effective workflow hides a shared column is omitted from that aggregate column even if another workflow exposes the same column id. That option is not editable, is persisted only as a Board view preference, 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`. +- Board and List workflow switchers use a themed dropdown instead of a native select. The closed trigger shows the workflow identity (Fusion icon for built-ins, optional custom icon for custom workflows), 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. Hidden columns stay workflow-scoped in the aggregate: a task whose effective workflow hides a shared column is omitted from that aggregate column even if another workflow exposes the same column id. That option is not editable, is persisted only as a Board view preference, 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, respecification flows, or refinement creation from Task Detail and done-task chat; Board also restores the dashboard-only **All workflows** aggregate view when that was the last selected Board view. If a saved real workflow is later deleted, Fusion falls back to a valid default/first workflow so tasks remain visible. - Briefly leaving Board/List for a task detail or another non-task-SSE view preserves the current in-memory task snapshot. Returning to Board/List reuses that fresh snapshot immediately and restores live SSE updates without an extra all-task fetch; Fusion still runs one catch-up fetch when task data is missing, stale, or from a failed refresh. @@ -243,7 +243,7 @@ Navigation: Behavior: - Opens a workflow node editor with a workflow list/sidebar, canvas, inspector, and settings/authoring panels - Built-in workflows are inspectable in the same canvas as custom workflows, including connected success, failure, and rework edges for their graph topology. Their graph structure stays read-only, but prompt/gate node Prompt fields can be edited per project and reset to the shipped default from the node inspector or expanded prompt editor. -- Custom workflows can be created from blank, duplicated from built-ins/custom definitions, imported/exported, AI-designed, validated, and saved from the editor. +- Custom workflows can be created from blank, duplicated from built-ins/custom definitions, imported/exported, AI-designed, validated, and saved from the editor. Custom workflows can carry an optional compact plain-text icon; built-in workflow rows use the Fusion mark instead of a textual built-in suffix. - Optional-group node inspectors include controls for `defaultOn` and per-step **Max revisions** (`maxRevisions`), including an **Unbounded** toggle for Code Review, Browser Verification, or custom pre-merge gates that should keep cycling until they approve. @@ -919,7 +919,7 @@ Features: - **Productivity** separates outcome counters (commits and pull requests), task-duration stats, and volume proxies such as modified files, lines changed, and files by language. The task-duration block counts done tasks completed in the selected range and shows average, median, p90, and total active execution time from `cumulativeActiveMs`; when no qualifying duration data exists, duration values render the unavailable `—` sentinel rather than `0`. The Lines changed card includes **Preview LOC backfill**, an explicit operator control for historical commit-association diff stats. Preview runs the project-scoped backfill in dry-run mode by default and reports scanned rows, distinct commits, updated rows, skipped unavailable commits, and skipped invalid SHAs without writing; **Apply backfill** appears after a preview and requires danger confirmation before persisting additions/deletions to `task_commit_associations`, then renders the same counts as an applied report. It keeps the files-by-language bar and adds a language-share pie from `ProductivityAnalytics.byLanguage`. There is intentionally no productivity line chart because the current productivity response has no per-day throughput or completion time series; no new endpoint is called. - **Team** shows the read-only agent org chart, heartbeat pause/resume backed by the existing `enginePaused` setting, a per-agent analytics table, tokens-by-agent and tasks-done-by-agent charts, and a real token-share pie from the same per-agent token totals. The org chart is styled by Command Center's Team CSS, not lazy Agents view CSS, auto-switches to a horizontal top-down tree when the container is wide enough using the same breakpoint resolver as the full Agents view, and otherwise keeps the vertical nested list inside the taller scrollable org-chart container. The org-chart scroll container supports mouse click-and-drag panning while touch devices keep native scrolling. Parent agents draw connector lines to child agents in both horizontal and vertical Team layouts across desktop and mobile breakpoints. Org nodes show only agent names so role/title description/meta text does not clutter Team operations. Metrics come only from the project-scoped `tasks` and `agents` tables: token totals and estimated cost are summed from the `tokenUsage*` columns by `assignedAgentId`, files changed counts parsed `tasks.modifiedFiles` paths, tasks done counts `column = 'done'` moves in the selected range, and in-progress / in-review values reflect current task columns. Agent name, role, and live state come from the `agents` table; deleted-agent task history falls back to the raw agent id instead of crashing. The tab uses `/api/command-center/team`, adds no schema, never calls GitHub, and intentionally leaves per-agent issues filed/fixed to FN-6653. Team has no per-day analytics series today, so it intentionally does not render a line chart or fabricate a trend. Decorative chart reveal motion uses duration tokens and is disabled for reduced-motion users. -- **Workflows** breaks down selected-range token totals, estimated cost, tasks done, in-progress and in-review counts, and files changed by workflow. Tasks with an explicit workflow selection appear under that built-in or custom workflow name; tasks without a selection are attributed to the project default workflow. Unknown model pricing keeps the unavailable `—` cost sentinel rather than displaying `$0`, matching Tokens and Team cost semantics. +- **Workflows** breaks down selected-range token totals, estimated cost, tasks done, in-progress and in-review counts, and files changed by workflow. Tasks with an explicit workflow selection appear under that built-in or custom workflow identity; tasks without a selection are attributed to the project default workflow. Unknown model pricing keeps the unavailable `—` cost sentinel rather than displaying `$0`, matching Tokens and Team cost semantics. - **Ecosystem** shows active model breadth, per-model task activity, and real plugin activations for the selected range. Plugin activation counts come from project-scoped plugin/extension load events via `/api/command-center/plugin-activations`; if no activation rows exist in range, the metric renders unavailable (`—`) rather than fabricating zero. The tab still reuses the tokens analytics endpoint grouped by model, adds a task-share-by-model pie from `TokenAnalytics.groups`, and renders a tokens/tasks trend line when `TokenAnalytics.series` buckets are present; if series buckets are absent, no synthetic trend is shown. - **GitHub** shows local GitHub issue flow for the selected range: **Filed by Fusion** counts tasks with a persisted `githubTracking.issue`, **Fixed by Fusion** counts tasks imported from GitHub source issues (`sourceIssueProvider = "github"`) that are currently in `done`, using the persisted `sourceIssueClosedAt` / `TaskSourceIssue.closedAt` close time when the reconciler has observed it. Rows that predate the field or have not been observed closed fall back to task `updatedAt` as the documented completion-time approximation; Fusion never fabricates a close timestamp and this analytics path never calls GitHub, the `gh` CLI, or any external network source. To make historical fixed dates exact, use **Backfill exact close times** in the Fixed by Fusion card; the dashboard calls the project-scoped manual `POST /api/git/github/backfill-source-issue-closed-at` endpoint in `{ offset, limit }` batches until `hasMore` is false, then surfaces the accumulated `scanned`, `filled`, `skipped`, and `errors` counts. The endpoint fetches real GitHub `closed_at` values once, fills only missing `sourceIssueClosedAt` values, and never runs automatically or from analytics-time rendering. The area shows filed/fixed/net stat cards, a filed-vs-fixed pie, a filed/fixed recharts trend line, existing daily sparklines, a by-repository bar breakdown, and a **Resolved issues** detail list. Resolved rows include the Fusion task, repository, source issue number, optional issue link, resolved timestamp, and whether that timestamp is exact (`sourceIssueClosedAt`) or the documented `updatedAt` approximation; missing issue URLs render as plain text rather than empty anchors or click targets. The same resolved rows are available from the GitHub analytics payload as `resolved` and from the CSV export. @@ -1040,7 +1040,7 @@ Inspect task definition, logs, review feedback, comments, artifacts, workflow ou - 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 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. +- Task metadata keeps priority, execution mode, provenance, optional workflow identity, 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/cli/skill/fusion/references/extension-tools.md b/packages/cli/skill/fusion/references/extension-tools.md index 51555bcc91..095ea51425 100644 --- a/packages/cli/skill/fusion/references/extension-tools.md +++ b/packages/cli/skill/fusion/references/extension-tools.md @@ -160,6 +160,7 @@ Create a custom Fusion workflow definition from a validated workflow IR. |-----------|------|----------|-------------| | `name` | string | ✓ | Workflow name (required, non-empty). | | `description` | string | — | Optional human-readable description. | +| `icon` | string | — | Optional compact plain-text icon for this custom workflow. | | `ir` | unknown | ✓ | Workflow graph (intermediate representation). Validated server-side; a malformed graph is rejected. | | `layout` | record | — | Optional node layout map keyed by node id. | | `confirm_policy_escalation` | boolean | — | Set true to confirm binding a column to an agent whose permission policy is broader (more privileged) than the project default. Required when such a binding is present; the create is otherwise rejected naming the offending column. | @@ -173,6 +174,7 @@ Update a custom Fusion workflow definition's metadata, IR, or layout. | `workflow_id` | string | ✓ | The workflow definition ID to update (built-ins cannot be edited). | | `name` | string | — | New name. | | `description` | string | — | New description. | +| `icon` | string | — | New compact plain-text icon; blank clears it. | | `ir` | unknown | — | Replacement workflow graph (validated server-side). | | `layout` | record | — | Replacement node layout map. | | `rehome_to` | string | — | When an IR update removes a column that still holds cards, supply the column id to re-home those occupants into. Required to resolve an OccupiedColumns conflict; the target must exist in the new IR. | diff --git a/packages/core/src/__tests__/builtin-workflows.test.ts b/packages/core/src/__tests__/builtin-workflows.test.ts index 8cd0e33c25..43eaf22197 100644 --- a/packages/core/src/__tests__/builtin-workflows.test.ts +++ b/packages/core/src/__tests__/builtin-workflows.test.ts @@ -339,7 +339,7 @@ describe("built-in workflows", () => { const coding = getBuiltinWorkflow("builtin:coding"); expect(coding).toBeDefined(); expect(coding!.id).toBe("builtin:coding"); - expect(coding!.name).toBe("Coding (built-in)"); + expect(coding!.name).toBe("Coding"); expect(coding!.description).toContain("optional final code review"); expect(coding!.kind).toBe("workflow"); expect(coding!.createdAt).toBe("2026-01-01T00:00:00.000Z"); @@ -352,13 +352,17 @@ describe("built-in workflows", () => { const legacy = getBuiltinWorkflow("builtin:legacy-coding"); expect(legacy).toBeDefined(); expect(legacy!.id).toBe("builtin:legacy-coding"); - expect(legacy!.name).toBe("Legacy coding (built-in)"); + expect(legacy!.name).toBe("Legacy coding"); expect(legacy!.description).toContain("original monolithic coding pipeline"); expect(legacy!.kind).toBe("workflow"); expect(legacy!.ir).toBe(BUILTIN_CODING_WORKFLOW_IR); expect(serializeWorkflowIr(legacy!.ir)).toBe(serializeWorkflowIr(BUILTIN_CODING_WORKFLOW_IR)); }); + it("built-in workflow names omit textual built-in suffixes", () => { + expect(BUILTIN_WORKFLOWS.map((workflow) => workflow.name)).not.toContainEqual(expect.stringContaining("(built-in)")); + }); + it("linear built-ins use the canonical trait-bearing default columns", () => { expect(BUILTIN_CODING_WORKFLOW_IR.version).toBe("v2"); if (BUILTIN_CODING_WORKFLOW_IR.version !== "v2") throw new Error("expected coding v2"); diff --git a/packages/core/src/__tests__/db-migrate.test.ts b/packages/core/src/__tests__/db-migrate.test.ts index 7421bb9d51..e82c6dd079 100644 --- a/packages/core/src/__tests__/db-migrate.test.ts +++ b/packages/core/src/__tests__/db-migrate.test.ts @@ -1339,9 +1339,11 @@ describe("schema migration", () => { name: string; }>; expect(workflowColumns.map((c) => c.name)).toContain("kind"); - // Existing rows default to 'workflow'. - const wfRow = db.prepare("SELECT kind FROM workflows WHERE id = 'WF-legacy'").get() as { kind: string }; + expect(workflowColumns.map((c) => c.name)).toContain("icon"); + // Existing rows default to 'workflow' and keep no icon metadata. + const wfRow = db.prepare("SELECT kind, icon FROM workflows WHERE id = 'WF-legacy'").get() as { kind: string; icon: string | null }; expect(wfRow.kind).toBe("workflow"); + expect(wfRow.icon).toBeNull(); // FNXC:WorkflowStepCRUD 2026-06-26-14:00: U7c — migration 109 adds // workflow_steps.migrated_fragment_id, but the cutover (migration 131) drops the whole @@ -1370,6 +1372,7 @@ describe("schema migration", () => { expect(reopened.getSchemaVersion()).toBe(SCHEMA_VERSION); const workflowColumns = reopened.prepare("PRAGMA table_info(workflows)").all() as Array<{ name: string }>; expect(workflowColumns.filter((c) => c.name === "kind")).toHaveLength(1); + expect(workflowColumns.filter((c) => c.name === "icon")).toHaveLength(1); const stepTable = reopened .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'workflow_steps'") .get(); diff --git a/packages/core/src/__tests__/workflow-analytics.test.ts b/packages/core/src/__tests__/workflow-analytics.test.ts index 361ac60bee..5ed8cf2daf 100644 --- a/packages/core/src/__tests__/workflow-analytics.test.ts +++ b/packages/core/src/__tests__/workflow-analytics.test.ts @@ -31,11 +31,11 @@ function modifiedFilesValue(value: unknown): string | null { return JSON.stringify(value); } -function insertWorkflow(db: Database, id: string, name: string): void { +function insertWorkflow(db: Database, id: string, name: string, icon?: string): void { db.prepare( - `INSERT INTO workflows (id, name, description, ir, layout, kind, createdAt, updatedAt) - VALUES (?, ?, '', '{"version":"v1","name":"test","nodes":[],"edges":[]}', '{}', 'workflow', ?, ?)`, - ).run(id, name, "2026-03-01T00:00:00.000Z", "2026-03-01T00:00:00.000Z"); + `INSERT INTO workflows (id, name, description, icon, ir, layout, kind, createdAt, updatedAt) + VALUES (?, ?, '', ?, '{"version":"v1","name":"test","nodes":[],"edges":[]}', '{}', 'workflow', ?, ?)`, + ).run(id, name, icon ?? null, "2026-03-01T00:00:00.000Z", "2026-03-01T00:00:00.000Z"); } function insertTask(db: Database, task: TaskSeed): void { @@ -87,7 +87,7 @@ describe("workflow-analytics", () => { }); it("groups selected and unselected tasks under resolved workflow names", () => { - insertWorkflow(db, "WF-custom", "Release workflow"); + insertWorkflow(db, "WF-custom", "Release workflow", "🚀"); insertTask(db, { id: "custom-tokens", workflowId: "WF-custom", @@ -141,6 +141,7 @@ describe("workflow-analytics", () => { const byWorkflow = new Map(result.workflows.map((workflow) => [workflow.workflowId, workflow])); expect(byWorkflow.get("WF-custom")).toMatchObject({ workflowName: "Release workflow", + workflowIcon: "🚀", isBuiltin: false, filesChanged: 2, tasksCompleted: 0, @@ -150,13 +151,13 @@ describe("workflow-analytics", () => { expect(byWorkflow.get("WF-custom")?.tokens.totalTokens).toBe(2_000_000); expect(byWorkflow.get("WF-custom")?.cost).toEqual({ usd: 12.5, unavailable: false, stale: false }); expect(byWorkflow.get("builtin:quick-fix")).toMatchObject({ - workflowName: "Quick fix (built-in)", + workflowName: "Quick fix", isBuiltin: true, filesChanged: 1, tasksCompleted: 1, }); expect(byWorkflow.get("builtin:coding")).toMatchObject({ - workflowName: "Coding (built-in)", + workflowName: "Coding", isBuiltin: true, filesChanged: 1, tasksInProgress: 1, diff --git a/packages/core/src/__tests__/workflow-definition-store.test.ts b/packages/core/src/__tests__/workflow-definition-store.test.ts index 0b30a486de..86da3d5b75 100644 --- a/packages/core/src/__tests__/workflow-definition-store.test.ts +++ b/packages/core/src/__tests__/workflow-definition-store.test.ts @@ -52,6 +52,37 @@ describe("TaskStore workflow definitions (U1)", () => { expect(userList[0].layout.lint).toEqual({ x: 120, y: 0 }); }); + it("creates, lists, updates, and clears compact custom workflow icons", async () => { + const created = await store.createWorkflowDefinition({ + name: "Iconic", + icon: "🚀", + ir: makeIr(), + }); + + expect(created.icon).toBe("🚀"); + expect((await store.getWorkflowDefinition(created.id))?.icon).toBe("🚀"); + const listed = (await store.listWorkflowDefinitions()).find((workflow) => workflow.id === created.id); + expect(listed?.icon).toBe("🚀"); + + const updated = await store.updateWorkflowDefinition(created.id, { icon: " QA " }); + expect(updated.icon).toBe("QA"); + expect((await store.getWorkflowDefinition(created.id))?.icon).toBe("QA"); + + const cleared = await store.updateWorkflowDefinition(created.id, { icon: " " }); + expect(cleared.icon).toBeUndefined(); + expect((await store.getWorkflowDefinition(created.id))?.icon).toBeUndefined(); + }); + + it("normalizes blank workflow icons and rejects unsafe icon metadata", async () => { + const blank = await store.createWorkflowDefinition({ name: "Blank", icon: " ", ir: makeIr() }); + expect(blank.icon).toBeUndefined(); + + await expect(store.createWorkflowDefinition({ name: "Html", icon: "", ir: makeIr() })).rejects.toThrow(/plain text/i); + await expect(store.createWorkflowDefinition({ name: "Url", icon: "http://x.y", ir: makeIr() })).rejects.toThrow(/plain text/i); + await expect(store.createWorkflowDefinition({ name: "Long", icon: "abcdefghijklmnopq", ir: makeIr() })).rejects.toThrow(/16 characters or fewer/i); + await expect(store.updateWorkflowDefinition(blank.id, { icon: "data:x" })).rejects.toThrow(/plain text/i); + }); + it("returns non-blocking lifecycle warnings for custom full workflows", async () => { const created = await store.createWorkflowDefinition({ name: "Unsafe terminal", diff --git a/packages/core/src/builtin-workflows.ts b/packages/core/src/builtin-workflows.ts index 21e6d7db5b..6f4062f6cd 100644 --- a/packages/core/src/builtin-workflows.ts +++ b/packages/core/src/builtin-workflows.ts @@ -327,7 +327,7 @@ function withPostMergeVerificationNode(nodes: BuiltinSpec["nodes"]): BuiltinSpec export const BUILTIN_WORKFLOWS: WorkflowDefinition[] = [ { id: "builtin:coding", - name: "Coding (built-in)", + name: "Coding", description: "Default coding pipeline: plan steps, execute them one at a time, then run the optional final code review and merge.", kind: "workflow", ir: BUILTIN_STEPWISE_FINAL_REVIEW_CODING_WORKFLOW_IR, @@ -358,7 +358,7 @@ export const BUILTIN_WORKFLOWS: WorkflowDefinition[] = [ }, { id: "builtin:legacy-coding", - name: "Legacy coding (built-in)", + name: "Legacy coding", description: "The original monolithic coding pipeline: implement, review, then merge without graph-owned per-step execution.", kind: "workflow", ir: BUILTIN_CODING_WORKFLOW_IR, @@ -393,7 +393,7 @@ export const BUILTIN_WORKFLOWS: WorkflowDefinition[] = [ }, linear({ id: "builtin:quick-fix", - name: "Quick fix (built-in)", + name: "Quick fix", description: "Implement and merge with no review step — for trivial, low-risk changes.", engineeringOptionalGroups: { planReviewDefaultOn: false, @@ -407,7 +407,7 @@ export const BUILTIN_WORKFLOWS: WorkflowDefinition[] = [ }), linear({ id: "builtin:review-heavy", - name: "Review-heavy (built-in)", + name: "Review-heavy", description: "Adds an extra security pass before merge, on top of the standard review.", engineeringOptionalGroups: {}, nodes: [ @@ -427,7 +427,7 @@ export const BUILTIN_WORKFLOWS: WorkflowDefinition[] = [ }), { id: "builtin:marketing", - name: "Marketing (built-in)", + name: "Marketing", description: "Marketing content pipeline: ideate, brief, draft, editorial review, then publish via the standard lifecycle merge primitives.", kind: "workflow", ir: BUILTIN_MARKETING_WORKFLOW_IR, @@ -456,7 +456,7 @@ export const BUILTIN_WORKFLOWS: WorkflowDefinition[] = [ */ linear({ id: "builtin:compound-engineering", - name: "Compound engineering (built-in)", + name: "Compound engineering", description: "Plan → implement → review → document, invoking the compound-engineering skills at each stage.", nodes: [ { @@ -548,7 +548,7 @@ export const BUILTIN_WORKFLOWS: WorkflowDefinition[] = [ // workflow graph runtime. { id: "builtin:stepwise-coding", - name: "Coding (per-step review) (built-in)", + name: "Coding (per-step review)", description: "Per-step review coding pipeline: each planned step runs and is reviewed (approve / revise / rethink) before the next, with bounded rework before final review and merge.", kind: "workflow", @@ -588,7 +588,7 @@ export const BUILTIN_WORKFLOWS: WorkflowDefinition[] = [ */ linear({ id: "builtin:design", - name: "Design (built-in)", + name: "Design", description: "Implement, then run a design/UX review gate before the standard review and merge — for UI-heavy work.", engineeringOptionalGroups: {}, nodes: [ @@ -630,7 +630,7 @@ export const BUILTIN_WORKFLOWS: WorkflowDefinition[] = [ // follow-up work"). { id: "builtin:pr-workflow", - name: "PR lifecycle (built-in)", + name: "PR lifecycle", description: "The unified PR lifecycle as graph nodes: create the PR, await review, respond to changes (bounded rework loop), gate on auto-merge, then merge — with GitHub reconciliation advancing the await holds.", kind: "fragment", @@ -653,7 +653,7 @@ export const BUILTIN_WORKFLOWS: WorkflowDefinition[] = [ }, { id: "builtin:lead-generation", - name: "Lead generation (built-in)", + name: "Lead generation", description: "A business pipeline for sourcing, qualifying, enriching, and contacting leads with custom lead fields and stage columns.", kind: "workflow", diff --git a/packages/core/src/db.ts b/packages/core/src/db.ts index cb653052b0..58dfde117c 100644 --- a/packages/core/src/db.ts +++ b/packages/core/src/db.ts @@ -183,7 +183,7 @@ export function isFts5CorruptionError(error: unknown): boolean { // ── Schema Definition ──────────────────────────────────────────────── -const SCHEMA_VERSION = 133; +const SCHEMA_VERSION = 134; const TASKS_FTS_AUTOMERGE = 8; const TASKS_FTS_CRISISMERGE = 16; @@ -437,6 +437,8 @@ CREATE TABLE IF NOT EXISTS workflows ( id TEXT PRIMARY KEY, name TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', + -- FNXC:WorkflowIcons 2026-06-30-12:03: custom workflow icons persist as short text metadata only. Built-in workflows stay catalog-defined/read-only and render the Fusion mark from their builtin: id rather than storing an icon row. + icon TEXT, ir TEXT NOT NULL, layout TEXT NOT NULL DEFAULT '{}', -- (workflow-editor-consolidation U1, KTD-1) discriminates reusable single-node @@ -5487,6 +5489,13 @@ export class Database { }); } + if (version < 134) { + // FNXC:WorkflowIcons 2026-06-30-12:04: add optional custom workflow icon metadata for migrated DBs. Existing rows remain NULL so legacy custom workflows keep clean names without empty icon shells. + this.applyMigration(134, () => { + this.addColumnIfMissing("workflows", "icon", "TEXT"); + }); + } + } /** diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f8fc4f4929..54f05dfe94 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -372,6 +372,10 @@ export type { WorkflowDefinitionKind, WorkflowNodeLayout, } from "./workflow-definition-types.js"; +export { + MAX_WORKFLOW_ICON_LENGTH, + normalizeWorkflowIcon, +} from "./workflow-definition-types.js"; export { compileWorkflowToSteps, validateLinearity, diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index bb65585532..30a09d595a 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -114,6 +114,7 @@ import type { WorkflowDefinitionUpdate, WorkflowNodeLayout, } from "./workflow-definition-types.js"; +import { normalizeWorkflowIcon } from "./workflow-definition-types.js"; import { compileWorkflowToSteps, isInterpreterDeferredWorkflowCompileError } from "./workflow-compiler.js"; import { analyzeWorkflowLifecycle } from "./workflow-lifecycle-validation.js"; import { resolveDefaultOnOptionalGroupIds } from "./workflow-optional-steps.js"; @@ -14798,6 +14799,7 @@ ${stepsSection}`; id: string; name: string; description: string; + icon?: string | null; ir: string; layout: string; kind?: string | null; @@ -14810,6 +14812,7 @@ ${stepsSection}`; id: row.id, name: row.name, description: row.description, + icon: normalizeWorkflowIcon(row.icon ?? undefined), // Legacy rows (pre-migration-109) have no kind column; default to "workflow". kind, ir, @@ -14861,6 +14864,7 @@ ${stepsSection}`; // not only in the editor's client-side validation. this.assertWorkflowIrTraitsValid(ir); const layout = input.layout ?? {}; + const icon = normalizeWorkflowIcon(input.icon); const now = new Date().toISOString(); const id = this.nextWorkflowDefinitionId(); const kind = input.kind === "fragment" ? "fragment" : "workflow"; @@ -14868,6 +14872,7 @@ ${stepsSection}`; id, name, description: input.description ?? "", + icon, // KTD-1: fragments are pure-v1 IRs and pass through downgradeIrToV1IfPure // unchanged; default to "workflow" when the caller omits the kind. kind, @@ -14886,13 +14891,14 @@ ${stepsSection}`; this.db .prepare( - `INSERT INTO workflows (id, name, description, ir, layout, kind, createdAt, updatedAt) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + `INSERT INTO workflows (id, name, description, icon, ir, layout, kind, createdAt, updatedAt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) .run( definition.id, definition.name, definition.description, + definition.icon ?? null, serializeWorkflowIr( flagOnForCreate ? definition.ir : downgradeIrToV1IfPure(definition.ir), ), @@ -14957,6 +14963,7 @@ ${stepsSection}`; description: string; ir: string; layout: string; + icon?: string | null; kind?: string | null; createdAt: string; updatedAt: string; @@ -14991,6 +14998,7 @@ ${stepsSection}`; description: string; ir: string; layout: string; + icon?: string | null; kind?: string | null; createdAt: string; updatedAt: string; @@ -15098,6 +15106,7 @@ ${stepsSection}`; const name = updates.name !== undefined ? updates.name.trim() : existing.name; if (!name) throw new Error("Workflow name is required"); const ir = updates.ir !== undefined ? parseWorkflowIr(updates.ir) : existing.ir; + const icon = updates.icon !== undefined ? normalizeWorkflowIcon(updates.icon) : existing.icon; // Residual A: reject save-blocking trait composition conflicts server-side // when the IR is being changed. if (updates.ir !== undefined) this.assertWorkflowIrTraitsValid(ir); @@ -15105,6 +15114,7 @@ ${stepsSection}`; ...existing, name, description: updates.description !== undefined ? updates.description : existing.description, + icon, ir, layout: updates.layout !== undefined ? updates.layout : existing.layout, lifecycleWarnings: analyzeWorkflowLifecycle(ir, { kind: existing.kind }), @@ -15113,11 +15123,12 @@ ${stepsSection}`; this.db .prepare( - `UPDATE workflows SET name = ?, description = ?, ir = ?, layout = ?, updatedAt = ? WHERE id = ?`, + `UPDATE workflows SET name = ?, description = ?, icon = ?, ir = ?, layout = ?, updatedAt = ? WHERE id = ?`, ) .run( next.name, next.description, + next.icon ?? null, // Rollback compat (#1405): persist v1 shape when pure and flag OFF. serializeWorkflowIr(flagOn ? next.ir : downgradeIrToV1IfPure(next.ir)), JSON.stringify(next.layout), @@ -15658,12 +15669,14 @@ ${stepsSection}`; const ir = parseWorkflowIr(input.ir); this.assertWorkflowIrTraitsValid(ir); const layout = input.layout ?? {}; + const icon = normalizeWorkflowIcon(input.icon); const now = new Date().toISOString(); const id = this.nextWorkflowDefinitionId(); const definition: WorkflowDefinition = { id, name, description: input.description ?? "", + icon, kind: input.kind === "fragment" ? "fragment" : "workflow", ir, layout, @@ -15672,13 +15685,14 @@ ${stepsSection}`; }; this.db .prepare( - `INSERT INTO workflows (id, name, description, ir, layout, kind, createdAt, updatedAt) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + `INSERT INTO workflows (id, name, description, icon, ir, layout, kind, createdAt, updatedAt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) .run( definition.id, definition.name, definition.description, + definition.icon ?? null, serializeWorkflowIr(flagOn ? definition.ir : downgradeIrToV1IfPure(definition.ir)), JSON.stringify(definition.layout), definition.kind, diff --git a/packages/core/src/workflow-analytics.ts b/packages/core/src/workflow-analytics.ts index 342c943a2a..d74f9ec8b6 100644 --- a/packages/core/src/workflow-analytics.ts +++ b/packages/core/src/workflow-analytics.ts @@ -28,6 +28,7 @@ export interface WorkflowMetricTotals { export interface WorkflowSummary extends WorkflowMetricTotals { workflowId: string; workflowName: string; + workflowIcon?: string; isBuiltin: boolean; } @@ -41,6 +42,7 @@ export interface WorkflowAnalytics { interface WorkflowNameRow { id: string; name: string | null; + icon: string | null; } interface TaskTokenRow { @@ -172,12 +174,13 @@ function addRangeClauses(column: string, clauses: string[], params: string[], qu } } -function resolveWorkflowName(db: Database, workflowId: string): { workflowName: string; isBuiltin: boolean } { +function resolveWorkflowName(db: Database, workflowId: string): { workflowName: string; workflowIcon?: string; isBuiltin: boolean } { const builtin = getBuiltinWorkflow(workflowId) ?? BUILTIN_WORKFLOWS.find((workflow) => workflow.id === workflowId); if (builtin) return { workflowName: builtin.name, isBuiltin: true }; - const row = db.prepare("SELECT id, name FROM workflows WHERE id = ?").get(workflowId) as WorkflowNameRow | undefined; + const row = db.prepare("SELECT id, name, icon FROM workflows WHERE id = ?").get(workflowId) as WorkflowNameRow | undefined; return { workflowName: row?.name && row.name.length > 0 ? row.name : workflowId, + ...(row?.icon && row.icon.length > 0 ? { workflowIcon: row.icon } : {}), isBuiltin: isBuiltinWorkflowId(workflowId), }; } diff --git a/packages/core/src/workflow-definition-types.ts b/packages/core/src/workflow-definition-types.ts index d02013beec..d8f16e9c29 100644 --- a/packages/core/src/workflow-definition-types.ts +++ b/packages/core/src/workflow-definition-types.ts @@ -14,6 +14,31 @@ export interface WorkflowNodeLayout { * compile/selection paths; both kinds are stored as parseable full IRs. */ export type WorkflowDefinitionKind = "workflow" | "fragment"; +export const MAX_WORKFLOW_ICON_LENGTH = 16; + +/** + * Normalize optional custom workflow icon metadata for persistence and API output. + * + * FNXC:WorkflowIcons 2026-06-30-12:00: + * Workflow icons are operator-authored compact plain text, not markup or remote media. + * Reject HTML/SVG/script/URL-shaped values at every write boundary so dashboard renderers can treat the icon as text-only identity metadata. + */ +export function normalizeWorkflowIcon(icon: unknown): string | undefined { + if (icon === undefined || icon === null) return undefined; + if (typeof icon !== "string") { + throw new Error("Workflow icon must be a string"); + } + const normalized = icon.trim(); + if (!normalized) return undefined; + if (normalized.length > MAX_WORKFLOW_ICON_LENGTH) { + throw new Error(`Workflow icon must be ${MAX_WORKFLOW_ICON_LENGTH} characters or fewer`); + } + if (/[<>]/.test(normalized) || /javascript:/i.test(normalized) || /^https?:\/\//i.test(normalized) || /^data:/i.test(normalized)) { + throw new Error("Workflow icon must be plain text, not HTML, SVG, script, or a URL"); + } + return normalized; +} + /** A named, persisted workflow authored as a WorkflowIr graph plus editor layout. */ export interface WorkflowDefinition { /** Unique identifier (e.g., "WF-001"). */ @@ -22,6 +47,8 @@ export interface WorkflowDefinition { name: string; /** Short description for UI display. */ description: string; + /** Optional compact plain-text icon for custom workflow identity. */ + icon?: string; /** Discriminates full workflows from reusable fragment templates (KTD-1). */ kind: WorkflowDefinitionKind; /** The validated workflow graph (v1 IR contract). */ @@ -40,6 +67,8 @@ export interface WorkflowDefinition { export interface WorkflowDefinitionInput { name: string; description?: string; + /** Optional compact plain-text icon for custom workflow identity. */ + icon?: string; /** Workflow graph; validated via parseWorkflowIr on write. */ ir: WorkflowIr; layout?: Record; @@ -52,6 +81,8 @@ export interface WorkflowDefinitionInput { export interface WorkflowDefinitionUpdate { name?: string; description?: string; + /** Optional compact plain-text icon for custom workflow identity; blank clears it. */ + icon?: string | null; ir?: WorkflowIr; layout?: Record; /** diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index d55f4bbbe6..518da8e1d1 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -630,6 +630,8 @@ export type { WorkflowSettingDefinition, WorkflowSettingType, WorkflowSettingOpt export interface BoardWorkflowDefinition { id: string; name: string; + /** Optional compact custom workflow icon; built-ins render the Fusion mark by id. */ + icon?: string; columns: BoardWorkflowColumn[]; /** Custom field definitions declared by this workflow (U13/KTD-14). Absent on * workflows with no fields, or from older servers. */ diff --git a/packages/dashboard/app/components/Board.tsx b/packages/dashboard/app/components/Board.tsx index be0d33d9fe..4d121033b6 100644 --- a/packages/dashboard/app/components/Board.tsx +++ b/packages/dashboard/app/components/Board.tsx @@ -617,11 +617,11 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o return map; }, [cardDefsByWorkflow, getEffectiveTaskWorkflowId, tasks, boardWorkflows]); - const workflowNameById = useMemo(() => { - const map = new Map(); + const workflowIdentityById = useMemo(() => { + const map = new Map(); if (!boardWorkflows) return map; for (const workflow of boardWorkflows.workflows) { - map.set(workflow.id, workflow.name); + map.set(workflow.id, { workflowName: workflow.name, workflowIcon: workflow.icon }); } return map; }, [boardWorkflows]); @@ -631,18 +631,18 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o 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(); + 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 }); + const workflowId = workflowIdentityById.has(assignedWorkflowId) ? assignedWorkflowId : boardWorkflows.defaultWorkflowId; + const workflowIdentity = workflowIdentityById.get(workflowId); + if (workflowIdentity) { + map.set(task.id, { workflowId, ...workflowIdentity }); } } return map; - }, [boardWorkflows, tasks, workflowNameById]); + }, [boardWorkflows, tasks, workflowIdentityById]); /* FNXC:WorkflowBoard 2026-06-29-16:00: diff --git a/packages/dashboard/app/components/Column.tsx b/packages/dashboard/app/components/Column.tsx index ba38bb26ca..16fe956eb8 100644 --- a/packages/dashboard/app/components/Column.tsx +++ b/packages/dashboard/app/components/Column.tsx @@ -155,7 +155,7 @@ interface ColumnProps { /** Per-task card-placed custom field definitions (U13/KTD-14). */ taskCardFieldDefs?: ReadonlyMap; /** Trusted aggregate-board workflow badges keyed by task id; omitted in per-workflow and non-board surfaces. */ - taskWorkflowBadges?: ReadonlyMap; + taskWorkflowBadges?: ReadonlyMap; /** Precomputed blocker fanout keyed by blocker task ID. */ blockerFanoutMap?: ReadonlyMap; /** Whether GitHub CLI auth is available for creating PRs from task cards. */ diff --git a/packages/dashboard/app/components/TaskCard.tsx b/packages/dashboard/app/components/TaskCard.tsx index f2e56dfd47..39a687814b 100644 --- a/packages/dashboard/app/components/TaskCard.tsx +++ b/packages/dashboard/app/components/TaskCard.tsx @@ -38,6 +38,7 @@ import { MAX_AUTO_MERGE_RETRIES, type BlockerFanoutEntry } from "../hooks/useBlo import { useRetryWarning } from "../context/RetryWarningContext"; import { useColumnLabel } from "../i18n/labels"; import { WorkspaceWorktreesSummary, isWorkspaceTask } from "./WorkspaceWorktreesSummary"; +import { WorkflowIcon } from "./WorkflowIcon"; import { TaskContextMenu, buildTaskActionMenuModel, getTaskPrAutomationLabel, type TaskContextMenuColumnFlags, type TaskContextMenuColumnMetadata, type TaskMenuActionDescriptor } from "./TaskContextMenu"; /** Per-branch progress snapshot (U13). Surfaced as an optional additive field @@ -427,7 +428,7 @@ interface TaskCardProps { * 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 }; + workflowBadge?: { workflowId: string; workflowName: string; workflowIcon?: 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). */ @@ -593,6 +594,7 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo previous.nearDuplicateCanonicalInactive === next.nearDuplicateCanonicalInactive && previous.workflowBadge?.workflowId === next.workflowBadge?.workflowId && previous.workflowBadge?.workflowName === next.workflowBadge?.workflowName && + previous.workflowBadge?.workflowIcon === next.workflowBadge?.workflowIcon && previous.taskColumnFlags === next.taskColumnFlags && previous.taskMoveColumns === next.taskMoveColumns && previous.cardFieldDefs === next.cardFieldDefs && @@ -3046,7 +3048,8 @@ function TaskCardComponent({ data-testid="card-workflow-badge" data-workflow-id={workflowBadge.workflowId} > - {workflowBadge.workflowName} + + {workflowBadge.workflowName} )} diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx index ff29c7b72d..f2baf47624 100644 --- a/packages/dashboard/app/components/TaskDetailModal.tsx +++ b/packages/dashboard/app/components/TaskDetailModal.tsx @@ -21,6 +21,7 @@ import { isNearDuplicateCanonicalInactive } from "../../../core/src/near-duplica 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 { BoardWorkflowsPayload, WorkflowFieldDefinition, CustomFieldRejection } from "../api"; +import { WorkflowIcon } from "./WorkflowIcon"; import { ApiRequestError } from "../api"; import { TaskFieldsSection } from "./TaskFieldsSection"; import type { ToastType } from "../hooks/useToast"; @@ -357,13 +358,13 @@ function normalizeTaskPriorityValue(priority: Task["priority"]): TaskPriority { : DEFAULT_TASK_PRIORITY; } -function resolveTaskWorkflowMetadata(payload: BoardWorkflowsPayload, taskId: string): { name: string; fields: WorkflowFieldDefinition[] | null } | null { +function resolveTaskWorkflowMetadata(payload: BoardWorkflowsPayload, taskId: string): { id: string; name: string; icon?: 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 }; + return { id: workflow.id, name, icon: workflow.icon, fields: workflow.fields ?? null }; } function normalizeExecutionModeValue(executionMode: Task["executionMode"]): "standard" | "fast" { @@ -726,7 +727,7 @@ export function TaskDetailContent({ 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); + const [taskWorkflowBadge, setTaskWorkflowBadge] = useState<{ id: string; name: string; icon?: string } | null>(null); // Custom field definitions (U13/KTD-14). Resolved for this task's workflow // from the board-workflows payload; absent when the workflow declares none, // in which case the fields section renders nothing (today's UI byte-identical). @@ -750,7 +751,7 @@ export function TaskDetailContent({ if (workflowFieldDefsProp !== undefined) { // Prop-driven path: keep in sync if the prop changes (task switch etc.). setCustomFieldDefs(workflowFieldDefsProp ?? null); - setTaskWorkflowName(null); + setTaskWorkflowBadge(null); return; } /* @@ -758,19 +759,19 @@ export function TaskDetailContent({ 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); + setTaskWorkflowBadge(null); let cancelled = false; void fetchBoardWorkflows(projectId) .then((payload) => { if (cancelled) return; const metadata = resolveTaskWorkflowMetadata(payload, task.id); setCustomFieldDefs(metadata?.fields ?? null); - setTaskWorkflowName(metadata?.name ?? null); + setTaskWorkflowBadge(metadata ? { id: metadata.id, name: metadata.name, icon: metadata.icon } : null); }) .catch(() => { if (!cancelled) { setCustomFieldDefs(null); - setTaskWorkflowName(null); + setTaskWorkflowBadge(null); } }); return () => { @@ -3088,9 +3089,10 @@ export function TaskDetailContent({ {formatTimestamp(task.updatedAt)} - {taskWorkflowName && ( + {taskWorkflowBadge && ( - {taskWorkflowName} + + {taskWorkflowBadge.name} )} diff --git a/packages/dashboard/app/components/WorkflowIcon.css b/packages/dashboard/app/components/WorkflowIcon.css new file mode 100644 index 0000000000..9181b75c9d --- /dev/null +++ b/packages/dashboard/app/components/WorkflowIcon.css @@ -0,0 +1,42 @@ +.workflow-icon { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + width: 1em; + height: 1em; + color: currentColor; + line-height: 1; +} + +.workflow-icon--builtin { + color: var(--accent); +} + +.workflow-icon-mark { + display: block; + width: 1em; + height: 1em; + overflow: visible; +} + +.workflow-icon-mark circle { + fill: none; + stroke: currentColor; + stroke-width: calc(var(--space-xs) * 2); +} + +.workflow-icon-mark path { + fill: currentColor; +} + +.workflow-icon--custom { + font: inherit; +} + +.workflow-icon-custom-glyph { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1em; +} diff --git a/packages/dashboard/app/components/WorkflowIcon.tsx b/packages/dashboard/app/components/WorkflowIcon.tsx new file mode 100644 index 0000000000..62073d7e73 --- /dev/null +++ b/packages/dashboard/app/components/WorkflowIcon.tsx @@ -0,0 +1,49 @@ +import "./WorkflowIcon.css"; + +export interface WorkflowIconProps { + workflowId: string; + icon?: string; + className?: string; + decorative?: boolean; +} + +function isBuiltinWorkflowId(workflowId: string): boolean { + return workflowId.startsWith("builtin:"); +} + +function classNames(...parts: Array): string { + return parts.filter(Boolean).join(" "); +} + +/** + * FNXC:WorkflowIcons 2026-06-30-12:12: + * Rich workflow identity surfaces render built-ins with the Fusion brand mark and custom workflows with compact text icons only when metadata exists. + * Return null for no-icon custom workflows so cards, badges, and switchers never leave empty icon shells. + */ +export function WorkflowIcon({ workflowId, icon, className, decorative = false }: WorkflowIconProps) { + const isBuiltin = isBuiltinWorkflowId(workflowId); + const label = isBuiltin ? "Fusion built-in workflow" : "Workflow icon"; + const accessibilityProps = decorative + ? { "aria-hidden": true as const } + : { role: "img" as const, "aria-label": label, title: label }; + + if (isBuiltin) { + return ( + + + + ); + } + + const trimmedIcon = typeof icon === "string" ? icon.trim() : ""; + if (!trimmedIcon) return null; + + return ( + + + + ); +} diff --git a/packages/dashboard/app/components/WorkflowNodeEditor.css b/packages/dashboard/app/components/WorkflowNodeEditor.css index 9a7dfcd7bc..c0f64fd5d5 100644 --- a/packages/dashboard/app/components/WorkflowNodeEditor.css +++ b/packages/dashboard/app/components/WorkflowNodeEditor.css @@ -529,6 +529,9 @@ tight; do not allow their short labels to split onto multiple lines. } .wf-editor-readonly-note { + display: inline-flex; + align-items: center; + gap: var(--space-xs); font-size: 0.75rem; color: var(--text-dim); text-transform: uppercase; @@ -1191,6 +1194,12 @@ Built-in workflow prompts need visible override state and a reset action without color: var(--ws-warning); } +.wf-inspector-note--with-icon { + display: inline-flex; + align-items: center; + gap: var(--space-xs); +} + /* ── Per-node Help (FNXC:WorkflowEditor 2026-06-21-10:00) ─────────── * Collapsible
teaching what the selected node does, how to * configure it, and its inputs/outputs/edges. Sits under the heading, @@ -1620,6 +1629,16 @@ Workflow authors need store-produced lifecycle warnings visible in the editor be padding: 2px var(--space-xs); } +.wf-workflow-icon-input { + width: calc(var(--space-xl) + var(--space-md)); + color: var(--text); + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 2px var(--space-xs); + text-align: center; +} + .wf-workflow-description, .wf-workflow-description--readonly { font-size: 0.78rem; @@ -1673,6 +1692,12 @@ Workflow authors need store-produced lifecycle warnings visible in the editor be color: var(--text-dim); } +.wf-template-section--icon { + display: inline-flex; + align-items: center; + gap: var(--space-xs); +} + .wf-template-option { display: flex; flex-direction: column; @@ -1698,6 +1723,13 @@ Workflow authors need store-produced lifecycle warnings visible in the editor be box-shadow: var(--focus-ring); } +.wf-template-option-heading { + display: inline-flex; + align-items: center; + gap: var(--space-xs); + min-width: 0; +} + .wf-template-option-name { font-size: 0.85rem; font-weight: 600; diff --git a/packages/dashboard/app/components/WorkflowNodeEditor.tsx b/packages/dashboard/app/components/WorkflowNodeEditor.tsx index 63af20ae93..3970bb6db2 100644 --- a/packages/dashboard/app/components/WorkflowNodeEditor.tsx +++ b/packages/dashboard/app/components/WorkflowNodeEditor.tsx @@ -53,6 +53,7 @@ FN-6770 localizes this workflow surface through t() and authored en catalog keys */ import { useEmbeddedPresentation, type ModalPresentation } from "../hooks/useEmbeddedPresentation"; import { isMobileViewport, useViewportMode } from "../hooks/useViewportMode"; +import { WorkflowIcon } from "./WorkflowIcon"; import { workflowNodeTypes, type WorkflowFlowNodeData, type WorkflowEditorNodeKind } from "./nodes/WorkflowNodeTypes"; import { WorkflowEditorCatalogContext } from "./nodes/WorkflowEditorCatalogContext"; import { bareSkillName, type NodeSummaryCatalogs } from "./nodes/node-summary"; @@ -189,6 +190,7 @@ function miniMapNodeStrokeColor(node: FlowNode): string { function serializeGraph( name: string, description: string, + icon: string | undefined, nodes: FlowNode[], edges: FlowEdge[], columns: WorkflowIrColumn[], @@ -203,7 +205,7 @@ function serializeGraph( fields.length ? fields : undefined, settings.length ? settings : undefined, ); - return JSON.stringify({ name, description, ir, layout }); + return JSON.stringify({ name, description, icon: icon?.trim() || undefined, ir, layout }); } interface WorkflowNodeEditorProps { @@ -338,6 +340,8 @@ interface WorkflowCreateTemplate { id: string | null; name: string; description: string; + /** Optional compact icon copied from custom workflow sources. */ + icon?: string; /** Node count of the source IR (0 for blank). */ nodeCount: number; /** Source definition for seeding via copyIrWithFreshIds (absent for blank). */ @@ -364,7 +368,7 @@ function CreateWorkflowDialog({ onClose, }: { workflows: WorkflowDefinition[]; - onCreate: (name: string, description: string, template: WorkflowCreateTemplate) => Promise; + onCreate: (name: string, description: string, icon: string, template: WorkflowCreateTemplate) => Promise; /** U10/R11: design a brand-new workflow from a prompt. Resolves on success * (the parent seeds + activates the workflow and closes the dialog); throws on * failure so the dialog surfaces the server message inline without closing. @@ -375,6 +379,7 @@ function CreateWorkflowDialog({ const { t } = useTranslation("app"); const [name, setName] = useState(""); const [description, setDescription] = useState(""); + const [icon, setIcon] = useState(""); const [error, setError] = useState(null); const [submitting, setSubmitting] = useState(false); // U10/R11: AI-design disclosure state. `aiOpen` reveals the prompt textarea; @@ -406,6 +411,7 @@ function CreateWorkflowDialog({ id: w.id, name: w.name, description: w.description ?? "", + icon: w.icon, nodeCount: w.ir.nodes.length, source: w, builtin: isBuiltinWorkflowId(w.id), @@ -423,21 +429,31 @@ function CreateWorkflowDialog({ }, []); // Apply a template selection: move the radio focus state and (R7) prefill the - // name (" copy") + description from the source, but only while the user - // has not edited the name. + // name (" copy") only while the user has not edited it. Description and + // copy icon follow the selected source so typed-name copies still satisfy the + // custom-workflow icon contract. const selectTemplate = useCallback( (index: number) => { const tmpl = templates[index]; if (!tmpl) return; setSelectedIndex(index); - if (!nameTouched) { - if (tmpl.id === null) { - setName(""); - setDescription(""); - } else { + if (tmpl.id === null) { + if (!nameTouched) setName(""); + setDescription(""); + setIcon(""); + } else { + if (!nameTouched) { setName(t("workflows.templateCopyName", "{{name}} copy", { name: tmpl.name })); - setDescription(tmpl.description); } + setDescription(tmpl.description); + /* + FNXC:WorkflowIcons 2026-06-30-12:20: + Copying a built-in or iconless workflow must create an editable custom workflow with a non-Fusion icon, while custom workflow copies preserve their existing non-Fusion icon. + + FNXC:WorkflowIcons 2026-06-30-15:06: + Template selection must default the copied icon even after the author types a custom name; only the name prefill is protected by the dirty-name guard. + */ + setIcon(tmpl.builtin || !tmpl.icon ? "✨" : tmpl.icon); } if (error) setError(null); }, @@ -480,14 +496,14 @@ function CreateWorkflowDialog({ setSubmitting(true); setError(null); try { - await onCreate(trimmed, description.trim(), selected); + await onCreate(trimmed, description.trim(), icon.trim(), selected); // Success path closes the dialog from the parent. } catch (err) { setError(getErrorMessage(err) || t("workflows.createFailed", "Failed to create workflow")); setSubmitting(false); } }, - [name, description, selected, onCreate, t], + [name, description, icon, selected, onCreate, t], ); // U10/R11: submit the AI design request. On success the parent seeds the @@ -638,8 +654,9 @@ function CreateWorkflowDialog({ return (
{index === firstBuiltinIndex && firstBuiltinIndex >= 0 && ( -

- {t("workflows.templateSectionBuiltin", "Built-in workflows")} +

+ + {t("workflows.templateSectionBuiltin", "Fusion workflows")}

)} {index === firstYoursIndex && firstYoursIndex >= 0 && ( @@ -662,7 +679,10 @@ function CreateWorkflowDialog({ }} onKeyDown={handleOptionKeyDown} > - {tmpl.name} + + + {tmpl.name} + {tmpl.description && ( {tmpl.description} )} @@ -690,6 +710,16 @@ function CreateWorkflowDialog({ }} /> +