The app is chat-first; when the user expands the canvas, the layout flips to full-canvas mode — the React Flow (@xyflow/react) editor fills the entire main area, and the existing assistant-ui <Thread> floats on top as a translucent card (backdrop-blurred, ~360–400 px wide, anchored to the right edge). This file is the design doc — layout, data model, auto-save cadence, the image-generation tool. For HTTP endpoints see docs/APIS.md § Canvas.
┌─────────────────────────────────────────────────────────────┐
│ Header: [Sidebar] [ThreadTitle] [Canvas toggle]│
├─────────────────────────────────────────────────────────────┤
│ │
│ │
│ React Flow (CanvasEditor) │
│ fills the full main area │
│ │
│ ┌──────────────────────────┐ │
│ │ <Thread /> [collapse] │ ← translucent card, │
│ │ │ bottom-left collapse │
│ └──────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
Collapsed: card hides; a chat-bubble button appears at the
viewport's bottom-right (click it to reopen the chat).
- Toggle lives in the header (
components/chat/CanvasToggleButton.tsx) — pushed to the far right withml-auto, usesLayoutPanelLeftglyph to mirror the sidebarPanelLeftIcon. Hidden< 768px(mobile stays chat-only, per the user's tapNow-style decision). Header keeps<ThreadTitle>at all times — the sidebar's<ThreadList>is the canonical thread switcher, the inline header dropdown was redundant once canvas took the full area. - Canvas is user-toggled only — no auto-open. The canvas opens when (and only when) the user clicks the toggle. There is no first-message auto-open, no first-thread heuristic, no rule that flips it on without an explicit click. Per-thread open/closed state is persisted in
localStorageundercanvas:<threadId>:openso the user's last choice is restored on refresh / thread-switch (lib/canvas/prefs.ts). - The
<Thread>is mounted inside the canvas overlay (CanvasSplitLayout) — toggling off the canvas teardown unmounts the editor (and its React Flow listeners) and switches to plain<Thread />.key={threadId}onCanvasEditorforces a remount per thread so xyflow's internal store doesn't leak nodes across threads. - Collapse control lives INSIDE the chat card at its bottom-left (
PanelRightCloseIcon,size-5); collapsed → chat-bubble button at the viewport's bottom-right (MessageCircleIcon,size-10). Both transitions are CSS-driven (transition-all duration-200 ease-out) ontranslate-x/opacity/scale— notw-animate-csskeyframes (they don't pin final state when the element stays mounted). - The React Flow editor uses three custom node types —
text,generate,preview— registered innodeTypes. Edges flow top→bottom;Background(dots) +Controls(zoom/pan) +MiniMapround out the chrome. No multi-page, no theme switching — one thread = one canvas.
One table: canvas_snapshots. One row per thread.
{
threadId: string; // PK + FK → threads.id (ON DELETE CASCADE)
document: Record<string, unknown>; // React Flow { nodes, edges } blob (jsonb)
createdAt: timestamp;
updatedAt: timestamp;
}documentis the live React Flow state —{ nodes: Node[], edges: Edge[] }fromuseReactFlow().getNodes()/getEdges(). We don't store viewport/camera state — that's per-browser in xyflow's localStorage, not part of the snapshot.- Cascade on thread delete: archiving a thread from the sidebar wipes its canvas row automatically (the FK does the work; no parallel canvas query).
- We deliberately don't re-validate the
documentserver-side — React Flow's loader IS the validator. Round-tripping through zod would duplicate validation that drifts when xyflow evolves (every minor version adds / removes node props). The route only checks "non-null object" — anything malformed gets caught when the next client callssetNodes/setEdges.
lib/canvas/auto-save.ts — debounced PUT with beforeunload flush.
- Debounce 2s (
DEBOUNCE_MS = 2000). Every change event fromgetDocumentActionresets the timer, so a continuous drag fires ONE save 2s after the user stops moving — not 100s while they drag. - last-write-wins. A ref holds the latest document; on every change we overwrite it. The next flush sends the freshest payload.
- beforeunload + pagehide flush via
navigator.sendBeacon. JSON blob, same endpoint.sendBeaconreturns false when the browser refuses (> 64KB) — we fall back tofetch(... keepalive: true)so nothing is silently dropped. - Status state machine:
idle → pending → saving → saved | error. UI badge reads fromstatus. 401/404 on save drops the pending payload (thread vanished or session expired — no point retrying).
backend/tool/image/generate-image.ts — generate_image LangGraph tool, registered in CHAT_TOOLS. Unconditional registration: the Pollinations backend is free (no key) and the fal.ai backend is opt-in via FAL_KEY. lib/image/ exposes a per-platform factory (createPollinationsBackend() / createFalBackend(apiKey)) sharing a single ImageBackend interface; pickImageBackend() selects at call time.
- Default (no
FAL_KEY): GETshttps://image.pollinations.ai/prompt/<encoded>?width=W&height=H&seed=S&nologo=true&model=gptimageand returns the URL.gptimageis pinned because it accepts theimage=query param for image-to-image; the unnamed default flux silently drops it. - With
FAL_KEY: POSTs JSON tohttps://fal.run/fal-ai/flux/schnellwith{ prompt, aspect_ratio, num_images, image_url? }and parses{ images: [{ url }] }. Supports image-to-image via theimage_urlbody key. - Both backends accept
image_urlfor image-to-image. The chat model copies the URL from upstream canvas Image attachments into the tool call; the canvas card does the wiring via theuseCanvas()bridge. - Charges go through the existing credit system using the
kind: "pic"model kind (lib/credit/zod.ts modelKindSchema+lib/provider/schema.ts ModelKind). Admins tag image-gen providers withkind: ["pic"]so the per-kind rate config routes correctly. Pollinations is free so no charges fire on the default path; fal.ai does, gated byFAL_KEY.
The tool result is rendered by components/tool-ui/image/generate-image-card.tsx (registered in toolkit.tsx). The card uses useCanvas() from lib/canvas/context.tsx to bridge into the live React Flow editor — see Cross-component wiring.
The right-hand <Thread> panel and the left-hand React Flow editor live in different component trees. A tiny context (lib/canvas/context.tsx) bridges them:
CanvasProviderlives inCanvasSplitLayoutand exposes two contexts:useCanvas()(read access for tool-ui cards) anduseCanvasRegister()(the mount-time setter forCanvasEditor).CanvasEditorcallsregister({ rf, getSourceNode, insertImage, focusNode, addEdge })inonInitandregister(null)on unmount. Tool cards readuseCanvas()→{ ready, insertImage({ url, w?, h? }), focusNode(id), addEdge({source, target, system}) }.- When the canvas is closed,
useCanvas()returns the no-op default (ready: false, …). The image card renders the button as disabled with the label "Open canvas to add". - Default size is 256×256 (canvas Preview node dims). The card scales to 192×256 (portrait) or 256×192 (landscape) when
aspect_ratiois set, dropping the node at the source's position +40px Y, +20px per variant so they don't overlap.
| File | Role |
|---|---|
lib/canvas/schema.ts |
canvas_snapshots table + zod insert/select schemas |
lib/canvas/validators.ts |
PutCanvasBody — document must be a plain record |
lib/canvas/queries.ts |
getCanvasSnapshotForUser, threadOwnedByUser, upsertCanvasSnapshot, deleteCanvasSnapshot |
lib/canvas/snapshot.ts |
EMPTY_DOCUMENT (initial { nodes: [], edges: [] }) + thread-id guards |
lib/canvas/types.ts |
CanvasDocumentT — typed { nodes: CanvasNode[], edges: Edge[] } shape |
lib/canvas/auto-save.ts |
useCanvasAutoSave hook — debounce + sendBeacon, reads live React Flow state |
lib/canvas/context.tsx |
CanvasProvider + useCanvas + useCanvasRegister (cross-component bridge) |
app/api/canvas/[threadId]/route.ts |
GET / PUT / DELETE — withAuth, 404 on non-owned |
components/chat/CanvasToggleButton.tsx |
Header toggle, hidden md:flex |
components/chat/CanvasEditor.tsx |
React Flow wrapper — onInit registers the bridge, setNodes/setEdges on thread change, three custom node types (text / generate / preview) |
components/chat/CanvasSplitLayout.tsx |
Full-canvas wrapper: <CanvasEditor> fills the area, <Thread> overlays as a translucent right-edge card; collapse icon inside the card, chat-bubble button outside when collapsed. Wrapped in CanvasProvider. |
backend/tool/image/generate-image.ts |
generate_image LangGraph tool (Pollinations default, fal.ai via FAL_KEY) |
lib/image/{types,pollinations,fal,index}.ts |
ImageBackend interface + per-platform factories + pickImageBackend() |
components/tool-ui/image/generate-image-card.tsx |
Image card — auto-adds Preview nodes to the canvas (consumes useCanvas()), onError fallback to placeholder |
- One table, not per-node diffs. React Flow's persistence model is "save the whole state." A diff table would need a merge function that re-implements xyflow's reconciliation — a complexity tax for no correctness gain. One PUT, one GET, one DELETE.
- FK cascade over a parallel sweep. Archiving / deleting a thread already wipes langgraph checkpointer rows + observability spans via cascades. Adding a third cascade for canvas is the same pattern, not new code.
- No re-validation server-side. React Flow's loader IS the validator. A zod schema mirroring
Node/Edgedrifts as xyflow evolves (every minor version adds / removes node props). The route only checks "non-null object" — anything malformed gets caught when the next client callssetNodes. - Debounce + sendBeacon, not autosave-on-interval. A 2s debounce keeps drag-induced saves to one write.
sendBeaconcovers the one failure mode debounce can't: the user closing the tab mid-debounce. FAL_KEYopt-in for fal.ai. Default path is Pollinations (free, no key, image-to-image viagptimagemodel).FAL_KEYflipspickImageBackend()to fal.ai for users who want higher quality. Both share the sameImageBackendshape, so the tool wrapper doesn't change.kind: "pic"instead of billing on a flat per-image rate. Image-gen pricing varies wildly across models (flux schnell vs. flux pro vs. imagen-4) — tagging with akindlets admins register each model under the same kind with differentinputPer1k/outputPer1k, and the credit callback already handles per-kind attribution viaagentName/modelName.
- Yjs CRDT multiplayer. Two users on the same thread both editing —
@xyflow/reactsupports it viauseYjsSync. Out of scope for the current MVP; the design (one-row snapshot) accommodates it by switching thedocumentwrite to a periodic Yjs update log instead. - Per-image kind for credit attribution. Today the tool call's tokens go through the standard LLM credit path; the actual image-generation call to FAL / Pollinations is NOT metered. Add a parallel
image_usage_logif/when fal.ai costs become non-trivial. - Drag-in image upload from canvas. React Flow doesn't ship drop handlers out of the box; wire an
<Editor>-levelonDropto the R2 attachment pipeline so dropped images land as Preview nodes instead of bloating the JSONB document.