-
Notifications
You must be signed in to change notification settings - Fork 294
feat(desktop): add a pinned canvas tab #221
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
framethespace
wants to merge
2
commits into
OpenCoworkAI:main
Choose a base branch
from
framethespace:codex/canvas-context-tab
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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'), | ||
| 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; | ||
| }); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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, andparseCanvasScene()currently assumes this raw shape forever.Suggested fix: