pmx-canvas 0.2.5 → 0.2.6

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.
@@ -3,6 +3,7 @@ import { AppBridge } from '@modelcontextprotocol/ext-apps/app-bridge';
3
3
  import type { CanvasNodeState } from '../types';
4
4
  type ExtAppBridgeNotifications = Pick<AppBridge, 'sendToolInput' | 'sendToolResult'>;
5
5
  type DisplayMode = 'inline' | 'fullscreen' | 'pip';
6
+ type ExtAppFrameStatus = 'loading' | 'ready' | 'done';
6
7
  interface ExtAppHostDimensionsTarget {
7
8
  clientWidth?: number;
8
9
  clientHeight?: number;
@@ -18,6 +19,8 @@ interface ExtAppHostDimensionsTarget {
18
19
  * everywhere we can test (Chrome / Codex / Playwright).
19
20
  */
20
21
  export declare function isWebKitOnlyHost(userAgent: string): boolean;
22
+ export declare function nextWebkitRepaintSlot(): number;
23
+ export declare function shouldScheduleWebKitRepaint(status: ExtAppFrameStatus, hasReplayToolResult: boolean): boolean;
21
24
  export declare function getExtAppBridgeInitKey(node: CanvasNodeState, retryKey: number): string;
22
25
  export declare function resolveExtAppDisplayModeRequest(requestedMode: DisplayMode, isExpanded: boolean): {
23
26
  nextMode: DisplayMode;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmx-canvas",
3
- "version": "0.2.5",
3
+ "version": "0.2.6",
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",
@@ -58,6 +58,15 @@ Both surfaces report `workspace`. It must match the intended workspace root.
58
58
  - Target that port and re-check `/health`.
59
59
  - `PMX_CANVAS_PORT` is the agent CLI target; the server's startup port is controlled by `--port`
60
60
  or `PMX_WEB_CANVAS_PORT`.
61
+ - **MCP transport caveat (wrong-workspace split).** An MCP server (`pmx-canvas --mcp`) holds its own
62
+ in-memory canvas and, if its preferred port is already taken by a *different* workspace's daemon,
63
+ binds the next free port adopting **its own launch `cwd`** as the workspace — so its writes land on
64
+ a daemon the browser panel never renders (it self-reports this fallback on stderr). Before trusting
65
+ MCP-written state, confirm the MCP server's workspace matches the panel's (`/health` on both ports)
66
+ and that no stray higher-numbered listener exists. The durable fix is to launch the MCP server with
67
+ `cwd=<project>` or `PMX_CANVAS_PORT=<panel-port>`. The CLI's query/mutation commands are a thin HTTP
68
+ client and never start a server of their own (only `serve` / `--mcp` spawn a process), so prefer
69
+ those CLI commands for watcher/automation loops until the launch config is pinned.
61
70
 
62
71
  ## Choose the Smallest Useful Node Type
63
72
 
@@ -197,11 +206,15 @@ Prefer `canvas_query { action: "search" }` over parsing the full layout.
197
206
  openable.
198
207
  - A hosted ext-app (Excalidraw) node that is already on the board when a **WebKit** host panel
199
208
  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.
209
+ compositor paint race on the nested iframe, **not** a broken node (the session is healthy and
210
+ `sessionStatus` is `ready`; it renders fine in Chrome, the Codex browser, and for nodes created
211
+ live after the panel hydrates). The canvas forces a one-time post-boot repaint remount under
212
+ WebKit, which reliably repaints a **single** present-at-load
213
+ ext-app — but a board with **several** ext-apps present at WebKit panel-load can still black out
214
+ (the simultaneous cold-hydration burst overwhelms the WebKit compositor). Recovery is
215
+ deterministic: **expand-then-close** any black tile (forces a fresh mount in the fullscreen
216
+ overlay, which always paints), or open the workbench in a normal browser (Chrome). Do not
217
+ diagnose a healthy app session as a broken node; the durable fix is upstream in the host panel.
205
218
  - Graph and json-render standalone surfaces use `display=site` and fill the browser viewport, and
206
219
  reflow on a live window resize in a normal browser. Some single-tab host browsers (e.g. the
207
220
  Codex in-app browser) don't deliver live-resize events, so a resized standalone chart can look
@@ -386,7 +386,16 @@ Agents tend to pack boards too tightly. Give nodes room to breathe — readabili
386
386
 
387
387
  ### Colors (Semantic)
388
388
 
389
- Use color consistently to convey meaning:
389
+ A `color` parameter is honored only for **group** nodes (frame accent) and **graph** nodes
390
+ (series/accent color). It is **not** a parameter for `markdown` / `status` / `context` nodes — a
391
+ top-level `color` on those is silently ignored over both HTTP and the CLI (report Finding H). Their
392
+ meaning comes from the node **type** and value instead: a `status` node colors its indicator from its
393
+ **phase** (`idle`/`running`/`planning`/`thinking`/`drafting`/`tooling`/`review`/`waiting-approval`/
394
+ `waiting` — e.g. `review` → green, `running` → accent; an unrecognized phase renders gray), and a
395
+ `trace` node from its `status` field (`success` → green, `failed` → red, `running` → accent). To
396
+ color an arbitrary region, group the nodes and set the group's `color`.
397
+
398
+ When you do set a color (group/graph), use this palette consistently to convey meaning:
390
399
  - **Green** (`#22c55e`) — success, done, healthy
391
400
  - **Yellow** (`#eab308`) — in progress, warning, attention needed
392
401
  - **Red** (`#ef4444`) — error, blocked, failing
@@ -523,7 +532,7 @@ retrying the generic add.
523
532
  - `content`: markdown text for most types. For `file`, pass the **file path** (e.g. `"src/auth/login.ts"`) —
524
533
  the server auto-loads + watches it. For `image`, pass a file path, URL, or data URI.
525
534
  - `path`: compatibility alias for image paths only; prefer `content` for new image calls
526
- - `x`, `y`: position (prefer omitting for auto-layout); `width`, `height`: dimensions (sensible defaults); `color`: semantic color; `metadata`: arbitrary JSON
535
+ - `x`, `y`: position (prefer omitting for auto-layout); `width`, `height`: dimensions (sensible defaults); `metadata`: arbitrary JSON. (`color` applies to **group**/**graph** nodes only — it is ignored on basic node types; see Colors (Semantic).)
527
536
  - Returns: `{ id: "<node-id>" }` — capture this ID for edges and groups
528
537
 
529
538
  **`canvas_node { action: "update", id, … }`** — Update an existing node in place (preferred over
@@ -23,6 +23,7 @@ type McpUiTheme = 'light' | 'dark';
23
23
 
24
24
  type ExtAppBridgeNotifications = Pick<AppBridge, 'sendToolInput' | 'sendToolResult'>;
25
25
  type DisplayMode = 'inline' | 'fullscreen' | 'pip';
26
+ type ExtAppFrameStatus = 'loading' | 'ready' | 'done';
26
27
 
27
28
  interface ExtAppHostDimensionsTarget {
28
29
  clientWidth?: number;
@@ -58,6 +59,28 @@ export function isWebKitOnlyHost(userAgent: string): boolean {
58
59
  return /AppleWebKit/.test(userAgent) && !/Chrome|Chromium|CriOS|Edg|Android/.test(userAgent);
59
60
  }
60
61
 
62
+ // Finding F (0.2.5): SERIALIZED WebKit repaint slots. The black tile is a cold-
63
+ // hydration BURST problem — a single ext-app repaints fine into an idle panel (like
64
+ // a live-created node, or expand+close), but several compositing at once overwhelm
65
+ // WebKit and all stay black. We give each mounting ext-app an increasing slot so its
66
+ // one-time repaint remount fires into a progressively-quieter panel (one app at a
67
+ // time), turning the burst back into a sequence of single repaints. The shared
68
+ // counter resets after the burst so a later board load starts from slot 0.
69
+ let webkitRepaintSlot = 0;
70
+ let webkitRepaintSlotResetTimer: ReturnType<typeof setTimeout> | null = null;
71
+ export function nextWebkitRepaintSlot(): number {
72
+ const slot = webkitRepaintSlot++;
73
+ if (typeof window !== 'undefined') {
74
+ if (webkitRepaintSlotResetTimer) clearTimeout(webkitRepaintSlotResetTimer);
75
+ webkitRepaintSlotResetTimer = setTimeout(() => { webkitRepaintSlot = 0; }, 3000);
76
+ }
77
+ return slot;
78
+ }
79
+
80
+ export function shouldScheduleWebKitRepaint(status: ExtAppFrameStatus, hasReplayToolResult: boolean): boolean {
81
+ return hasReplayToolResult ? status === 'done' : status === 'ready' || status === 'done';
82
+ }
83
+
61
84
  export function getExtAppBridgeInitKey(node: CanvasNodeState, retryKey: number): string {
62
85
  const html = typeof node.data.html === 'string' ? node.data.html : '';
63
86
  const serverName = typeof node.data.serverName === 'string' ? node.data.serverName : '';
@@ -208,7 +231,8 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
208
231
  const bridgeReadyRef = useRef(false);
209
232
  const themeUnsubRef = useRef<(() => void) | null>(null);
210
233
  const webkitRepaintDoneRef = useRef(false);
211
- const [status, setStatus] = useState<'loading' | 'ready' | 'done'>('loading');
234
+ const webkitRepaintTimerRef = useRef<number | null>(null);
235
+ const [status, setStatus] = useState<ExtAppFrameStatus>('loading');
212
236
  const [error, setError] = useState<string | null>(null);
213
237
  const [retryKey, setRetryKey] = useState(0);
214
238
 
@@ -231,6 +255,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
231
255
  const maxHeight = node.size.height;
232
256
  const nodeId = node.id;
233
257
  const frameKey = getExtAppBridgeInitKey(node, retryKey);
258
+ const hasReplayToolResult = toolResult != null;
234
259
  const iframeSandbox = resolveExtAppSandbox(null);
235
260
  // Phase 6 — opt-in ext-app AX bridge. When the node sets data.axCapabilities.enabled,
236
261
  // inject window.PMX_AX into the app HTML and accept emits below (server re-validates).
@@ -277,26 +302,42 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
277
302
  return () => window.removeEventListener('message', onAxMessage);
278
303
  }, [axEnabled, axToken, nodeId]);
279
304
 
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.
305
+ // Finding F (0.2.4/0.2.5): in a WebKit host panel (e.g. the GitHub Copilot app's
306
+ // WKWebView, and Bun's headless WebKit WebView) the doubly-nested ext-app iframe
307
+ // (workbench iframe mcp-app.html iframe) can come up as a black tile for nodes
308
+ // present at panel-load. The mcp-app shell loads blank, then the app boots over the
309
+ // bridge and draws its content AFTER load; under a cold-hydration burst WebKit does
310
+ // not composite that late draw, so the layer stays black (clean in Blink, and clean
311
+ // for a node created live into an already-idle panel). A parent-side transform/src
312
+ // nudge does NOT repair a black layer only a full remount (new iframe element +
313
+ // bridge re-init, what expand+close does) does, and only when it lands in an idle
314
+ // moment. So: once the app has booted `ready` for empty apps, `done` for restored
315
+ // apps that must replay saved tool output — under WebKit only, force ONE remount,
316
+ // SERIALIZED per node (an increasing slot delay) so concurrent ext-apps
317
+ // don't re-form a single compositing burst (the 0.2.4 fixed-250ms-on-mount remount
318
+ // fired mid-burst before the app had even booted, and all siblings re-raced —
319
+ // insufficient). This reliably repaints a SINGLE present-at-load ext-app; a board
320
+ // with several ext-apps present at WebKit panel-load can still black out (a host
321
+ // compositor limit — expand+close or Chrome remains the fallback; see SKILL.md).
322
+ // Strict no-op in Blink/Gecko; the e2e engine is unaffected. Inline instance only.
290
323
  useEffect(() => {
291
324
  if (expanded || webkitRepaintDoneRef.current) return;
325
+ if (!shouldScheduleWebKitRepaint(status, hasReplayToolResult)) return;
292
326
  if (typeof navigator === 'undefined' || typeof window === 'undefined') return;
293
327
  if (!isWebKitOnlyHost(navigator.userAgent)) return;
294
328
  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);
329
+ // Serialize: each ext-app repaints into a progressively-quieter panel so the
330
+ // cold-hydration burst becomes a sequence of single (always-successful) repaints.
331
+ // The timer is held in a ref and cleared only on UNMOUNT (separate effect), NOT in
332
+ // this effect's cleanup — otherwise a later status change (ready→done) would cancel
333
+ // the one-shot remount before it fires. The gate above runs once per node.
334
+ const delayMs = 250 + nextWebkitRepaintSlot() * 450;
335
+ webkitRepaintTimerRef.current = window.setTimeout(() => setRetryKey((k) => k + 1), delayMs);
299
336
  // eslint-disable-next-line react-hooks/exhaustive-deps
337
+ }, [status, hasReplayToolResult]);
338
+
339
+ useEffect(() => () => {
340
+ if (webkitRepaintTimerRef.current !== null) window.clearTimeout(webkitRepaintTimerRef.current);
300
341
  }, []);
301
342
 
302
343
  const toMcpTheme = (theme: string): McpUiTheme => (theme === 'light' ? 'light' : 'dark');
@@ -829,6 +870,10 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
829
870
  {...iframeDocument.attributes}
830
871
  sandbox={iframeSandbox}
831
872
  allow={buildAllowAttribute(resourceMeta?.permissions)}
873
+ // NB: do NOT add the `.mcp-app-frame` GPU-layer class (translateZ(0)) here —
874
+ // it creates a stacking context that breaks the AX emit→ack round-trip in the
875
+ // expanded ext-app overlay (#55 e2e); the post-boot WebKit repaint remount
876
+ // below recovers a single present-at-load ext-app without it (Finding F).
832
877
  style={{
833
878
  flex: 1,
834
879
  width: '100%',