Skip to content

Commit 387bbf9

Browse files
committed
feat(n8n-canvas): phase 2 — render the canonical workflow on a custom canvas
Adds <N8nBuildCanvas/>: a React Flow canvas that renders the agent's workflow draft (or get_node placeholders during exploration) in codevibe's design language while the n8n iframe is still loading. - N8nBuildCanvas component: - 'codevibe' custom node type — n8n-style tile (rounded square, monogram glyph, name + node-type label) with subtle codevibe touches (zinc palette, soft shadow, type label in monospace). - During 'exploring': horizontal row of pulsing amber placeholders in a parking-lot row, one per get_node call. - During 'drafting': nodes positioned per workflow.json with n8n's authored coordinates, normalized to bring the workflow into a comfortable margin. Edges drawn as smoothstep curves with subtle animation. - Read-only canvas: panOnDrag/zoomOnScroll/etc. all disabled. The build canvas is for *watching*; users edit in the real n8n UI once it loads. - Wired into renderPreview() in chat/[id]/page.tsx: when template is n8n and the phase is exploring/drafting, the canvas takes over the preview pane. The iframe takes back over once workflowReady flips phase to 'finalized' (handled in use-agent-stream.ts). Phase 3 will add the entry/transition animations and the canvas → iframe handoff fade.
1 parent 87eac96 commit 387bbf9

2 files changed

Lines changed: 271 additions & 0 deletions

File tree

src/app/chat/[id]/page.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { GithubButton } from "@/components/GithubButton";
1616
import { TemplateApprovalCard } from "@/components/TemplateApprovalCard";
1717
import { SandboxExpiredPanel } from "@/components/SandboxExpiredPanel";
1818
import { BackendWarmingBanner } from "@/components/BackendWarmingBanner";
19+
import { N8nBuildCanvas } from "@/components/N8nBuildCanvas";
1920
import { Users } from "lucide-react";
2021
import { useIsMobile } from "@/hooks/use-mobile";
2122
import { useAgentStream } from "@/hooks/use-agent-stream";
@@ -848,6 +849,23 @@ function ChatPage() {
848849
);
849850
}
850851

852+
// n8n build canvas — replaces the iframe while the agent is composing the
853+
// workflow. Snaps from placeholders (exploring) to a positioned canonical
854+
// graph (drafting) before handing off to the real n8n UI in the iframe
855+
// once `workflowReady` flips phase to `finalized`.
856+
if (
857+
ctx.templateType === "n8n"
858+
&& (ctx.n8nBuildState.phase === "exploring" || ctx.n8nBuildState.phase === "drafting")
859+
) {
860+
return (
861+
<N8nBuildCanvas
862+
phase={ctx.n8nBuildState.phase}
863+
exploredNodeTypes={ctx.n8nBuildState.exploredNodeTypes}
864+
draft={ctx.n8nBuildState.draft}
865+
/>
866+
);
867+
}
868+
851869
if (ctx.sandboxUrl && !ctx.isSandboxExpired) {
852870
return (
853871
<div className="relative w-full h-full flex flex-col bg-background">

src/components/N8nBuildCanvas.tsx

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
"use client";
2+
3+
import { useMemo } from "react";
4+
import {
5+
ReactFlow,
6+
Background,
7+
type Edge,
8+
type Node,
9+
type NodeProps,
10+
Handle,
11+
Position,
12+
ReactFlowProvider,
13+
} from "@xyflow/react";
14+
import "@xyflow/react/dist/style.css";
15+
16+
// ─── Types ──────────────────────────────────────────────────────────────────
17+
//
18+
// The n8n workflow shape (subset): `nodes` carries position + type + name,
19+
// `connections` is keyed by source node *name* (not id) and emits arrays of
20+
// arrays for n8n's multi-output model. We only render `main[0]` (the primary
21+
// flow) — that's what >95% of workflows use.
22+
23+
interface N8nNode {
24+
id?: string;
25+
name: string;
26+
type: string;
27+
typeVersion?: number;
28+
position: [number, number];
29+
}
30+
31+
interface N8nConnections {
32+
[sourceNodeName: string]: {
33+
main?: Array<Array<{ node: string; type: string; index: number }>>;
34+
};
35+
}
36+
37+
interface N8nBuildCanvasProps {
38+
/** Phase from chat-context.n8nBuildState.phase. */
39+
phase: "idle" | "exploring" | "drafting" | "finalized";
40+
/** Explored node types from the agent's get_node calls. Used during the
41+
* `exploring` phase to render placeholders. */
42+
exploredNodeTypes: string[];
43+
/** Canonical workflow draft from the agent's workflow.json write. */
44+
draft: {
45+
name: string;
46+
nodes: N8nNode[];
47+
connections: N8nConnections;
48+
} | null;
49+
}
50+
51+
// ─── Node visual ────────────────────────────────────────────────────────────
52+
//
53+
// Match n8n's tile aesthetic: rounded square, icon glyph (we use a single
54+
// generic icon since we don't have all 1,650 node-type icons), label below.
55+
// Codevibe touches: subtle border glow, slightly more compact padding,
56+
// node-type label in monospace.
57+
58+
interface CodevibeNodeData extends Record<string, unknown> {
59+
label: string;
60+
nodeType: string;
61+
isPlaceholder?: boolean;
62+
}
63+
64+
function CodevibeNode({ data }: NodeProps) {
65+
const { label, nodeType, isPlaceholder } = data as CodevibeNodeData;
66+
const initial = label.trim().charAt(0).toUpperCase() || "?";
67+
return (
68+
<div
69+
className={`group relative flex flex-col items-center gap-1.5 transition-all ${
70+
isPlaceholder ? "opacity-60 animate-pulse" : "opacity-100"
71+
}`}
72+
>
73+
<Handle
74+
type="target"
75+
position={Position.Left}
76+
className="!bg-zinc-600 !border-zinc-500"
77+
/>
78+
{/* Tile */}
79+
<div
80+
className={`flex h-16 w-16 items-center justify-center rounded-lg border ${
81+
isPlaceholder
82+
? "border-amber-500/40 bg-zinc-900/60"
83+
: "border-zinc-700 bg-zinc-800 shadow-[0_0_0_1px_rgba(255,255,255,0.04),0_8px_24px_rgba(0,0,0,0.4)]"
84+
}`}
85+
>
86+
<span
87+
className={`text-2xl font-semibold ${
88+
isPlaceholder ? "text-amber-400" : "text-zinc-100"
89+
}`}
90+
>
91+
{initial}
92+
</span>
93+
</div>
94+
{/* Label */}
95+
<div className="flex flex-col items-center gap-0.5 max-w-[160px]">
96+
<div className="text-xs font-medium text-zinc-100 truncate w-full text-center">
97+
{label}
98+
</div>
99+
<div className="text-[10px] font-mono text-zinc-500 truncate w-full text-center">
100+
{shortenType(nodeType)}
101+
</div>
102+
</div>
103+
<Handle
104+
type="source"
105+
position={Position.Right}
106+
className="!bg-zinc-600 !border-zinc-500"
107+
/>
108+
</div>
109+
);
110+
}
111+
112+
// "n8n-nodes-base.googleSheetsTrigger" → "googleSheetsTrigger"
113+
function shortenType(t: string): string {
114+
const dot = t.lastIndexOf(".");
115+
return dot >= 0 ? t.substring(dot + 1) : t;
116+
}
117+
118+
const nodeTypes = { codevibe: CodevibeNode };
119+
120+
// ─── Layout ─────────────────────────────────────────────────────────────────
121+
//
122+
// During `drafting`, n8n already gave us positions in the JSON — use them
123+
// verbatim, just normalize so the workflow is centered in the viewport.
124+
// During `exploring`, we have only nodeType strings — lay them out in a
125+
// horizontal row in the lower third (the "parking lot") so they read as
126+
// candidates that haven't been arranged yet.
127+
128+
const PLACEHOLDER_Y = 320;
129+
const PLACEHOLDER_X_STEP = 140;
130+
131+
function buildExploringNodes(nodeTypes: string[]): Node[] {
132+
return nodeTypes.map((t, i) => ({
133+
id: `placeholder-${t}`,
134+
type: "codevibe",
135+
position: { x: 100 + i * PLACEHOLDER_X_STEP, y: PLACEHOLDER_Y },
136+
data: {
137+
label: shortenType(t),
138+
nodeType: t,
139+
isPlaceholder: true,
140+
} as CodevibeNodeData,
141+
}));
142+
}
143+
144+
function buildDraftGraph(draft: { nodes: N8nNode[]; connections: N8nConnections }): {
145+
nodes: Node[];
146+
edges: Edge[];
147+
} {
148+
// Normalize positions so the smallest x/y land at a comfortable margin.
149+
// n8n positions are absolute and can land at weird coordinates — keep
150+
// their relative shape, just shift into view.
151+
const xs = draft.nodes.map((n) => n.position[0]);
152+
const ys = draft.nodes.map((n) => n.position[1]);
153+
const minX = xs.length ? Math.min(...xs) : 0;
154+
const minY = ys.length ? Math.min(...ys) : 0;
155+
const offsetX = 80 - minX;
156+
const offsetY = 80 - minY;
157+
158+
// n8n IDs aren't guaranteed unique across all reasonable graphs (sometimes
159+
// missing). Fall back to name as the React Flow id; we use name everywhere
160+
// anyway because connections reference by name.
161+
const nodes: Node[] = draft.nodes.map((n) => ({
162+
id: n.name,
163+
type: "codevibe",
164+
position: { x: n.position[0] + offsetX, y: n.position[1] + offsetY },
165+
data: {
166+
label: n.name,
167+
nodeType: n.type,
168+
isPlaceholder: false,
169+
} as CodevibeNodeData,
170+
}));
171+
172+
const edges: Edge[] = [];
173+
for (const [sourceName, conn] of Object.entries(draft.connections)) {
174+
const mainOutputs = conn.main?.[0] ?? [];
175+
for (const target of mainOutputs) {
176+
edges.push({
177+
id: `${sourceName}->${target.node}`,
178+
source: sourceName,
179+
target: target.node,
180+
type: "smoothstep",
181+
animated: true,
182+
style: { stroke: "rgb(161 161 170)", strokeWidth: 1.5 },
183+
});
184+
}
185+
}
186+
187+
return { nodes, edges };
188+
}
189+
190+
// ─── Component ──────────────────────────────────────────────────────────────
191+
192+
function N8nBuildCanvasInner({ phase, exploredNodeTypes, draft }: N8nBuildCanvasProps) {
193+
const { nodes, edges } = useMemo(() => {
194+
if (phase === "drafting" && draft) return buildDraftGraph(draft);
195+
if (phase === "exploring") return { nodes: buildExploringNodes(exploredNodeTypes), edges: [] };
196+
return { nodes: [], edges: [] };
197+
}, [phase, exploredNodeTypes, draft]);
198+
199+
// Title bar mirrors n8n's chrome but uses zinc tokens to match codevibe.
200+
const title = draft?.name ?? "Building workflow…";
201+
const subtitle =
202+
phase === "exploring"
203+
? `Exploring nodes (${exploredNodeTypes.length})`
204+
: phase === "drafting"
205+
? `${draft?.nodes.length ?? 0} nodes · ${edges.length} connections`
206+
: "";
207+
208+
return (
209+
<div className="relative h-full w-full bg-zinc-950">
210+
{/* Header */}
211+
<div className="absolute left-0 right-0 top-0 z-10 flex items-center justify-between px-4 py-2.5 border-b border-zinc-800 bg-zinc-900/80 backdrop-blur">
212+
<div className="flex flex-col">
213+
<span className="text-xs font-medium text-zinc-100">{title}</span>
214+
{subtitle && (
215+
<span className="text-[10px] text-zinc-500">{subtitle}</span>
216+
)}
217+
</div>
218+
<div className="flex items-center gap-1.5 text-[10px] uppercase tracking-wider">
219+
<div className="h-1.5 w-1.5 rounded-full bg-amber-400 animate-pulse" />
220+
<span className="text-amber-400">Building</span>
221+
</div>
222+
</div>
223+
{/* Canvas */}
224+
<div className="absolute inset-0 pt-[42px]">
225+
<ReactFlow
226+
nodes={nodes}
227+
edges={edges}
228+
nodeTypes={nodeTypes}
229+
fitView
230+
fitViewOptions={{ padding: 0.2, maxZoom: 1.4 }}
231+
proOptions={{ hideAttribution: true }}
232+
nodesDraggable={false}
233+
nodesConnectable={false}
234+
elementsSelectable={false}
235+
panOnDrag={false}
236+
zoomOnScroll={false}
237+
zoomOnPinch={false}
238+
zoomOnDoubleClick={false}
239+
>
240+
<Background color="#27272a" gap={24} size={1} />
241+
</ReactFlow>
242+
</div>
243+
</div>
244+
);
245+
}
246+
247+
export function N8nBuildCanvas(props: N8nBuildCanvasProps) {
248+
return (
249+
<ReactFlowProvider>
250+
<N8nBuildCanvasInner {...props} />
251+
</ReactFlowProvider>
252+
);
253+
}

0 commit comments

Comments
 (0)