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
5 changes: 5 additions & 0 deletions .changeset/canvas-context-tab.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@open-codesign/desktop": patch
---

Add a pinned Canvas tab in the desktop preview pane so sketches can live alongside generated files, and send canvas context back with the next prompt when the sketch changes.
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,27 @@

---

## Recent Desktop Additions

The items in this section are the latest workflow upgrades added on top of the
core Open CoDesign experience, so they stay separate from the baseline repo
feature list below.

- **Pinned `Canvas` tab with full Excalidraw UI** — sketch wireframes, widgets, motion notes, and layout ideas directly in the app before the first generation
- **Canvas-to-model context export** — the canvas is packaged into prompt context automatically as a summary plus SVG exports, with frame-aware exports when the scene is too large
- **Imported canvas images are sent separately too** — reference images dropped into the canvas also show up as standalone chat attachments for clearer model context
- **Visible send confirmation** — the composer and chat now show when canvas context was actually included, so you can confirm it at a glance
- **Canvas autosave and save-on-submit** — rough sketches are persisted per design, with an extra flush when you send a prompt
- **Smart follow-up reuse** — canvas context is only resent on later turns when the canvas changed since the last successful generation

<p align="center">
<img src="./website/Excalidraw-canvas.png" alt="Open CoDesign canvas tab with an Excalidraw wireframe and imported UI references" width="1000" />
</p>

For implementation details, see [`apps/desktop/CANVAS_CONTEXT.md`](./apps/desktop/CANVAS_CONTEXT.md).

---

## What it is

Turn a prompt into a polished prototype, slide deck, or marketing asset, locally, with the model you already use.
Expand Down
40 changes: 40 additions & 0 deletions apps/desktop/CANVAS_CONTEXT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Canvas Context

The desktop app now includes a pinned `Canvas` tab beside `Files`. It embeds a
full Excalidraw surface that can be used before the first generation, so users
can sketch wireframes, widgets, animation notes, and layout ideas before asking
the model to build or edit the UI.

## How It Works

- The renderer mounts Excalidraw in `CanvasSketchView.tsx`.
- Canvas state is stored per design through `canvas:v1:*` IPC handlers in
`src/main/canvas-ipc.ts`.
- Scene JSON is persisted under the app user-data directory, alongside a small
list of imported local files.
- Imported images are also surfaced as regular chat attachments so the model
receives both the scene context and the original image files.

## Prompt Context Export

Before generation, the store converts the current scene into prompt attachments:

- `canvas-summary.md` with a compact summary of visible elements and labels
- one `canvas.svg` export for the whole scene, or
- up to four frame-specific SVG exports when Excalidraw frames are present

These artifacts are written to a temp directory and attached automatically when
the canvas contains visible content.

## Current Limitation

The current generation pipeline is still text-first. In practice that means the
model receives SVG and markdown artifacts derived from the Excalidraw scene,
plus any imported source images, rather than true bitmap-vision analysis of the
canvas itself.

## Testing Note

Vitest uses a local Excalidraw shim so renderer tests stay deterministic and do
not depend on the full browser/runtime behavior of the published Excalidraw
bundle.
1 change: 1 addition & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"test": "vitest run --passWithNoTests"
},
"dependencies": {
"@excalidraw/excalidraw": "^0.18.1",
"@open-codesign/artifacts": "workspace:*",
"@open-codesign/core": "workspace:*",
"@open-codesign/exporters": "workspace:*",
Expand Down
143 changes: 143 additions & 0 deletions apps/desktop/src/main/canvas-ipc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { basename, join } from 'node:path';
import { LocalInputFile } from '@open-codesign/shared';
import { app, ipcMain } from './electron-runtime';

interface CanvasStatePayload {
sceneJson: string | null;
importedFiles: Array<{
path: string;
name: string;
size: number;
}>;
}

function canvasStateDir(designId: string): string {
return join(app.getPath('userData'), 'canvas-state', designId);
}

function canvasScenePath(designId: string): string {
return join(canvasStateDir(designId), 'scene.excalidraw.json');
}

function canvasImportsPath(designId: string): string {
return join(canvasStateDir(designId), 'imports.json');
}

function canvasExportDir(designId: string): string {
return join(app.getPath('temp'), 'open-codesign-canvas-context', designId);
}

async function readTextIfPresent(path: string): Promise<string | null> {
try {
return await readFile(path, 'utf8');
} catch (error) {
const code = (error as { code?: unknown })?.code;
if (code === 'ENOENT') return null;
throw error;
}
}

function requireSchemaV1(raw: unknown, channel: string): Record<string, unknown> {
if (typeof raw !== 'object' || raw === null) {
throw new Error(`${channel} expects an object payload`);
}
const record = raw as Record<string, unknown>;
if (record['schemaVersion'] !== 1) {
throw new Error(`${channel} requires schemaVersion: 1`);
}
return record;
}

function requireDesignId(record: Record<string, unknown>, channel: string): string {
const designId = record['designId'];
if (typeof designId !== 'string' || designId.trim().length === 0) {
throw new Error(`${channel} requires a non-empty designId`);
}
return designId;
}

function parseImportedFiles(raw: unknown): CanvasStatePayload['importedFiles'] {
if (!Array.isArray(raw)) return [];
return raw
.map((entry) => LocalInputFile.parse(entry))
.map((file) => ({ path: file.path, name: file.name, size: file.size }));
}

function sanitizeFileName(name: string): string {
const clean = basename(name).replace(/[^\w.\-]+/g, '-');
return clean.length > 0 ? clean : 'canvas-context.txt';
}

export function registerCanvasIpc(): void {
ipcMain.handle('canvas:v1:load-state', async (_event: unknown, raw: unknown) => {
const record = requireSchemaV1(raw, 'canvas:v1:load-state');
const designId = requireDesignId(record, 'canvas:v1:load-state');
const [sceneJson, importsJson] = await Promise.all([
readTextIfPresent(canvasScenePath(designId)),
readTextIfPresent(canvasImportsPath(designId)),
]);

let importedFiles: CanvasStatePayload['importedFiles'] = [];
if (importsJson) {
try {
importedFiles = parseImportedFiles(JSON.parse(importsJson));
} catch {
importedFiles = [];
}
}

return {
sceneJson,
importedFiles,
} satisfies CanvasStatePayload;
});

ipcMain.handle('canvas:v1:save-state', async (_event: unknown, raw: unknown) => {
const record = requireSchemaV1(raw, 'canvas:v1:save-state');
const designId = requireDesignId(record, 'canvas:v1:save-state');
const sceneJson = record['sceneJson'];
if (sceneJson !== null && typeof sceneJson !== 'string') {
throw new Error('canvas:v1:save-state requires sceneJson to be a string or null');
}

const importedFiles = parseImportedFiles(record['importedFiles']);
await mkdir(canvasStateDir(designId), { recursive: true });
await Promise.all([
writeFile(canvasScenePath(designId), sceneJson ?? '', 'utf8'),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Blocker] This writes raw Excalidraw JSON / a bare imports array directly to disk with no app-level schemaVersion. The repo rule is explicit that persisted disk formats must be versioned, and parseCanvasScene() currently assumes this raw shape forever.

Suggested fix:

const payload = {
  schemaVersion: 1,
  sceneJson,
  importedFiles,
  lastGeneratedCanvasRevision,
};
await writeFile(canvasStatePath(designId), JSON.stringify(payload, null, 2), 'utf8');

writeFile(canvasImportsPath(designId), JSON.stringify(importedFiles, null, 2), 'utf8'),
]);
return { ok: true as const };
});

ipcMain.handle('canvas:v1:write-context-files', async (_event: unknown, raw: unknown) => {
const record = requireSchemaV1(raw, 'canvas:v1:write-context-files');
const designId = requireDesignId(record, 'canvas:v1:write-context-files');
const files = record['files'];
if (!Array.isArray(files)) {
throw new Error('canvas:v1:write-context-files requires files[]');
}
await mkdir(canvasExportDir(designId), { recursive: true });
const stamp = Date.now().toString(36);
const written = await Promise.all(
files.map(async (entry, index) => {
if (typeof entry !== 'object' || entry === null) {
throw new Error('canvas:v1:write-context-files received an invalid file entry');
}
const file = entry as Record<string, unknown>;
const name = sanitizeFileName(
typeof file['name'] === 'string' ? file['name'] : `canvas-context-${index + 1}.txt`,
);
const content = typeof file['content'] === 'string' ? file['content'] : '';
const path = join(canvasExportDir(designId), `${stamp}-${index + 1}-${name}`);
await writeFile(path, content, 'utf8');
return LocalInputFile.parse({
path,
name,
size: Buffer.byteLength(content, 'utf8'),
});
}),
);
return written;
});
}
32 changes: 32 additions & 0 deletions apps/desktop/src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,17 @@ export interface Preferences {
diagnosticsLastReadTs: number;
}

export interface CanvasStoredState {
sceneJson: string | null;
importedFiles: LocalInputFile[];
}

export interface CanvasContextFile {
name: string;
content: string;
encoding?: 'utf8' | 'base64';
}

/**
* Streaming events emitted by the (future) Agent runtime. Phase 1 emits
* turn_start / text_delta / turn_end. Phase 2 adds tool_call_*. Kept
Expand Down Expand Up @@ -528,6 +539,27 @@ const api = {
snapshotId,
}) as Promise<CommentRow[]>,
},
canvas: {
loadState: (designId: string) =>
ipcRenderer.invoke('canvas:v1:load-state', {
schemaVersion: 1,
designId,
}) as Promise<CanvasStoredState>,
saveState: (input: {
designId: string;
sceneJson: string | null;
importedFiles: LocalInputFile[];
}) =>
ipcRenderer.invoke('canvas:v1:save-state', {
schemaVersion: 1,
...input,
}) as Promise<{ ok: true }>,
writeContextFiles: (input: { designId: string; files: CanvasContextFile[] }) =>
ipcRenderer.invoke('canvas:v1:write-context-files', {
schemaVersion: 1,
...input,
}) as Promise<LocalInputFile[]>,
},
diagnostics: {
log: (entry: {
schemaVersion: 1;
Expand Down
7 changes: 5 additions & 2 deletions apps/desktop/src/renderer/src/components/CanvasErrorBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ import { useCodesignStore } from '../store';
* from broken snapshots surface as "Inline Babel script" — the user can't
* do anything about them except regenerate, so say that plainly.
*/
function humanizeError(raw: string, t: (k: string, d?: Record<string, unknown>) => string): string {
export function humanizePreviewError(
raw: string,
t: (k: string, d?: Record<string, unknown>) => string,
): string {
if (/Inline Babel script/i.test(raw) || /Unexpected token/.test(raw)) {
return t('preview.error.brokenJsx', {
defaultValue:
Expand All @@ -29,7 +32,7 @@ export function CanvasErrorBar() {
if (errors.length === 0) return null;
const latest = errors[errors.length - 1];
if (!latest) return null;
const friendly = humanizeError(latest, t);
const friendly = humanizePreviewError(latest, t);
return (
<div
role="alert"
Expand Down
Loading
Loading