diff --git a/packages/dashboard/app/components/WorkflowNodeEditor.css b/packages/dashboard/app/components/WorkflowNodeEditor.css index efb2c06f24..b91dc4267a 100644 --- a/packages/dashboard/app/components/WorkflowNodeEditor.css +++ b/packages/dashboard/app/components/WorkflowNodeEditor.css @@ -121,6 +121,52 @@ justify-content: center; } +/* No-workflow onboarding panel (R9): icon + heading + explanation + create CTA. */ +.wf-editor-onboard { + flex-direction: column; + text-align: center; + gap: var(--space-sm); + padding: var(--space-lg); +} + +.wf-editor-onboard-icon { + color: var(--text-muted); +} + +.wf-editor-onboard-title { + margin: 0; + font-size: 1rem; + color: var(--text); +} + +.wf-editor-onboard-text { + margin: 0; + max-width: 36ch; + color: var(--text-muted); +} + +.wf-editor-onboard-cta { + margin-top: var(--space-xs); +} + +/* Trivial-graph palette hint (R9): non-blocking banner over the canvas. */ +.wf-trivial-hint { + position: absolute; + top: var(--space-sm); + left: 50%; + transform: translateX(-50%); + z-index: 5; + pointer-events: none; + max-width: min(90%, 42ch); + padding: var(--space-xs) var(--space-sm); + background: color-mix(in srgb, var(--ws-info) 8%, var(--bg-secondary)); + border: 1px solid var(--ws-info); + border-radius: var(--radius-sm); + color: var(--ws-info); + font-size: 0.8rem; + text-align: center; +} + .wf-editor-toolbar { display: flex; align-items: center; diff --git a/packages/dashboard/app/components/WorkflowNodeEditor.tsx b/packages/dashboard/app/components/WorkflowNodeEditor.tsx index 6979b4baa1..e9ea4d7b7e 100644 --- a/packages/dashboard/app/components/WorkflowNodeEditor.tsx +++ b/packages/dashboard/app/components/WorkflowNodeEditor.tsx @@ -14,7 +14,7 @@ import { type Edge as FlowEdge, } from "@xyflow/react"; import { useTranslation } from "react-i18next"; -import { X, Plus, Trash2, Save, MessageSquare, Terminal, Shield, GitMerge, Loader2, HelpCircle, PauseCircle, Split, Merge, Repeat, ClipboardCheck, ListChecks, Code2, LayoutGrid } from "lucide-react"; +import { X, Plus, Trash2, Save, MessageSquare, Terminal, Shield, GitMerge, Loader2, HelpCircle, PauseCircle, Split, Merge, Repeat, ClipboardCheck, ListChecks, Code2, LayoutGrid, Workflow } from "lucide-react"; import type { WorkflowDefinition, WorkflowIrColumn, TraitViolation } from "@fusion/core"; import { getErrorMessage } from "@fusion/core"; import { @@ -149,6 +149,23 @@ const PALETTE: Array<{ kind: WorkflowEditorNodeKind; label: string; icon: typeof { kind: "code", label: "Code", icon: Code2, presetConfig: { source: "" } }, ]; +// Node kinds a user authors from the palette. Structural/derived nodes +// (start/end and column bands — which map to data.kind "start") are excluded, so +// a fresh start→end graph counts as trivial. Used by the palette-hint (R9). +const USER_NODE_KINDS: ReadonlySet = new Set([ + "prompt", + "script", + "gate", + "code", + "hold", + "split", + "join", + "foreach", + "step-review", + "parse-steps", + "merge", +]); + /** Local create-workflow dialog (KTD-7). Built on the shared `.modal` primitives * (precedent: NewTaskModal). Owns its own name/description/error state; the * parent supplies an async `onCreate` that performs the createWorkflow call and @@ -324,6 +341,15 @@ function InnerEditor({ const activeWorkflow = useMemo(() => workflows.find((w) => w.id === activeId), [workflows, activeId]); const isBuiltin = !!activeWorkflow && isBuiltinWorkflowId(activeWorkflow.id); + // Trivial-graph palette hint (R9): a user-owned workflow whose graph carries no + // user-authored node yet (everything is start/end/column-band — column bands map + // to data.kind "start"). Disappears as soon as any user node exists; never shows + // for built-ins. + const isTrivialUserGraph = useMemo(() => { + if (!activeWorkflow || isBuiltin) return false; + return !nodes.some((n) => USER_NODE_KINDS.has(n.data.kind)); + }, [activeWorkflow, isBuiltin, nodes]); + // Trait catalog (for client-side composition validation; the panel fetches its // own copy for the picker, but the editor needs the flags to validate). useEffect(() => { @@ -1227,6 +1253,14 @@ function InnerEditor({ )}
+ {isTrivialUserGraph && ( +
+ {t( + "workflowNodes.trivialGraphHint", + "This workflow only runs start → end. Add steps from the palette above to build it out.", + )} +
+ )} ) : ( -
- {t("workflows.selectOrCreate", "Select or create a workflow to start editing.")} +
+ +

+ {t("workflows.emptyTitle", "No workflow selected")} +

+

+ {t( + "workflows.emptyDescription", + "Workflows orchestrate the steps and gates that run around task execution. Create one to start arranging that flow.", + )} +

+
)} diff --git a/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx b/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx index 3b2f8f8a40..367de47902 100644 --- a/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx +++ b/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx @@ -136,7 +136,8 @@ describe("WorkflowNodeEditor", () => { render( {}} addToast={() => {}} />); expect(await screen.findByText("Workflows")).toBeInTheDocument(); await waitFor(() => expect(screen.getByText(/No workflows yet/i)).toBeInTheDocument()); - expect(screen.getByText(/Select or create a workflow/i)).toBeInTheDocument(); + expect(screen.getByText(/No workflow selected/i)).toBeInTheDocument(); + expect(screen.getByTestId("wf-empty-create")).toBeInTheDocument(); }); it("renders nothing when closed", () => { @@ -1007,3 +1008,72 @@ describe("WorkflowNodeEditor — U4 create dialog / delete / inline rename / dir expect(screen.getByTestId("wf-workflow-name")).toHaveTextContent("Edited"); }); }); + +// ── U6: empty / onboarding states ─────────────────────────────────────────── + +// A user-owned workflow whose graph is only start→end (no user-authored nodes). +function trivialUserDef(): WorkflowDefinition { + return { + id: "WF-TRIVIAL", + name: "Trivial", + description: "", + ir: { + version: "v1", + name: "Trivial", + nodes: [ + { id: "start", kind: "start" }, + { id: "end", kind: "end" }, + ], + edges: [{ from: "start", to: "end", condition: "success" }], + }, + layout: { start: { x: 0, y: 0 }, end: { x: 240, y: 0 } }, + createdAt: "2026-06-03T00:00:00.000Z", + updatedAt: "2026-06-03T00:00:00.000Z", + }; +} + +describe("WorkflowNodeEditor — U6 empty/onboarding states", () => { + beforeEach(() => { + vi.mocked(fetchTraits).mockResolvedValue(TRAIT_CATALOG); + vi.mocked(fetchStepParsers).mockResolvedValue(["step-headings", "json-steps"]); + }); + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + it("no-workflow empty state CTA opens the create dialog", async () => { + vi.mocked(fetchWorkflows).mockResolvedValue([]); + render( {}} addToast={() => {}} />); + const cta = await screen.findByTestId("wf-empty-create"); + expect(screen.queryByTestId("wf-create-dialog")).not.toBeInTheDocument(); + fireEvent.click(cta); + expect(await screen.findByTestId("wf-create-dialog")).toBeInTheDocument(); + }); + + it("renders the trivial-graph palette hint for a user-owned start→end workflow", async () => { + vi.mocked(fetchWorkflows).mockResolvedValue([trivialUserDef()]); + render( {}} addToast={() => {}} />); + // Wait for hydration (the palette appears for editable workflows). + await screen.findByText("Save"); + expect(await screen.findByTestId("wf-trivial-hint")).toBeInTheDocument(); + }); + + it("hides the trivial-graph hint once a user node exists", async () => { + // v2Def() carries a "prompt" step → a user-authored node. + vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]); + render( {}} addToast={() => {}} />); + await screen.findByText("Save"); + await screen.findByTestId("wf-column-panel"); + expect(screen.queryByTestId("wf-trivial-hint")).not.toBeInTheDocument(); + }); + + it("never renders the trivial-graph hint for a built-in workflow", async () => { + // Built-in that is itself trivial (start→end only) — must still not show the hint. + const builtinTrivial: WorkflowDefinition = { ...trivialUserDef(), id: "builtin:trivial", name: "Built-in" }; + vi.mocked(fetchWorkflows).mockResolvedValue([builtinTrivial]); + render( {}} addToast={() => {}} />); + expect(await screen.findByTestId("wf-readonly-banner")).toBeInTheDocument(); + expect(screen.queryByTestId("wf-trivial-hint")).not.toBeInTheDocument(); + }); +}); diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json index 5be1155102..47e68c1f70 100644 --- a/packages/i18n/locales/en/app.json +++ b/packages/i18n/locales/en/app.json @@ -6807,7 +6807,8 @@ "reviewPlan": "Plan review", "reviewType": "Review type", "splitNote": "Branches run concurrently from this node. Execute and merge seams are not allowed inside a branch.", - "stepExecuteLabel": "Step execute" + "stepExecuteLabel": "Step execute", + "trivialGraphHint": "This workflow only runs start → end. Add steps from the palette above to build it out." }, "workflows": { "clickToEditDescription": "Click to edit description", @@ -6829,13 +6830,14 @@ "discardMessage": "You have unsaved changes to this workflow. Discard them?", "discardTitle": "Discard unsaved changes?", "duplicateToCustomize": "Duplicate to customize", + "emptyDescription": "Workflows orchestrate the steps and gates that run around task execution. Create one to start arranging that flow.", + "emptyTitle": "No workflow selected", "nameLabel": "Workflow name", "newWorkflow": "New workflow", "readOnlyBuiltin": "Read-only built-in workflow", "saved": "Workflow saved", "savedNotCompilable": "Workflow saved but cannot be compiled", - "saveFailed": "Failed to save workflow", - "selectOrCreate": "Select or create a workflow to start editing." + "saveFailed": "Failed to save workflow" }, "workflowSelector": { "switchActiveMessage": "This task has an active session. Switching workflows aborts it and re-homes the card into the new workflow's entry column. Continue?", diff --git a/packages/i18n/locales/es/app.json b/packages/i18n/locales/es/app.json index b5cf2e4fe2..1f90270e98 100644 --- a/packages/i18n/locales/es/app.json +++ b/packages/i18n/locales/es/app.json @@ -6807,7 +6807,8 @@ "reviewPlan": "Plan review", "reviewType": "Review type", "splitNote": "Las ramas se ejecutan de forma concurrente desde este nodo. No se permiten uniones de ejecución y fusión dentro de una rama.", - "stepExecuteLabel": "Step execute" + "stepExecuteLabel": "Step execute", + "trivialGraphHint": "" }, "workflows": { "clickToEditDescription": "", @@ -6829,13 +6830,14 @@ "discardMessage": "", "discardTitle": "", "duplicateToCustomize": "", + "emptyDescription": "", + "emptyTitle": "", "nameLabel": "", "newWorkflow": "", "readOnlyBuiltin": "", "saved": "", "savedNotCompilable": "", - "saveFailed": "", - "selectOrCreate": "" + "saveFailed": "" }, "workflowSelector": { "switchActiveMessage": "", diff --git a/packages/i18n/locales/fr/app.json b/packages/i18n/locales/fr/app.json index bf378d4d82..f11c40013b 100644 --- a/packages/i18n/locales/fr/app.json +++ b/packages/i18n/locales/fr/app.json @@ -6807,7 +6807,8 @@ "reviewPlan": "Plan review", "reviewType": "Review type", "splitNote": "Les branches s’exécutent simultanément depuis ce nœud. Les jointures d’exécution et de fusion ne sont pas autorisées dans une branche.", - "stepExecuteLabel": "Step execute" + "stepExecuteLabel": "Step execute", + "trivialGraphHint": "" }, "workflows": { "clickToEditDescription": "", @@ -6829,13 +6830,14 @@ "discardMessage": "", "discardTitle": "", "duplicateToCustomize": "", + "emptyDescription": "", + "emptyTitle": "", "nameLabel": "", "newWorkflow": "", "readOnlyBuiltin": "", "saved": "", "savedNotCompilable": "", - "saveFailed": "", - "selectOrCreate": "" + "saveFailed": "" }, "workflowSelector": { "switchActiveMessage": "", diff --git a/packages/i18n/locales/ko/app.json b/packages/i18n/locales/ko/app.json index 48ac35e960..ef35a87c64 100644 --- a/packages/i18n/locales/ko/app.json +++ b/packages/i18n/locales/ko/app.json @@ -6807,7 +6807,8 @@ "reviewPlan": "Plan review", "reviewType": "Review type", "splitNote": "분기는 이 노드에서 동시에 실행됩니다. 분기 내에서는 실행 및 병합 이음새가 허용되지 않습니다.", - "stepExecuteLabel": "Step execute" + "stepExecuteLabel": "Step execute", + "trivialGraphHint": "" }, "workflows": { "clickToEditDescription": "", @@ -6829,13 +6830,14 @@ "discardMessage": "", "discardTitle": "", "duplicateToCustomize": "", + "emptyDescription": "", + "emptyTitle": "", "nameLabel": "", "newWorkflow": "", "readOnlyBuiltin": "", "saved": "", "savedNotCompilable": "", - "saveFailed": "", - "selectOrCreate": "" + "saveFailed": "" }, "workflowSelector": { "switchActiveMessage": "", diff --git a/packages/i18n/locales/zh-CN/app.json b/packages/i18n/locales/zh-CN/app.json index 4254c20a8d..7de0ae5f55 100644 --- a/packages/i18n/locales/zh-CN/app.json +++ b/packages/i18n/locales/zh-CN/app.json @@ -6807,7 +6807,8 @@ "reviewPlan": "Plan review", "reviewType": "Review type", "splitNote": "分支从此节点并发运行。分支内不允许执行和合并接缝。", - "stepExecuteLabel": "Step execute" + "stepExecuteLabel": "Step execute", + "trivialGraphHint": "" }, "workflows": { "clickToEditDescription": "", @@ -6829,13 +6830,14 @@ "discardMessage": "", "discardTitle": "", "duplicateToCustomize": "", + "emptyDescription": "", + "emptyTitle": "", "nameLabel": "", "newWorkflow": "", "readOnlyBuiltin": "", "saved": "", "savedNotCompilable": "", - "saveFailed": "", - "selectOrCreate": "" + "saveFailed": "" }, "workflowSelector": { "switchActiveMessage": "", diff --git a/packages/i18n/locales/zh-TW/app.json b/packages/i18n/locales/zh-TW/app.json index 39eea53ea1..d09474f792 100644 --- a/packages/i18n/locales/zh-TW/app.json +++ b/packages/i18n/locales/zh-TW/app.json @@ -6807,7 +6807,8 @@ "reviewPlan": "Plan review", "reviewType": "Review type", "splitNote": "分支從此節點並行執行。分支內不允許執行與合併接縫。", - "stepExecuteLabel": "Step execute" + "stepExecuteLabel": "Step execute", + "trivialGraphHint": "" }, "workflows": { "clickToEditDescription": "", @@ -6829,13 +6830,14 @@ "discardMessage": "", "discardTitle": "", "duplicateToCustomize": "", + "emptyDescription": "", + "emptyTitle": "", "nameLabel": "", "newWorkflow": "", "readOnlyBuiltin": "", "saved": "", "savedNotCompilable": "", - "saveFailed": "", - "selectOrCreate": "" + "saveFailed": "" }, "workflowSelector": { "switchActiveMessage": "", diff --git a/packages/i18n/src/resources.d.ts b/packages/i18n/src/resources.d.ts index 011e72c528..ad49c536fc 100644 --- a/packages/i18n/src/resources.d.ts +++ b/packages/i18n/src/resources.d.ts @@ -6809,7 +6809,8 @@ export default interface Resources { "reviewPlan": "Plan review", "reviewType": "Review type", "splitNote": "Branches run concurrently from this node. Execute and merge seams are not allowed inside a branch.", - "stepExecuteLabel": "Step execute" + "stepExecuteLabel": "Step execute", + "trivialGraphHint": "This workflow only runs start → end. Add steps from the palette above to build it out." }, "workflowSelector": { "switchActiveMessage": "This task has an active session. Switching workflows aborts it and re-homes the card into the new workflow's entry column. Continue?", @@ -6837,13 +6838,14 @@ export default interface Resources { "discardMessage": "You have unsaved changes to this workflow. Discard them?", "discardTitle": "Discard unsaved changes?", "duplicateToCustomize": "Duplicate to customize", + "emptyDescription": "Workflows orchestrate the steps and gates that run around task execution. Create one to start arranging that flow.", + "emptyTitle": "No workflow selected", "nameLabel": "Workflow name", "newWorkflow": "New workflow", "readOnlyBuiltin": "Read-only built-in workflow", "saveFailed": "Failed to save workflow", "saved": "Workflow saved", - "savedNotCompilable": "Workflow saved but cannot be compiled", - "selectOrCreate": "Select or create a workflow to start editing." + "savedNotCompilable": "Workflow saved but cannot be compiled" }, "workspace": { "projectRoot": "Project Root",