From feb9ffd38380e9225fc4f97bc892b79b321f3bcb Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 21 Jun 2026 00:18:15 -0700 Subject: [PATCH] feat(dashboard): add optional-steps authoring panel to the node editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New WorkflowOptionalStepsPanel (sibling to Fields/Settings) lets authors add/remove optional steps and set each one's defaultOn, with unknown ids shown as muted removable rows. Wire optionalSteps state through both load paths (incl. the fragment path, which also dropped settings), every flowToIr/serializeGraph call site, and the save handler deps — fixing a stale-closure that dropped defaultOn edits on save. Extract the shared phaseBadge helper. Mobile gets an Optional steps tab too. --- .../app/components/WorkflowNodeEditor.tsx | 72 +++++++- .../components/WorkflowOptionalStepsPanel.css | 107 +++++++++++ .../components/WorkflowOptionalStepsPanel.tsx | 172 ++++++++++++++++++ .../app/components/WorkflowResultsTab.tsx | 14 +- .../__tests__/WorkflowNodeEditor.test.tsx | 36 ++++ .../WorkflowOptionalStepsPanel.test.tsx | 92 ++++++++++ .../app/components/workflow-phase-badge.tsx | 23 +++ 7 files changed, 500 insertions(+), 16 deletions(-) create mode 100644 packages/dashboard/app/components/WorkflowOptionalStepsPanel.css create mode 100644 packages/dashboard/app/components/WorkflowOptionalStepsPanel.tsx create mode 100644 packages/dashboard/app/components/__tests__/WorkflowOptionalStepsPanel.test.tsx create mode 100644 packages/dashboard/app/components/workflow-phase-badge.tsx diff --git a/packages/dashboard/app/components/WorkflowNodeEditor.tsx b/packages/dashboard/app/components/WorkflowNodeEditor.tsx index 0a9390a394..7e51096525 100644 --- a/packages/dashboard/app/components/WorkflowNodeEditor.tsx +++ b/packages/dashboard/app/components/WorkflowNodeEditor.tsx @@ -17,7 +17,7 @@ import { import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { X, Plus, Trash2, Save, MessageSquare, Terminal, Shield, GitMerge, Loader2, HelpCircle, PauseCircle, Split, Merge, Repeat, ClipboardCheck, ListChecks, Code2, Bell, LayoutGrid, Workflow, Download, Upload, ChevronDown, ChevronRight, ChevronLeft, Library, Sparkles, Maximize2, Minimize2 } from "lucide-react"; -import type { WorkflowDefinition, WorkflowIrColumn, TraitViolation, WorkflowStepTemplate } from "@fusion/core"; +import type { WorkflowDefinition, WorkflowIrColumn, TraitViolation, WorkflowStepTemplate, WorkflowOptionalStep } from "@fusion/core"; import { getErrorMessage } from "@fusion/core"; import { fetchWorkflows, @@ -64,6 +64,7 @@ import { columnsOf, fieldsOf, settingsOf, + optionalStepsOf, columnsToBandNodes, reconcileNodeColumns, strictColumnForY, @@ -87,6 +88,7 @@ import { fetchTraits, fetchStepParsers, type TraitCatalogEntry } from "../api"; import { WorkflowColumnPanel } from "./WorkflowColumnPanel"; import { WorkflowFieldsPanel } from "./WorkflowFieldsPanel"; import { WorkflowSettingsPanel } from "./WorkflowSettingsPanel"; +import { WorkflowOptionalStepsPanel } from "./WorkflowOptionalStepsPanel"; import type { WorkflowFieldDefinition, WorkflowSettingDefinition } from "../api"; import { CustomModelDropdown } from "./CustomModelDropdown"; import { MobileWorkflowGraphView } from "./MobileWorkflowGraphView"; @@ -98,7 +100,7 @@ import { } from "./workflow-mobile-graph"; type ExecutorKind = "model" | "agent" | "skill" | "cli" | "cli-agent"; -type MobileWorkflowPanel = "graph" | "add" | "settings" | "fields" | "columns" | "actions"; +type MobileWorkflowPanel = "graph" | "add" | "settings" | "fields" | "optional-steps" | "columns" | "actions"; function builtinSeamPrompt(config: Record | undefined): string { const seam = typeof config?.seam === "string" ? config.seam : ""; @@ -165,6 +167,7 @@ function serializeGraph( columns: WorkflowIrColumn[], fields: WorkflowFieldDefinition[], settings: WorkflowSettingDefinition[], + optionalSteps: WorkflowOptionalStep[], ): string { const { ir, layout } = flowToIr( name, @@ -173,6 +176,7 @@ function serializeGraph( columns.length ? columns : undefined, fields.length ? fields : undefined, settings.length ? settings : undefined, + optionalSteps.length ? optionalSteps : undefined, ); return JSON.stringify({ name, description, ir, layout }); } @@ -740,6 +744,7 @@ function InnerEditor({ // VALUES live per-project in the workflow_settings table (KTD-2) and are // managed by the panel's Values tab, not this declaration array. const [settings, setSettings] = useState([]); + const [optionalSteps, setOptionalSteps] = useState([]); // Ref to the settings panel so a `?panel=settings` deep link can scroll it // into view on mount (U6/U9 redirect stubs). const settingsPanelRef = useRef(null); @@ -778,6 +783,7 @@ function InnerEditor({ const columnsCollapsedStorageKey = "fusion:wf-sidebar-columns-collapsed"; const fieldsCollapsedStorageKey = "fusion:wf-sidebar-fields-collapsed"; const settingsCollapsedStorageKey = "fusion:wf-sidebar-settings-collapsed"; + const optionalStepsCollapsedStorageKey = "fusion:wf-sidebar-optional-steps-collapsed"; const [columnsCollapsed, setColumnsCollapsed] = useState(() => { try { return localStorage.getItem(columnsCollapsedStorageKey) === "1"; @@ -799,6 +805,13 @@ function InnerEditor({ return false; } }); + const [optionalStepsCollapsed, setOptionalStepsCollapsed] = useState(() => { + try { + return localStorage.getItem(optionalStepsCollapsedStorageKey) === "1"; + } catch { + return false; + } + }); useEffect(() => { try { localStorage.setItem(columnsCollapsedStorageKey, columnsCollapsed ? "1" : "0"); @@ -820,6 +833,13 @@ function InnerEditor({ // localStorage unavailable (private mode / SSR): non-fatal. } }, [settingsCollapsed]); + useEffect(() => { + try { + localStorage.setItem(optionalStepsCollapsedStorageKey, optionalStepsCollapsed ? "1" : "0"); + } catch { + // localStorage unavailable (private mode / SSR): non-fatal. + } + }, [optionalStepsCollapsed]); // React Flow instance for programmatic viewport control (auto-layout on load). const { setViewport } = useReactFlow(); // Wrapper around so keyboard deletion can return focus to the @@ -1009,10 +1029,10 @@ function InnerEditor({ if (isBuiltin) return false; if (!activeWorkflow || loadedSnapshotRef.current === null) return false; return ( - serializeGraph(name, description, nodes, edges, columns, fields, settings) !== + serializeGraph(name, description, nodes, edges, columns, fields, settings, optionalSteps) !== loadedSnapshotRef.current ); - }, [isBuiltin, activeWorkflow, name, description, nodes, edges, columns, fields, settings]); + }, [isBuiltin, activeWorkflow, name, description, nodes, edges, columns, fields, settings, optionalSteps]); const loadWorkflows = useCallback(async () => { setLoading(true); @@ -1160,6 +1180,7 @@ function InnerEditor({ setColumns([]); setFields([]); setSettings([]); + setOptionalSteps([]); setName(""); setDescription(""); loadedSnapshotRef.current = null; @@ -1169,6 +1190,7 @@ function InnerEditor({ const loadedColumns = columnsOf(activeWorkflow); const loadedFields = fieldsOf(activeWorkflow); const loadedSettings = settingsOf(activeWorkflow); + const loadedOptionalSteps = optionalStepsOf(activeWorkflow); // Auto-layout on load: compute tidy positions and apply them before the // first render so nodes are visible in the top-left viewport. const layoutPositions = autoLayout(flow.nodes, flow.edges, loadedColumns); @@ -1178,6 +1200,7 @@ function InnerEditor({ setColumns(loadedColumns); setFields(loadedFields); setSettings(loadedSettings); + setOptionalSteps(loadedOptionalSteps); setName(activeWorkflow.name); setDescription(activeWorkflow.description ?? ""); setEditingName(false); @@ -1192,6 +1215,7 @@ function InnerEditor({ loadedColumns, loadedFields, loadedSettings, + loadedOptionalSteps, ); setSelectedNodeId(null); setSelectedEdgeId(null); @@ -1495,6 +1519,11 @@ function InnerEditor({ setEdges(flow.edges); setColumns(columnsOf({ ...targetWorkflow, ir: result.ir })); setFields(fieldsOf({ ...targetWorkflow, ir: result.ir })); + // Hydrate settings + optionalSteps on the fragment/generate path too — it + // previously dropped both, which silently lost the declarations on the next + // save (the round-trip data loss U2 fixes for the primary load path). + setSettings(settingsOf({ ...targetWorkflow, ir: result.ir })); + setOptionalSteps(optionalStepsOf({ ...targetWorkflow, ir: result.ir })); setSelectedNodeId(null); setSelectedEdgeId(null); setValidationError(null); @@ -1806,6 +1835,7 @@ function InnerEditor({ columns.length ? columns : undefined, fields.length ? fields : undefined, settings.length ? settings : undefined, + optionalSteps.length ? optionalSteps : undefined, ); // Include name/description in the PATCH only when they changed from the // loaded workflow (KTD-10 inline rename/description persist here). @@ -1823,6 +1853,7 @@ function InnerEditor({ columns, fields, settings, + optionalSteps, ); setName(updated.name); setDescription(updated.description ?? ""); @@ -1892,7 +1923,7 @@ function InnerEditor({ } finally { setSaving(false); } - }, [activeWorkflow, name, description, nodes, edges, columns, fields, settings, unplaced, blockingViolationCount, projectId, addToast, t]); + }, [activeWorkflow, name, description, nodes, edges, columns, fields, settings, optionalSteps, unplaced, blockingViolationCount, projectId, addToast, t]); // Stamp the shared error-state badge onto offending nodes: unplaced step // nodes and any node the server flagged (seam-in-branch). One component @@ -2454,6 +2485,26 @@ function InnerEditor({ )} + +
+ + {!optionalStepsCollapsed && ( + + )} +
)} @@ -2576,6 +2627,7 @@ function InnerEditor({ ["add", t("workflowNodes.mobileAdd", "Add")], ["settings", t("workflowSettings.title", "Settings")], ["fields", t("workflowFields.title", "Fields")], + ["optional-steps", t("workflowOptionalSteps.title", "Optional steps")], ["columns", t("workflowColumns.title", "Columns")], ["actions", t("workflowNodes.mobileActions", "Actions")], ] as Array<[MobileWorkflowPanel, string]>).map(([panel, label]) => ( @@ -2754,6 +2806,16 @@ function InnerEditor({ )} + {mobilePanel === "optional-steps" && ( +
+ +
+ )} + {mobilePanel === "columns" && (
void; + readOnly: boolean; + /** Plugin-contributed templates, merged into the catalog when available. */ + pluginTemplates?: WorkflowStepTemplate[]; +} + +export function WorkflowOptionalStepsPanel({ + optionalSteps, + onChange, + readOnly, + pluginTemplates = [], +}: WorkflowOptionalStepsPanelProps) { + const { t } = useTranslation("app"); + + const templatesById = useMemo(() => { + const map = new Map(); + for (const tpl of [...WORKFLOW_STEP_TEMPLATES, ...pluginTemplates]) map.set(tpl.id, tpl); + return map; + }, [pluginTemplates]); + + const declaredIds = useMemo(() => new Set(optionalSteps.map((s) => s.templateId)), [optionalSteps]); + + // Catalog entries not already declared — the "Add optional step" picker source. + const available = useMemo( + () => [...templatesById.values()].filter((tpl) => !declaredIds.has(tpl.id)), + [templatesById, declaredIds], + ); + + const addStep = useCallback( + (templateId: string) => { + if (!templateId || declaredIds.has(templateId)) return; + onChange([...optionalSteps, { templateId, defaultOn: false }]); + }, + [optionalSteps, onChange, declaredIds], + ); + + const removeStep = useCallback( + (templateId: string) => onChange(optionalSteps.filter((s) => s.templateId !== templateId)), + [optionalSteps, onChange], + ); + + const toggleDefaultOn = useCallback( + (templateId: string, defaultOn: boolean) => + onChange(optionalSteps.map((s) => (s.templateId === templateId ? { ...s, defaultOn } : s))), + [optionalSteps, onChange], + ); + + return ( + + ); +} + +export default WorkflowOptionalStepsPanel; diff --git a/packages/dashboard/app/components/WorkflowResultsTab.tsx b/packages/dashboard/app/components/WorkflowResultsTab.tsx index dbd22cff8b..a87efbc6dc 100644 --- a/packages/dashboard/app/components/WorkflowResultsTab.tsx +++ b/packages/dashboard/app/components/WorkflowResultsTab.tsx @@ -15,6 +15,7 @@ import type { AgentLogEntry, Settings, Task, TaskDetail, WorkflowDefinition, Wor import { getErrorMessage, resolveTaskExecutionModel, resolveTaskPlanningModel, resolveTaskValidatorModel } from "@fusion/core"; import { approveTaskWorkflowCli, fetchWorkflow, fetchWorkflows, fetchWorkflowSteps, fetchTaskWorkflow, fetchWorkflowOptionalSteps, selectTaskWorkflow, submitTaskWorkflowInput } from "../api"; import { WorkflowSelector } from "./WorkflowSelector"; +import { phaseBadge } from "./workflow-phase-badge"; import { useAgentLogs } from "../hooks/useAgentLogs"; import { ProviderIcon } from "./ProviderIcon"; import { irToFlow } from "./workflow-flow-mapping"; @@ -134,17 +135,8 @@ function getOutputPreview(output: string): string { return `${lines.length} lines`; } -function phaseBadge(phase: "pre-merge" | "post-merge", id: string, prefix: string, t: ReturnType["t"]): ReactNode { - const phaseClass = phase === "post-merge" ? "phase-badge--post-merge" : "phase-badge--pre-merge"; - return ( - - {phase === "post-merge" ? t("app:workflow.postMerge", "Post-merge") : t("app:workflow.preMerge", "Pre-merge")} - - ); -} +// phaseBadge moved to ./workflow-phase-badge (shared with the optional-steps panel +// and the optional-steps dropdown). Imported above. function getWorkflowName( selectedWorkflowId: string | null, diff --git a/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx b/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx index b8ae11847d..a7e54ef6b4 100644 --- a/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx +++ b/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx @@ -164,6 +164,14 @@ function v2Def(): WorkflowDefinition { }; } +function v2DefWithOptional(): WorkflowDefinition { + const base = v2Def(); + return { + ...base, + ir: { ...(base.ir as object), optionalSteps: [{ templateId: "browser-verification" }] } as WorkflowDefinition["ir"], + }; +} + function builtinDef(): WorkflowDefinition { return { id: "builtin:coding", @@ -742,6 +750,34 @@ describe("WorkflowNodeEditor", () => { expect(start?.column).toBe("done"); }); + it("hydrates declared optional steps and preserves them through a dirty save (round-trip)", async () => { + vi.mocked(fetchWorkflows).mockResolvedValue([v2DefWithOptional()]); + vi.mocked(updateWorkflow).mockImplementation(async (_id, updates) => ({ + ...v2DefWithOptional(), + ...(updates as object), + })); + vi.mocked(compileWorkflow).mockResolvedValue({ steps: [] }); + + render( {}} addToast={() => {}} />); + + await screen.findByText("Save"); + // The declared optional step is hydrated into the panel (optionalStepsOf). + const row = await screen.findByTestId("wf-optional-step-browser-verification"); + expect(within(row).getByText("Browser Verification")).toBeTruthy(); + + // Toggling defaultOn must mark the editor dirty (serializeGraph threading) so + // the Save button enables and persists the change. + fireEvent.click(within(row).getByRole("checkbox")); + fireEvent.click(screen.getByText("Save").closest("button")!); + + await waitFor(() => expect(updateWorkflow).toHaveBeenCalled()); + const [, updates] = vi.mocked(updateWorkflow).mock.calls[0]; + const ir = (updates as { ir: WorkflowDefinition["ir"] }).ir as { + optionalSteps?: { templateId: string; defaultOn?: boolean }[]; + }; + expect(ir.optionalSteps).toEqual([{ templateId: "browser-verification", defaultOn: true }]); + }); + it("renders the start inspector without the entry-column select for v1 workflows", async () => { vi.mocked(fetchWorkflows).mockResolvedValue([def()]); diff --git a/packages/dashboard/app/components/__tests__/WorkflowOptionalStepsPanel.test.tsx b/packages/dashboard/app/components/__tests__/WorkflowOptionalStepsPanel.test.tsx new file mode 100644 index 0000000000..93398b01f9 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/WorkflowOptionalStepsPanel.test.tsx @@ -0,0 +1,92 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen, fireEvent, cleanup, within } from "@testing-library/react"; +import { useState } from "react"; +import type { WorkflowOptionalStep } from "@fusion/core"; +import { WorkflowOptionalStepsPanel } from "../WorkflowOptionalStepsPanel"; + +// Controlled host mirroring how WorkflowNodeEditor drives the panel. +function Host({ + initial, + readOnly = false, + onState, +}: { + initial: WorkflowOptionalStep[]; + readOnly?: boolean; + onState?: (s: WorkflowOptionalStep[]) => void; +}) { + const [optionalSteps, setOptionalSteps] = useState(initial); + return ( + { + setOptionalSteps(next); + onState?.(next); + }} + /> + ); +} + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe("WorkflowOptionalStepsPanel", () => { + it("renders the empty state and an add picker when no steps are declared", () => { + render(); + expect(screen.getByText(/No optional steps/i)).toBeTruthy(); + const select = screen.getByTestId("wf-optional-steps-add-select") as HTMLSelectElement; + // browser-verification is in the catalog and not yet declared → available. + expect(within(select).getByRole("option", { name: "Browser Verification" })).toBeTruthy(); + }); + + it("adds a step from the picker (defaultOn false) and removes it from the picker", () => { + const onState = vi.fn(); + render(); + fireEvent.change(screen.getByTestId("wf-optional-steps-add-select"), { + target: { value: "browser-verification" }, + }); + expect(onState).toHaveBeenCalledWith([{ templateId: "browser-verification", defaultOn: false }]); + // The declared row is shown with the resolved template name… + const row = screen.getByTestId("wf-optional-step-browser-verification"); + expect(within(row).getByText("Browser Verification")).toBeTruthy(); + // …and the picker no longer offers it. + const select = screen.getByTestId("wf-optional-steps-add-select") as HTMLSelectElement; + expect(within(select).queryByRole("option", { name: "Browser Verification" })).toBeNull(); + }); + + it("toggles defaultOn for a declared step", () => { + const onState = vi.fn(); + render(); + const row = screen.getByTestId("wf-optional-step-browser-verification"); + fireEvent.click(within(row).getByRole("checkbox")); + expect(onState).toHaveBeenCalledWith([{ templateId: "browser-verification", defaultOn: true }]); + }); + + it("removes a declared step and returns it to the picker", () => { + render(); + const row = screen.getByTestId("wf-optional-step-browser-verification"); + fireEvent.click(within(row).getByRole("button", { name: /Remove optional step/i })); + expect(screen.queryByTestId("wf-optional-step-browser-verification")).toBeNull(); + const select = screen.getByTestId("wf-optional-steps-add-select") as HTMLSelectElement; + expect(within(select).getByRole("option", { name: "Browser Verification" })).toBeTruthy(); + }); + + it("renders an unknown/stale templateId as a muted, still-removable row", () => { + const onState = vi.fn(); + render(); + const row = screen.getByTestId("wf-optional-step-does-not-exist"); + expect(row.className).toContain("is-unknown"); + expect(within(row).getByText(/Unknown step/i)).toBeTruthy(); + fireEvent.click(within(row).getByRole("button", { name: /Remove optional step/i })); + expect(onState).toHaveBeenCalledWith([]); + }); + + it("disables editing when readOnly", () => { + render(); + const row = screen.getByTestId("wf-optional-step-browser-verification"); + expect((within(row).getByRole("checkbox") as HTMLInputElement).disabled).toBe(true); + expect((within(row).getByRole("button", { name: /Remove optional step/i }) as HTMLButtonElement).disabled).toBe(true); + }); +}); diff --git a/packages/dashboard/app/components/workflow-phase-badge.tsx b/packages/dashboard/app/components/workflow-phase-badge.tsx new file mode 100644 index 0000000000..2993d011f4 --- /dev/null +++ b/packages/dashboard/app/components/workflow-phase-badge.tsx @@ -0,0 +1,23 @@ +/** + * Shared phase chip for workflow steps (pre-merge / post-merge). Extracted from + * WorkflowResultsTab so the node-editor optional-steps panel and the optional-step + * dropdown render an identical badge without duplicating markup. + */ +import type { ReactNode } from "react"; +import type { useTranslation } from "react-i18next"; + +export function phaseBadge( + phase: "pre-merge" | "post-merge", + id: string, + prefix: string, + t: ReturnType["t"], +): ReactNode { + const phaseClass = phase === "post-merge" ? "phase-badge--post-merge" : "phase-badge--pre-merge"; + return ( + + {phase === "post-merge" + ? t("app:workflow.postMerge", "Post-merge") + : t("app:workflow.preMerge", "Pre-merge")} + + ); +}