feat(dashboard): workflow editor onboarding and empty states

This commit is contained in:
gsxdsm
2026-06-04 21:13:13 -07:00
parent d382d51c75
commit 7e239e257f
10 changed files with 205 additions and 25 deletions

View File

@@ -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;

View File

@@ -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<WorkflowEditorNodeKind> = new Set<WorkflowEditorNodeKind>([
"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({
)}
<div className="wf-editor-canvas" ref={canvasRef} tabIndex={-1}>
{isTrivialUserGraph && (
<div className="wf-trivial-hint" role="status" data-testid="wf-trivial-hint">
{t(
"workflowNodes.trivialGraphHint",
"This workflow only runs start → end. Add steps from the palette above to build it out.",
)}
</div>
)}
<WorkflowEditorCatalogContext.Provider value={catalogs}>
<ReactFlow
nodes={nodesForRender}
@@ -1263,8 +1297,24 @@ function InnerEditor({
</div>
</>
) : (
<div className="wf-editor-empty wf-editor-canvas-empty">
{t("workflows.selectOrCreate", "Select or create a workflow to start editing.")}
<div className="wf-editor-empty wf-editor-canvas-empty wf-editor-onboard">
<Workflow className="wf-editor-onboard-icon" size={40} aria-hidden />
<h3 className="wf-editor-onboard-title">
{t("workflows.emptyTitle", "No workflow selected")}
</h3>
<p className="wf-editor-onboard-text">
{t(
"workflows.emptyDescription",
"Workflows orchestrate the steps and gates that run around task execution. Create one to start arranging that flow.",
)}
</p>
<button
className="wf-editor-save wf-editor-onboard-cta"
data-testid="wf-empty-create"
onClick={() => setCreateOpen(true)}
>
<Plus size={14} /> {t("workflows.newWorkflow", "New workflow")}
</button>
</div>
)}
</section>

View File

@@ -136,7 +136,8 @@ describe("WorkflowNodeEditor", () => {
render(<WorkflowNodeEditor isOpen onClose={() => {}} 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(<WorkflowNodeEditor isOpen onClose={() => {}} 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(<WorkflowNodeEditor isOpen onClose={() => {}} 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(<WorkflowNodeEditor isOpen onClose={() => {}} 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(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
expect(await screen.findByTestId("wf-readonly-banner")).toBeInTheDocument();
expect(screen.queryByTestId("wf-trivial-hint")).not.toBeInTheDocument();
});
});

View File

@@ -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?",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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",