pmx-canvas 0.4.8 → 0.5.1

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 (212) hide show
  1. package/.github/extensions/pmx-canvas/extension.mjs +154 -5
  2. package/.github/extensions/pmx-canvas/steering-delivery.mjs +79 -0
  3. package/CHANGELOG.md +156 -0
  4. package/Readme.md +72 -23
  5. package/dist/canvas/global.css +3071 -796
  6. package/dist/canvas/index.js +122 -104
  7. package/dist/json-render/index.css +1 -1
  8. package/dist/types/cli/agent.d.ts +1 -0
  9. package/dist/types/cli/commands/pump.d.ts +9 -0
  10. package/dist/types/client/App.d.ts +1 -1
  11. package/dist/types/client/canvas/AgentPresenceLayer.d.ts +11 -0
  12. package/dist/types/client/canvas/BarHint.d.ts +22 -0
  13. package/dist/types/client/canvas/CanvasNode.d.ts +5 -0
  14. package/dist/types/client/canvas/CanvasViewport.d.ts +4 -4
  15. package/dist/types/client/canvas/CommandBar.d.ts +10 -0
  16. package/dist/types/client/canvas/ConnectionBanner.d.ts +13 -0
  17. package/dist/types/client/canvas/ContextMenu.d.ts +6 -0
  18. package/dist/types/client/canvas/EdgeLayer.d.ts +2 -1
  19. package/dist/types/client/canvas/EmptyState.d.ts +11 -0
  20. package/dist/types/client/canvas/ExternalWriters.d.ts +5 -0
  21. package/dist/types/client/canvas/HumanPresenceLayer.d.ts +8 -0
  22. package/dist/types/client/canvas/Minimap.d.ts +12 -1
  23. package/dist/types/client/canvas/ScopeFenceLayer.d.ts +8 -0
  24. package/dist/types/client/canvas/SelectionBar.d.ts +6 -0
  25. package/dist/types/client/canvas/SessionPanel.d.ts +1 -0
  26. package/dist/types/client/canvas/SessionReceipt.d.ts +17 -0
  27. package/dist/types/client/canvas/TextPrompt.d.ts +25 -0
  28. package/dist/types/client/canvas/ToolRail.d.ts +21 -0
  29. package/dist/types/client/canvas/TopBar.d.ts +7 -0
  30. package/dist/types/client/canvas/canvas-area.d.ts +39 -0
  31. package/dist/types/client/canvas/create-in-view.d.ts +18 -0
  32. package/dist/types/client/canvas/import-files.d.ts +8 -0
  33. package/dist/types/client/canvas/kind-colors.d.ts +3 -0
  34. package/dist/types/client/canvas/use-focus-trap.d.ts +14 -0
  35. package/dist/types/client/canvas/use-now.d.ts +6 -0
  36. package/dist/types/client/icons.d.ts +11 -4
  37. package/dist/types/client/nodes/ExtAppFrame.d.ts +2 -0
  38. package/dist/types/client/nodes/GroupNode.d.ts +6 -1
  39. package/dist/types/client/nodes/MarkdownNode.d.ts +2 -0
  40. package/dist/types/client/nodes/inline-editor-commands.d.ts +3 -4
  41. package/dist/types/client/state/canvas-store.d.ts +49 -5
  42. package/dist/types/client/state/human-store.d.ts +26 -0
  43. package/dist/types/client/state/iframe-mode.d.ts +1 -0
  44. package/dist/types/client/state/intent-bridge.d.ts +37 -5
  45. package/dist/types/client/state/node-factory.d.ts +0 -1
  46. package/dist/types/client/state/presence-store.d.ts +78 -0
  47. package/dist/types/client/state/session-store.d.ts +176 -0
  48. package/dist/types/client/types.d.ts +2 -1
  49. package/dist/types/client/utils/platform.d.ts +2 -0
  50. package/dist/types/server/agent-presence.d.ts +192 -0
  51. package/dist/types/server/ax-gate-ttl.d.ts +6 -0
  52. package/dist/types/server/ax-interaction.d.ts +4 -10
  53. package/dist/types/server/ax-state-manager.d.ts +21 -2
  54. package/dist/types/server/ax-state.d.ts +49 -3
  55. package/dist/types/server/ax-wait.d.ts +13 -0
  56. package/dist/types/server/canvas-db.d.ts +9 -1
  57. package/dist/types/server/canvas-operations.d.ts +11 -0
  58. package/dist/types/server/canvas-state.d.ts +27 -13
  59. package/dist/types/server/canvas-validation.d.ts +0 -14
  60. package/dist/types/server/html-surface.d.ts +1 -1
  61. package/dist/types/server/human-presence.d.ts +40 -0
  62. package/dist/types/server/index.d.ts +29 -3
  63. package/dist/types/server/intent-registry.d.ts +7 -0
  64. package/dist/types/server/mutation-history.d.ts +15 -2
  65. package/dist/types/server/operations/invoker.d.ts +13 -2
  66. package/dist/types/server/operations/ops/ax-presence.d.ts +2 -0
  67. package/dist/types/server/operations/ops/human-presence.d.ts +3 -0
  68. package/dist/types/server/operations/registry.d.ts +11 -0
  69. package/dist/types/server/operations/types.d.ts +1 -1
  70. package/dist/types/server/scope-fence.d.ts +62 -0
  71. package/dist/types/server/server.d.ts +0 -12
  72. package/dist/types/shared/agent-presence.d.ts +130 -0
  73. package/dist/types/shared/approval-gates.d.ts +19 -0
  74. package/dist/types/shared/auto-arrange.d.ts +0 -1
  75. package/dist/types/shared/ax-kinds.d.ts +11 -0
  76. package/dist/types/shared/human-presence.d.ts +31 -0
  77. package/dist/types/shared/iframe-probe.d.ts +8 -0
  78. package/dist/types/shared/scope-fence.d.ts +21 -0
  79. package/docs/RELEASE.md +20 -0
  80. package/docs/ax-host-adapter-contract.md +30 -14
  81. package/docs/cli.md +37 -1
  82. package/docs/environment.md +3 -0
  83. package/docs/http-api.md +222 -5
  84. package/docs/mcp.md +6 -5
  85. package/docs/node-types.md +3 -1
  86. package/docs/plans/plan-009-tech-debt-backlog.md +13 -0
  87. package/docs/screenshot.png +0 -0
  88. package/docs/screenshots/multi-agent-live.png +0 -0
  89. package/docs/screenshots/workbench-live-dark.png +0 -0
  90. package/docs/screenshots/workbench-live-light.png +0 -0
  91. package/docs/sdk.md +1 -1
  92. package/package.json +4 -4
  93. package/skills/pmx-canvas/SKILL.md +112 -17
  94. package/skills/pmx-canvas/references/codex-app-adapter.md +28 -0
  95. package/skills/pmx-canvas/references/full-reference.md +55 -16
  96. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +41 -24
  97. package/skills/pmx-canvas-orchestration/SKILL.md +56 -2
  98. package/skills/pmx-canvas-testing/SKILL.md +17 -0
  99. package/src/cli/agent.ts +1 -0
  100. package/src/cli/commands/copilot.ts +15 -8
  101. package/src/cli/commands/nodes.ts +1 -19
  102. package/src/cli/commands/pump.ts +263 -0
  103. package/src/cli/index.ts +2 -5
  104. package/src/cli/shared.ts +1 -1
  105. package/src/client/App.tsx +209 -585
  106. package/src/client/canvas/AgentPresenceLayer.tsx +74 -0
  107. package/src/client/canvas/AttentionHistory.tsx +0 -10
  108. package/src/client/canvas/BarHint.tsx +49 -0
  109. package/src/client/canvas/CanvasNode.tsx +488 -136
  110. package/src/client/canvas/CanvasViewport.tsx +190 -94
  111. package/src/client/canvas/CommandBar.tsx +140 -0
  112. package/src/client/canvas/CommandPalette.tsx +133 -58
  113. package/src/client/canvas/ConnectionBanner.tsx +65 -0
  114. package/src/client/canvas/ContextMenu.tsx +88 -27
  115. package/src/client/canvas/ContextPinBar.tsx +2 -2
  116. package/src/client/canvas/EdgeLayer.tsx +121 -38
  117. package/src/client/canvas/EmptyState.tsx +93 -0
  118. package/src/client/canvas/ExpandedNodeOverlay.tsx +64 -118
  119. package/src/client/canvas/ExternalWriters.tsx +308 -0
  120. package/src/client/canvas/FocusFieldLayer.tsx +12 -1
  121. package/src/client/canvas/HumanPresenceLayer.tsx +45 -0
  122. package/src/client/canvas/IntentLayer.tsx +1 -1
  123. package/src/client/canvas/Minimap.tsx +117 -190
  124. package/src/client/canvas/ScopeFenceLayer.tsx +38 -0
  125. package/src/client/canvas/SelectionBar.tsx +175 -23
  126. package/src/client/canvas/SessionPanel.tsx +481 -0
  127. package/src/client/canvas/SessionReceipt.tsx +141 -0
  128. package/src/client/canvas/ShortcutOverlay.tsx +21 -6
  129. package/src/client/canvas/SnapshotPanel.tsx +166 -80
  130. package/src/client/canvas/TextPrompt.tsx +128 -0
  131. package/src/client/canvas/ToolRail.tsx +447 -0
  132. package/src/client/canvas/TopBar.tsx +308 -0
  133. package/src/client/canvas/auto-fit.ts +1 -5
  134. package/src/client/canvas/canvas-area.ts +74 -0
  135. package/src/client/canvas/create-in-view.ts +48 -0
  136. package/src/client/canvas/import-files.ts +64 -0
  137. package/src/client/canvas/kind-colors.ts +22 -0
  138. package/src/client/canvas/snap-guides.ts +1 -1
  139. package/src/client/canvas/use-focus-trap.ts +54 -0
  140. package/src/client/canvas/use-now.ts +17 -0
  141. package/src/client/canvas/use-pan-zoom.ts +13 -2
  142. package/src/client/icons.tsx +57 -18
  143. package/src/client/nodes/ExtAppFrame.tsx +86 -20
  144. package/src/client/nodes/GroupNode.tsx +9 -28
  145. package/src/client/nodes/InlineFormatBar.tsx +4 -3
  146. package/src/client/nodes/InlineMarkdownEditor.tsx +27 -0
  147. package/src/client/nodes/MarkdownNode.tsx +59 -24
  148. package/src/client/nodes/MdFormatBar.tsx +2 -4
  149. package/src/client/nodes/inline-editor-commands.ts +17 -6
  150. package/src/client/nodes/use-surface-frame.ts +3 -1
  151. package/src/client/state/attention-bridge.ts +3 -0
  152. package/src/client/state/canvas-store.ts +285 -90
  153. package/src/client/state/human-store.ts +175 -0
  154. package/src/client/state/iframe-mode.ts +33 -9
  155. package/src/client/state/intent-bridge.ts +75 -13
  156. package/src/client/state/node-factory.ts +2 -8
  157. package/src/client/state/presence-store.ts +168 -0
  158. package/src/client/state/session-store.ts +419 -0
  159. package/src/client/state/sse-bridge.ts +93 -30
  160. package/src/client/theme/global.css +3071 -796
  161. package/src/client/types.ts +3 -1
  162. package/src/client/utils/platform.ts +6 -1
  163. package/src/mcp/canvas-access.ts +2 -2
  164. package/src/server/agent-presence.ts +755 -0
  165. package/src/server/ax-context.ts +45 -0
  166. package/src/server/ax-gate-ttl.ts +61 -0
  167. package/src/server/ax-interaction.ts +29 -1
  168. package/src/server/ax-state-manager.ts +78 -6
  169. package/src/server/ax-state.ts +82 -15
  170. package/src/server/ax-wait.ts +33 -0
  171. package/src/server/canvas-db.ts +103 -33
  172. package/src/server/canvas-operations.ts +58 -14
  173. package/src/server/canvas-state.ts +131 -58
  174. package/src/server/canvas-validation.ts +3 -53
  175. package/src/server/demo-state.json +2 -70
  176. package/src/server/html-surface.ts +11 -2
  177. package/src/server/human-presence.ts +160 -0
  178. package/src/server/index.ts +111 -6
  179. package/src/server/intent-registry.ts +13 -0
  180. package/src/server/mutation-history.ts +47 -2
  181. package/src/server/operations/composites.ts +6 -3
  182. package/src/server/operations/http.ts +10 -1
  183. package/src/server/operations/index.ts +4 -0
  184. package/src/server/operations/invoker.ts +33 -6
  185. package/src/server/operations/ops/ax-presence.ts +62 -0
  186. package/src/server/operations/ops/ax-read.ts +8 -0
  187. package/src/server/operations/ops/ax-state.ts +106 -1
  188. package/src/server/operations/ops/ax-timeline.ts +54 -5
  189. package/src/server/operations/ops/ax-work.ts +46 -0
  190. package/src/server/operations/ops/batch.ts +3 -1
  191. package/src/server/operations/ops/edges.ts +88 -2
  192. package/src/server/operations/ops/groups.ts +4 -3
  193. package/src/server/operations/ops/human-presence.ts +35 -0
  194. package/src/server/operations/ops/json-render.ts +11 -1
  195. package/src/server/operations/ops/nodes.ts +77 -16
  196. package/src/server/operations/ops/query.ts +1 -0
  197. package/src/server/operations/ops/snapshots.ts +7 -4
  198. package/src/server/operations/registry.ts +196 -1
  199. package/src/server/operations/types.ts +1 -1
  200. package/src/server/scope-fence.ts +207 -0
  201. package/src/server/server.ts +177 -79
  202. package/src/server/trace-manager.ts +0 -2
  203. package/src/server/web-artifacts.ts +0 -1
  204. package/src/shared/agent-presence.ts +162 -0
  205. package/src/shared/approval-gates.ts +35 -0
  206. package/src/shared/auto-arrange.ts +121 -13
  207. package/src/shared/ax-kinds.ts +23 -0
  208. package/src/shared/human-presence.ts +30 -0
  209. package/src/shared/iframe-probe.ts +8 -0
  210. package/src/shared/scope-fence.ts +31 -0
  211. package/dist/types/client/canvas/DockedNode.d.ts +0 -4
  212. package/src/client/canvas/DockedNode.tsx +0 -165
@@ -34,20 +34,6 @@ export function IconFitAll(p: IconProps): JSX.Element {
34
34
  );
35
35
  }
36
36
 
37
- /** Framed crosshair — reset view 1:1 */
38
- export function IconResetView(p: IconProps): JSX.Element {
39
- return (
40
- <Icon {...p}>
41
- <rect x="2" y="2" width="12" height="12" rx="2" />
42
- <line x1="8" y1="4.5" x2="8" y2="6.5" />
43
- <line x1="8" y1="9.5" x2="8" y2="11.5" />
44
- <line x1="4.5" y1="8" x2="6.5" y2="8" />
45
- <line x1="9.5" y1="8" x2="11.5" y2="8" />
46
- <circle cx="8" cy="8" r="1" fill="currentColor" stroke="none" />
47
- </Icon>
48
- );
49
- }
50
-
51
37
  /** Magnifier with + */
52
38
  export function IconZoomIn(p: IconProps): JSX.Element {
53
39
  return (
@@ -214,12 +200,11 @@ export function IconShortcuts(p: IconProps): JSX.Element {
214
200
  );
215
201
  }
216
202
 
217
- /** Half-lit circle — theme toggle (generic). */
218
- export function IconTheme(p: IconProps): JSX.Element {
203
+ /** Speech bubble — steer the agent (command bar composer). */
204
+ export function IconSteer(p: IconProps): JSX.Element {
219
205
  return (
220
206
  <Icon {...p}>
221
- <circle cx="8" cy="8" r="6" />
222
- <path d="M8 2 A 6 6 0 0 1 8 14 Z" fill="currentColor" stroke="none" />
207
+ <path d="M2 13 L2 4 A2 2 0 0 1 4 2 L12 2 A2 2 0 0 1 14 4 L14 9 A2 2 0 0 1 12 11 L5 11 Z" />
223
208
  </Icon>
224
209
  );
225
210
  }
@@ -277,6 +262,60 @@ export function IconLogo({ size = 22, class: className }: IconProps): JSX.Elemen
277
262
  /* ── Node-type icons · Focus Field family ─────────────────── */
278
263
 
279
264
  /** Framed document with lines — markdown */
265
+ export function IconCursorTool({ size = 15 }: { size?: number }) {
266
+ return (
267
+ <svg
268
+ width={size}
269
+ height={size}
270
+ viewBox="0 0 16 16"
271
+ fill="none"
272
+ stroke="currentColor"
273
+ stroke-width="1.5"
274
+ stroke-linecap="round"
275
+ stroke-linejoin="round"
276
+ aria-hidden="true"
277
+ >
278
+ <path d="M3 2l10 5.5-4.2 1.3L7.5 13z" />
279
+ </svg>
280
+ );
281
+ }
282
+
283
+ export function IconHandTool({ size = 15 }: { size?: number }) {
284
+ return (
285
+ <svg
286
+ width={size}
287
+ height={size}
288
+ viewBox="0 0 16 16"
289
+ fill="none"
290
+ stroke="currentColor"
291
+ stroke-width="1.5"
292
+ stroke-linecap="round"
293
+ stroke-linejoin="round"
294
+ aria-hidden="true"
295
+ >
296
+ <path d="M5 7.5V3.8a1 1 0 0 1 2 0V7m0-3.9a1 1 0 0 1 2 0V7m0-2.7a1 1 0 0 1 2 0V7.4m0-.9a1 1 0 0 1 2 0v3.2A4.3 4.3 0 0 1 8.7 14H8a4.3 4.3 0 0 1-3.6-2L2.7 9.3a1 1 0 0 1 1.6-1.2L5 9.2" />
297
+ </svg>
298
+ );
299
+ }
300
+
301
+ export function IconNodeHtml({ size = 15 }: { size?: number }) {
302
+ return (
303
+ <svg
304
+ width={size}
305
+ height={size}
306
+ viewBox="0 0 16 16"
307
+ fill="none"
308
+ stroke="currentColor"
309
+ stroke-width="1.5"
310
+ stroke-linecap="round"
311
+ stroke-linejoin="round"
312
+ aria-hidden="true"
313
+ >
314
+ <path d="M6 4.5L2.5 8 6 11.5M10 4.5L13.5 8 10 11.5" />
315
+ </svg>
316
+ );
317
+ }
318
+
280
319
  export function IconNodeMarkdown(p: IconProps): JSX.Element {
281
320
  return (
282
321
  <Icon {...p}>
@@ -30,6 +30,8 @@ type McpUiTheme = 'light' | 'dark';
30
30
  type ExtAppBridgeNotifications = Pick<AppBridge, 'sendToolInput' | 'sendToolResult'>;
31
31
  type DisplayMode = 'inline' | 'fullscreen' | 'pip';
32
32
  type ExtAppFrameStatus = 'loading' | 'ready' | 'done';
33
+ /** Paint probe outcome: only a content-carrying tick counts as painted. */
34
+ export type PaintProbeVerdict = 'content-verified' | 'tick-no-content' | 'timeout';
33
35
 
34
36
  interface ExtAppHostDimensionsTarget {
35
37
  clientWidth?: number;
@@ -285,10 +287,28 @@ export function buildExtAppBootBeaconScript(frameToken: string, nodeId: string):
285
287
  // rendering pipeline is actually running — a frame whose layer WebKit never
286
288
  // composites stays silent, and that silence drives the parent's recovery
287
289
  // ladder. One unsolicited tick at boot, then on-demand answers to probes.
290
+ // Content oracle (Finding N, 0.5.0 reopen): a double rAF fires even while
291
+ // WKWebKit composites nothing — the tick alone was a false green. The tick
292
+ // now carries a verdict: does this document actually CONTAIN rendered
293
+ // content (a laid-out canvas/svg, or real text)? The parent only accepts
294
+ // paint-ok when it does.
295
+ function contentReady() {
296
+ try {
297
+ var els = document.querySelectorAll('canvas, svg');
298
+ for (var i = 0; i < els.length; i++) {
299
+ var r = els[i].getBoundingClientRect();
300
+ if (r.width > 8 && r.height > 8) return true;
301
+ }
302
+ var body = document.body;
303
+ return !!(body && body.innerText && body.innerText.trim().length > 0);
304
+ } catch (e) {
305
+ return false;
306
+ }
307
+ }
288
308
  function sendPaintTick() {
289
309
  requestAnimationFrame(function () {
290
310
  requestAnimationFrame(function () {
291
- window.parent.postMessage({ source: '${EXT_APP_BOOT_BEACON_SOURCE}', token: TOKEN, nodeId: NODE_ID, kind: 'paint-tick' }, '*');
311
+ window.parent.postMessage({ source: '${EXT_APP_BOOT_BEACON_SOURCE}', token: TOKEN, nodeId: NODE_ID, kind: 'paint-tick', content: contentReady() }, '*');
292
312
  });
293
313
  });
294
314
  }
@@ -367,8 +387,9 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
367
387
  const bridgeReadyRef = useRef(false);
368
388
  const themeUnsubRef = useRef<(() => void) | null>(null);
369
389
  const webkitRemountAttemptsRef = useRef(0);
370
- // Paint oracle (Finding N): resolvers waiting for the frame's next paint-tick.
371
- const paintTickWaitersRef = useRef<Array<() => void>>([]);
390
+ // Paint oracle (Finding N): resolvers waiting for the frame's next paint-tick,
391
+ // each handed the tick's content verdict.
392
+ const paintTickWaitersRef = useRef<Array<(content: boolean) => void>>([]);
372
393
  // Genuine boot signal: set ONLY when the app completes the ui/initialize
373
394
  // handshake (bridge.oninitialized) — NOT by the 1200ms bootstrap fallback,
374
395
  // which flips status via notifications that resolve even into a dead iframe.
@@ -450,7 +471,8 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
450
471
  return;
451
472
  appScriptsRanRef.current = true;
452
473
  if (data.kind === 'paint-tick') {
453
- for (const waiter of paintTickWaitersRef.current.splice(0)) waiter();
474
+ const content = (data as { content?: unknown }).content === true;
475
+ for (const waiter of paintTickWaitersRef.current.splice(0)) waiter(content);
454
476
  }
455
477
  }
456
478
  window.addEventListener('message', onBootBeacon);
@@ -503,7 +525,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
503
525
  enqueueWebkitRemount({
504
526
  remount: () => {
505
527
  remountQueuedRef.current = false;
506
- if (unmountedRef.current || expandedNodeId.value === nodeId) {
528
+ if (unmountedRef.current || (!expanded && expandedNodeId.value === nodeId)) {
507
529
  extAppRecoveryLog(nodeId, 'remount-skipped');
508
530
  // The ladder left paintPending set on the promise that this remount
509
531
  // would re-enter it via the new frame's settle path. A skip means
@@ -555,28 +577,42 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
555
577
  // again → serialized remount (bounded by the shared attempt cap) → explicit
556
578
  // Retry affordance. Never silent eternal black. WebKit inline frames only.
557
579
  const PAINT_TICK_TIMEOUT_MS = 1500;
558
- const probePaint = (): Promise<boolean> =>
580
+ const probePaint = (): Promise<PaintProbeVerdict> =>
559
581
  new Promise((resolve) => {
560
582
  const contentWindow = iframeRef.current?.contentWindow;
561
583
  if (!contentWindow || unmountedRef.current) {
562
- resolve(false);
584
+ resolve('timeout');
563
585
  return;
564
586
  }
565
587
  let settled = false;
566
588
  const timer = window.setTimeout(() => {
567
589
  if (settled) return;
568
590
  settled = true;
569
- resolve(false);
591
+ resolve('timeout');
570
592
  }, PAINT_TICK_TIMEOUT_MS);
571
- paintTickWaitersRef.current.push(() => {
593
+ paintTickWaitersRef.current.push((content) => {
572
594
  if (settled) return;
573
595
  settled = true;
574
596
  window.clearTimeout(timer);
575
- resolve(true);
597
+ // A tick without content is NOT paint-ok: the rAF pipeline runs while
598
+ // the document is empty cream / never composited (Finding N reopen).
599
+ resolve(content ? 'content-verified' : 'tick-no-content');
576
600
  });
577
601
  contentWindow.postMessage({ source: EXT_APP_PAINT_PROBE_SOURCE, token: axToken }, '*');
578
602
  });
579
603
 
604
+ // Bounded wait for the frame's content oracle to turn green — the mount
605
+ // queue's release gate (guidance: never green-light the NEXT app while the
606
+ // previous document is still contentless; D-then-E was the repro).
607
+ const awaitContentVerified = async (maxMs: number): Promise<boolean> => {
608
+ const deadline = Date.now() + maxMs;
609
+ while (Date.now() < deadline && !unmountedRef.current) {
610
+ if ((await probePaint()) === 'content-verified') return true;
611
+ await new Promise((resolve) => window.setTimeout(resolve, 400));
612
+ }
613
+ return false;
614
+ };
615
+
580
616
  // Force a real layer reallocation: grow the iframe's box meaningfully for two
581
617
  // frames, then restore. Invisible under the paint-pending overlay.
582
618
  const runSoftExpandCycle = (): Promise<void> =>
@@ -638,29 +674,44 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
638
674
  // 0.4.7). Say so in the trail instead of recording an unqualified success.
639
675
  const paintOkLabel = (suffix = ''): string =>
640
676
  typeof document !== 'undefined' && document.visibilityState === 'hidden'
641
- ? `paint-ok${suffix} (unverified: host hidden)`
642
- : `paint-ok${suffix}`;
677
+ ? `paint-ok (content-verified)${suffix} (host hidden)`
678
+ : `paint-ok (content-verified)${suffix}`;
643
679
 
644
680
  const runPaintLadder = async (): Promise<void> => {
645
- if (expanded) return;
681
+ // Runs for the EXPANDED instance too (Finding N reopen: expanded C was a
682
+ // black frame with no recovery — the ladder used to early-return here).
646
683
  if (typeof navigator === 'undefined' || typeof window === 'undefined') return;
647
684
  if (!isWebKitOnlyHost(navigator.userAgent)) return;
648
685
  setPaintPending(true);
649
- if (await probePaint()) {
686
+ let verdict = await probePaint();
687
+ if (verdict === 'content-verified') {
650
688
  extAppRecoveryLog(nodeId, paintOkLabel());
651
689
  setPaintPending(false);
652
690
  maybeAssumeVisibleRearm();
653
691
  return;
654
692
  }
655
- extAppRecoveryLog(nodeId, 'paint-fail');
693
+ extAppRecoveryLog(
694
+ nodeId,
695
+ verdict === 'tick-no-content' ? 'paint-fail (tick without content)' : 'paint-fail (no tick)',
696
+ );
656
697
  await runSoftExpandCycle();
657
- if (await probePaint()) {
698
+ verdict = await probePaint();
699
+ if (verdict === 'content-verified') {
658
700
  extAppRecoveryLog(nodeId, paintOkLabel(' (after soft-expand)'));
659
701
  setPaintPending(false);
660
702
  maybeAssumeVisibleRearm();
661
703
  return;
662
704
  }
663
705
  if (unmountedRef.current) return;
706
+ // A contentless-but-alive app may still be DRAWING its scene: give the
707
+ // oracle a bounded settle before burning a remount attempt on it.
708
+ if (verdict === 'tick-no-content' && (await awaitContentVerified(3_000))) {
709
+ extAppRecoveryLog(nodeId, paintOkLabel(' (after content wait)'));
710
+ setPaintPending(false);
711
+ maybeAssumeVisibleRearm();
712
+ return;
713
+ }
714
+ if (unmountedRef.current) return;
664
715
  // The remounted frame re-enters this ladder from its own settle path.
665
716
  if (!scheduleWebkitRemount('paint-fail')) {
666
717
  extAppRecoveryLog(nodeId, 'recovery-exhausted');
@@ -709,7 +760,8 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
709
760
  // serialized queue (bounded by the shared attempt cap).
710
761
  const WEBKIT_BOOT_WATCHDOG_MS = 6000;
711
762
  useEffect(() => {
712
- if (expanded) return;
763
+ // Covers the expanded instance too — its fresh iframe was black with no
764
+ // recovery at all (Finding N reopen, expanded C).
713
765
  if (typeof navigator === 'undefined' || typeof window === 'undefined') return;
714
766
  if (!isWebKitOnlyHost(navigator.userAgent)) return;
715
767
  if (!iframeDocument.ready || !mountGranted) return;
@@ -725,7 +777,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
725
777
  () => () => {
726
778
  unmountedRef.current = true;
727
779
  for (const waiter of bootWaitersRef.current.splice(0)) waiter();
728
- for (const waiter of paintTickWaitersRef.current.splice(0)) waiter();
780
+ for (const waiter of paintTickWaitersRef.current.splice(0)) waiter(false);
729
781
  },
730
782
  [],
731
783
  );
@@ -1035,8 +1087,22 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
1035
1087
  // its own settle pause covering the draw) only from this genuine path;
1036
1088
  // the bootstrap fallback never releases it (a dead iframe must run the
1037
1089
  // queue's bounded timeout instead of green-lighting the next remount).
1038
- extAppRecoveryLog(nodeId, 'settled');
1039
- for (const waiter of bootWaitersRef.current.splice(0)) waiter();
1090
+ // Release the mount queue only once this document's CONTENT
1091
+ // oracle is green (bounded): a settled-but-empty app must not
1092
+ // green-light the next mount slot (D-then-E repro, Finding N
1093
+ // reopen). WebKit only — elsewhere the oracle race doesn't exist.
1094
+ const releaseQueue = () => {
1095
+ for (const waiter of bootWaitersRef.current.splice(0)) waiter();
1096
+ };
1097
+ if (typeof navigator !== 'undefined' && isWebKitOnlyHost(navigator.userAgent)) {
1098
+ void awaitContentVerified(4_000).then((verified) => {
1099
+ extAppRecoveryLog(nodeId, verified ? 'settled (content-verified)' : 'settled (content-timeout)');
1100
+ releaseQueue();
1101
+ });
1102
+ } else {
1103
+ extAppRecoveryLog(nodeId, 'settled');
1104
+ releaseQueue();
1105
+ }
1040
1106
  nudgeHostContextAfterLayout();
1041
1107
  schedulePaintLadder();
1042
1108
  })
@@ -1,37 +1,18 @@
1
- import type { CanvasNodeState } from '../types';
2
1
  import { nodes } from '../state/canvas-store';
2
+ import type { CanvasNodeState } from '../types';
3
3
 
4
4
  interface GroupNodeProps {
5
5
  node: CanvasNodeState;
6
6
  }
7
7
 
8
+ /**
9
+ * Group frame body (rail-chrome-v2 groups v2): the children live in the world
10
+ * layer and the name/count/actions sit on the frame edge, so the body is just
11
+ * the wash — plus a hint while the group is empty.
12
+ */
8
13
  export function GroupNode({ node }: GroupNodeProps) {
9
14
  const childIds = (node.data.children as string[]) ?? [];
10
- const allNodes = nodes.value;
11
-
12
- // Count how many children actually exist
13
- const liveChildren = childIds.filter((id) => allNodes.has(id));
14
- const childCount = liveChildren.length;
15
-
16
- // Build a type summary for the collapsed view
17
- const typeCounts: Record<string, number> = {};
18
- for (const id of liveChildren) {
19
- const child = allNodes.get(id);
20
- if (child) typeCounts[child.type] = (typeCounts[child.type] ?? 0) + 1;
21
- }
22
- const typeSummary = Object.entries(typeCounts)
23
- .map(([type, count]) => `${count} ${type}`)
24
- .join(', ');
25
-
26
- return (
27
- <div class="group-node-body">
28
- <div class="group-summary">
29
- <span class="group-child-count">
30
- {childCount} node{childCount !== 1 ? 's' : ''}
31
- </span>
32
- {typeSummary && <span class="group-type-summary">{typeSummary}</span>}
33
- </div>
34
- {childCount === 0 && <div class="group-empty-hint">Drag nodes here or use the selection bar to group nodes</div>}
35
- </div>
36
- );
15
+ const liveChildren = childIds.filter((id) => nodes.value.has(id));
16
+ if (liveChildren.length > 0) return null;
17
+ return <div class="group-empty-hint">Drop nodes here — release while the frame lights up to add them</div>;
37
18
  }
@@ -1,5 +1,6 @@
1
1
  import type { RefObject } from 'preact';
2
2
  import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks';
3
+ import { modChord } from '../utils/platform';
3
4
  import { promptAndInsertLink, wrapSelectionInCode } from './inline-editor-commands';
4
5
 
5
6
  const GAP = 8;
@@ -15,8 +16,8 @@ type Action =
15
16
  | { kind: 'link'; icon: string; title: string; dividerBefore?: boolean };
16
17
 
17
18
  const ACTIONS: Action[] = [
18
- { kind: 'exec', command: 'bold', icon: 'B', title: 'Bold (⌘B)' },
19
- { kind: 'exec', command: 'italic', icon: 'I', title: 'Italic (⌘I)' },
19
+ { kind: 'exec', command: 'bold', icon: 'B', title: `Bold (${modChord('B')})` },
20
+ { kind: 'exec', command: 'italic', icon: 'I', title: `Italic (${modChord('I')})` },
20
21
  { kind: 'exec', command: 'strikeThrough', icon: 'S', title: 'Strikethrough' },
21
22
  { kind: 'code', icon: '{ }', title: 'Inline code', dividerBefore: true },
22
23
  { kind: 'block', tag: 'H1', icon: 'H1', title: 'Heading 1', dividerBefore: true },
@@ -26,7 +27,7 @@ const ACTIONS: Action[] = [
26
27
  { kind: 'block', tag: 'BLOCKQUOTE', icon: '❝', title: 'Quote', dividerBefore: true },
27
28
  { kind: 'exec', command: 'insertUnorderedList', icon: '•', title: 'Bullet list' },
28
29
  { kind: 'exec', command: 'insertOrderedList', icon: '1.', title: 'Numbered list' },
29
- { kind: 'link', icon: '🔗', title: 'Link (⌘K)', dividerBefore: true },
30
+ { kind: 'link', icon: '🔗', title: `Link (${modChord('K')})`, dividerBefore: true },
30
31
  ];
31
32
 
32
33
  function runAction(action: Action): void {
@@ -46,6 +46,11 @@ export function InlineMarkdownEditor({
46
46
  const el = rootRef.current;
47
47
  if (!el) return;
48
48
  el.innerHTML = initialHtmlRef.current;
49
+ // GFM task checkboxes are live in the editor too: marked emits them
50
+ // `disabled`, which also swallows clicks in the expanded view.
51
+ for (const box of el.querySelectorAll('input[type="checkbox"][disabled]')) {
52
+ box.removeAttribute('disabled');
53
+ }
49
54
  }, []);
50
55
 
51
56
  const serialize = useCallback((): string => {
@@ -59,6 +64,10 @@ export function InlineMarkdownEditor({
59
64
  }, [onChange, serialize]);
60
65
 
61
66
  const handleSave = useCallback(() => {
67
+ // A blur fired by unmounting (Esc closes the overlay while the editor is
68
+ // focused) reads back an empty tree — saving that would WIPE the document.
69
+ const el = rootRef.current;
70
+ if (!el || !el.isConnected || el.innerHTML === '') return;
62
71
  onSave?.(serialize());
63
72
  }, [onSave, serialize]);
64
73
 
@@ -91,6 +100,23 @@ export function InlineMarkdownEditor({
91
100
  [handleInput, handleSave],
92
101
  );
93
102
 
103
+ // A checkbox click inside contenteditable toggles the DOM property but
104
+ // fires no `input` event — serialize explicitly (turndown's GFM rule reads
105
+ // `node.checked`) so the tick persists like any other edit.
106
+ const handleClick = useCallback(
107
+ (e: MouseEvent) => {
108
+ const target = e.target as HTMLElement;
109
+ if (target instanceof HTMLInputElement && target.type === 'checkbox') {
110
+ // Serialization parses innerHTML — a string, which carries ATTRIBUTES
111
+ // only. Mirror the clicked property onto the attribute or the tick
112
+ // vanishes on the next round-trip.
113
+ target.toggleAttribute('checked', target.checked);
114
+ handleInput();
115
+ }
116
+ },
117
+ [handleInput],
118
+ );
119
+
94
120
  // Arbitrary rich HTML (Word, the web) would force turndown to sanitize a
95
121
  // far wider surface and tends to lose fidelity — plain text is safer.
96
122
  const handlePaste = useCallback(
@@ -114,6 +140,7 @@ export function InlineMarkdownEditor({
114
140
  onInput={handleInput}
115
141
  onKeyDown={handleKeyDown}
116
142
  onPaste={handlePaste}
143
+ onClick={handleClick}
117
144
  onBlur={handleSave}
118
145
  />
119
146
  <InlineFormatBar hostRef={rootRef} onChange={handleInput} />
@@ -11,12 +11,23 @@ function RenderedMarkdown({
11
11
  html,
12
12
  className,
13
13
  style,
14
+ onTaskToggle,
14
15
  }: {
15
16
  html: string;
16
17
  className?: string;
17
18
  style?: string | JSX.CSSProperties;
19
+ /** Enables the GFM task checkboxes and reports which one was clicked (document order). */
20
+ onTaskToggle?: (index: number) => void;
18
21
  }) {
19
22
  const containerRef = useRef<HTMLDivElement>(null);
23
+ // The handler lives in a ref so the DOM effect depends on `html` ALONE. With
24
+ // the inline prop in the deps, every unrelated re-render (bring-to-front on
25
+ // pointerdown, presence frames) replaced the children — and a HUMAN press is
26
+ // ~100ms, so the checkbox under the finger was swapped mid-press and the
27
+ // click never fired. Synthetic test clicks are fast enough to win the race,
28
+ // which is exactly why this shipped "verified".
29
+ const onTaskToggleRef = useRef(onTaskToggle);
30
+ onTaskToggleRef.current = onTaskToggle;
20
31
 
21
32
  useEffect(() => {
22
33
  const container = containerRef.current;
@@ -26,9 +37,48 @@ function RenderedMarkdown({
26
37
  const template = document.createElement('template');
27
38
  template.innerHTML = html;
28
39
  container.append(template.content.cloneNode(true));
40
+ if (onTaskToggleRef.current) {
41
+ for (const box of container.querySelectorAll('input[type="checkbox"][disabled]')) {
42
+ box.removeAttribute('disabled');
43
+ }
44
+ }
29
45
  }, [html]);
30
46
 
31
- return <div ref={containerRef} class={className} style={style} />;
47
+ const handleClick = onTaskToggle
48
+ ? (e: MouseEvent) => {
49
+ const onToggle = onTaskToggleRef.current;
50
+ const container = containerRef.current;
51
+ if (!container || !onToggle) return;
52
+ const target = e.target as HTMLElement;
53
+ // The box itself, or anywhere in the row's checkbox gutter — a 15px
54
+ // input alone is a miserable target at zoom and in embedded panes
55
+ // that forward clicks imprecisely.
56
+ let box: HTMLInputElement | null = null;
57
+ if (target instanceof HTMLInputElement && target.type === 'checkbox') {
58
+ box = target;
59
+ } else {
60
+ const row = target.closest('li');
61
+ const rowBox = row?.querySelector<HTMLInputElement>(':scope > input[type="checkbox"]');
62
+ if (rowBox && e.clientX <= rowBox.getBoundingClientRect().right + 6) box = rowBox;
63
+ }
64
+ if (!box) return;
65
+ e.stopPropagation();
66
+ const index = [...container.querySelectorAll('input[type="checkbox"]')].indexOf(box);
67
+ if (index >= 0) onToggle(index);
68
+ }
69
+ : undefined;
70
+
71
+ return <div ref={containerRef} class={className} style={style} onClick={handleClick} />;
72
+ }
73
+
74
+ /** Flip the index-th GFM task marker in the source (document order matches the rendered boxes). */
75
+ export function toggleTaskMarker(markdown: string, index: number): string {
76
+ let seen = -1;
77
+ return markdown.replace(/^(\s*(?:[-*+]|\d+[.)])\s+\[)([ xX])(\])/gm, (match, pre, state, post) => {
78
+ seen += 1;
79
+ if (seen !== index) return match;
80
+ return `${pre}${state === ' ' ? 'x' : ' '}${post}`;
81
+ });
32
82
  }
33
83
 
34
84
  export function MarkdownNode({ node, expanded = false }: { node: CanvasNodeState; expanded?: boolean }) {
@@ -317,34 +367,19 @@ export function MarkdownNode({ node, expanded = false }: { node: CanvasNodeState
317
367
  <RenderedMarkdown
318
368
  html={rendered}
319
369
  style={{ padding: rendered ? '0' : '12px', color: rendered ? undefined : 'var(--c-dim)' }}
370
+ // Task checkboxes are live on the card itself — no need to expand to tick one.
371
+ onTaskToggle={(index) => {
372
+ const next = toggleTaskMarker(content, index);
373
+ if (next !== content) handleInlineSave(next);
374
+ }}
320
375
  />
321
376
  {!loaded && <div style={{ color: 'var(--c-dim)', fontStyle: 'italic', padding: '12px' }}>Loading…</div>}
322
377
  {loaded && !rendered && (
323
378
  <div style={{ color: 'var(--c-dim)', fontStyle: 'italic', padding: '12px' }}>Empty node</div>
324
379
  )}
325
- <button
326
- type="button"
327
- onClick={() => expandNode(node.id)}
328
- style={{
329
- position: 'absolute',
330
- top: '4px',
331
- right: '4px',
332
- padding: '3px 8px',
333
- fontSize: '10px',
334
- background: 'var(--c-panel-overlay)',
335
- border: '1px solid var(--c-line)',
336
- borderRadius: '4px',
337
- color: 'var(--c-text-soft)',
338
- cursor: 'pointer',
339
- opacity: 0.7,
340
- }}
341
- onMouseEnter={(e) => {
342
- (e.target as HTMLElement).style.opacity = '1';
343
- }}
344
- onMouseLeave={(e) => {
345
- (e.target as HTMLElement).style.opacity = '0.7';
346
- }}
347
- >
380
+ {/* Hover-revealed, solid-backed chip — always-on it sat on the first
381
+ line of content (double-click edits inline; this is discovery). */}
382
+ <button type="button" class="md-edit-btn" onClick={() => expandNode(node.id)}>
348
383
  Edit
349
384
  </button>
350
385
  </div>
@@ -1,5 +1,5 @@
1
1
  import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
2
- import { isMac } from '../utils/platform';
2
+ import { modChord } from '../utils/platform';
3
3
  import { type FormatAction, FORMAT_ACTIONS, getSelectionRect } from './md-format';
4
4
 
5
5
  const PRIMARY_ACTIONS = FORMAT_ACTIONS.filter((a) => a.shortcut);
@@ -75,8 +75,6 @@ export function MdFormatBar({ textareaRef }: { textareaRef: { current: HTMLTextA
75
75
 
76
76
  if (!visible) return null;
77
77
 
78
- const modLabel = isMac ? '⌘' : 'Ctrl';
79
-
80
78
  return (
81
79
  <div
82
80
  ref={barRef}
@@ -89,7 +87,7 @@ export function MdFormatBar({ textareaRef }: { textareaRef: { current: HTMLTextA
89
87
  key={a.key}
90
88
  type="button"
91
89
  class={`md-format-btn md-format-btn-${a.key}`}
92
- title={`${a.label} (${modLabel}+${a.shortcut!.toUpperCase()})`}
90
+ title={`${a.label} (${modChord(a.shortcut!.toUpperCase())})`}
93
91
  onClick={() => runAction(a)}
94
92
  >
95
93
  {a.icon}
@@ -1,16 +1,27 @@
1
1
  /** Shared contentEditable command helpers used by both the inline editor
2
2
  * and its floating toolbar. Kept in a module that neither component
3
3
  * depends on transitively so we don't get a circular import. */
4
+ import { askText } from '../canvas/TextPrompt';
4
5
 
5
6
  /** Prompt for a URL and insert it as a link on the current selection.
6
7
  * Rejects `javascript:` and `data:` schemes so a link can't execute script
7
- * when clicked. */
8
+ * when clicked. Uses the in-canvas prompt (`window.prompt` is a silent no-op
9
+ * in embedded panes); the selection is saved before the dialog takes focus
10
+ * and restored before inserting. */
8
11
  export function promptAndInsertLink(): void {
9
- const url = window.prompt('Link URL:');
10
- if (!url) return;
11
- const trimmed = url.trim().toLowerCase();
12
- if (trimmed.startsWith('javascript:') || trimmed.startsWith('data:')) return;
13
- document.execCommand('createLink', false, url);
12
+ const selection = window.getSelection();
13
+ const saved = selection && selection.rangeCount > 0 ? selection.getRangeAt(0).cloneRange() : null;
14
+ void askText('Link URL', 'https://example.com').then((url) => {
15
+ if (!url) return;
16
+ const trimmed = url.trim().toLowerCase();
17
+ if (trimmed.startsWith('javascript:') || trimmed.startsWith('data:')) return;
18
+ if (saved) {
19
+ const restore = window.getSelection();
20
+ restore?.removeAllRanges();
21
+ restore?.addRange(saved);
22
+ }
23
+ document.execCommand('createLink', false, url);
24
+ });
14
25
  }
15
26
 
16
27
  /** Wrap the current non-empty selection in an inline `<code>` element and
@@ -27,7 +27,9 @@ export function useSurfaceFrame(url: string): { src?: string; srcdoc?: string }
27
27
  useEffect(() => {
28
28
  if (!wantsSrcdoc) return;
29
29
  let cancelled = false;
30
- void fetch(url, { credentials: 'same-origin' })
30
+ // Ask the server to inline bundle assets (the mermaid renderer): hosts
31
+ // that force srcdoc mode block sub-frame subresource requests too.
32
+ void fetch(`${url}${url.includes('?') ? '&' : '?'}inline-assets=1`, { credentials: 'same-origin' })
31
33
  .then((res) => (res.ok ? res.text() : Promise.reject(new Error(`HTTP ${res.status}`))))
32
34
  .then((html) => {
33
35
  if (!cancelled) setDoc({ url, html });
@@ -266,6 +266,9 @@ function flushToastQueue(): void {
266
266
 
267
267
  function enqueueToast(entry: AttentionEntry): void {
268
268
  toastQueue.push(entry);
269
+ // A burst of writes must not replay as a minute of stale toasts — keep the
270
+ // newest few; the history drawer still has every entry.
271
+ if (toastQueue.length > 3) toastQueue.splice(0, toastQueue.length - 3);
269
272
  flushToastQueue();
270
273
  }
271
274