pmx-canvas 0.2.4 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,6 +8,16 @@ interface ExtAppHostDimensionsTarget {
8
8
  clientHeight?: number;
9
9
  getBoundingClientRect(): Pick<DOMRectReadOnly, 'width' | 'height'>;
10
10
  }
11
+ /**
12
+ * Finding F (0.2.4): detect a WebKit-only host — Safari or a WKWebView (e.g. the
13
+ * GitHub Copilot app's embedded panel). Blink engines (Chrome / Chromium / Edge /
14
+ * the Codex browser, all of which carry a Chrome/Chromium/CriOS/Edg token) and
15
+ * Android WebView are excluded, as is Gecko (no `AppleWebKit`). Used to gate the
16
+ * one-time ext-app iframe repaint remount to the only engine that exhibits the
17
+ * present-at-load black-tile paint race, so the remount is a strict no-op
18
+ * everywhere we can test (Chrome / Codex / Playwright).
19
+ */
20
+ export declare function isWebKitOnlyHost(userAgent: string): boolean;
11
21
  export declare function getExtAppBridgeInitKey(node: CanvasNodeState, retryKey: number): string;
12
22
  export declare function resolveExtAppDisplayModeRequest(requestedMode: DisplayMode, isExpanded: boolean): {
13
23
  nextMode: DisplayMode;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmx-canvas",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
4
4
  "description": "Spatial canvas workbench for coding agents — infinite 2D canvas with agent-native CLI, MCP integration, nodes, edges, file watching, and snapshots",
5
5
  "type": "module",
6
6
  "main": "./src/server/index.ts",
@@ -195,6 +195,13 @@ Prefer `canvas_query { action: "search" }` over parsing the full layout.
195
195
  - Hosted MCP-app/ext-app nodes such as Excalidraw require the in-canvas host bridge and are not
196
196
  standalone **Open as site** targets. URL-backed viewers and bundled web artifacts remain
197
197
  openable.
198
+ - A hosted ext-app (Excalidraw) node that is already on the board when a **WebKit** host panel
199
+ loads (e.g. the GitHub Copilot app's embedded WKWebView) can render as a black tile — a host
200
+ paint race on the nested iframe, not a broken node (the session is healthy and `sessionStatus`
201
+ is `ready`; it renders fine in Chrome, the Codex browser, and for nodes created live after the
202
+ panel hydrates). The canvas auto-remounts the iframe once on load under WebKit to force a
203
+ repaint; if a tile is still black, expand-then-close it (forces a remount) or open the workbench
204
+ in a normal browser. Do not diagnose a healthy app session as a broken node.
198
205
  - Graph and json-render standalone surfaces use `display=site` and fill the browser viewport, and
199
206
  reflow on a live window resize in a normal browser. Some single-tab host browsers (e.g. the
200
207
  Codex in-app browser) don't deliver live-resize events, so a resized standalone chart can look
@@ -764,7 +764,8 @@ tools below operate on the live canvas state.
764
764
  > "evaluate" | "resize" }` respectively. Field names are unchanged.
765
765
 
766
766
  **`canvas_webview_status`** — Inspect the current automation session
767
- - Returns `{ supported, active, backend, viewportWidth, viewportHeight, url, lastError }`
767
+ - Returns `{ supported, active, headlessOnly, url, backend, width, height, dataStoreDir, startedAt, lastError }`
768
+ (the viewport size is `width` / `height`, not `viewportWidth` / `viewportHeight`)
768
769
  - Call before `start` to check whether a session is already alive
769
770
 
770
771
  **`canvas_webview_start`** — Start (or replace) the automation session
@@ -45,6 +45,19 @@ async function postJson<T>(url: string, body: Record<string, unknown>): Promise<
45
45
  return json.result as T;
46
46
  }
47
47
 
48
+ /**
49
+ * Finding F (0.2.4): detect a WebKit-only host — Safari or a WKWebView (e.g. the
50
+ * GitHub Copilot app's embedded panel). Blink engines (Chrome / Chromium / Edge /
51
+ * the Codex browser, all of which carry a Chrome/Chromium/CriOS/Edg token) and
52
+ * Android WebView are excluded, as is Gecko (no `AppleWebKit`). Used to gate the
53
+ * one-time ext-app iframe repaint remount to the only engine that exhibits the
54
+ * present-at-load black-tile paint race, so the remount is a strict no-op
55
+ * everywhere we can test (Chrome / Codex / Playwright).
56
+ */
57
+ export function isWebKitOnlyHost(userAgent: string): boolean {
58
+ return /AppleWebKit/.test(userAgent) && !/Chrome|Chromium|CriOS|Edg|Android/.test(userAgent);
59
+ }
60
+
48
61
  export function getExtAppBridgeInitKey(node: CanvasNodeState, retryKey: number): string {
49
62
  const html = typeof node.data.html === 'string' ? node.data.html : '';
50
63
  const serverName = typeof node.data.serverName === 'string' ? node.data.serverName : '';
@@ -194,6 +207,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
194
207
  const toolResultSendingRef = useRef<Promise<void> | null>(null);
195
208
  const bridgeReadyRef = useRef(false);
196
209
  const themeUnsubRef = useRef<(() => void) | null>(null);
210
+ const webkitRepaintDoneRef = useRef(false);
197
211
  const [status, setStatus] = useState<'loading' | 'ready' | 'done'>('loading');
198
212
  const [error, setError] = useState<string | null>(null);
199
213
  const [retryKey, setRetryKey] = useState(0);
@@ -262,6 +276,29 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
262
276
  window.addEventListener('message', onAxMessage);
263
277
  return () => window.removeEventListener('message', onAxMessage);
264
278
  }, [axEnabled, axToken, nodeId]);
279
+
280
+ // Finding F (0.2.4): in a WebKit host panel (e.g. the GitHub Copilot app's
281
+ // WKWebView), the doubly-nested ext-app iframe (workbench iframe → mcp-app.html
282
+ // iframe) can lose the initial-paint race during board hydration and come up as a
283
+ // black tile for nodes already present at panel-load — clean in Blink (Chrome /
284
+ // Codex / our Playwright e2e) and clean for nodes created live after hydration.
285
+ // The deterministic recovery is an iframe remount (exactly what expand+close, and
286
+ // the error-retry button, already do). Replicate that recovery ONCE, on mount, in
287
+ // WebKit-only — gated on the engine so it is a strict no-op in Blink and cannot
288
+ // regress the engine we test against. Inline board instance only (the one that
289
+ // loads black); the expanded overlay already remounts on open.
290
+ useEffect(() => {
291
+ if (expanded || webkitRepaintDoneRef.current) return;
292
+ if (typeof navigator === 'undefined' || typeof window === 'undefined') return;
293
+ if (!isWebKitOnlyHost(navigator.userAgent)) return;
294
+ webkitRepaintDoneRef.current = true;
295
+ // Let the initial hydration burst settle, then force one remount so the nested
296
+ // iframe repaints (mirrors the post-hydration expand+close recovery).
297
+ const timer = window.setTimeout(() => setRetryKey((k) => k + 1), 250);
298
+ return () => window.clearTimeout(timer);
299
+ // eslint-disable-next-line react-hooks/exhaustive-deps
300
+ }, []);
301
+
265
302
  const toMcpTheme = (theme: string): McpUiTheme => (theme === 'light' ? 'light' : 'dark');
266
303
  const isExpanded = expanded || expandedNodeId.value === nodeId;
267
304
 
@@ -60,6 +60,7 @@ const intentUpdateSchema = z.looseObject({
60
60
  confidence: z.number().min(0).max(1).optional(),
61
61
  seq: z.number().int().min(0).max(9999).optional(),
62
62
  ttlMs: z.number().positive().max(MAX_INTENT_TTL_MS).optional(),
63
+ vetoed: z.boolean().optional(),
63
64
  });
64
65
 
65
66
  function parseOrThrow<T>(schema: z.ZodType<T>, raw: unknown, label: string): T {
@@ -165,6 +166,21 @@ export class IntentRegistry {
165
166
  if (!existing) throw new OperationError(`No live intent "${id}".`, 404);
166
167
  const patch = parseOrThrow(intentUpdateSchema, raw, 'intent update');
167
168
  const now = Date.now();
169
+
170
+ // A vetoed update is a veto, not a patch: poison the id and dissolve the ghost
171
+ // so a later linked settle (beginCommit) is rejected — for a live, non-committing
172
+ // intent the same authoritative outcome as clear({ vetoed:true }). (A committing
173
+ // intent is already gated above with a 409, so the commit wins the race.) Without
174
+ // this, vetoed-via-update only dissolved the ghost visually while the linked
175
+ // mutation still landed (#C, 0.2.4).
176
+ if (patch.vetoed === true) {
177
+ this.rememberVeto(id);
178
+ this.intents.delete(id);
179
+ this.emit('ax-intent-clear', { id, vetoed: true });
180
+ this.maybeStopSweeper();
181
+ return { ...existing, expiresAt: now };
182
+ }
183
+
168
184
  const ttl = typeof patch.ttlMs === 'number' ? patch.ttlMs : DEFAULT_INTENT_TTL_MS;
169
185
 
170
186
  const intent: PmxAxIntent = {
@@ -66,6 +66,7 @@ const intentUpdateShape = {
66
66
  confidence: z.number().optional().describe('0..1 → ghost opacity/solidity.'),
67
67
  seq: z.number().optional().describe('New ordering hint.'),
68
68
  ttlMs: z.number().optional().describe('Reset the TTL to this many ms from now.'),
69
+ vetoed: z.boolean().optional().describe('Veto the intent: dissolves the ghost AND poisons the id so a later linked settle is rejected (same as clear { vetoed:true }).'),
69
70
  };
70
71
 
71
72
  const intentUpdateSchema = z.looseObject(intentUpdateShape);