Files
wursor/docs/SOURCE-AWARE-CANVAS.md
T
2026-05-01 10:41:55 +01:00

25 KiB
Raw Blame History

Source-Aware Canvas & Agent Bridge: Implementation Design

Status: Draft — awaiting review before implementation begins
Scope: Two-layer system to close the gap between visual intent and real code edits
Packages touched: @originmain/renderer, @originmain/cli, @originmain/agent-bridge, @originmain/app


1. Problem Statement

The current canvas pipeline gives us runtime truth — what the component looks like, what props it accepts, what DOM rect it occupies — but zero source truth: which file defines it, what token it consumes, how to write a change back to disk.

Style patches applied via PATCH_ELEMENT_STYLE mutate the live DOM through el.style.setProperty(). They vanish on the next hot-reload. That is not an Intent Diff; it is DevTools with a nicer UI.

The corrected architecture requires two layers working together:

┌─────────────────────────────────────────────────────────────┐
│  LAYER 1 — Runtime (exists today)                            │
│  CLI proxy → iframe → fiber hook → FIBER_TREE_UPDATE         │
│  Gives: visual render, component name, DOM rect, live props  │
└───────────────────────────┬─────────────────────────────────┘
                            │ component name + _debugSource
┌───────────────────────────▼─────────────────────────────────┐
│  LAYER 2 — Source (what we are building)                     │
│                                                              │
│  2a. _debugSource extraction (renderer)                      │
│      React dev mode attaches fileName + lineNumber to every  │
│      fiber. We already walk the fiber tree — we just need    │
│      to forward this field. Zero CLI changes required.       │
│                                                              │
│  2b. CLI AST Indexer                                         │
│      Watches .tsx/.ts files, builds component→file map.      │
│      Exposes a local HTTP API: GET /components?name=Card     │
│      Returns: file, line, exported props, design tokens used │
│                                                              │
│  2c. Intent Message protocol                                 │
│      Canvas emits a structured Intent (not a DOM patch).     │
│      Intent carries: component, file, change, before/after.  │
│      Sent to Agent Bridge → Claude Code / Cursor apply it.   │
└─────────────────────────────────────────────────────────────┘

2. What Exists Today (audit)

@originmain/cli — proxy only

  • Reverse-proxies the user's dev server through port 4170
  • Strips X-Frame-Options / CSP so the iframe can load
  • Injects buildProxyFiberHookScript() into HTML responses via string injection
  • Passes WebSocket upgrades for HMR
  • No AST indexing. No file watching. No component→file map.

@originmain/renderer — fiber hook + DOM inspector

  • buildProxyFiberHookScript(): hooks window.__REACT_DEVTOOLS_GLOBAL_HOOK__.onCommitFiberRoot
  • Serialises the fiber tree → FIBER_TREE_UPDATE to parent canvas
  • Sends COMPONENT_SELECTED on click, ELEMENT_STYLES on request
  • Node IDs are stable path strings: "ComponentName:idx/Child:idx/…"
  • _debugSource is present on fiber objects in dev mode but is never read or forwarded
  • PATCH_ELEMENT_STYLE / REMOVE_ELEMENT apply DOM-only mutations — no source awareness

@originmain/agent-bridge — MCP over JSON-RPC 2.0

  • 5 tools: get_pending_diffs, get_artboard_context, ask_design_agent, update_diff_status, get_design_language
  • Transport: WebSocket (long-running) or HTTP POST (polling)
  • Auth: signed workspace token in first message (AuthRequest)
  • Claude Code adapter generates CLAUDE.md section + .claude/settings.json block
  • No tool for "I changed this component — apply it to source"
  • No tool for querying component→file mapping

@originmain/app — canvas host

  • Canvas receives FIBER_TREE_UPDATE, stores in Zustand (artboardFiberRoots)
  • Inspector reads selectedComponentData (a FiberNode) — name + props + domRect
  • DOM patches queued in styleEditQueue, drained by LiveArtboard
  • No intent message emitted. Style changes stop at the DOM.

3. Layer 2a — _debugSource Extraction (Quick Win)

What it is: React's dev build attaches _debugSource: { fileName, lineNumber, columnNumber } to every fiber created by JSX. This is the same data React DevTools uses to show "Defined in src/components/Card.tsx:12".

What we need to do:

  • In buildProxyFiberHookScript() (renderer package), read fiber._debugSource during serializeFiber()
  • Forward it as an optional sourceFile: { fileName, lineNumber } field on FiberNode
  • Update the FiberNode TypeScript interface to include it

Protocol change — add to FiberNode:

export interface FiberNode {
  id: string;
  name: string;
  props: Record<string, unknown>;
  children: FiberNode[];
  domRect?: DOMRectLike;
  // NEW
  sourceFile?: { fileName: string; lineNumber: number; columnNumber?: number };
}

Constraint: _debugSource is only present in development builds (when React is compiled with __DEV__ = true). Production builds strip it. The CLI proxy is a dev-only tool, so this is acceptable. The field must be optional everywhere.

What this unlocks immediately (no CLI changes):

  • Inspector can show "Defined in src/components/DashboardCard.tsx:12"
  • Intent messages can carry an accurate file field without needing the AST indexer
  • The AST indexer becomes a fallback/enrichment path, not a blocker

Files to change:

File Change
packages/renderer/src/fiber-hook.ts Read fiber._debugSource in serializeFiber(), include in serialised node
packages/renderer/src/protocol.ts Add sourceFile? to FiberNode interface
packages/app/src/components/inspector/Inspector.tsx Display file path + line in the Inspector panel

4. Layer 2b — CLI AST Indexer

What it does: Watches the user's project .tsx/.ts files, builds and maintains an in-memory map of componentName → { filePath, exportedProps, designTokensUsed }. Exposes a local HTTP API the canvas queries to enrich intent messages.

4.1 The index structure

interface ComponentEntry {
  name: string;
  filePath: string;          // relative to project root, e.g. "src/components/Card.tsx"
  lineNumber: number;        // line of the export declaration
  isDefaultExport: boolean;
  props: PropEntry[];        // extracted from the component's Props interface / type
  tokensUsed: string[];      // CSS custom property references found in the file (--color-primary etc.)
  lastIndexed: number;       // Date.now() — for cache invalidation
}

interface PropEntry {
  name: string;
  type: string;              // string representation, e.g. "string | undefined"
  optional: boolean;
  defaultValue?: string;
}

4.2 AST parsing approach

Use TypeScript's compiler API (ts.createSourceFile()) — it is already available in the dev environment since projects are TypeScript. The indexer does NOT need to type-check; it only needs to parse.

What to extract per file:

  1. All named and default exported functions/arrow functions that start with a capital letter (component heuristic)
  2. Their associated Props / interface / type parameter (first parameter type annotation)
  3. All string literals matching var(--…) in JSX attribute values and template literals (design token references)

What NOT to do:

  • Full type resolution (expensive, requires full tsconfig program)
  • Module graph traversal (only index direct file exports)
  • Watch node_modules (skip entirely)

4.3 Local HTTP API (exposed by CLI)

The CLI adds a second local server on port 4171 (or configurable):

GET  /components             → ComponentEntry[]    all indexed components
GET  /components?name=Card   → ComponentEntry[]    fuzzy-matched by name
GET  /components?file=src/…  → ComponentEntry[]    all exports from a file
GET  /health                 → { status, indexed, lastScan }
POST /reindex                → triggers full rescan (useful after large refactors)

The canvas calls this API when an intent is about to be emitted — it enriches the intent with props and tokensUsed before sending it to the Agent Bridge.

4.4 File watching

Use Node's fs.watch() (recursive, available since Node 22 which is the CLI's minimum). On change events, re-index only the changed file (incremental update, not full rescan). Full rescan on startup.

4.5 CLI command change

originmain dev --target http://localhost:3000 [--port 4170] [--index-port 4171] [--no-index]

--no-index disables the AST indexer for users who only want the proxy (e.g., non-TypeScript projects). The indexer is opt-in-by-default, gracefully degraded.

4.6 Files to create / change

File Change
packages/cli/src/indexer.ts NEW — AST walker, component→file map, file watcher
packages/cli/src/index-server.ts NEW — HTTP API server on port 4171
packages/cli/src/cli.ts Add --index-port / --no-index flags, start indexer alongside proxy
packages/cli/src/proxy.ts Inject index server URL into HTML as window.__OM_INDEX_URL__

5. Layer 2c — Intent Message Protocol

An Intent Message is the structured description of what the user wants to change — precise enough for a coding agent to apply it without guessing.

5.1 Intent Message format

interface IntentMessage {
  // Identity
  intentId: string;                  // uuid, for tracking
  workspaceId: string;
  artboardId: string;

  // Component context
  component: {
    name: string;                    // "DashboardCard"
    nodeId: string;                  // fiber path ID "DashboardCard:0/..."
    sourceFile?: string;             // "src/components/DashboardCard.tsx"
    sourceLine?: number;             // 12
    props: Record<string, unknown>;  // current runtime props
    propsSchema?: PropEntry[];       // from AST indexer (richer type info)
  };

  // The change(s) — an array to support multi-property edits
  changes: IntentChange[];

  // Before/after visual evidence
  snapshot?: {
    before: string;                  // base64 PNG of the component at the time of selection
    after?: string;                  // base64 PNG after DOM preview (optional, captured on confirm)
  };

  // Context the agent needs
  designLanguage?: {
    tokensUsed: string[];            // CSS custom props referenced in source
    palette: Record<string, string>; // current token values
  };

  createdAt: string;                 // ISO 8601
}

interface IntentChange {
  type: 'style' | 'prop' | 'layout' | 'remove';
  cssProperty?: string;             // for type: 'style'  e.g. "border-radius"
  propName?: string;                // for type: 'prop'   e.g. "variant"
  from: unknown;                    // previous value
  to: unknown;                      // intended value
  confidence: 'exact' | 'approximate'; // 'exact' = token match; 'approximate' = raw value
}

5.2 Where intent is created — canvas flow

User adjusts border-radius slider in Inspector
         ↓
Inspector dispatches patchStyleEdit() to Zustand
         ↓
LiveArtboard applies PATCH_ELEMENT_STYLE → DOM preview
         ↓
Inspector shows "Confirm as Intent" button
         ↓
User clicks Confirm
         ↓
Canvas builds IntentMessage:
  - component name + sourceFile from selectedComponentData (fiber node)
  - change: { cssProperty: 'border-radius', from: '8px', to: '12px' }
  - enriches with indexer API if available (/components?name=DashboardCard)
         ↓
Canvas sends IntentMessage to Agent Bridge via new MCP tool: push_intent
         ↓
Agent Bridge stores intent, forwards to connected Claude Code / Cursor session
         ↓
Agent reads file, makes edit, hot reload confirms visually

5.3 The "Confirm as Intent" UX decision

There are two valid approaches here, with different trade-offs:

Option A — Optimistic / auto-emit Every DOM patch immediately generates an intent. No confirm button. The agent applies changes in real-time as the user drags.

  • Pro: Fluid, no friction
  • Con: Noisy — dragging a slider fires dozens of intents. The agent would be overwhelmed. Race conditions between hot-reload and the next drag.

Option B — Explicit confirm DOM patch is a preview only. A "Commit as Intent →" button appears. The user confirms when satisfied.

  • Pro: Clean one-shot intents. Agent gets a stable before/after to work with.
  • Con: Extra click. Users might forget to commit.

Recommendation: Option B — matches the git mental model (stage → commit). The canvas already has the styleEditQueue pattern which fits this model. A "commit" button in the Inspector is the natural end of the interaction.


6. Layer 2d — Agent Bridge Enhancements

Two new MCP tools and one protocol extension are required.

6.1 New tool: push_intent

Direction: Canvas → Agent Bridge → coding agent
Purpose: Deliver a confirmed IntentMessage to the connected agent session

// Input schema
{
  workspace_id: z.string().uuid(),
  intent: z.object({
    component: z.object({
      name: z.string(),
      sourceFile: z.string().optional(),
      sourceLine: z.number().optional(),
    }),
    changes: z.array(z.object({
      type: z.enum(['style', 'prop', 'layout', 'remove']),
      cssProperty: z.string().optional(),
      propName: z.string().optional(),
      from: z.unknown(),
      to: z.unknown(),
    })),
    snapshot: z.object({
      before: z.string(),    // base64 PNG
      after: z.string().optional(),
    }).optional(),
  }),
}

What the tool does:

  1. Stores the intent in the intent_diffs table with status EXPORTED
  2. Broadcasts it over the WebSocket connection to any connected agent
  3. Returns the intent's ID so the canvas can poll for IMPLEMENTED status

The existing update_diff_status tool is how the agent signals completion. No changes needed there.

6.2 New tool: resolve_component

Direction: Coding agent → Agent Bridge → CLI indexer
Purpose: Let Claude Code ask "where is DashboardCard defined?" without needing direct filesystem access through the MCP.

// Input
{ component_name: z.string() }

// Output
{
  name: string;
  sourceFile: string;
  sourceLine: number;
  props: PropEntry[];
  tokensUsed: string[];
}

How it works: The Agent Bridge proxies the request to the CLI indexer's HTTP API (GET /components?name=DashboardCard). This requires the CLI to register its indexer URL with the Agent Bridge on startup.

CLI registration flow:

originmain dev starts
  → proxy on :4170
  → indexer on :4171
  → POST /api/agent-bridge/register-indexer
    { workspaceToken, indexerUrl: "http://localhost:4171" }
  → Agent Bridge stores indexerUrl per workspace session
  → resolve_component proxies to it

6.3 Protocol extension — server-initiated INTENT_RECEIVED

The Agent Bridge needs to push intents to the agent without the agent polling. For WebSocket connections this is a server-initiated message (not a JSON-RPC request/response):

// Server pushes to agent over WebSocket
{
  type: 'INTENT_RECEIVED',
  intent: IntentMessage,
}

Claude Code / Cursor sessions listening over WebSocket receive this immediately after push_intent is called from the canvas.

6.4 Files to create / change

File Change
packages/agent-bridge/src/tools.ts Add push_intent and resolve_component tools
packages/agent-bridge/src/protocol.ts Add IntentMessage, IntentChange types; INTENT_RECEIVED server push
packages/agent-bridge/src/index.ts Handle /register-indexer endpoint, store per-workspace indexer URL
packages/agent-bridge/src/adapters/claude-code.ts Document new tools in CLAUDE.md section
packages/app/src/app/api/agent-bridge/route.ts Wire push_intent and resolve_component to the tool executor

7. Complete Data Flow

─────────────────────────────────────── DEVELOPMENT MACHINE ────
user's next dev       originmain CLI        Originmain canvas
  server :3000     proxy :4170  indexer:4171    (browser tab)
      │                │              │               │
      │   GET /page    │              │               │
      │◄───────────────│              │               │
      │   HTML + HMR   │              │               │
      │───────────────►│  inject      │               │
      │                │  fiber hook  │               │
      │                │─────────────────────────────►│
      │                │              │    READY       │
      │                │◄─────────────────────────────│
      │                │   FIBER_TREE_UPDATE           │
      │                │◄─────────────────────────────│
      │                │              │  user clicks   │
      │                │   COMPONENT_SELECTED          │
      │                │◄─────────────────────────────│
      │                │              │  user adjusts  │
      │                │   PATCH_ELEMENT_STYLE         │
      │                │───────────────────────────── │
      │   DOM preview  │              │               │
      │                │              │  user confirms │
      │                │   GET /components?name=Card  │
      │                │──────────────►│              │
      │                │   ComponentEntry             │
      │                │◄─────────────│              │
      │                │              │  IntentMessage │
      │                │              │◄──────────────│
─────────────────────────────── CLOUD ──────────────────────────
      │                │              │  push_intent   │
      │                │              │───────────────►│ Agent Bridge
      │                │              │                │   stores intent
      │                │              │                │   → INTENT_RECEIVED
      │                │              │                │───────────────►
      │                │              │                │            Claude Code
      │                │              │                │         reads file, edits
      │                │              │                │◄───────────────
      │   hot reload   │              │   FIBER_TREE_UPDATE (post-edit)
      │◄───────────────│◄─────────────────────────────│
      │                │              │  update_diff_status(IMPLEMENTED)

8. Implementation Phases

Phase 1 — _debugSource (12 days, no dependency)

  1. Extend FiberNode in renderer/src/protocol.ts
  2. Read fiber._debugSource in buildProxyFiberHookScript()
  3. Display file path in Inspector panel
  4. No CLI or Agent Bridge changes needed

Milestone: Inspector shows "src/components/DashboardCard.tsx:12" for any selected component.


Phase 2 — Intent Message + "Commit" UX (23 days, depends on Phase 1)

  1. Define IntentMessage and IntentChange types in a new shared package or agent-bridge/src/protocol.ts
  2. Add "Commit as Intent →" button to Inspector (appears after any DOM patch)
  3. Canvas builds IntentMessage from selectedComponentData + styleEditQueue
  4. For now, log intent to console and show a "copied to clipboard" confirmation
  5. No Agent Bridge wiring yet — this phase validates the UX

Milestone: User can make a visual change and see a well-formed IntentMessage describing it.


Phase 3 — CLI AST Indexer (34 days, parallel to Phase 2)

  1. Implement packages/cli/src/indexer.ts — TypeScript AST walker
  2. Implement packages/cli/src/index-server.ts — HTTP API on :4171
  3. Wire into cli.ts alongside proxy
  4. Inject window.__OM_INDEX_URL__ into proxied HTML
  5. Canvas queries indexer to enrich IntentMessage before emit

Milestone: GET http://localhost:4171/components?name=DashboardCard returns file path, props, tokens.


Phase 4 — Agent Bridge push_intent + resolve_component (23 days, depends on Phase 2)

  1. Add push_intent tool to agent-bridge/src/tools.ts
  2. Add resolve_component tool (proxies to CLI indexer via registered URL)
  3. Add /register-indexer endpoint
  4. Implement INTENT_RECEIVED server push over WebSocket
  5. Update Claude Code adapter's CLAUDE.md section to document the new workflow
  6. Wire canvas Intent emit through to the API route → Agent Bridge

Milestone: User confirms an intent in the canvas; Claude Code session receives it and can open the right file.


Phase 5 — E2E validation (12 days)

  1. Full flow test: proxy → fiber hook → component select → DOM patch → confirm → Claude Code applies edit → hot reload
  2. Validate that _debugSource.fileName matches the AST indexer's filePath for the same component
  3. Edge cases: components without _debugSource (production build, dynamically generated), components defined in node_modules

9. Open Questions / Decisions Needed

Q1: Intent storage — Supabase or in-memory only?

push_intent can either:

  • A) Write to Supabase intent_diffs table, return an ID, allow polling via existing get_pending_diffs. Agent Bridge is stateless.
  • B) In-memory queue per WebSocket session. Simpler, no DB write, but intents are lost if the agent disconnects.

Option A integrates with the existing diff workflow and gives the canvas visibility into IMPLEMENTED status. Option B is faster to ship but creates a parallel, disconnected system.

Recommendation: Option A. The existing DiffStatus enum (DRAFT → EXPORTED → IMPLEMENTED → BLOCKED) maps cleanly onto the intent lifecycle. The canvas already has diff-tracking UX built.


Q2: Source of truth for component→file mapping — _debugSource or AST indexer?

_debugSource AST Indexer
Availability Dev builds only, present in fiber Any .ts/.tsx file
Accuracy Exact (React sets it) High (AST, not heuristics)
Prop types Not available Full type extraction
Token refs Not available String-literal scan
Setup cost Zero CLI must be running
Fallback if absent Graceful (no file info) N/A — indexer is offline

Recommendation: Use _debugSource as the primary source (always present if CLI is running in dev). Use AST indexer as enrichment (adds prop schema + token list). If neither is available, IntentMessage is valid but less specific.


Q3: Snapshot capture — before only, or before + after?

Capturing a before-snapshot when the component is selected is straightforward (html2canvas or getComputedStyle snapshot). Capturing an after-snapshot requires waiting for the DOM patch to settle.

Recommendation: Capture before on component selection (cheap). Capture after on "Confirm" click with a 100ms delay to allow the DOM patch to apply. Make both optional — the agent can act on the textual change description alone.


Q4: AST indexer scope — props only, or prop defaults too?

Extracting default values requires evaluating expressions (e.g., const { size = 'md' } = props). This touches type inference territory and can be expensive.

Recommendation: Phase 3 extracts prop names and types only. Default values are Phase 5+ once the basic pipeline is proven.


10. Non-Goals for This Iteration

  • Vue / Svelte support: The fiber hook is React-specific. The DOM inspector (buildDomInspectorScript) is framework-agnostic but has no AST indexer equivalent. Out of scope.
  • Production build support: _debugSource is dev-only. Intent messages from production artboards will lack source file info; the AST indexer can partially fill this gap via name matching.
  • Multi-file edits: A single intent maps to a single component change. Refactoring that spans multiple files (e.g., extracting a token to a design system) is out of scope for Phase 14.
  • Automatic apply without agent: The Agent Bridge is the exit point. We are not building a code-writing engine inside the canvas. The canvas describes intent; an agent with file access applies it.