From 38618479e45ba75d6fdf8bed374fd72adf62cdc7 Mon Sep 17 00:00:00 2001
From: eleven_qy <64311644+alph-cmky@users.noreply.github.com>
Date: Fri, 29 May 2026 14:07:57 +0800
Subject: [PATCH] feat: add workflow canvas mvp
---
apps/web/package.json | 3 +
.../src/features/workflows/node-inspector.tsx | 45 +++++
.../src/features/workflows/node-palette.tsx | 46 +++++
.../workflows/workflow-canvas.test.ts | 85 +++++++++
.../features/workflows/workflow-canvas.tsx | 76 ++++++++
apps/web/src/index.ts | 9 +
apps/web/src/styles/globals.css | 175 ++++++++++++++++++
docs/development/task-backlog.md | 8 +-
packages/workflow-core/package.json | 2 +-
pnpm-lock.yaml | 6 +-
vitest.config.ts | 9 +
11 files changed, 458 insertions(+), 6 deletions(-)
create mode 100644 apps/web/src/features/workflows/node-inspector.tsx
create mode 100644 apps/web/src/features/workflows/node-palette.tsx
create mode 100644 apps/web/src/features/workflows/workflow-canvas.test.ts
create mode 100644 apps/web/src/features/workflows/workflow-canvas.tsx
diff --git a/apps/web/package.json b/apps/web/package.json
index ea23393..bc67fc4 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -10,5 +10,8 @@
"lint": "echo \"@agentdeck/web lint is not scaffolded yet\"",
"typecheck": "tsc -p tsconfig.json --noEmit",
"format": "echo \"@agentdeck/web format is not scaffolded yet\""
+ },
+ "dependencies": {
+ "@agentdeck/workflow-core": "workspace:*"
}
}
diff --git a/apps/web/src/features/workflows/node-inspector.tsx b/apps/web/src/features/workflows/node-inspector.tsx
new file mode 100644
index 0000000..0bce05d
--- /dev/null
+++ b/apps/web/src/features/workflows/node-inspector.tsx
@@ -0,0 +1,45 @@
+import type { WorkflowNode } from "@agentdeck/workflow-core";
+
+import { labelForWorkflowNodeType } from "./node-palette.js";
+
+export function renderNodeInspectorHtml(node: WorkflowNode | undefined): string {
+ if (!node) {
+ return ``;
+ }
+
+ return ``;
+}
+
+function renderNodeSpecificSettings(node: WorkflowNode): string {
+ switch (node.type) {
+ case "agent":
+ return `
Agent ID${escapeHtml(node.agentId)}`;
+ case "tool":
+ return `Tool ID${escapeHtml(node.toolId)}`;
+ case "condition":
+ return `Expression${escapeHtml(node.expression)}`;
+ case "humanApproval":
+ return `Approver role${escapeHtml(node.approverRole)}`;
+ case "start":
+ case "end":
+ return `SettingsNo additional settings`;
+ }
+}
+
+function escapeHtml(value: string): string {
+ return value
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll('"', """);
+}
diff --git a/apps/web/src/features/workflows/node-palette.tsx b/apps/web/src/features/workflows/node-palette.tsx
new file mode 100644
index 0000000..07f4466
--- /dev/null
+++ b/apps/web/src/features/workflows/node-palette.tsx
@@ -0,0 +1,46 @@
+import type { WorkflowNodeType } from "@agentdeck/workflow-core";
+
+export interface WorkflowPaletteItem {
+ readonly type: WorkflowNodeType;
+ readonly label: string;
+ readonly description: string;
+}
+
+export const WORKFLOW_PALETTE_ITEMS: readonly WorkflowPaletteItem[] = [
+ { type: "start", label: "Start", description: "Entry point for one workflow run." },
+ { type: "agent", label: "Agent", description: "Delegate work to a configured coding agent." },
+ { type: "tool", label: "Tool", description: "Call an approved local tool or MCP action." },
+ { type: "condition", label: "Condition", description: "Branch the DAG with an expression." },
+ {
+ type: "humanApproval",
+ label: "Human Approval",
+ description: "Pause until a reviewer approves the next step.",
+ },
+ { type: "end", label: "End", description: "Finish the workflow run." },
+] as const;
+
+export function renderNodePaletteHtml(): string {
+ return ``;
+}
+
+export function labelForWorkflowNodeType(type: WorkflowNodeType): string {
+ return WORKFLOW_PALETTE_ITEMS.find((item) => item.type === type)?.label ?? type;
+}
+
+function escapeHtml(value: string): string {
+ return value
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll('"', """);
+}
diff --git a/apps/web/src/features/workflows/workflow-canvas.test.ts b/apps/web/src/features/workflows/workflow-canvas.test.ts
new file mode 100644
index 0000000..4a94dca
--- /dev/null
+++ b/apps/web/src/features/workflows/workflow-canvas.test.ts
@@ -0,0 +1,85 @@
+import { describe, expect, it } from "vitest";
+
+import { renderWorkflowCanvasHtml, type WorkflowCanvasState } from "./workflow-canvas.js";
+
+const validWorkflow: WorkflowCanvasState["workflow"] = {
+ id: "code-review",
+ name: "Code Review",
+ version: 1,
+ status: "draft",
+ variables: {},
+ permissions: {
+ read: true,
+ write: true,
+ install: false,
+ arbitraryCommands: false,
+ commit: false,
+ push: false,
+ },
+ nodes: [
+ { id: "start", type: "start", label: "Start" },
+ { id: "agent-review", type: "agent", label: "Review", agentId: "reviewer" },
+ { id: "tool-diff", type: "tool", label: "Diff", toolId: "git.diff" },
+ { id: "condition-risk", type: "condition", label: "Risk check", expression: "risk > 0.7" },
+ {
+ id: "approval",
+ type: "humanApproval",
+ label: "Approve",
+ approverRole: "maintainer",
+ },
+ { id: "end", type: "end", label: "End" },
+ ],
+ edges: [
+ { id: "edge-start-review", from: "start", to: "agent-review" },
+ { id: "edge-review-diff", from: "agent-review", to: "tool-diff" },
+ { id: "edge-diff-risk", from: "tool-diff", to: "condition-risk" },
+ {
+ id: "edge-risk-approval",
+ from: "condition-risk",
+ to: "approval",
+ condition: "needsApproval",
+ },
+ { id: "edge-approval-end", from: "approval", to: "end" },
+ ],
+ createdAt: "2026-05-29T00:00:00.000Z",
+ updatedAt: "2026-05-29T00:00:00.000Z",
+};
+
+describe("renderWorkflowCanvasHtml", () => {
+ it("renders a palette for all MVP workflow node types and a valid DAG canvas", async () => {
+ const html = await renderWorkflowCanvasHtml({ workflow: validWorkflow });
+
+ for (const label of ["Start", "Agent", "Tool", "Condition", "Human Approval", "End"]) {
+ expect(html).toContain(label);
+ }
+ expect(html).toContain("Code Review");
+ expect(html).toContain("edge-risk-approval");
+ expect(html).toContain("Ready to save");
+ });
+
+ it("opens selected node settings in the right inspector", async () => {
+ const html = await renderWorkflowCanvasHtml({
+ workflow: validWorkflow,
+ selectedNodeId: "approval",
+ });
+
+ expect(html).toContain("Node Settings");
+ expect(html).toContain("Approve");
+ expect(html).toContain("Human Approval");
+ expect(html).toContain("Approver role");
+ expect(html).toContain("maintainer");
+ });
+
+ it("runs validation before saving and renders invalid DAG errors", async () => {
+ const html = await renderWorkflowCanvasHtml({
+ workflow: {
+ ...validWorkflow,
+ edges: [...validWorkflow.edges, { id: "edge-cycle", from: "approval", to: "agent-review" }],
+ },
+ selectedNodeId: "agent-review",
+ });
+
+ expect(html).toContain("Save disabled");
+ expect(html).toContain("workflow contains a cycle involving node "agent-review".");
+ });
+});
diff --git a/apps/web/src/features/workflows/workflow-canvas.tsx b/apps/web/src/features/workflows/workflow-canvas.tsx
new file mode 100644
index 0000000..788c7f0
--- /dev/null
+++ b/apps/web/src/features/workflows/workflow-canvas.tsx
@@ -0,0 +1,76 @@
+import {
+ validateWorkflowSafety,
+ type WorkflowDefinition,
+ type WorkflowNode,
+} from "@agentdeck/workflow-core";
+
+import { renderNodeInspectorHtml } from "./node-inspector.js";
+import { labelForWorkflowNodeType, renderNodePaletteHtml } from "./node-palette.js";
+
+export interface WorkflowCanvasState {
+ readonly workflow: WorkflowDefinition;
+ readonly selectedNodeId?: string;
+}
+
+export async function renderWorkflowCanvasHtml(state: WorkflowCanvasState): Promise {
+ const selectedNode = state.workflow.nodes.find((node) => node.id === state.selectedNodeId);
+ const validation = await validateWorkflowSafety(state.workflow);
+ const canSave = validation.success;
+
+ return `
+ ${renderNodePaletteHtml()}
+
+
+
+ ${state.workflow.nodes.map((node) => renderCanvasNodeHtml(node, node.id === state.selectedNodeId)).join("")}
+
+
+ ${state.workflow.edges
+ .map(
+ (edge) => `
+ ${escapeHtml(edge.id)}
+ ${escapeHtml(edge.from)} -> ${escapeHtml(edge.to)}
+ ${edge.condition ? `${escapeHtml(edge.condition)}` : ""}
+
`,
+ )
+ .join("")}
+
+ ${renderValidationHtml(validation.errors)}
+
+ ${renderNodeInspectorHtml(selectedNode)}
+`;
+}
+
+function renderCanvasNodeHtml(node: WorkflowNode, selected: boolean): string {
+ return `
+ ${escapeHtml(node.label)}
+ ${escapeHtml(labelForWorkflowNodeType(node.type))}
+`;
+}
+
+function renderValidationHtml(errors: readonly { readonly message: string }[]): string {
+ if (errors.length === 0) {
+ return ``;
+ }
+
+ return `
+ Validation errors
+
+ ${errors.map((error) => `- ${escapeHtml(error.message)}
`).join("")}
+
+`;
+}
+
+function escapeHtml(value: string): string {
+ return value
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll('"', """);
+}
diff --git a/apps/web/src/index.ts b/apps/web/src/index.ts
index 4852018..5d03aa4 100644
--- a/apps/web/src/index.ts
+++ b/apps/web/src/index.ts
@@ -15,3 +15,12 @@ export type {
RuntimeDashboardCapabilities,
RuntimeDashboardResult,
} from "./features/runtimes/runtime-status-row.js";
+export { renderNodeInspectorHtml } from "./features/workflows/node-inspector.js";
+export {
+ WORKFLOW_PALETTE_ITEMS,
+ labelForWorkflowNodeType,
+ renderNodePaletteHtml,
+} from "./features/workflows/node-palette.js";
+export type { WorkflowPaletteItem } from "./features/workflows/node-palette.js";
+export { renderWorkflowCanvasHtml } from "./features/workflows/workflow-canvas.js";
+export type { WorkflowCanvasState } from "./features/workflows/workflow-canvas.js";
diff --git a/apps/web/src/styles/globals.css b/apps/web/src/styles/globals.css
index e20e91d..4f31ed3 100644
--- a/apps/web/src/styles/globals.css
+++ b/apps/web/src/styles/globals.css
@@ -171,3 +171,178 @@ body {
margin: 3px 0 0;
font-size: 13px;
}
+
+.workflow-canvas-shell {
+ display: grid;
+ grid-template-columns: 220px minmax(0, 1fr) 300px;
+ min-height: 620px;
+ border: 1px solid #d9e0e4;
+ background: #ffffff;
+}
+
+.workflow-palette,
+.workflow-node-inspector {
+ padding: 12px;
+ background: #fbfcfc;
+}
+
+.workflow-palette {
+ border-right: 1px solid #d9e0e4;
+}
+
+.workflow-node-inspector {
+ border-left: 1px solid #d9e0e4;
+}
+
+.workflow-palette h2,
+.workflow-node-inspector h2,
+.workflow-validation h2 {
+ margin: 0 0 10px;
+ font-size: 13px;
+}
+
+.workflow-palette-list {
+ display: grid;
+ gap: 8px;
+}
+
+.workflow-palette-list button {
+ display: grid;
+ gap: 3px;
+ width: 100%;
+ padding: 9px;
+ border: 1px solid #d9e0e4;
+ border-radius: 6px;
+ background: #ffffff;
+ color: #172026;
+ text-align: left;
+}
+
+.workflow-palette-list span {
+ font-size: 13px;
+ font-weight: 700;
+}
+
+.workflow-palette-list small,
+.workflow-canvas-header p,
+.workflow-edge small,
+.workflow-node span,
+.workflow-node-inspector p {
+ color: #66747d;
+ font-size: 12px;
+}
+
+.workflow-canvas-main {
+ min-width: 0;
+ padding: 14px;
+ background: #f4f6f7;
+}
+
+.workflow-canvas-header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 12px;
+ margin-bottom: 12px;
+}
+
+.workflow-canvas-header h1 {
+ margin: 0 0 4px;
+ font-size: 18px;
+}
+
+.workflow-canvas-header p {
+ margin: 0;
+}
+
+.workflow-canvas-header button {
+ height: 32px;
+ padding: 0 12px;
+ border: 1px solid #0e4f43;
+ border-radius: 6px;
+ background: #0e4f43;
+ color: #ffffff;
+ font-weight: 700;
+}
+
+.workflow-canvas-header button:disabled {
+ border-color: #a94333;
+ background: #a94333;
+}
+
+.workflow-canvas-board {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(120px, 1fr));
+ gap: 10px;
+}
+
+.workflow-node {
+ display: grid;
+ gap: 6px;
+ min-height: 72px;
+ padding: 10px;
+ border: 1px solid #cfd8dd;
+ border-radius: 8px;
+ background: #ffffff;
+}
+
+.workflow-node-selected {
+ border-color: #0e4f43;
+ box-shadow: inset 3px 0 0 #0e4f43;
+}
+
+.workflow-edge-list {
+ display: grid;
+ gap: 8px;
+ margin-top: 12px;
+}
+
+.workflow-edge {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ padding: 8px 10px;
+ border: 1px solid #d9e0e4;
+ border-radius: 6px;
+ background: #ffffff;
+}
+
+.workflow-edge code {
+ color: #28343b;
+ font-size: 12px;
+}
+
+.workflow-validation {
+ margin-top: 12px;
+ padding: 10px;
+ border: 1px solid #d9e0e4;
+ border-radius: 8px;
+ background: #ffffff;
+}
+
+.workflow-validation p,
+.workflow-validation ul {
+ margin: 0;
+ font-size: 13px;
+}
+
+.workflow-validation li {
+ color: #8a3326;
+}
+
+.workflow-node-inspector dl {
+ display: grid;
+ gap: 10px;
+ margin: 0;
+}
+
+.workflow-node-inspector dt {
+ color: #66747d;
+ font-size: 11px;
+}
+
+.workflow-node-inspector dd {
+ margin: 3px 0 0;
+ font-size: 13px;
+}
diff --git a/docs/development/task-backlog.md b/docs/development/task-backlog.md
index 204d98d..7b9a459 100644
--- a/docs/development/task-backlog.md
+++ b/docs/development/task-backlog.md
@@ -468,10 +468,10 @@ Status markers:
**Tasks:**
-- [ ] Support Start, Agent, Tool, Condition, Human Approval, and End nodes.
-- [ ] Open selected node settings in the right inspector.
-- [ ] Run validation before saving.
-- [ ] Commit with message `feat: add workflow canvas mvp`.
+- [x] Support Start, Agent, Tool, Condition, Human Approval, and End nodes.
+- [x] Open selected node settings in the right inspector.
+- [x] Run validation before saving.
+- [x] Commit with message `feat: add workflow canvas mvp`.
**Acceptance Criteria:**
diff --git a/packages/workflow-core/package.json b/packages/workflow-core/package.json
index 60782b6..1242be5 100644
--- a/packages/workflow-core/package.json
+++ b/packages/workflow-core/package.json
@@ -5,7 +5,7 @@
"type": "module",
"exports": {
".": {
- "types": "./dist/index.d.ts",
+ "types": "./src/index.ts",
"import": "./dist/index.js"
}
},
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 5bf347b..8b271ac 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -47,7 +47,11 @@ importers:
apps/server: {}
- apps/web: {}
+ apps/web:
+ dependencies:
+ '@agentdeck/workflow-core':
+ specifier: workspace:*
+ version: link:../../packages/workflow-core
packages/mcp-server: {}
diff --git a/vitest.config.ts b/vitest.config.ts
index 1f78b7f..ae7aaf4 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -1,6 +1,15 @@
+import { fileURLToPath } from "node:url";
+
import { defineConfig } from "vitest/config";
export default defineConfig({
+ resolve: {
+ alias: {
+ "@agentdeck/workflow-core": fileURLToPath(
+ new URL("./packages/workflow-core/src/index.ts", import.meta.url),
+ ),
+ },
+ },
test: {
include: ["**/*.test.ts", "**/*.test.tsx"],
exclude: ["**/node_modules/**", "**/dist/**", ".worktrees/**"],