Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*"
}
}
45 changes: 45 additions & 0 deletions apps/web/src/features/workflows/node-inspector.tsx
Original file line number Diff line number Diff line change
@@ -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 `<aside class="workflow-node-inspector" aria-label="Node inspector">
<h2>Node Settings</h2>
<p>Select a node to edit its settings.</p>
</aside>`;
}

return `<aside class="workflow-node-inspector" aria-label="Node inspector">
<h2>Node Settings</h2>
<dl>
<div><dt>Label</dt><dd>${escapeHtml(node.label)}</dd></div>
<div><dt>Type</dt><dd>${escapeHtml(labelForWorkflowNodeType(node.type))}</dd></div>
${renderNodeSpecificSettings(node)}
</dl>
</aside>`;
}

function renderNodeSpecificSettings(node: WorkflowNode): string {
switch (node.type) {
case "agent":
return `<div><dt>Agent ID</dt><dd>${escapeHtml(node.agentId)}</dd></div>`;
case "tool":
return `<div><dt>Tool ID</dt><dd>${escapeHtml(node.toolId)}</dd></div>`;
case "condition":
return `<div><dt>Expression</dt><dd>${escapeHtml(node.expression)}</dd></div>`;
case "humanApproval":
return `<div><dt>Approver role</dt><dd>${escapeHtml(node.approverRole)}</dd></div>`;
case "start":
case "end":
return `<div><dt>Settings</dt><dd>No additional settings</dd></div>`;
}
}

function escapeHtml(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
46 changes: 46 additions & 0 deletions apps/web/src/features/workflows/node-palette.tsx
Original file line number Diff line number Diff line change
@@ -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 `<aside class="workflow-palette" aria-label="Node palette">
<h2>Nodes</h2>
<div class="workflow-palette-list">
${WORKFLOW_PALETTE_ITEMS.map(
(item) => `<button type="button" data-node-type="${item.type}">
<span>${escapeHtml(item.label)}</span>
<small>${escapeHtml(item.description)}</small>
</button>`,
).join("")}
</div>
</aside>`;
}

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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
85 changes: 85 additions & 0 deletions apps/web/src/features/workflows/workflow-canvas.test.ts
Original file line number Diff line number Diff line change
@@ -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 &quot;agent-review&quot;.");
});
});
76 changes: 76 additions & 0 deletions apps/web/src/features/workflows/workflow-canvas.tsx
Original file line number Diff line number Diff line change
@@ -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<string> {
const selectedNode = state.workflow.nodes.find((node) => node.id === state.selectedNodeId);
const validation = await validateWorkflowSafety(state.workflow);
const canSave = validation.success;

return `<section class="workflow-canvas-shell" aria-label="Workflow canvas">
${renderNodePaletteHtml()}
<main class="workflow-canvas-main">
<header class="workflow-canvas-header">
<div>
<h1>${escapeHtml(state.workflow.name)}</h1>
<p>${state.workflow.nodes.length} nodes, ${state.workflow.edges.length} edges</p>
</div>
<button type="button"${canSave ? "" : " disabled"}>${canSave ? "Ready to save" : "Save disabled"}</button>
</header>
<div class="workflow-canvas-board" role="list" aria-label="Workflow DAG nodes">
${state.workflow.nodes.map((node) => renderCanvasNodeHtml(node, node.id === state.selectedNodeId)).join("")}
</div>
<div class="workflow-edge-list" aria-label="Workflow DAG edges">
${state.workflow.edges
.map(
(edge) => `<div class="workflow-edge" data-edge-id="${escapeHtml(edge.id)}">
<span>${escapeHtml(edge.id)}</span>
<code>${escapeHtml(edge.from)} -> ${escapeHtml(edge.to)}</code>
${edge.condition ? `<small>${escapeHtml(edge.condition)}</small>` : ""}
</div>`,
)
.join("")}
</div>
${renderValidationHtml(validation.errors)}
</main>
${renderNodeInspectorHtml(selectedNode)}
</section>`;
}

function renderCanvasNodeHtml(node: WorkflowNode, selected: boolean): string {
return `<article class="workflow-node${selected ? " workflow-node-selected" : ""}" data-node-id="${escapeHtml(node.id)}" role="listitem">
<strong>${escapeHtml(node.label)}</strong>
<span>${escapeHtml(labelForWorkflowNodeType(node.type))}</span>
</article>`;
}

function renderValidationHtml(errors: readonly { readonly message: string }[]): string {
if (errors.length === 0) {
return `<section class="workflow-validation" aria-label="Workflow validation"><p>Ready to save</p></section>`;
}

return `<section class="workflow-validation" aria-label="Workflow validation">
<h2>Validation errors</h2>
<ul>
${errors.map((error) => `<li>${escapeHtml(error.message)}</li>`).join("")}
</ul>
</section>`;
}

function escapeHtml(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
9 changes: 9 additions & 0 deletions apps/web/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Loading