pmx-canvas 0.3.0 → 0.3.2

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.
Files changed (161) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/dist/canvas/index.js +65 -65
  3. package/dist/types/cli/agent.d.ts +9 -1
  4. package/dist/types/cli/daemon.d.ts +74 -0
  5. package/dist/types/cli/watch.d.ts +2 -2
  6. package/dist/types/client/canvas/CanvasViewport.d.ts +1 -1
  7. package/dist/types/client/canvas/CommandPalette.d.ts +1 -1
  8. package/dist/types/client/canvas/Minimap.d.ts +1 -1
  9. package/dist/types/client/nodes/ExtAppFrame.d.ts +9 -1
  10. package/dist/types/client/nodes/FileNode.d.ts +1 -1
  11. package/dist/types/client/nodes/ImageNode.d.ts +1 -1
  12. package/dist/types/client/nodes/InlineFormatBar.d.ts +1 -1
  13. package/dist/types/client/nodes/MarkdownNode.d.ts +1 -1
  14. package/dist/types/client/nodes/PromptNode.d.ts +1 -1
  15. package/dist/types/client/nodes/ResponseNode.d.ts +1 -1
  16. package/dist/types/server/canvas-schema.d.ts +1 -1
  17. package/dist/types/server/html-primitives.d.ts +1 -1
  18. package/dist/types/server/index.d.ts +4 -4
  19. package/dist/types/server/operations/index.d.ts +1 -1
  20. package/dist/types/server/operations/ops/ax-read.d.ts +2 -0
  21. package/dist/types/server/operations/ops/canvas-wire.d.ts +2 -0
  22. package/dist/types/server/operations/ops/ext-app.d.ts +2 -0
  23. package/dist/types/server/server.d.ts +8 -0
  24. package/docs/cli.md +10 -1
  25. package/docs/http-api.md +28 -0
  26. package/docs/plans/plan-009-tech-debt-backlog.md +5 -5
  27. package/docs/screenshot.png +0 -0
  28. package/docs/tech-debt-assessment-2026-07.md +2 -2
  29. package/package.json +5 -2
  30. package/skills/pmx-canvas/SKILL.md +15 -10
  31. package/skills/pmx-canvas/references/full-reference.md +17 -3
  32. package/src/cli/agent.ts +1951 -1571
  33. package/src/cli/daemon.ts +460 -0
  34. package/src/cli/index.ts +80 -323
  35. package/src/cli/watch.ts +2 -10
  36. package/src/client/App.tsx +48 -46
  37. package/src/client/canvas/AttentionHistory.tsx +11 -1
  38. package/src/client/canvas/CanvasNode.tsx +41 -29
  39. package/src/client/canvas/CanvasViewport.tsx +101 -66
  40. package/src/client/canvas/CommandPalette.tsx +61 -27
  41. package/src/client/canvas/ContextMenu.tsx +13 -20
  42. package/src/client/canvas/ContextPinBar.tsx +1 -5
  43. package/src/client/canvas/ContextPinHud.tsx +1 -6
  44. package/src/client/canvas/DockedNode.tsx +4 -4
  45. package/src/client/canvas/EdgeLayer.tsx +37 -36
  46. package/src/client/canvas/ExpandedNodeOverlay.tsx +69 -45
  47. package/src/client/canvas/FocusFieldLayer.tsx +20 -22
  48. package/src/client/canvas/IntentLayer.tsx +31 -14
  49. package/src/client/canvas/Minimap.tsx +11 -16
  50. package/src/client/canvas/SelectionBar.tsx +4 -11
  51. package/src/client/canvas/ShortcutOverlay.tsx +3 -1
  52. package/src/client/canvas/SnapshotPanel.tsx +77 -95
  53. package/src/client/canvas/auto-fit.ts +15 -14
  54. package/src/client/canvas/snap-guides.ts +12 -12
  55. package/src/client/canvas/use-node-resize.ts +1 -5
  56. package/src/client/canvas/use-pan-zoom.ts +25 -26
  57. package/src/client/ext-app/bridge.ts +3 -12
  58. package/src/client/icons.tsx +63 -20
  59. package/src/client/nodes/ContextNode.tsx +14 -25
  60. package/src/client/nodes/ExtAppFrame.tsx +194 -80
  61. package/src/client/nodes/FileNode.tsx +74 -62
  62. package/src/client/nodes/GroupNode.tsx +4 -6
  63. package/src/client/nodes/HtmlNode.tsx +76 -46
  64. package/src/client/nodes/ImageNode.tsx +18 -27
  65. package/src/client/nodes/InlineFormatBar.tsx +4 -21
  66. package/src/client/nodes/InlineMarkdownEditor.tsx +0 -1
  67. package/src/client/nodes/LedgerNode.tsx +10 -4
  68. package/src/client/nodes/MarkdownNode.tsx +3 -10
  69. package/src/client/nodes/McpAppNode.tsx +26 -22
  70. package/src/client/nodes/MdFormatBar.tsx +10 -7
  71. package/src/client/nodes/PromptNode.tsx +23 -51
  72. package/src/client/nodes/ResponseNode.tsx +3 -13
  73. package/src/client/nodes/StatusNode.tsx +5 -9
  74. package/src/client/nodes/StatusSummary.tsx +2 -8
  75. package/src/client/nodes/WebpageNode.tsx +20 -14
  76. package/src/client/nodes/iframe-document-url.ts +25 -16
  77. package/src/client/nodes/image-warnings.ts +1 -7
  78. package/src/client/nodes/md-format.ts +20 -5
  79. package/src/client/state/attention-bridge.ts +4 -9
  80. package/src/client/state/attention-store.ts +1 -7
  81. package/src/client/state/canvas-store.ts +52 -36
  82. package/src/client/state/intent-bridge.ts +176 -112
  83. package/src/client/state/intent-store.ts +4 -1
  84. package/src/client/state/sse-bridge.ts +53 -70
  85. package/src/json-render/catalog.ts +12 -16
  86. package/src/json-render/charts/components.tsx +16 -20
  87. package/src/json-render/charts/extra-components.tsx +8 -16
  88. package/src/json-render/charts/extra-definitions.ts +1 -2
  89. package/src/json-render/charts/tufte-components.tsx +37 -20
  90. package/src/json-render/charts/tufte-definitions.ts +8 -2
  91. package/src/json-render/renderer/index.tsx +42 -22
  92. package/src/json-render/schema.ts +6 -3
  93. package/src/json-render/server.ts +33 -39
  94. package/src/mcp/canvas-access.ts +35 -21
  95. package/src/mcp/server.ts +132 -70
  96. package/src/server/agent-context.ts +63 -36
  97. package/src/server/ax-context.ts +7 -5
  98. package/src/server/ax-interaction.ts +176 -43
  99. package/src/server/ax-state-manager.ts +182 -39
  100. package/src/server/ax-state.ts +142 -47
  101. package/src/server/canvas-db.ts +213 -95
  102. package/src/server/canvas-operations.ts +180 -123
  103. package/src/server/canvas-provenance.ts +1 -4
  104. package/src/server/canvas-schema.ts +454 -73
  105. package/src/server/canvas-serialization.ts +27 -35
  106. package/src/server/canvas-state.ts +150 -58
  107. package/src/server/chart-template.ts +4 -5
  108. package/src/server/code-graph.ts +19 -6
  109. package/src/server/diagram-presets.ts +28 -29
  110. package/src/server/ext-app-lookup.ts +3 -12
  111. package/src/server/html-node-summary.ts +19 -10
  112. package/src/server/html-primitives.ts +326 -97
  113. package/src/server/html-surface.ts +6 -9
  114. package/src/server/image-source.ts +6 -4
  115. package/src/server/index.ts +320 -217
  116. package/src/server/intent-registry.ts +2 -5
  117. package/src/server/mcp-app-candidate.ts +5 -10
  118. package/src/server/mcp-app-host.ts +14 -38
  119. package/src/server/mcp-app-runtime.ts +12 -20
  120. package/src/server/mutation-history.ts +15 -5
  121. package/src/server/operations/composites.ts +1 -3
  122. package/src/server/operations/http.ts +2 -3
  123. package/src/server/operations/index.ts +7 -1
  124. package/src/server/operations/invoker.ts +4 -3
  125. package/src/server/operations/mcp.ts +22 -30
  126. package/src/server/operations/ops/annotation.ts +122 -10
  127. package/src/server/operations/ops/app.ts +98 -73
  128. package/src/server/operations/ops/ax-await.ts +17 -10
  129. package/src/server/operations/ops/ax-read.ts +347 -0
  130. package/src/server/operations/ops/ax-shared.ts +2 -7
  131. package/src/server/operations/ops/ax-state.ts +32 -14
  132. package/src/server/operations/ops/ax-timeline.ts +32 -19
  133. package/src/server/operations/ops/ax-work.ts +54 -37
  134. package/src/server/operations/ops/batch.ts +41 -15
  135. package/src/server/operations/ops/canvas-wire.ts +91 -0
  136. package/src/server/operations/ops/edges.ts +37 -25
  137. package/src/server/operations/ops/ext-app.ts +346 -0
  138. package/src/server/operations/ops/groups.ts +49 -20
  139. package/src/server/operations/ops/intent.ts +18 -12
  140. package/src/server/operations/ops/json-render.ts +239 -98
  141. package/src/server/operations/ops/nodes.ts +298 -109
  142. package/src/server/operations/ops/query.ts +46 -28
  143. package/src/server/operations/ops/snapshots.ts +35 -26
  144. package/src/server/operations/ops/validate.ts +2 -1
  145. package/src/server/operations/ops/viewport.ts +60 -16
  146. package/src/server/operations/ops/webview.ts +44 -18
  147. package/src/server/operations/registry.ts +2 -3
  148. package/src/server/operations/types.ts +7 -5
  149. package/src/server/operations/webview-runner.ts +1 -3
  150. package/src/server/placement.ts +8 -18
  151. package/src/server/server.ts +122 -1028
  152. package/src/server/spatial-analysis.ts +39 -25
  153. package/src/server/trace-manager.ts +3 -8
  154. package/src/server/web-artifacts.ts +23 -27
  155. package/src/server/webpage-node.ts +5 -13
  156. package/src/shared/auto-arrange.ts +12 -5
  157. package/src/shared/content-height-reporter.ts +8 -6
  158. package/src/shared/ext-app-tool-result.ts +2 -6
  159. package/src/shared/placement.ts +1 -4
  160. package/src/shared/semantic-attention.ts +39 -37
  161. package/src/shared/surface.ts +8 -4
@@ -37,7 +37,7 @@ async function postJson<T>(url: string, body: Record<string, unknown>): Promise<
37
37
  headers: { 'Content-Type': 'application/json' },
38
38
  body: JSON.stringify(body),
39
39
  });
40
- const json = await response.json() as {
40
+ const json = (await response.json()) as {
41
41
  ok: boolean;
42
42
  result?: T;
43
43
  error?: string;
@@ -59,22 +59,50 @@ export function isWebKitOnlyHost(userAgent: string): boolean {
59
59
  return /AppleWebKit/.test(userAgent) && !/Chrome|Chromium|CriOS|Edg|Android/.test(userAgent);
60
60
  }
61
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;
62
+ // Finding F (0.2.5, reworked 0.3.1): BOOT-AWARE serialized WebKit remount queue.
63
+ // The black tile is a cold-hydration BURST problem — a single ext-app repaints fine
64
+ // into an idle panel (like a live-created node, or expand+close), but several
65
+ // compositing at once overwhelm WebKit and all stay black. The 0.2.5 fixed-stagger
66
+ // slots (450ms apart) were NOT boot-aware: each recovery remount reboots the app
67
+ // (~1-2s for Excalidraw), so N staggered remounts overlapped into a fresh burst and
68
+ // the per-node one-shot flag was spent exactly the multi-app reload blackout the
69
+ // 0.3.1 report shows. This queue runs remounts strictly one at a time: each entry
70
+ // performs its remount, then waits for that app's GENUINE initialized handshake
71
+ // (or a bounded timeout for an app that never boots) plus a settle delay before the
72
+ // next entry fires — so every remount lands in an idle panel, which is the
73
+ // empirically always-successful recovery (what expand+close does manually).
74
+ // Settle covers the scene DRAW: the app's initialized handshake fires before the
75
+ // replayed tool result arrives and the scene is painted, so the queue waits for the
76
+ // settled signal (bootstrap chain incl. tool-result send complete) plus this pause.
77
+ export const WEBKIT_REMOUNT_SETTLE_MS = 1000;
78
+ const WEBKIT_BOOT_TIMEOUT_MS = 7000;
79
+
80
+ export interface WebkitRemountTask {
81
+ /** Perform the remount. Return false if the node no longer needs it (skips the boot wait). */
82
+ remount: () => boolean;
83
+ /** Resolves when the remounted app genuinely boots AND finishes its bootstrap replay, or after a bounded timeout. */
84
+ awaitBoot: () => Promise<void>;
85
+ }
86
+
87
+ // Bounded recovery trail readable via `webview evaluate` / devtools
88
+ // (window.__PMX_EXTAPP_LOG). The WebKit compositor dropout is otherwise
89
+ // unobservable from page JS — this is the only diagnostic surface.
90
+ export function extAppRecoveryLog(nodeId: string, event: string): void {
91
+ if (typeof window === 'undefined') return;
92
+ const host = window as unknown as { __PMX_EXTAPP_LOG?: Array<{ t: number; nodeId: string; event: string }> };
93
+ host.__PMX_EXTAPP_LOG ??= [];
94
+ if (host.__PMX_EXTAPP_LOG.length < 500) host.__PMX_EXTAPP_LOG.push({ t: Date.now(), nodeId, event });
95
+ }
96
+
97
+ let webkitRemountChain: Promise<void> = Promise.resolve();
98
+ export function enqueueWebkitRemount(task: WebkitRemountTask): void {
99
+ webkitRemountChain = webkitRemountChain
100
+ .then(async () => {
101
+ if (!task.remount()) return;
102
+ await task.awaitBoot();
103
+ await new Promise((resolve) => setTimeout(resolve, WEBKIT_REMOUNT_SETTLE_MS));
104
+ })
105
+ .catch(() => {});
78
106
  }
79
107
 
80
108
  export function shouldScheduleWebKitRepaint(status: ExtAppFrameStatus, hasReplayToolResult: boolean): boolean {
@@ -128,9 +156,7 @@ export async function sendExtAppBootstrapState(
128
156
  }
129
157
 
130
158
  export function resolveExtAppSandbox(value: unknown): string {
131
- return typeof value === 'string' && value.trim().length > 0
132
- ? value.trim()
133
- : DEFAULT_EXT_APP_SANDBOX;
159
+ return typeof value === 'string' && value.trim().length > 0 ? value.trim() : DEFAULT_EXT_APP_SANDBOX;
134
160
  }
135
161
 
136
162
  export function buildExtAppAxBridgeScript(axToken: string, nodeId: string): string {
@@ -231,7 +257,14 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
231
257
  const bridgeReadyRef = useRef(false);
232
258
  const themeUnsubRef = useRef<(() => void) | null>(null);
233
259
  const webkitRepaintDoneRef = useRef(false);
234
- const webkitRepaintTimerRef = useRef<number | null>(null);
260
+ const webkitRemountAttemptsRef = useRef(0);
261
+ // Genuine boot signal: set ONLY when the app completes the ui/initialize
262
+ // handshake (bridge.oninitialized) — NOT by the 1200ms bootstrap fallback,
263
+ // which flips status via notifications that resolve even into a dead iframe.
264
+ const appInitializedRef = useRef(false);
265
+ const bootWaitersRef = useRef<Array<() => void>>([]);
266
+ const remountQueuedRef = useRef(false);
267
+ const unmountedRef = useRef(false);
235
268
  const [status, setStatus] = useState<ExtAppFrameStatus>('loading');
236
269
  const [error, setError] = useState<string | null>(null);
237
270
  const [retryKey, setRetryKey] = useState(0);
@@ -246,10 +279,12 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
246
279
  const rawToolCallId = node.data.toolCallId;
247
280
  const toolCallId: RequestId | undefined =
248
281
  typeof rawToolCallId === 'string' || typeof rawToolCallId === 'number' ? rawToolCallId : undefined;
249
- const resourceMeta = node.data.resourceMeta as {
250
- csp?: Record<string, unknown>;
251
- permissions?: Record<string, unknown>;
252
- } | undefined;
282
+ const resourceMeta = node.data.resourceMeta as
283
+ | {
284
+ csp?: Record<string, unknown>;
285
+ permissions?: Record<string, unknown>;
286
+ }
287
+ | undefined;
253
288
  const sessionStatus = node.data.sessionStatus as string | undefined;
254
289
  const sessionError = node.data.sessionError as string | undefined;
255
290
  const maxHeight = node.size.height;
@@ -262,9 +297,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
262
297
  const axCaps = node.data.axCapabilities as { enabled?: boolean } | undefined;
263
298
  const axEnabled = axCaps?.enabled === true && typeof html === 'string' && html.length > 0;
264
299
  const axToken = useMemo(() => `ax-${crypto.randomUUID()}`, []);
265
- const axBridgeScript = axEnabled
266
- ? buildExtAppAxBridgeScript(axToken, nodeId)
267
- : '';
300
+ const axBridgeScript = axEnabled ? buildExtAppAxBridgeScript(axToken, nodeId) : '';
268
301
  const iframeDocument = useIframeDocument(injectExtAppAxBridgeScript(html ?? '', axBridgeScript), iframeSandbox);
269
302
 
270
303
  useEffect(() => {
@@ -272,7 +305,10 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
272
305
  function onAxMessage(event: MessageEvent) {
273
306
  if (event.source !== iframeRef.current?.contentWindow) return;
274
307
  const data = event.data as {
275
- source?: string; token?: string; nodeId?: string; correlationId?: string;
308
+ source?: string;
309
+ token?: string;
310
+ nodeId?: string;
311
+ correlationId?: string;
276
312
  interaction?: { type?: unknown; payload?: unknown };
277
313
  } | null;
278
314
  if (!data || data.source !== 'pmx-canvas-ax' || data.token !== axToken || data.nodeId !== nodeId) return;
@@ -289,36 +325,71 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
289
325
  }).then((res) => {
290
326
  if (res.ok) showToast('context', 'AX interaction', interactionType, [nodeId]);
291
327
  else showToast('remove', 'AX interaction rejected', res.error ?? res.code ?? '', [nodeId]);
292
- iframeRef.current?.contentWindow?.postMessage({
293
- source: 'pmx-canvas-ax-ack',
294
- token: axToken,
295
- ...(data.correlationId ? { correlationId: data.correlationId } : {}),
296
- interaction: { type: interactionType },
297
- result: res,
298
- }, '*');
328
+ iframeRef.current?.contentWindow?.postMessage(
329
+ {
330
+ source: 'pmx-canvas-ax-ack',
331
+ token: axToken,
332
+ ...(data.correlationId ? { correlationId: data.correlationId } : {}),
333
+ interaction: { type: interactionType },
334
+ result: res,
335
+ },
336
+ '*',
337
+ );
299
338
  });
300
339
  }
301
340
  window.addEventListener('message', onAxMessage);
302
341
  return () => window.removeEventListener('message', onAxMessage);
303
342
  }, [axEnabled, axToken, nodeId]);
304
343
 
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).
344
+ // Enqueue one serialized remount attempt for this node (Finding F recovery).
345
+ // Attempts are capped so a persistently-failing app degrades to the manual
346
+ // fallback (expand+close / Retry) instead of remount-looping forever.
347
+ const WEBKIT_MAX_REMOUNT_ATTEMPTS = 3;
348
+ const scheduleWebkitRemount = (reason: string): void => {
349
+ if (webkitRemountAttemptsRef.current >= WEBKIT_MAX_REMOUNT_ATTEMPTS) {
350
+ extAppRecoveryLog(nodeId, `remount-cap-hit (${reason})`);
351
+ return;
352
+ }
353
+ if (remountQueuedRef.current) return; // an attempt is already queued and has not run yet
354
+ webkitRemountAttemptsRef.current += 1;
355
+ remountQueuedRef.current = true;
356
+ extAppRecoveryLog(nodeId, `remount-queued #${webkitRemountAttemptsRef.current} (${reason})`);
357
+ enqueueWebkitRemount({
358
+ remount: () => {
359
+ remountQueuedRef.current = false;
360
+ if (unmountedRef.current || expandedNodeId.value === nodeId) {
361
+ extAppRecoveryLog(nodeId, 'remount-skipped');
362
+ return false;
363
+ }
364
+ extAppRecoveryLog(nodeId, `remount-run #${webkitRemountAttemptsRef.current}`);
365
+ setRetryKey((k) => k + 1);
366
+ return true;
367
+ },
368
+ awaitBoot: () =>
369
+ new Promise<void>((resolve) => {
370
+ const timer = window.setTimeout(finish, WEBKIT_BOOT_TIMEOUT_MS);
371
+ function finish() {
372
+ window.clearTimeout(timer);
373
+ resolve();
374
+ }
375
+ bootWaitersRef.current.push(finish);
376
+ }),
377
+ });
378
+ };
379
+
380
+ // Finding F (0.2.4/0.2.5, reworked 0.3.1): in a WebKit host panel (e.g. the GitHub
381
+ // Copilot app's WKWebView, and Bun's headless WebKit WebView) the doubly-nested
382
+ // ext-app iframe (workbench iframe → mcp-app.html iframe) can come up as a black
383
+ // tile for nodes present at panel-load. The mcp-app shell loads blank, then the app
384
+ // boots over the bridge and draws its content AFTER load; under a cold-hydration
385
+ // burst WebKit does not composite that late draw, so the layer stays black (clean
386
+ // in Blink, and clean for a node created live into an already-idle panel). A
387
+ // parent-side transform/src nudge does NOT repair a black layer — only a full
388
+ // remount (new iframe element + bridge re-init, what expand+close does) does, and
389
+ // only when it lands in an idle moment. So: once the app has booted — `ready` for
390
+ // empty apps, `done` for restored apps that must replay saved tool output — under
391
+ // WebKit only, enqueue ONE recovery remount through the boot-aware queue above, so
392
+ // concurrent ext-apps remount strictly one at a time instead of re-bursting.
322
393
  // Strict no-op in Blink/Gecko; the e2e engine is unaffected. Inline instance only.
323
394
  useEffect(() => {
324
395
  if (expanded || webkitRepaintDoneRef.current) return;
@@ -326,19 +397,36 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
326
397
  if (typeof navigator === 'undefined' || typeof window === 'undefined') return;
327
398
  if (!isWebKitOnlyHost(navigator.userAgent)) return;
328
399
  webkitRepaintDoneRef.current = true;
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);
400
+ scheduleWebkitRemount('post-boot-repaint');
336
401
  // eslint-disable-next-line react-hooks/exhaustive-deps
337
402
  }, [status, hasReplayToolResult]);
338
403
 
339
- useEffect(() => () => {
340
- if (webkitRepaintTimerRef.current !== null) window.clearTimeout(webkitRepaintTimerRef.current);
341
- }, []);
404
+ // Never-booted watchdog (0.3.1): an iframe whose scripts never ran in the burst
405
+ // shows no error — the bootstrap fallback flips status via notifications that
406
+ // resolve into a dead window, so the tile just stays black. If the CURRENT frame
407
+ // has not completed the genuine initialize handshake within the watchdog window,
408
+ // retry it through the same serialized queue (bounded by the shared attempt cap).
409
+ const WEBKIT_BOOT_WATCHDOG_MS = 6000;
410
+ useEffect(() => {
411
+ if (expanded) return;
412
+ if (typeof navigator === 'undefined' || typeof window === 'undefined') return;
413
+ if (!isWebKitOnlyHost(navigator.userAgent)) return;
414
+ if (!iframeDocument.ready) return;
415
+ const timer = window.setTimeout(() => {
416
+ if (appInitializedRef.current || unmountedRef.current) return;
417
+ scheduleWebkitRemount('boot-watchdog');
418
+ }, WEBKIT_BOOT_WATCHDOG_MS);
419
+ return () => window.clearTimeout(timer);
420
+ // eslint-disable-next-line react-hooks/exhaustive-deps
421
+ }, [frameKey, iframeDocument.ready, expanded]);
422
+
423
+ useEffect(
424
+ () => () => {
425
+ unmountedRef.current = true;
426
+ for (const waiter of bootWaitersRef.current.splice(0)) waiter();
427
+ },
428
+ [],
429
+ );
342
430
 
343
431
  const toMcpTheme = (theme: string): McpUiTheme => (theme === 'light' ? 'light' : 'dark');
344
432
  const isExpanded = expanded || expandedNodeId.value === nodeId;
@@ -368,10 +456,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
368
456
  if (lastSentToolResultRef.current === pendingToolResult) {
369
457
  return null;
370
458
  }
371
- if (
372
- lastSentToolResultRef.current &&
373
- extAppToolResultsMatch(lastSentToolResultRef.current, pendingToolResult)
374
- ) {
459
+ if (lastSentToolResultRef.current && extAppToolResultsMatch(lastSentToolResultRef.current, pendingToolResult)) {
375
460
  lastSentToolResultRef.current = pendingToolResult;
376
461
  return null;
377
462
  }
@@ -409,6 +494,9 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
409
494
  lastSentToolResultRef.current = undefined;
410
495
  toolResultSendingRef.current = null;
411
496
  bridgeReadyRef.current = false;
497
+ // New frame = new boot attempt: the genuine-initialized signal belongs to the
498
+ // CURRENT iframe. The queue's awaitBoot waits on this frame's handshake.
499
+ appInitializedRef.current = false;
412
500
 
413
501
  const clearFallbackTimer = (): void => {
414
502
  if (!fallbackTimer) return;
@@ -423,7 +511,9 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
423
511
  throw new Error('Ext-app iframe window is unavailable');
424
512
  }
425
513
 
426
- const buildHostContext = (displayMode: DisplayMode = expandedNodeId.value === nodeId ? 'fullscreen' : 'inline') => ({
514
+ const buildHostContext = (
515
+ displayMode: DisplayMode = expandedNodeId.value === nodeId ? 'fullscreen' : 'inline',
516
+ ) => ({
427
517
  theme: toMcpTheme(canvasTheme.value),
428
518
  platform: 'web' as const,
429
519
  containerDimensions: resolveExtAppContainerDimensions(iframe, {
@@ -433,12 +523,14 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
433
523
  displayMode,
434
524
  locale: navigator.language,
435
525
  timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
436
- ...(toolDefinition ? {
437
- toolInfo: {
438
- id: toolCallId,
439
- tool: toolDefinition,
440
- },
441
- } : {}),
526
+ ...(toolDefinition
527
+ ? {
528
+ toolInfo: {
529
+ id: toolCallId,
530
+ tool: toolDefinition,
531
+ },
532
+ }
533
+ : {}),
442
534
  });
443
535
 
444
536
  const scheduleHostContextUpdate = () => {
@@ -601,12 +693,23 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
601
693
  if (disposed) return;
602
694
  clearFallbackTimer();
603
695
  bridgeReadyRef.current = true;
696
+ appInitializedRef.current = true;
697
+ extAppRecoveryLog(nodeId, 'initialized');
604
698
  setStatus('ready');
605
699
  setError(null);
606
700
  void Promise.resolve(bridge.sendHostContextChange(buildHostContext(isExpanded ? 'fullscreen' : 'inline')))
607
701
  .then(() => sendExtAppBootstrapState(bridge, latestToolInputRef.current, undefined))
608
702
  .then(() => flushToolResult(bridge))
609
- .then(() => nudgeHostContextAfterLayout())
703
+ .then(() => {
704
+ // Settled: handshake + bootstrap replay delivered — the app draws its
705
+ // scene right after this. Release the remount queue (which then adds
706
+ // its own settle pause covering the draw) only from this genuine path;
707
+ // the bootstrap fallback never releases it (a dead iframe must run the
708
+ // queue's bounded timeout instead of green-lighting the next remount).
709
+ extAppRecoveryLog(nodeId, 'settled');
710
+ for (const waiter of bootWaitersRef.current.splice(0)) waiter();
711
+ nudgeHostContextAfterLayout();
712
+ })
610
713
  .catch((err) => {
611
714
  const msg = err instanceof Error ? err.message : String(err);
612
715
  setError(`Bridge bootstrap failed: ${msg}`);
@@ -655,7 +758,10 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
655
758
  // at fire time so the widget keeps its fullscreen/inline context accurate.
656
759
  let firstFire = true;
657
760
  themeUnsubRef.current = canvasTheme.subscribe((newTheme) => {
658
- if (firstFire) { firstFire = false; return; }
761
+ if (firstFire) {
762
+ firstFire = false;
763
+ return;
764
+ }
659
765
  if (disposed) return;
660
766
  bridge.setHostContext?.({
661
767
  ...buildHostContext(),
@@ -787,7 +893,17 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
787
893
  }
788
894
 
789
895
  return (
790
- <div style={{ flex: 1, width: '100%', height: '100%', minWidth: 0, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
896
+ <div
897
+ style={{
898
+ flex: 1,
899
+ width: '100%',
900
+ height: '100%',
901
+ minWidth: 0,
902
+ minHeight: 0,
903
+ display: 'flex',
904
+ flexDirection: 'column',
905
+ }}
906
+ >
791
907
  {sessionStatus && sessionStatus !== 'ready' && (
792
908
  <div
793
909
  style={{
@@ -852,9 +968,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
852
968
  </div>
853
969
  )}
854
970
  {status === 'loading' && (
855
- <div style={{ padding: '8px', fontSize: '11px', color: 'var(--c-muted)' }}>
856
- Connecting to ext-app viewer...
857
- </div>
971
+ <div style={{ padding: '8px', fontSize: '11px', color: 'var(--c-muted)' }}>Connecting to ext-app viewer...</div>
858
972
  )}
859
973
  {/* Iframe stack: the widget renders a preview; when not expanded, a
860
974
  transparent click-catcher sits on top so the first click always
@@ -8,20 +8,38 @@ import { runNodeAxInteraction } from './ax-node-actions';
8
8
  function langFromPath(path: string): string {
9
9
  const ext = path.split('.').pop()?.toLowerCase() ?? '';
10
10
  const map: Record<string, string> = {
11
- ts: 'TypeScript', tsx: 'TSX', js: 'JavaScript', jsx: 'JSX',
12
- py: 'Python', rs: 'Rust', go: 'Go', rb: 'Ruby',
13
- java: 'Java', kt: 'Kotlin', swift: 'Swift', c: 'C', cpp: 'C++', h: 'C/C++',
14
- css: 'CSS', html: 'HTML', json: 'JSON', yaml: 'YAML', yml: 'YAML',
15
- md: 'Markdown', toml: 'TOML', sql: 'SQL', sh: 'Shell', bash: 'Shell',
16
- xml: 'XML', graphql: 'GraphQL', proto: 'Protobuf',
11
+ ts: 'TypeScript',
12
+ tsx: 'TSX',
13
+ js: 'JavaScript',
14
+ jsx: 'JSX',
15
+ py: 'Python',
16
+ rs: 'Rust',
17
+ go: 'Go',
18
+ rb: 'Ruby',
19
+ java: 'Java',
20
+ kt: 'Kotlin',
21
+ swift: 'Swift',
22
+ c: 'C',
23
+ cpp: 'C++',
24
+ h: 'C/C++',
25
+ css: 'CSS',
26
+ html: 'HTML',
27
+ json: 'JSON',
28
+ yaml: 'YAML',
29
+ yml: 'YAML',
30
+ md: 'Markdown',
31
+ toml: 'TOML',
32
+ sql: 'SQL',
33
+ sh: 'Shell',
34
+ bash: 'Shell',
35
+ xml: 'XML',
36
+ graphql: 'GraphQL',
37
+ proto: 'Protobuf',
17
38
  };
18
39
  return map[ext] ?? (ext.toUpperCase() || 'Text');
19
40
  }
20
41
 
21
- export function FileNode({
22
- node,
23
- expanded = false,
24
- }: { node: CanvasNodeState; expanded?: boolean }) {
42
+ export function FileNode({ node, expanded = false }: { node: CanvasNodeState; expanded?: boolean }) {
25
43
  const filePath = (node.data.path as string) || (node.data.content as string) || '';
26
44
  const title = (node.data.title as string) || filePath.split('/').pop() || 'File';
27
45
  const cachedContent = node.data.fileContent as string | undefined;
@@ -44,26 +62,30 @@ export function FileNode({
44
62
  let cancelled = false;
45
63
  setLoading(true);
46
64
  setError(null);
47
- fetchFile(filePath).then(({ content: fileText }) => {
48
- if (cancelled) return;
49
- if (!fileText && fileText !== '') {
50
- setError('File not found');
65
+ fetchFile(filePath)
66
+ .then(({ content: fileText }) => {
67
+ if (cancelled) return;
68
+ if (!fileText && fileText !== '') {
69
+ setError('File not found');
70
+ setLoading(false);
71
+ return;
72
+ }
73
+ setContent(fileText);
51
74
  setLoading(false);
52
- return;
53
- }
54
- setContent(fileText);
55
- setLoading(false);
56
- // Cache content in node data so it survives re-renders
57
- const lines = fileText.split('\n').length;
58
- updateNodeData(node.id, { fileContent: fileText, lineCount: lines });
59
- void updateNodeFromClient(node.id, { data: { fileContent: fileText, lineCount: lines } });
60
- }).catch(() => {
61
- if (!cancelled) {
62
- setError('Failed to load file');
63
- setLoading(false);
64
- }
65
- });
66
- return () => { cancelled = true; };
75
+ // Cache content in node data so it survives re-renders
76
+ const lines = fileText.split('\n').length;
77
+ updateNodeData(node.id, { fileContent: fileText, lineCount: lines });
78
+ void updateNodeFromClient(node.id, { data: { fileContent: fileText, lineCount: lines } });
79
+ })
80
+ .catch(() => {
81
+ if (!cancelled) {
82
+ setError('Failed to load file');
83
+ setLoading(false);
84
+ }
85
+ });
86
+ return () => {
87
+ cancelled = true;
88
+ };
67
89
  }, [filePath, cachedContent]);
68
90
 
69
91
  // Sync content when server pushes updates via SSE
@@ -80,27 +102,29 @@ export function FileNode({
80
102
  // Clear cached content to force a fresh fetch
81
103
  updateNodeData(node.id, { fileContent: undefined });
82
104
  void updateNodeFromClient(node.id, { data: { fileContent: undefined } });
83
- fetchFile(filePath).then(({ content: fileText }) => {
84
- setContent(fileText);
85
- setLoading(false);
86
- const lines = fileText.split('\n').length;
87
- const updatedAt = new Date().toISOString();
88
- updateNodeData(node.id, {
89
- fileContent: fileText,
90
- lineCount: lines,
91
- updatedAt,
92
- });
93
- void updateNodeFromClient(node.id, {
94
- data: {
105
+ fetchFile(filePath)
106
+ .then(({ content: fileText }) => {
107
+ setContent(fileText);
108
+ setLoading(false);
109
+ const lines = fileText.split('\n').length;
110
+ const updatedAt = new Date().toISOString();
111
+ updateNodeData(node.id, {
95
112
  fileContent: fileText,
96
113
  lineCount: lines,
97
114
  updatedAt,
98
- },
115
+ });
116
+ void updateNodeFromClient(node.id, {
117
+ data: {
118
+ fileContent: fileText,
119
+ lineCount: lines,
120
+ updatedAt,
121
+ },
122
+ });
123
+ })
124
+ .catch(() => {
125
+ setError('Failed to reload');
126
+ setLoading(false);
99
127
  });
100
- }).catch(() => {
101
- setError('Failed to reload');
102
- setLoading(false);
103
- });
104
128
  }, [filePath, node.id]);
105
129
 
106
130
  const lang = langFromPath(filePath);
@@ -108,11 +132,7 @@ export function FileNode({
108
132
  const gutterWidth = `${String(lines.length).length + 1}ch`;
109
133
 
110
134
  if (!filePath) {
111
- return (
112
- <div style={{ color: 'var(--c-dim)', fontStyle: 'italic', padding: '12px' }}>
113
- No file path set
114
- </div>
115
- );
135
+ return <div style={{ color: 'var(--c-dim)', fontStyle: 'italic', padding: '12px' }}>No file path set</div>;
116
136
  }
117
137
 
118
138
  return (
@@ -162,9 +182,7 @@ export function FileNode({
162
182
  {filePath}
163
183
  </span>
164
184
  {lineCount !== undefined && (
165
- <span style={{ color: 'var(--c-dim)', fontSize: '10px', flexShrink: 0 }}>
166
- {lineCount} lines
167
- </span>
185
+ <span style={{ color: 'var(--c-dim)', fontSize: '10px', flexShrink: 0 }}>{lineCount} lines</span>
168
186
  )}
169
187
  {updatedAt && (
170
188
  <span style={{ color: 'var(--c-dim)', fontSize: '10px', flexShrink: 0 }}>
@@ -224,14 +242,8 @@ export function FileNode({
224
242
  borderRadius: expanded ? '0 0 8px 8px' : undefined,
225
243
  }}
226
244
  >
227
- {loading && (
228
- <div style={{ color: 'var(--c-dim)', padding: '12px', fontStyle: 'italic' }}>
229
- Loading…
230
- </div>
231
- )}
232
- {error && (
233
- <div style={{ color: 'var(--c-danger)', padding: '12px' }}>{error}</div>
234
- )}
245
+ {loading && <div style={{ color: 'var(--c-dim)', padding: '12px', fontStyle: 'italic' }}>Loading…</div>}
246
+ {error && <div style={{ color: 'var(--c-danger)', padding: '12px' }}>{error}</div>}
235
247
  {!loading && !error && (
236
248
  <pre
237
249
  style={{
@@ -26,14 +26,12 @@ export function GroupNode({ node }: GroupNodeProps) {
26
26
  return (
27
27
  <div class="group-node-body">
28
28
  <div class="group-summary">
29
- <span class="group-child-count">{childCount} node{childCount !== 1 ? 's' : ''}</span>
29
+ <span class="group-child-count">
30
+ {childCount} node{childCount !== 1 ? 's' : ''}
31
+ </span>
30
32
  {typeSummary && <span class="group-type-summary">{typeSummary}</span>}
31
33
  </div>
32
- {childCount === 0 && (
33
- <div class="group-empty-hint">
34
- Drag nodes here or use the selection bar to group nodes
35
- </div>
36
- )}
34
+ {childCount === 0 && <div class="group-empty-hint">Drag nodes here or use the selection bar to group nodes</div>}
37
35
  </div>
38
36
  );
39
37
  }