From a1b078b5e64a62e402492cc9d853e2ab45d13ce4 Mon Sep 17 00:00:00 2001 From: Vitali sharp8n Date: Wed, 16 Sep 2026 13:09:12 +0300 Subject: [PATCH] upgrade --- .mcp-probe.mjs | 41 ++++++++++ .ui-headless-shot.mjs | 70 +++++++++++++++++ CHANGELOG.md | 3 + src/app/browser-bridge.ts | 2 + src/app/document/io/create.ts | 3 +- src/app/document/io/source.ts | 5 ++ src/app/editor/session/modules.ts | 1 + src/app/embed-bridge.ts | 84 +++++++++++++++++++- src/app/tabs/index.ts | 90 ++++++++++++++++------ src/components/recovery/RecoveryDialog.vue | 19 +++-- src/views/EditorView.vue | 11 ++- 11 files changed, 290 insertions(+), 39 deletions(-) create mode 100644 .mcp-probe.mjs create mode 100644 .ui-headless-shot.mjs diff --git a/.mcp-probe.mjs b/.mcp-probe.mjs new file mode 100644 index 000000000..4666ad052 --- /dev/null +++ b/.mcp-probe.mjs @@ -0,0 +1,41 @@ +// Temporary probe: exercise the OpenPencil MCP server against the headless canvas +// started by w4c-chatapi/scripts/openpencil-headless-canvas.mjs. Deleted after use. +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' + +const url = new URL('http://127.0.0.1:7600/mcp') +const client = new Client({ name: 'w4c-probe', version: '0.0.0' }) +await client.connect(new StreamableHTTPClientTransport(url)) + +const { tools } = await client.listTools() +console.log(`tools=${tools.length}`) +const interesting = tools + .map((t) => t.name) + .filter((n) => /shape|rect|screenshot|selection|current_page|page|node|export/i.test(n)) +console.log('matching:', interesting.join(', ')) + +const mode = process.argv[2] || 'list' + +if (mode === 'draw') { + const rect = tools.find((t) => /create.*(rectangle|shape|rect)/i.test(t.name)) + console.log('drawTool:', rect?.name) + console.log('schema:', JSON.stringify(rect?.inputSchema).slice(0, 1200)) +} else if (mode === 'call') { + const name = process.argv[3] + const args = JSON.parse(process.argv[4] || '{}') + const res = await client.callTool({ name, arguments: args }) + console.log( + `RESULT ${name}:`, + JSON.stringify(res.content).slice(0, 1500), + res.isError ? '(isError)' : '' + ) +} else { + // read-back: current page + selection + for (const name of ['get_current_page', 'get_selection']) { + if (!tools.some((t) => t.name === name)) continue + const res = await client.callTool({ name, arguments: {} }) + console.log(`${name}:`, JSON.stringify(res.content).slice(0, 700), res.isError ? '(isError)' : '') + } +} + +await client.close() diff --git a/.ui-headless-shot.mjs b/.ui-headless-shot.mjs new file mode 100644 index 000000000..ee2a0f9d5 --- /dev/null +++ b/.ui-headless-shot.mjs @@ -0,0 +1,70 @@ +// UI check for headless OpenPencil design mode: stubs the canvas store into "headless running" +// (the backend cannot report it while chatapi is down) and screenshots the Design Studio page and +// the highlighted drawer entry + tooltip. Temporary script — deleted after the run. +import { chromium } from '@playwright/test' + +const out = process.argv[2] || '/home/joe/sources/wiz4apps/phase-artifacts/phase-design-headless-canvas' +const browser = await chromium.launch({ headless: true, args: ['--no-sandbox', '--disable-dev-shm-usage'] }) +const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }) + +// The app shows /login unless a session token is present. Borrow the dev token from the already +// authed tab through the debug bridge instead of logging in again (never printed here). +const bridge = await fetch('http://localhost:9000/__debug/eval', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + code: "return { dev: sessionStorage.getItem('w4c-dev-token') || '', jwt: localStorage.getItem('w4c-token') || '' };", + }), +}).then((r) => r.json()) +const { dev: devToken, jwt } = bridge.result ?? {} +const token = devToken || jwt +console.log('auth: tokenAquired=', Boolean(token), 'kind=', jwt ? 'jwt' : devToken ? 'dev' : 'none') +await page.addInitScript( + ([dev, j]) => { + if (dev) sessionStorage.setItem('w4c-dev-token', dev) + if (j) localStorage.setItem('w4c-token', j) + }, + [devToken, jwt] +) + +await page.goto('http://localhost:9000/#/design-studio', { waitUntil: 'domcontentloaded' }) +await page.waitForTimeout(4000) + +const stubbed = await page.evaluate(async () => { + const m = await import('/src/stores/openPencilCanvasStore.ts') + const s = m.useOpenPencilCanvasStore() + s.stopPolling() + s.status = { + mode: 'headless', + connected: true, + headless: { + running: true, + startedAt: new Date(Date.now() - 95_000).toISOString(), + lastUsedAt: new Date().toISOString(), + idleSeconds: 180, + autoStart: true, + lastError: null, + }, + } + return { mode: s.mode, headlessRunning: s.headlessRunning } +}) +console.log('stubbed:', JSON.stringify(stubbed)) + +await page.waitForTimeout(700) +const iframes = await page.locator('iframe').count() +const panel = page.locator('.op-headless') +console.log('iframeCount:', iframes, 'headlessPanelVisible:', await panel.isVisible()) +await page.locator('.openpencil-page').screenshot({ path: `${out}/design-studio-headless.png` }) + +// Drawer entry: highlight + robot icon + tooltip. +const item = page.locator('.drawer-item-headless') +console.log('highlightedDrawerItems:', await item.count()) +await page.locator('.menu-headless').first().hover({ force: true }) +await page.waitForTimeout(2500) +await page.locator('.menu-headless').first().hover({ force: true }) +await page.waitForTimeout(1500) +const tooltip = await page.locator('.q-tooltip').first().innerText().catch(() => '(none)') +console.log('tooltip:', tooltip.replace(/\n+/g, ' | ')) +await page.screenshot({ path: `${out}/drawer-headless-highlight.png` }) + +await browser.close() diff --git a/CHANGELOG.md b/CHANGELOG.md index 03a74761c..d9c3763fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ ### Fixed - Keep an explicitly closed tab closed in auto-recover mode (`?recover=auto`) by discarding its recovery snapshot instead of retaining it, so an embedded host no longer resurrects the tab on reload. +- Reuse the open tab when an embedding host pushes a document it already opened, so a host that pushes its project document on every mount no longer stacks up one tab per visit. +- Stop restoring crash-recovery snapshots in auto-recover mode (`?recover=auto`) and discard them instead: the embedding host pushes the document it wants opened, so a restored snapshot was stale and raced that push, opening a second tab with the old document and hiding the host's fresh bytes. ### Changed @@ -13,6 +15,7 @@ ### Added - Open design documents pushed by an embedding host through a cross-origin `postMessage` bridge: the editor announces readiness and opens the received `.fig`/`.pen` bytes in a new tab, reporting success or failure back to the host. +- Notify an embedding host when the open document's scene changes (`openpencil:document-changed`, debounced), so a host can persist browser edits into its own project file instead of losing them on reload. - Create, select, move, transfer, and delete canvas and frame guides directly from rulers, with undoable edits and `.fig` round-trip fidelity. - Snap vector points, moved layers, and resized edges to nearby geometry, sibling layer bounds, canvas and frame layout guides, and whole-pixel coordinates with visible alignment guides, fractional-coordinate preservation when pixel snapping is off, and persistent geometry, object, and pixel-grid controls in General settings and the Preferences menu. - Run Pi through AI SDK HarnessAgent as a configurable desktop provider with multiple saved model profiles, secure credentials, existing MCP design tools, and per-profile thinking and permission settings. diff --git a/src/app/browser-bridge.ts b/src/app/browser-bridge.ts index 42030de20..7fda435ec 100644 --- a/src/app/browser-bridge.ts +++ b/src/app/browser-bridge.ts @@ -27,6 +27,8 @@ export interface OpenPencilWindowAPI { bytes: ArrayBuffer | Uint8Array | number[], mime?: string ) => Promise + /** Serializes the active document to `.fig` bytes (used by embedders/automation hosts). */ + exportFigBytes?: () => Promise test?: OpenPencilTestHooks } diff --git a/src/app/document/io/create.ts b/src/app/document/io/create.ts index 4b28a9d4d..552b24e91 100644 --- a/src/app/document/io/create.ts +++ b/src/app/document/io/create.ts @@ -83,6 +83,7 @@ export function createDocumentIOActions( openDOMFile, importDOMText, saveFigFile: sourceActions.saveFigFile, - saveFigFileAs: sourceActions.saveFigFileAs + saveFigFileAs: sourceActions.saveFigFileAs, + exportFigBytes: sourceActions.exportFigBytes } } diff --git a/src/app/document/io/source.ts b/src/app/document/io/source.ts index f271ba0b0..b29b73de7 100644 --- a/src/app/document/io/source.ts +++ b/src/app/document/io/source.ts @@ -153,6 +153,11 @@ export function createDocumentSourceActions({ disposeDocumentIO, saveFigFile, saveFigFileAs, + // Serialize the active document to .fig bytes without prompting for a save + // target. Used by the W4C automation/embed bridges so a host (the Design + // Studio iframe or the backend's headless canvas) can read the document the + // user is actually working on instead of a throwaway one. + exportFigBytes: () => Promise.resolve(buildFigFile()), getStorageBinding, getRecoveryId: () => recovery.getRecoveryId(), adoptRecoverySnapshot: (id: string, version: number) => diff --git a/src/app/editor/session/modules.ts b/src/app/editor/session/modules.ts index 897ae4a0e..7f33e596c 100644 --- a/src/app/editor/session/modules.ts +++ b/src/app/editor/session/modules.ts @@ -73,6 +73,7 @@ export function createEditorStoreModules( fitCurrentPageToViewport: documentIO.fitCurrentPageToViewport, saveFigFile: documentIO.saveFigFile, saveFigFileAs: documentIO.saveFigFileAs, + exportFigBytes: documentIO.exportFigBytes, getDocumentFilePath: documentIO.getDocumentFilePath, getSourceIdentity: documentIO.getSourceIdentity, getStorageBinding: documentIO.getStorageBinding, diff --git a/src/app/embed-bridge.ts b/src/app/embed-bridge.ts index 66bb125cb..07cc9397e 100644 --- a/src/app/embed-bridge.ts +++ b/src/app/embed-bridge.ts @@ -1,5 +1,8 @@ +import { watchDebounced } from '@vueuse/core' + import { setOpenPencilOpenFileFromBytesHandler } from '@/app/browser-bridge' -import { openFileInNewTab } from '@/app/tabs' +import { useActiveEditorStoreRef } from '@/app/editor/active-store' +import { openFileReusingMatchingTab } from '@/app/tabs' import { IS_BROWSER } from '@/constants' /** @@ -19,6 +22,9 @@ export const EMBED_OPEN_FILE_MESSAGE = 'openpencil:open-file' export const EMBED_READY_MESSAGE = 'openpencil:ready' export const EMBED_FILE_OPENED_MESSAGE = 'openpencil:file-opened' export const EMBED_FILE_FAILED_MESSAGE = 'openpencil:file-open-failed' +export const EMBED_EXPORT_FILE_MESSAGE = 'openpencil:export-file' +export const EMBED_FILE_EXPORTED_MESSAGE = 'openpencil:file-exported' +export const EMBED_DOCUMENT_CHANGED_MESSAGE = 'openpencil:document-changed' export interface EmbedOpenFileMessage { type: typeof EMBED_OPEN_FILE_MESSAGE @@ -43,6 +49,34 @@ function normalizeBytes(bytes: EmbedOpenFileMessage['bytes']): Uint8Array { * Opens a design document received from the embedder as a new editor tab. * Exposed as `window.openPencil.openFileFromBytes` for programmatic hosts. */ +// Opening a document pushed by the host is itself a scene change. Remember the version that push +// produced and only report later versions, so the host does not upload back the bytes it just sent. +let changeBaselineVersion: number | null = null + +/** + * Tells the embedding host that the open document changed, so it can persist it. The host owns the + * project file (a served editor cannot write it), embed mode keeps no crash snapshots, and the + * editor's own autosave only covers documents that have a writable source — without this signal + * browser edits were lost on reload or navigation. Debounced, since shapes are edited continuously. + */ +function installEmbedDocumentChangeNotifier(): void { + if (!IS_BROWSER || window.parent === window) return + const store = useActiveEditorStoreRef() + watchDebounced( + () => store.value?.state.sceneVersion ?? 0, + (version) => { + // The first version seen (and whatever a host push produces) is the baseline, not an edit. + if (changeBaselineVersion === null || version <= changeBaselineVersion) { + changeBaselineVersion = version + return + } + changeBaselineVersion = version + postToEmbedder({ type: EMBED_DOCUMENT_CHANGED_MESSAGE }) + }, + { debounce: 700, maxWait: 3000 } + ) +} + export async function openDesignFileFromBytes( name: string, bytes: EmbedOpenFileMessage['bytes'], @@ -57,7 +91,12 @@ export async function openDesignFileFromBytes( const fileName = name || 'design.fig' const file = new File([copy], fileName, { type: mime }) try { - await openFileInNewTab(file) + // Reuse the tab when the embedder pushes the same document again (the Design Studio pushes its + // project document on every mount) instead of stacking up a tab per push. + await openFileReusingMatchingTab(file) + // The push itself bumped the scene version: treat that as the baseline so the host is not asked + // to save back the very bytes it just sent. + changeBaselineVersion = useActiveEditorStoreRef().value?.state.sceneVersion ?? null postToEmbedder({ type: EMBED_FILE_OPENED_MESSAGE, name: fileName }) } catch (error) { postToEmbedder({ @@ -79,6 +118,28 @@ function isEmbedOpenFileMessage(value: unknown): value is EmbedOpenFileMessage { ) } +/** + * Serializes the document currently open in this editor and hands the bytes back + * to the embedder. The Design Studio uses this to sync the user's live document + * to the backend before/after the design agent works on it, so the agent never + * edits a throwaway document. Read-only: it does not touch the save path. + */ +export async function exportDesignFileBytes(): Promise<{ + name: string + bytes: Uint8Array +}> { + const store = window.openPencil?.getStore?.() + if (!store) return { name: 'design.fig', bytes: new Uint8Array() } + const bytes = await store.exportFigBytes() + return { name: store.state.documentName || 'design.fig', bytes } +} + +/** Programmatic hosts (the backend's headless canvas) export through the window API. */ +export async function exportDesignFileForEmbedder(): Promise { + const { name, bytes } = await exportDesignFileBytes() + postToEmbedder({ type: EMBED_FILE_EXPORTED_MESSAGE, name, bytes }) +} + /** Tells the embedding parent that the editor is mounted and can accept documents. */ export function announceEmbedReady(): void { postToEmbedder({ type: EMBED_READY_MESSAGE }) @@ -91,10 +152,24 @@ export function announceEmbedReady(): void { export function installEmbedBridge(): void { if (!IS_BROWSER) return setOpenPencilOpenFileFromBytesHandler(openDesignFileFromBytes) + // Expose the byte serializer on the window API so a programmatic host that is + // not a frame parent (the backend's headless canvas) can read the document. + const api = (window.openPencil ??= {}) + api.exportFigBytes = async () => (await exportDesignFileBytes()).bytes window.addEventListener('message', (event: MessageEvent) => { - // Only the direct embedder may push documents into this editor; a tab that - // was opened standalone has no parent and ignores the protocol entirely. + // Only the direct embedder may drive this editor; a tab that was opened + // standalone has no parent and ignores the protocol entirely. if (window.parent === window || event.source !== window.parent) return + if ( + event.data && + typeof event.data === 'object' && + (event.data as { type?: unknown }).type === EMBED_EXPORT_FILE_MESSAGE + ) { + void exportDesignFileForEmbedder().catch((error) => { + console.error('[Embed] Failed to export design document:', error) + }) + return + } if (!isEmbedOpenFileMessage(event.data)) return const message = event.data void openDesignFileFromBytes(message.name ?? 'design.fig', message.bytes, message.mime).catch( @@ -103,5 +178,6 @@ export function installEmbedBridge(): void { } ) }) + installEmbedDocumentChangeNotifier() announceEmbedReady() } diff --git a/src/app/tabs/index.ts b/src/app/tabs/index.ts index 32f31df8a..607b59284 100644 --- a/src/app/tabs/index.ts +++ b/src/app/tabs/index.ts @@ -201,6 +201,70 @@ export async function openStorageDocumentInNewTab(document: StorageDocument): Pr } } +/** + * Reads a file into an existing store, replacing its document. Shared by the normal open path and + * the embedder path (which reuses a tab instead of adding one). + */ +async function loadFileIntoStore( + store: EditorStore, + file: File, + handle?: FileSystemFileHandle, + path?: string +): Promise { + if (isDOMImportFile(file)) { + await store.openDOMFile(file, { handle, path }) + return + } + + await yieldToUI() + const isFig = file.name.toLowerCase().endsWith('.fig') + const { graph: imported, sourceFormat } = isFig + ? { graph: await readFigFile(file, { populate: 'first-page' }), sourceFormat: 'fig' } + : await io.readDocument({ + name: file.name, + mimeType: file.type || undefined, + data: new Uint8Array(await file.arrayBuffer()) + }) + + const firstPageId = imported.getPages()[0]?.id + if (firstPageId) computeAllLayouts(imported, firstPageId) + store.replaceGraph(imported) + store.undo.clear() + store.setDocumentSource(file.name, sourceFormat, handle, path) + store.clearSelection() + const pageId = store.graph.getPages()[0]?.id ?? store.graph.rootId + await store.switchPage(pageId) + await store.fitCurrentPageToViewport() +} + +/** + * Opens a document pushed by an embedder (the w4c Design Studio) without multiplying tabs. + * + * Embedder pushes carry no file identity, so `findTabByFileIdentity` cannot match them and + * `reusableTabStore()` only reuses an untouched "Untitled" tab — every later push added another tab + * (observed as several tabs all named after the same project document). When a tab already shows a + * document with this name its content is replaced in place, because the embedder's bytes are the + * source of truth for that document. + */ +export async function openFileReusingMatchingTab(file: File): Promise { + const name = file.name.replace(/\.[^.]+$/i, '') + const existing = tabsRef.value.find((tab) => tab.store.state.documentName === name) + if (!existing) { + await openFileInNewTab(file) + return + } + + const store = existing.store + store.state.loading = true + try { + await loadFileIntoStore(store, file) + store.state.documentName = name + } finally { + store.state.loading = false + switchTab(existing.id) + } +} + export async function openFileInNewTab( file: File, handle?: FileSystemFileHandle, @@ -243,31 +307,7 @@ export async function openFileInNewTab( const { completion, pendingOpen, store } = decision try { - if (isDOMImportFile(file)) { - await store.openDOMFile(file, { handle, path }) - completion.resolve(undefined) - return - } - - await yieldToUI() - const isFig = file.name.toLowerCase().endsWith('.fig') - const { graph: imported, sourceFormat } = isFig - ? { graph: await readFigFile(file, { populate: 'first-page' }), sourceFormat: 'fig' } - : await io.readDocument({ - name: file.name, - mimeType: file.type || undefined, - data: new Uint8Array(await file.arrayBuffer()) - }) - - const firstPageId = imported.getPages()[0]?.id - if (firstPageId) computeAllLayouts(imported, firstPageId) - store.replaceGraph(imported) - store.undo.clear() - store.setDocumentSource(file.name, sourceFormat, handle, path) - store.clearSelection() - const pageId = store.graph.getPages()[0]?.id ?? store.graph.rootId - await store.switchPage(pageId) - await store.fitCurrentPageToViewport() + await loadFileIntoStore(store, file, handle, path) completion.resolve(undefined) } catch (error) { completion.reject(error) diff --git a/src/components/recovery/RecoveryDialog.vue b/src/components/recovery/RecoveryDialog.vue index 30d518da8..de2176179 100644 --- a/src/components/recovery/RecoveryDialog.vue +++ b/src/components/recovery/RecoveryDialog.vue @@ -57,17 +57,20 @@ onMounted(async () => { if (route.path !== '/') return try { snapshots.value = await listRecoverySnapshots() - // When embedded (e.g. the w4c Design Studio passes `?recover=auto`), - // restore every snapshot silently instead of showing the dialog, so - // unsaved work comes back on every open without an extra prompt. + // When embedded (e.g. the w4c Design Studio passes `?recover=auto`), the Recovery dialog is never + // shown: the embedding host pushes the document it wants opened. const autoRestore = isAutoRecoverMode() if (autoRestore && snapshots.value.length > 0) { - // restore() reassigns snapshots.value, so iterate the array as it was - // when listed — the original reference is not mutated. - const pending = snapshots.value - for (const snapshot of pending) { - await restore(snapshot) + // An embedded host (the w4c Design Studio) pushes its project document on every mount, so a + // restored snapshot is always stale: restoring it raced the host push and produced a second + // tab showing the old document instead of the host's fresh bytes. The host document is the + // source of truth in embed mode (there is no Recovery dialog to offer snapshots in), so drop + // them — the same stance `closeTab` already takes for auto-recover mode. + for (const snapshot of snapshots.value) { + await discardRecoverySnapshot(snapshot.id) } + snapshots.value = [] + open.value = false return } open.value = snapshots.value.length > 0 diff --git a/src/views/EditorView.vue b/src/views/EditorView.vue index 60dce7059..6568e70b6 100644 --- a/src/views/EditorView.vue +++ b/src/views/EditorView.vue @@ -97,7 +97,16 @@ onMounted(async () => { const mcp = await spawnMCPIfNeeded() mcpCleanup.value = mcp?.disconnect ?? null const tauri = isTauri() - if (import.meta.env.DEV || (tauri && mcp)) { + // The automation bridge is the WebSocket the MCP design tools drive the open canvas over. It used + // to be dev-only (plus the Tauri desktop app), which left a *served* build (W4C Design Studio's + // iframe, or the backend's headless canvas host) with no bridge at all — the MCP server then + // reports "OpenPencil app is not connected" for every tool call. Allow an explicit opt-in so a + // served build can host a canvas: build-time `VITE_OPENPENCIL_AUTOMATION=1`, or runtime + // `?automation=1`. Normal web users without the opt-in are unaffected. + const automationOptIn = + import.meta.env.VITE_OPENPENCIL_AUTOMATION === '1' || + new URLSearchParams(window.location.search).get('automation') === '1' + if (import.meta.env.DEV || (tauri && mcp) || automationOptIn) { automationCleanup.value = connectAutomation(getActiveStore, mcp?.authToken ?? null).disconnect }