pmx-canvas 0.4.8 → 0.5.0

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 (210) 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 +139 -0
  4. package/Readme.md +72 -23
  5. package/dist/canvas/global.css +3069 -796
  6. package/dist/canvas/index.js +104 -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 +25 -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/GroupNode.d.ts +6 -1
  38. package/dist/types/client/nodes/MarkdownNode.d.ts +2 -0
  39. package/dist/types/client/nodes/inline-editor-commands.d.ts +3 -4
  40. package/dist/types/client/state/canvas-store.d.ts +49 -5
  41. package/dist/types/client/state/human-store.d.ts +26 -0
  42. package/dist/types/client/state/iframe-mode.d.ts +1 -0
  43. package/dist/types/client/state/intent-bridge.d.ts +37 -5
  44. package/dist/types/client/state/node-factory.d.ts +0 -1
  45. package/dist/types/client/state/presence-store.d.ts +78 -0
  46. package/dist/types/client/state/session-store.d.ts +176 -0
  47. package/dist/types/client/types.d.ts +2 -1
  48. package/dist/types/client/utils/platform.d.ts +2 -0
  49. package/dist/types/server/agent-presence.d.ts +192 -0
  50. package/dist/types/server/ax-gate-ttl.d.ts +6 -0
  51. package/dist/types/server/ax-interaction.d.ts +4 -10
  52. package/dist/types/server/ax-state-manager.d.ts +21 -2
  53. package/dist/types/server/ax-state.d.ts +49 -3
  54. package/dist/types/server/ax-wait.d.ts +13 -0
  55. package/dist/types/server/canvas-db.d.ts +9 -1
  56. package/dist/types/server/canvas-operations.d.ts +1 -0
  57. package/dist/types/server/canvas-state.d.ts +27 -13
  58. package/dist/types/server/canvas-validation.d.ts +0 -14
  59. package/dist/types/server/html-surface.d.ts +1 -1
  60. package/dist/types/server/human-presence.d.ts +40 -0
  61. package/dist/types/server/index.d.ts +29 -3
  62. package/dist/types/server/intent-registry.d.ts +7 -0
  63. package/dist/types/server/mutation-history.d.ts +15 -2
  64. package/dist/types/server/operations/invoker.d.ts +13 -2
  65. package/dist/types/server/operations/ops/ax-presence.d.ts +2 -0
  66. package/dist/types/server/operations/ops/human-presence.d.ts +3 -0
  67. package/dist/types/server/operations/registry.d.ts +11 -0
  68. package/dist/types/server/operations/types.d.ts +1 -1
  69. package/dist/types/server/scope-fence.d.ts +62 -0
  70. package/dist/types/server/server.d.ts +0 -12
  71. package/dist/types/shared/agent-presence.d.ts +130 -0
  72. package/dist/types/shared/approval-gates.d.ts +19 -0
  73. package/dist/types/shared/auto-arrange.d.ts +0 -1
  74. package/dist/types/shared/ax-kinds.d.ts +11 -0
  75. package/dist/types/shared/human-presence.d.ts +31 -0
  76. package/dist/types/shared/iframe-probe.d.ts +8 -0
  77. package/dist/types/shared/scope-fence.d.ts +21 -0
  78. package/docs/RELEASE.md +6 -0
  79. package/docs/ax-host-adapter-contract.md +30 -14
  80. package/docs/cli.md +37 -1
  81. package/docs/environment.md +3 -0
  82. package/docs/http-api.md +222 -5
  83. package/docs/mcp.md +6 -5
  84. package/docs/node-types.md +3 -1
  85. package/docs/plans/plan-009-tech-debt-backlog.md +13 -0
  86. package/docs/screenshot.png +0 -0
  87. package/docs/screenshots/multi-agent-live.png +0 -0
  88. package/docs/screenshots/workbench-live-dark.png +0 -0
  89. package/docs/screenshots/workbench-live-light.png +0 -0
  90. package/docs/sdk.md +1 -1
  91. package/package.json +1 -1
  92. package/skills/pmx-canvas/SKILL.md +103 -16
  93. package/skills/pmx-canvas/references/codex-app-adapter.md +28 -0
  94. package/skills/pmx-canvas/references/full-reference.md +55 -16
  95. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +41 -24
  96. package/skills/pmx-canvas-orchestration/SKILL.md +56 -2
  97. package/skills/pmx-canvas-testing/SKILL.md +17 -0
  98. package/src/cli/agent.ts +1 -0
  99. package/src/cli/commands/copilot.ts +15 -8
  100. package/src/cli/commands/nodes.ts +1 -19
  101. package/src/cli/commands/pump.ts +252 -0
  102. package/src/cli/index.ts +1 -5
  103. package/src/cli/shared.ts +1 -1
  104. package/src/client/App.tsx +209 -585
  105. package/src/client/canvas/AgentPresenceLayer.tsx +74 -0
  106. package/src/client/canvas/AttentionHistory.tsx +0 -10
  107. package/src/client/canvas/BarHint.tsx +49 -0
  108. package/src/client/canvas/CanvasNode.tsx +488 -136
  109. package/src/client/canvas/CanvasViewport.tsx +190 -94
  110. package/src/client/canvas/CommandBar.tsx +140 -0
  111. package/src/client/canvas/CommandPalette.tsx +133 -58
  112. package/src/client/canvas/ConnectionBanner.tsx +65 -0
  113. package/src/client/canvas/ContextMenu.tsx +88 -27
  114. package/src/client/canvas/ContextPinBar.tsx +2 -2
  115. package/src/client/canvas/EdgeLayer.tsx +121 -38
  116. package/src/client/canvas/EmptyState.tsx +93 -0
  117. package/src/client/canvas/ExpandedNodeOverlay.tsx +64 -118
  118. package/src/client/canvas/ExternalWriters.tsx +308 -0
  119. package/src/client/canvas/FocusFieldLayer.tsx +12 -1
  120. package/src/client/canvas/HumanPresenceLayer.tsx +45 -0
  121. package/src/client/canvas/IntentLayer.tsx +1 -1
  122. package/src/client/canvas/Minimap.tsx +117 -190
  123. package/src/client/canvas/ScopeFenceLayer.tsx +38 -0
  124. package/src/client/canvas/SelectionBar.tsx +175 -23
  125. package/src/client/canvas/SessionPanel.tsx +481 -0
  126. package/src/client/canvas/SessionReceipt.tsx +141 -0
  127. package/src/client/canvas/ShortcutOverlay.tsx +21 -6
  128. package/src/client/canvas/SnapshotPanel.tsx +166 -80
  129. package/src/client/canvas/TextPrompt.tsx +128 -0
  130. package/src/client/canvas/ToolRail.tsx +447 -0
  131. package/src/client/canvas/TopBar.tsx +308 -0
  132. package/src/client/canvas/auto-fit.ts +1 -5
  133. package/src/client/canvas/canvas-area.ts +41 -0
  134. package/src/client/canvas/create-in-view.ts +48 -0
  135. package/src/client/canvas/import-files.ts +64 -0
  136. package/src/client/canvas/kind-colors.ts +22 -0
  137. package/src/client/canvas/snap-guides.ts +1 -1
  138. package/src/client/canvas/use-focus-trap.ts +54 -0
  139. package/src/client/canvas/use-now.ts +17 -0
  140. package/src/client/canvas/use-pan-zoom.ts +13 -2
  141. package/src/client/icons.tsx +57 -18
  142. package/src/client/nodes/GroupNode.tsx +9 -28
  143. package/src/client/nodes/InlineFormatBar.tsx +4 -3
  144. package/src/client/nodes/InlineMarkdownEditor.tsx +27 -0
  145. package/src/client/nodes/MarkdownNode.tsx +59 -24
  146. package/src/client/nodes/MdFormatBar.tsx +2 -4
  147. package/src/client/nodes/inline-editor-commands.ts +17 -6
  148. package/src/client/nodes/use-surface-frame.ts +3 -1
  149. package/src/client/state/attention-bridge.ts +3 -0
  150. package/src/client/state/canvas-store.ts +273 -84
  151. package/src/client/state/human-store.ts +175 -0
  152. package/src/client/state/iframe-mode.ts +33 -9
  153. package/src/client/state/intent-bridge.ts +75 -13
  154. package/src/client/state/node-factory.ts +2 -8
  155. package/src/client/state/presence-store.ts +168 -0
  156. package/src/client/state/session-store.ts +419 -0
  157. package/src/client/state/sse-bridge.ts +93 -30
  158. package/src/client/theme/global.css +3069 -796
  159. package/src/client/types.ts +3 -1
  160. package/src/client/utils/platform.ts +6 -1
  161. package/src/mcp/canvas-access.ts +2 -2
  162. package/src/server/agent-presence.ts +755 -0
  163. package/src/server/ax-context.ts +45 -0
  164. package/src/server/ax-gate-ttl.ts +61 -0
  165. package/src/server/ax-interaction.ts +29 -1
  166. package/src/server/ax-state-manager.ts +78 -6
  167. package/src/server/ax-state.ts +82 -15
  168. package/src/server/ax-wait.ts +33 -0
  169. package/src/server/canvas-db.ts +103 -33
  170. package/src/server/canvas-operations.ts +17 -8
  171. package/src/server/canvas-state.ts +131 -58
  172. package/src/server/canvas-validation.ts +3 -53
  173. package/src/server/demo-state.json +2 -70
  174. package/src/server/html-surface.ts +11 -2
  175. package/src/server/human-presence.ts +160 -0
  176. package/src/server/index.ts +107 -4
  177. package/src/server/intent-registry.ts +13 -0
  178. package/src/server/mutation-history.ts +47 -2
  179. package/src/server/operations/composites.ts +6 -3
  180. package/src/server/operations/http.ts +10 -1
  181. package/src/server/operations/index.ts +4 -0
  182. package/src/server/operations/invoker.ts +33 -6
  183. package/src/server/operations/ops/ax-presence.ts +62 -0
  184. package/src/server/operations/ops/ax-read.ts +8 -0
  185. package/src/server/operations/ops/ax-state.ts +106 -1
  186. package/src/server/operations/ops/ax-timeline.ts +54 -5
  187. package/src/server/operations/ops/ax-work.ts +46 -0
  188. package/src/server/operations/ops/batch.ts +3 -1
  189. package/src/server/operations/ops/edges.ts +88 -2
  190. package/src/server/operations/ops/groups.ts +4 -3
  191. package/src/server/operations/ops/human-presence.ts +35 -0
  192. package/src/server/operations/ops/json-render.ts +11 -1
  193. package/src/server/operations/ops/nodes.ts +65 -16
  194. package/src/server/operations/ops/query.ts +1 -0
  195. package/src/server/operations/ops/snapshots.ts +16 -0
  196. package/src/server/operations/registry.ts +196 -1
  197. package/src/server/operations/types.ts +1 -1
  198. package/src/server/scope-fence.ts +207 -0
  199. package/src/server/server.ts +177 -79
  200. package/src/server/trace-manager.ts +0 -2
  201. package/src/server/web-artifacts.ts +0 -1
  202. package/src/shared/agent-presence.ts +162 -0
  203. package/src/shared/approval-gates.ts +35 -0
  204. package/src/shared/auto-arrange.ts +121 -13
  205. package/src/shared/ax-kinds.ts +23 -0
  206. package/src/shared/human-presence.ts +30 -0
  207. package/src/shared/iframe-probe.ts +8 -0
  208. package/src/shared/scope-fence.ts +31 -0
  209. package/dist/types/client/canvas/DockedNode.d.ts +0 -4
  210. 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}>
@@ -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