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")} + + ); +}