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
8 changes: 8 additions & 0 deletions .changeset/figma-plan-canvas.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"helmor": minor
---

Reworked the plan canvas into a freeform, figma-style prototyping surface and hardened plan parsing against leaked tool-call tags.

- `<PlanCanvas>` is now a pan/zoom board of agent-positioned frames that embed live previews, greyscale wireframes, and sticky notes, wired together with labeled flow arrows and grouped into labeled sections. Older `connects=`-only mind-map plans still render via an auto-layout fallback, so nothing breaks.
- Plans that accidentally contain leaked agent tool-call wrapper tags (e.g. a stray trailing `</content></invoke>` from a Write call) no longer collapse to plain text. The parser strips the stray tags before parsing so the canvas and other rich plan components keep rendering.
8 changes: 6 additions & 2 deletions src-tauri/src/agents/system_prompt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -246,8 +246,10 @@ Allowed components:
- `<OpenQuestions>` containing a markdown list of decisions that need the user's input.
- `<AnnotatedCode lang="…" note="…">` … code … `</AnnotatedCode>` for an annotated snippet. PREFER passing the code as the component's children (between the tags) — especially when it contains quotes or `<…>`. Only use a `code="…"` attribute for short, quote-free one-liners. NEVER put backslash-escaped quotes (`\"`) inside an attribute value: JSX attributes have no backslash escaping, so a value like `code="<Foo bar=\"x\">"` is invalid markup and will fail to render.
- `<Diagram>` containing mermaid diagram source (no code fences) for architecture or data flow.
- `<PlanCanvas direction="TB|LR">` containing `<CanvasNode>` children — an interactive node graph shown at the TOP of the plan. ALWAYS lead a non-trivial plan with one. It must map the ACTUAL logic of THIS task — its real subsystems/modules/files/steps and how they genuinely depend on or flow into each other (use `connects` for real edges: what calls/feeds/blocks what, not decorative links). Do NOT emit a generic five-box "overview/structure/visuals/decisions" diagram — that conveys nothing. Pick `kind` to match each node's real role (see below) so the graph is legible. Use `direction="LR"` for a pipeline/flow and `"TB"` for a hierarchy. Keep it focused (roughly 3–8 nodes) but every node and edge should carry real meaning. Do NOT close the graph into a loop — never wire the last node back to the first/overview node just to "complete" it; only add an edge where a real dependency exists. The graph should stay acyclic.
- `<CanvasNode id="unique-id" title="Short title" connects="other-id,another-id">` … short markdown … `</CanvasNode>` — one box in the PlanCanvas. `id` must be unique; `connects` is a comma-separated list of other node ids this box links to. Keep the body to a sentence or a short list. Use a self-closing `<CanvasNode ... />` when there is no body. CanvasNode is ONLY valid inside a PlanCanvas. Set `kind` to the node's real role — it drives a coloured badge + icon: `resume` = the overview/summary node, `phase` = a sequenced step/milestone, `option` = an alternative/branch, `wireframe` = a UI/screen node, `note` = plain detail (the default). Choose deliberately so the badges read as a real map.
- `<PlanCanvas theme="repo|wireframe" height="640">` containing `<CanvasNode>` / `<CanvasFlow>` / `<CanvasGroup>` children — a freeform, figma-style board shown at the TOP of the plan. ALWAYS lead a non-trivial UI/design plan with one. You place frames at explicit coordinates, fill each with a real live preview, a low-fi wireframe, or a sticky note, and wire the user journey with labeled flow arrows. `theme="repo"` (default) renders polished, accent-colored frames; `theme="wireframe"` renders the whole board greyscale to focus on layout over color. `height` is the board's pixel height (optional, default 720 — go taller for a busy board). Frames float freely on the open canvas (no enclosing card), so lay them out left-to-right along the primary journey — flows enter a frame on its left edge and leave on its right — and leave GENEROUS gaps between frames (roughly 80–160px of empty space, more between groups) so the board reads as a spacious canvas, not a tight cluster. Group related frames with `<CanvasGroup>`. Keep it focused (roughly 3–10 frames) but every frame and arrow should carry real meaning. Cycles ARE allowed here: a real user journey may loop back. (A legacy `<PlanCanvas direction="TB|LR">` with `<CanvasNode connects="…">` and no coordinates still renders via auto-layout, but prefer the coordinate-based board.)
- `<CanvasNode id="unique-id" title="Short title" x="40" y="80" w="360" h="420" device="browser|app|mobile|panel|popover" accent="neutral|info|success|warning|danger|highlight">` … one `<Preview>` OR one `<Wireframe>` OR short markdown … `</CanvasNode>` — one frame on the board. `id` must be unique. `x`/`y` set the top-left position and `w`/`h` the size (all optional — omit them to auto-layout, but for a figma board set them so frames sit where you intend). The frame's kind is inferred from its body: a nested `<Preview>` makes a LIVE preview frame (high-fidelity, runs live on the canvas), a nested `<Wireframe>` makes a low-fi wireframe frame, and anything else is a sticky note for rationale. `device` picks the window/device chrome; `accent` colors the frame in `repo` theme. Self-close `<CanvasNode ... />` for an empty placeholder frame. CanvasNode is ONLY valid inside a PlanCanvas.
- `<CanvasFlow from="frame-id" to="frame-id" label="Submit" kind="primary|secondary|back" />` — a labeled arrow between two frames marking a user-flow transition. `kind` styles the line: `primary` is a solid accent path (the happy path), `secondary` and `back` are quieter dashed lines (branches / return steps). Self-closing. CanvasFlow is ONLY valid inside a PlanCanvas.
- `<CanvasGroup id="auth" title="Onboarding" contains="login,verify" accent="info" />` — a labeled section drawn BEHIND the listed frames to group a flow or area. `contains` is a comma-separated list of frame ids; the section auto-sizes to enclose them. Self-closing. CanvasGroup is ONLY valid inside a PlanCanvas.
- `<Decision>` containing `<Option title="..." recommended>` … markdown pros/cons … `</Option>` children — present 2–4 candidate approaches as cards and mark the best one with the boolean `recommended` attribute. `<Option>` is ONLY valid inside a `<Decision>`.
- `<BeforeAfter>` containing exactly one `<Before>` … markdown … `</Before>` and one `<After>` … markdown … `</After>` — a side-by-side comparison of current vs. proposed behavior. `<Before>`/`<After>` are ONLY valid inside a `<BeforeAfter>`.
- `<Diff lang="...">` whose contents are unified-diff lines: each line starts with `+` (added line), `-` (removed line), or a space (unchanged context). Use it for concrete before/after code changes. `lang` is an optional language label.
Expand Down Expand Up @@ -535,6 +537,8 @@ mod tests {
assert!(prompt.contains("Diagram"));
assert!(prompt.contains("PlanCanvas"));
assert!(prompt.contains("CanvasNode"));
assert!(prompt.contains("CanvasFlow"));
assert!(prompt.contains("CanvasGroup"));
assert!(prompt.contains("Decision"));
assert!(prompt.contains("BeforeAfter"));
assert!(prompt.contains("Diff"));
Expand Down
84 changes: 84 additions & 0 deletions src/features/plan-viewer/components/canvas/auto-layout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { describe, expect, it } from "vitest";
import { autoLayoutFrames, computeGroupBounds } from "./auto-layout";
import type { FlowEdge, FrameNode, GroupSpec } from "./build-graph";

function frame(id: string, position?: { x: number; y: number }): FrameNode {
return {
id,
frameKind: "note",
data: {
title: id,
frameKind: "note",
device: "browser",
theme: "repo",
accent: "neutral",
previewCode: "",
wireframeSource: "",
bodyBlocks: [],
},
position,
width: 200,
height: 120,
};
}

const edge: FlowEdge = {
id: "a->b",
source: "a",
target: "b",
label: "",
kind: "primary",
};

describe("autoLayoutFrames", () => {
it("assigns positions to a fully coordless graph (Dagre fallback)", () => {
const out = autoLayoutFrames([frame("a"), frame("b")], [edge], "LR");
expect(out.every((f) => f.position != null)).toBe(true);
const [a, b] = out;
expect(a.position).not.toEqual(b.position);
});

it("leaves a fully positioned graph untouched", () => {
const a = frame("a", { x: 10, y: 20 });
const b = frame("b", { x: 300, y: 20 });
const out = autoLayoutFrames([a, b], [], "LR");
expect(out[0].position).toEqual({ x: 10, y: 20 });
expect(out[1].position).toEqual({ x: 300, y: 20 });
});

it("fills only the coordless frames in a mixed graph", () => {
const a = frame("a", { x: 10, y: 20 });
const out = autoLayoutFrames([a, frame("b")], [], "LR");
expect(out[0].position).toEqual({ x: 10, y: 20 });
expect(out[1].position).toBeDefined();
});
});

describe("computeGroupBounds", () => {
it("frames the bounding box of its members", () => {
const a = frame("a", { x: 0, y: 0 });
const b = frame("b", { x: 300, y: 100 });
const group: GroupSpec = {
id: "g",
title: "Sec",
contains: ["a", "b"],
accent: "info",
};
const [bounds] = computeGroupBounds([group], [a, b]);
// Encloses both members (a at 0,0 and b ending at 500,220) with padding.
expect(bounds.x).toBeLessThan(0);
expect(bounds.y).toBeLessThan(0);
expect(bounds.width).toBeGreaterThan(500);
expect(bounds.height).toBeGreaterThan(220);
});

it("skips a group whose members have no position", () => {
const group: GroupSpec = {
id: "g",
title: "Sec",
contains: ["ghost"],
accent: "neutral",
};
expect(computeGroupBounds([group], [frame("a")])).toHaveLength(0);
});
});
125 changes: 125 additions & 0 deletions src/features/plan-viewer/components/canvas/auto-layout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import dagre from "@dagrejs/dagre";
import type { CanvasGraph, FrameNode, GroupSpec } from "./build-graph";

/** Auto-layout direction, from `<PlanCanvas direction="…">` (legacy coordless). */
export type CanvasDirection = "TB" | "LR";

export function parseDirection(value: string | undefined): CanvasDirection {
return value === "LR" ? "LR" : "TB";
}

const GROUP_PADDING = 36;
const GROUP_HEADER = 26;
const STACK_GAP = 72;

/** Run Dagre over every frame, sizing each by its own width/height. Cycles are
* tolerated (Dagre breaks them internally) so user journeys can loop. */
function dagreLayout(
frames: FrameNode[],
edges: CanvasGraph["edges"],
direction: CanvasDirection,
): FrameNode[] {
const g = new dagre.graphlib.Graph();
g.setGraph({ rankdir: direction, nodesep: 110, ranksep: 160 });
g.setDefaultEdgeLabel(() => ({}));
for (const frame of frames) {
g.setNode(frame.id, { width: frame.width, height: frame.height });
}
for (const edge of edges) {
g.setEdge(edge.source, edge.target);
}
dagre.layout(g);
return frames.map((frame) => {
const pos = g.node(frame.id);
// Dagre returns the node center; React Flow wants the top-left corner.
return {
...frame,
position: { x: pos.x - frame.width / 2, y: pos.y - frame.height / 2 },
};
});
}

/** Bounding box of frames that already have a position. */
function boundsOf(frames: FrameNode[]): {
minX: number;
minY: number;
maxX: number;
maxY: number;
} {
let minX = Number.POSITIVE_INFINITY;
let minY = Number.POSITIVE_INFINITY;
let maxX = Number.NEGATIVE_INFINITY;
let maxY = Number.NEGATIVE_INFINITY;
for (const f of frames) {
if (!f.position) continue;
minX = Math.min(minX, f.position.x);
minY = Math.min(minY, f.position.y);
maxX = Math.max(maxX, f.position.x + f.width);
maxY = Math.max(maxY, f.position.y + f.height);
}
return { minX, minY, maxX, maxY };
}

/**
* Ensure every frame has a position.
* - Fully coordless graph (old `connects=` plans): Dagre-layout everything.
* - Fully positioned graph (new freeform plans): return as-is.
* - Mixed: honor authored coords, stack the coordless frames in a column just
* right of the positioned cluster so nothing overlaps.
*/
export function autoLayoutFrames(
frames: FrameNode[],
edges: CanvasGraph["edges"],
direction: CanvasDirection,
): FrameNode[] {
const missing = frames.filter((f) => !f.position);
if (missing.length === 0) return frames;
if (missing.length === frames.length) {
return dagreLayout(frames, edges, direction);
}
const { maxX, minY } = boundsOf(frames);
const startX = Number.isFinite(maxX) ? maxX + STACK_GAP : 0;
let cursorY = Number.isFinite(minY) ? minY : 0;
return frames.map((frame) => {
if (frame.position) return frame;
const position = { x: startX, y: cursorY };
cursorY += frame.height + STACK_GAP;
return { ...frame, position };
});
}

export type GroupBounds = {
spec: GroupSpec;
x: number;
y: number;
width: number;
height: number;
};

/**
* Compute each group's background rectangle from its member frames' positions
* (run AFTER {@link autoLayoutFrames} so every member has a position). Padding
* leaves room for the group's title strip above its members.
*/
export function computeGroupBounds(
groups: GroupSpec[],
frames: FrameNode[],
): GroupBounds[] {
const byId = new Map(frames.map((f) => [f.id, f]));
const out: GroupBounds[] = [];
for (const spec of groups) {
const members = spec.contains
.map((id) => byId.get(id))
.filter((f): f is FrameNode => f != null && f.position != null);
if (members.length === 0) continue;
const { minX, minY, maxX, maxY } = boundsOf(members);
out.push({
spec,
x: minX - GROUP_PADDING,
y: minY - GROUP_PADDING - GROUP_HEADER,
width: maxX - minX + GROUP_PADDING * 2,
height: maxY - minY + GROUP_PADDING * 2 + GROUP_HEADER,
});
}
return out;
}
Loading
Loading