pmx-canvas 0.7.2 → 0.8.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 (251) hide show
  1. package/.github/extensions/pmx-canvas/THIRD_PARTY_LICENSES.txt +331 -0
  2. package/.github/extensions/pmx-canvas/extension.mjs +25 -172
  3. package/.github/extensions/pmx-canvas/mcp-bootstrap.ts +198 -0
  4. package/.github/extensions/pmx-canvas/runtime.d.mts +41 -0
  5. package/.github/extensions/pmx-canvas/runtime.json +3 -0
  6. package/.github/extensions/pmx-canvas/runtime.mjs +194 -0
  7. package/.github/extensions/pmx-canvas/start-mcp.mjs +51 -0
  8. package/.github/extensions/pmx-canvas/tools.json +2560 -0
  9. package/CHANGELOG.md +60 -0
  10. package/Readme.md +44 -16
  11. package/dist/canvas/fonts/OFL.txt +93 -0
  12. package/dist/canvas/fonts/ibm-plex-mono-latin-400-normal.woff2 +0 -0
  13. package/dist/canvas/fonts/ibm-plex-mono-latin-500-normal.woff2 +0 -0
  14. package/dist/canvas/fonts/ibm-plex-mono-latin-ext-400-normal.woff2 +0 -0
  15. package/dist/canvas/fonts/ibm-plex-mono-latin-ext-500-normal.woff2 +0 -0
  16. package/dist/canvas/fonts/ibm-plex-sans-latin-400-normal.woff2 +0 -0
  17. package/dist/canvas/fonts/ibm-plex-sans-latin-500-normal.woff2 +0 -0
  18. package/dist/canvas/fonts/ibm-plex-sans-latin-600-normal.woff2 +0 -0
  19. package/dist/canvas/fonts/ibm-plex-sans-latin-ext-400-normal.woff2 +0 -0
  20. package/dist/canvas/fonts/ibm-plex-sans-latin-ext-500-normal.woff2 +0 -0
  21. package/dist/canvas/fonts/ibm-plex-sans-latin-ext-600-normal.woff2 +0 -0
  22. package/dist/canvas/global.css +844 -710
  23. package/dist/canvas/index.js +92 -90
  24. package/dist/canvas/mcp-app.html +7878 -0
  25. package/dist/canvas/mermaid-entry.js +1 -1
  26. package/dist/canvas/surface-theme.css +151 -46
  27. package/dist/json-render/index.css +1 -1
  28. package/dist/json-render/index.js +55 -55
  29. package/dist/types/cli/commands/record.d.ts +1 -1
  30. package/dist/types/client/canvas/ChoiceMenu.d.ts +13 -0
  31. package/dist/types/client/canvas/NodeContextMark.d.ts +24 -0
  32. package/dist/types/client/canvas/SessionReceipt.d.ts +3 -1
  33. package/dist/types/client/icons.d.ts +1 -2
  34. package/dist/types/client/mcp-app/index.d.ts +1 -0
  35. package/dist/types/client/nodes/McpAppNode.d.ts +2 -1
  36. package/dist/types/client/nodes/ax-node-actions.d.ts +2 -2
  37. package/dist/types/client/nodes/markdown-sanitize.d.ts +2 -0
  38. package/dist/types/client/nodes/use-workbench-asset.d.ts +2 -0
  39. package/dist/types/client/nodes/viewer-runtime-state.d.ts +1 -0
  40. package/dist/types/client/state/attention-store.d.ts +0 -5
  41. package/dist/types/client/state/canvas-store.d.ts +2 -0
  42. package/dist/types/client/state/context-status-store.d.ts +53 -0
  43. package/dist/types/client/state/intent-bridge.d.ts +4 -1
  44. package/dist/types/client/state/near-pin-store.d.ts +16 -0
  45. package/dist/types/client/state/presence-store.d.ts +5 -0
  46. package/dist/types/client/state/session-store.d.ts +33 -3
  47. package/dist/types/client/state/workbench-transport.d.ts +16 -0
  48. package/dist/types/client/theme/tokens.d.ts +1 -3
  49. package/dist/types/client/types.d.ts +9 -0
  50. package/dist/types/json-render/renderer/index.d.ts +2 -1
  51. package/dist/types/json-render/server.d.ts +3 -1
  52. package/dist/types/mcp/app-server.d.ts +3 -0
  53. package/dist/types/mcp/canvas-access.d.ts +1 -1
  54. package/dist/types/mcp/canvas-app.d.ts +4 -0
  55. package/dist/types/mcp/workbench-app.d.ts +4 -0
  56. package/dist/types/server/agent-presence.d.ts +1 -1
  57. package/dist/types/server/ax-state.d.ts +1 -1
  58. package/dist/types/server/canvas-db.d.ts +8 -0
  59. package/dist/types/server/canvas-geometry.d.ts +25 -0
  60. package/dist/types/server/canvas-operations.d.ts +1 -11
  61. package/dist/types/server/canvas-state.d.ts +25 -3
  62. package/dist/types/server/context-brief.d.ts +8 -1
  63. package/dist/types/server/context-reads.d.ts +30 -7
  64. package/dist/types/server/index.d.ts +6 -1
  65. package/dist/types/server/mcp-app-runtime.d.ts +1 -0
  66. package/dist/types/server/operations/http.d.ts +2 -0
  67. package/dist/types/server/operations/invoker.d.ts +4 -2
  68. package/dist/types/server/operations/ops/nodes.d.ts +0 -6
  69. package/dist/types/server/server.d.ts +1 -0
  70. package/dist/types/server/spatial-analysis.d.ts +2 -0
  71. package/dist/types/shared/agent-context.d.ts +22 -0
  72. package/dist/types/shared/agent-presence.d.ts +3 -10
  73. package/dist/types/shared/ax-surface-protocol.d.ts +2 -0
  74. package/dist/types/shared/canvas-app.d.ts +54 -0
  75. package/dist/types/shared/themes.d.ts +1 -1
  76. package/docs/RELEASE.md +7 -3
  77. package/docs/agent-plugin.md +123 -0
  78. package/docs/ax-host-adapter-contract.md +13 -2
  79. package/docs/ax-state-contract.md +40 -0
  80. package/docs/cli.md +7 -2
  81. package/docs/design/AgentContext.dc.html +186 -0
  82. package/docs/design/Before.dc.html +208 -0
  83. package/docs/design/BoardPins.dc.html +151 -0
  84. package/docs/design/Chrome.dc.html +208 -0
  85. package/docs/design/Context.dc.html +239 -0
  86. package/docs/design/Flows.dc.html +181 -0
  87. package/docs/design/Home.dc.html +209 -0
  88. package/docs/design/HomeGraph.dc.html +211 -0
  89. package/docs/design/HomeMap.dc.html +117 -0
  90. package/docs/design/HomeTeam.dc.html +176 -0
  91. package/docs/design/Light.dc.html +28 -0
  92. package/docs/design/Main.dc.html +280 -0
  93. package/docs/design/NearPin.dc.html +260 -0
  94. package/docs/design/Pane600.dc.html +120 -0
  95. package/docs/design/Relations.dc.html +297 -0
  96. package/docs/design/Themes.dc.html +121 -0
  97. package/docs/design/TokenSystem.dc.html +136 -0
  98. package/docs/design/Tokens.dc.html +163 -0
  99. package/docs/design/Types1.dc.html +177 -0
  100. package/docs/design/Types2.dc.html +226 -0
  101. package/docs/design/Types3.dc.html +195 -0
  102. package/docs/design/Uses.dc.html +152 -0
  103. package/docs/design/Vision.dc.html +116 -0
  104. package/docs/design/Zoom.dc.html +82 -0
  105. package/docs/design/canvas.json +274 -0
  106. package/docs/design.md +214 -0
  107. package/docs/environment.md +1 -1
  108. package/docs/http-api.md +80 -15
  109. package/docs/mcp.md +198 -3
  110. package/docs/node-types.md +34 -10
  111. package/docs/plans/plan-009-tech-debt-backlog.md +1 -1
  112. package/docs/plans/plan-015-design-foundation-wave-0.md +177 -0
  113. package/docs/plans/plan-016-context-made-visible-wave-1.md +148 -0
  114. package/docs/product-vision-2026-09.md +47 -7
  115. package/docs/screenshot.png +0 -0
  116. package/docs/sdk.md +1 -0
  117. package/package.json +10 -4
  118. package/plugins/pmx-canvas/LICENSE +21 -0
  119. package/plugins/pmx-canvas/THIRD_PARTY_LICENSES.txt +331 -0
  120. package/plugins/pmx-canvas/assets/preview.png +0 -0
  121. package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/extension.mjs +930 -0
  122. package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/runtime.json +3 -0
  123. package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/runtime.mjs +194 -0
  124. package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/steering-delivery.mjs +79 -0
  125. package/plugins/pmx-canvas/mcp.json +12 -0
  126. package/plugins/pmx-canvas/plugin.json +24 -0
  127. package/plugins/pmx-canvas/runtime.json +3 -0
  128. package/plugins/pmx-canvas/runtime.mjs +194 -0
  129. package/plugins/pmx-canvas/start-mcp.mjs +51 -0
  130. package/plugins/pmx-canvas/tools.json +2560 -0
  131. package/skills/pmx-canvas/SKILL.md +31 -16
  132. package/skills/pmx-canvas/evals/evals.json +69 -0
  133. package/skills/pmx-canvas/references/full-reference.md +14 -10
  134. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +20 -8
  135. package/skills/pmx-canvas/references/installing-pmx-canvas.md +76 -4
  136. package/skills/pmx-canvas-testing/SKILL.md +54 -4
  137. package/src/cli/commands/copilot.ts +5 -0
  138. package/src/cli/commands/view.ts +1 -1
  139. package/src/cli/index.ts +8 -3
  140. package/src/client/App.tsx +6 -1
  141. package/src/client/canvas/AgentPresenceLayer.tsx +3 -3
  142. package/src/client/canvas/AnnotationLayer.tsx +1 -1
  143. package/src/client/canvas/CanvasNode.tsx +208 -220
  144. package/src/client/canvas/CanvasViewport.tsx +6 -2
  145. package/src/client/canvas/ChoiceMenu.tsx +122 -0
  146. package/src/client/canvas/CommandBar.tsx +25 -23
  147. package/src/client/canvas/CommandPalette.tsx +3 -2
  148. package/src/client/canvas/ContextMenu.tsx +11 -0
  149. package/src/client/canvas/EdgeLayer.tsx +1 -1
  150. package/src/client/canvas/ExportDialog.tsx +33 -6
  151. package/src/client/canvas/FeedbackDialog.tsx +10 -8
  152. package/src/client/canvas/IntentLayer.tsx +2 -2
  153. package/src/client/canvas/Minimap.tsx +12 -7
  154. package/src/client/canvas/NodeContextMark.tsx +156 -0
  155. package/src/client/canvas/SessionPanel.tsx +4 -7
  156. package/src/client/canvas/SessionReceipt.tsx +114 -17
  157. package/src/client/canvas/ShortcutOverlay.tsx +6 -1
  158. package/src/client/canvas/SnapshotPanel.tsx +11 -2
  159. package/src/client/canvas/ToolRail.tsx +58 -108
  160. package/src/client/canvas/TopBar.tsx +16 -11
  161. package/src/client/icons.tsx +2 -12
  162. package/src/client/mcp-app/index.tsx +137 -0
  163. package/src/client/mcp-app/style.css +10 -0
  164. package/src/client/nodes/AttachmentNode.tsx +8 -1
  165. package/src/client/nodes/AxStepControls.tsx +1 -1
  166. package/src/client/nodes/ContextNode.tsx +9 -9
  167. package/src/client/nodes/ExtAppFrame.tsx +13 -8
  168. package/src/client/nodes/FileNode.tsx +8 -5
  169. package/src/client/nodes/ImageNode.tsx +4 -2
  170. package/src/client/nodes/InlineMarkdownEditor.tsx +2 -1
  171. package/src/client/nodes/LedgerNode.tsx +2 -2
  172. package/src/client/nodes/MarkdownNode.tsx +44 -9
  173. package/src/client/nodes/McpAppNode.tsx +22 -4
  174. package/src/client/nodes/PromptNode.tsx +5 -17
  175. package/src/client/nodes/ResponseNode.tsx +3 -15
  176. package/src/client/nodes/StatusNode.tsx +2 -2
  177. package/src/client/nodes/StatusSummary.tsx +3 -1
  178. package/src/client/nodes/TraceNode.tsx +6 -4
  179. package/src/client/nodes/WebpageNode.tsx +1 -1
  180. package/src/client/nodes/ax-node-actions.ts +2 -2
  181. package/src/client/nodes/iframe-document-url.ts +3 -2
  182. package/src/client/nodes/markdown-sanitize.ts +10 -0
  183. package/src/client/nodes/surface-url.ts +10 -0
  184. package/src/client/nodes/use-ax-surface-bridge.ts +4 -2
  185. package/src/client/nodes/use-surface-frame.ts +3 -1
  186. package/src/client/nodes/use-workbench-asset.ts +26 -0
  187. package/src/client/nodes/viewer-runtime-state.ts +78 -0
  188. package/src/client/state/attention-bridge.ts +0 -8
  189. package/src/client/state/attention-store.ts +0 -17
  190. package/src/client/state/canvas-store.ts +7 -2
  191. package/src/client/state/context-status-store.ts +66 -0
  192. package/src/client/state/human-store.ts +2 -0
  193. package/src/client/state/iframe-mode.ts +5 -0
  194. package/src/client/state/intent-bridge.ts +24 -6
  195. package/src/client/state/near-pin-store.ts +53 -0
  196. package/src/client/state/presence-store.ts +5 -10
  197. package/src/client/state/session-store.ts +106 -9
  198. package/src/client/state/sse-bridge.ts +26 -7
  199. package/src/client/state/theme-override.ts +1 -1
  200. package/src/client/state/workbench-transport.ts +36 -0
  201. package/src/client/theme/global.css +844 -710
  202. package/src/client/theme/surface-theme.css +151 -46
  203. package/src/client/theme/tokens.ts +7 -11
  204. package/src/client/types.ts +10 -0
  205. package/src/json-render/renderer/index.tsx +58 -38
  206. package/src/json-render/server.ts +79 -15
  207. package/src/mcp/app-server.ts +64 -0
  208. package/src/mcp/canvas-access.ts +14 -11
  209. package/src/mcp/canvas-app.ts +318 -0
  210. package/src/mcp/server.ts +1 -1
  211. package/src/mcp/workbench-app.ts +211 -0
  212. package/src/server/agent-presence.ts +45 -6
  213. package/src/server/ax-context.ts +1 -1
  214. package/src/server/ax-state.ts +1 -1
  215. package/src/server/board-export.ts +5 -2
  216. package/src/server/canvas-db.ts +46 -12
  217. package/src/server/canvas-geometry.ts +64 -0
  218. package/src/server/canvas-operations.ts +6 -24
  219. package/src/server/canvas-state.ts +105 -15
  220. package/src/server/canvas-validation.ts +19 -4
  221. package/src/server/context-brief.ts +39 -2
  222. package/src/server/context-reads.ts +86 -15
  223. package/src/server/demo-state.json +31 -31
  224. package/src/server/html-surface.ts +4 -5
  225. package/src/server/index.ts +19 -3
  226. package/src/server/mcp-app-runtime.ts +11 -0
  227. package/src/server/operations/composites.ts +4 -3
  228. package/src/server/operations/http.ts +4 -4
  229. package/src/server/operations/invoker.ts +13 -4
  230. package/src/server/operations/ops/ax-read.ts +1 -1
  231. package/src/server/operations/ops/ax-timeline.ts +37 -0
  232. package/src/server/operations/ops/boards.ts +1 -1
  233. package/src/server/operations/ops/canvas-wire.ts +3 -6
  234. package/src/server/operations/ops/intent.ts +31 -2
  235. package/src/server/operations/ops/json-render.ts +1 -1
  236. package/src/server/operations/ops/nodes.ts +8 -76
  237. package/src/server/operations/ops/query.ts +8 -2
  238. package/src/server/operations/ops/snapshots.ts +4 -1
  239. package/src/server/operations/ops/viewport.ts +29 -5
  240. package/src/server/operations/registry.ts +38 -4
  241. package/src/server/scope-fence.ts +55 -10
  242. package/src/server/server.ts +86 -6
  243. package/src/server/spatial-analysis.ts +2 -2
  244. package/src/server/workboard.ts +6 -1
  245. package/src/{server → shared}/agent-context.ts +2 -2
  246. package/src/shared/agent-presence.ts +3 -15
  247. package/src/shared/ax-surface-protocol.ts +3 -0
  248. package/src/shared/canvas-app.ts +62 -0
  249. package/src/shared/themes.ts +5 -5
  250. package/src/client/canvas/FocusFieldLayer.tsx +0 -116
  251. package/src/client/canvas/kind-colors.ts +0 -23
@@ -3,6 +3,7 @@ import { IconClose } from '../icons';
3
3
  import { updateNodeData } from '../state/canvas-store';
4
4
  import { fetchSlashCommands, renderMarkdown, submitCanvasPrompt, submitThreadReply } from '../state/intent-bridge';
5
5
  import type { CanvasNodeState } from '../types';
6
+ import { sanitizeMarkdownHtml } from './markdown-sanitize';
6
7
 
7
8
  // Cached slash commands — fetched once on first use.
8
9
  let cachedCommands: Array<{ name: string; description: string }> | null = null;
@@ -27,19 +28,6 @@ interface ThreadTurn {
27
28
  status?: string;
28
29
  }
29
30
 
30
- /** Strip dangerous HTML from rendered markdown to prevent XSS. */
31
- function sanitizeHtml(html: string): string {
32
- return html
33
- .replace(/<script[\s>][\s\S]*?<\/script>/gi, '')
34
- .replace(/<iframe[\s>][\s\S]*?<\/iframe>/gi, '')
35
- .replace(/<object[\s>][\s\S]*?<\/object>/gi, '')
36
- .replace(/<embed[\s>][\s\S]*?(?:\/>|<\/embed>)/gi, '')
37
- .replace(/<link[\s>][\s\S]*?(?:\/>|<\/link>)/gi, '')
38
- .replace(/\bon\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, '')
39
- .replace(/href\s*=\s*"javascript:[^"]*"/gi, 'href="#"')
40
- .replace(/href\s*=\s*'javascript:[^']*'/gi, "href='#'");
41
- }
42
-
43
31
  function RenderedMarkdown({ html }: { html: string }) {
44
32
  const containerRef = useRef<HTMLDivElement>(null);
45
33
 
@@ -50,7 +38,7 @@ function RenderedMarkdown({ html }: { html: string }) {
50
38
  if (!html) return;
51
39
 
52
40
  const template = document.createElement('template');
53
- template.innerHTML = html;
41
+ template.innerHTML = sanitizeMarkdownHtml(html);
54
42
  container.append(template.content.cloneNode(true));
55
43
  }, [html]);
56
44
 
@@ -181,7 +169,7 @@ export function PromptNode({ node, expanded = false }: { node: CanvasNodeState;
181
169
  Promise.all(
182
170
  assistantTurns.map(async ({ turn, index }) => {
183
171
  const html = await renderMarkdown(turn.text);
184
- return { index, html: sanitizeHtml(html) };
172
+ return { index, html: sanitizeMarkdownHtml(html) };
185
173
  }),
186
174
  ).then((results) => {
187
175
  if (cancelled) return;
@@ -328,7 +316,7 @@ export function PromptNode({ node, expanded = false }: { node: CanvasNodeState;
328
316
  border: '1px solid var(--c-line)',
329
317
  borderRadius: 'var(--radius-sm)',
330
318
  color: 'var(--c-text)',
331
- fontFamily: 'var(--font)',
319
+ fontFamily: 'var(--font-ui)',
332
320
  fontSize: '13px',
333
321
  lineHeight: '1.5',
334
322
  padding: '8px 10px',
@@ -529,7 +517,7 @@ export function PromptNode({ node, expanded = false }: { node: CanvasNodeState;
529
517
  border: '1px solid var(--c-line)',
530
518
  borderRadius: 'var(--radius-sm)',
531
519
  color: 'var(--c-text)',
532
- fontFamily: 'var(--font)',
520
+ fontFamily: 'var(--font-ui)',
533
521
  fontSize: '12px',
534
522
  lineHeight: '1.5',
535
523
  padding: '6px 8px',
@@ -1,19 +1,7 @@
1
1
  import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
2
2
  import { renderMarkdown, submitCanvasPrompt } from '../state/intent-bridge';
3
3
  import type { CanvasNodeState } from '../types';
4
-
5
- /** Strip dangerous HTML from rendered markdown to prevent XSS. */
6
- function sanitizeHtml(html: string): string {
7
- return html
8
- .replace(/<script[\s>][\s\S]*?<\/script>/gi, '')
9
- .replace(/<iframe[\s>][\s\S]*?<\/iframe>/gi, '')
10
- .replace(/<object[\s>][\s\S]*?<\/object>/gi, '')
11
- .replace(/<embed[\s>][\s\S]*?(?:\/>|<\/embed>)/gi, '')
12
- .replace(/<link[\s>][\s\S]*?(?:\/>|<\/link>)/gi, '')
13
- .replace(/\bon\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, '')
14
- .replace(/href\s*=\s*"javascript:[^"]*"/gi, 'href="#"')
15
- .replace(/href\s*=\s*'javascript:[^']*'/gi, "href='#'");
16
- }
4
+ import { sanitizeMarkdownHtml } from './markdown-sanitize';
17
5
 
18
6
  function RenderedMarkdown({ html }: { html: string }) {
19
7
  const containerRef = useRef<HTMLDivElement>(null);
@@ -25,7 +13,7 @@ function RenderedMarkdown({ html }: { html: string }) {
25
13
  if (!html) return;
26
14
 
27
15
  const template = document.createElement('template');
28
- template.innerHTML = html;
16
+ template.innerHTML = sanitizeMarkdownHtml(html);
29
17
  container.append(template.content.cloneNode(true));
30
18
  }, [html]);
31
19
 
@@ -48,7 +36,7 @@ export function ResponseNode({ node, expanded = false }: { node: CanvasNodeState
48
36
  }
49
37
  let cancelled = false;
50
38
  renderMarkdown(content).then((html) => {
51
- if (!cancelled) setRendered(sanitizeHtml(html));
39
+ if (!cancelled) setRendered(sanitizeMarkdownHtml(html));
52
40
  });
53
41
  return () => {
54
42
  cancelled = true;
@@ -64,9 +64,9 @@ export function StatusNode({ node }: { node: CanvasNodeState }) {
64
64
 
65
65
  {/* Active tool */}
66
66
  {activeTool && (
67
- <div style={{ display: 'flex', alignItems: 'center', gap: '6px', color: 'var(--c-warn)' }}>
67
+ <div style={{ display: 'flex', alignItems: 'center', gap: '6px', color: 'var(--c-agent)' }}>
68
68
  <span style={{ fontSize: '10px' }}>⚙</span>
69
- <span style={{ fontFamily: 'var(--mono)', fontSize: '11px' }}>{activeTool}</span>
69
+ <span style={{ fontFamily: 'var(--font-code)', fontSize: '11px' }}>{activeTool}</span>
70
70
  </div>
71
71
  )}
72
72
 
@@ -23,7 +23,9 @@ export function StatusSummary({ node }: { node: CanvasNodeState }) {
23
23
  />
24
24
  <span style={{ color: phaseColor, fontSize: '10px', textTransform: 'uppercase', fontWeight: 600 }}>{phase}</span>
25
25
  {activeTool && (
26
- <span style={{ color: 'var(--c-warn)', fontSize: '10px', fontFamily: 'var(--mono)' }}>⚙ {activeTool}</span>
26
+ <span style={{ color: 'var(--c-agent)', fontSize: '10px', fontFamily: 'var(--font-code)' }}>
27
+ ⚙ {activeTool}
28
+ </span>
27
29
  )}
28
30
  {subagent && subagent.state !== 'completed' && (
29
31
  <span style={{ color: 'var(--c-subagent)', fontSize: '10px' }}>⠉ {subagent.name}</span>
@@ -1,10 +1,12 @@
1
1
  import type { CanvasNodeState } from '../types';
2
2
  import { buildTraceDisplayModel } from './trace-model';
3
3
 
4
+ // Categories carry no colour (docs/design.md, "Kinds carry no colour"); only a
5
+ // subagent's work takes its meaning colour.
4
6
  const CATEGORY_COLORS: Record<string, string> = {
5
- mcp: 'var(--c-accent)',
6
- file: 'var(--c-warn)',
7
- subagent: 'var(--c-purple)',
7
+ mcp: 'var(--c-muted)',
8
+ file: 'var(--c-muted)',
9
+ subagent: 'var(--c-subagent)',
8
10
  other: 'var(--c-muted)',
9
11
  };
10
12
 
@@ -68,7 +70,7 @@ export function TraceNode({ node }: { node: CanvasNodeState }) {
68
70
  >
69
71
  <div
70
72
  style={{
71
- fontFamily: 'var(--mono)',
73
+ fontFamily: 'var(--font-code)',
72
74
  fontSize: '11px',
73
75
  fontWeight: 600,
74
76
  color: catColor,
@@ -56,7 +56,7 @@ export function WebpageNode({ node, expanded = false }: { node: CanvasNodeState;
56
56
  return <div style={{ color: 'var(--c-dim)', fontStyle: 'italic', padding: '12px' }}>No webpage URL set</div>;
57
57
  }
58
58
 
59
- const statusTone = status === 'ready' ? 'var(--c-ok)' : status === 'error' ? 'var(--c-danger)' : 'var(--c-warn)';
59
+ const statusTone = status === 'ready' ? 'var(--c-ok)' : status === 'error' ? 'var(--c-danger)' : 'var(--c-muted)';
60
60
 
61
61
  return (
62
62
  <div style={{ display: 'flex', flexDirection: 'column', gap: '12px', height: '100%' }}>
@@ -27,8 +27,8 @@ export async function runNodeAxInteraction(
27
27
  export const axNodeActionButtonStyle = {
28
28
  padding: '3px 8px',
29
29
  fontSize: '10px',
30
- background: 'var(--c-accent-12)',
31
- border: '1px solid var(--c-accent-25)',
30
+ background: 'color-mix(in srgb, var(--c-accent) 12%, transparent)',
31
+ border: '1px solid color-mix(in srgb, var(--c-accent) 25%, transparent)',
32
32
  borderRadius: '4px',
33
33
  color: 'var(--c-text-soft)',
34
34
  cursor: 'pointer',
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
2
2
  import { workbenchConnectionEpoch } from '../state/canvas-store';
3
3
  import { iframeMode } from '../state/iframe-mode';
4
4
  import { surfaceContentHash } from './surface-url';
5
+ import { workbenchFetch } from '../state/workbench-transport';
5
6
 
6
7
  interface FrameDocumentCreateResponse {
7
8
  ok: boolean;
@@ -21,7 +22,7 @@ function isFrameDocumentCreateResponse(value: unknown): value is FrameDocumentCr
21
22
  }
22
23
 
23
24
  export async function createIframeDocumentUrl(html: string, sandbox: string): Promise<string> {
24
- const response = await fetch('/api/canvas/frame-documents', {
25
+ const response = await workbenchFetch('/api/canvas/frame-documents', {
25
26
  method: 'POST',
26
27
  headers: { 'Content-Type': 'application/json' },
27
28
  body: JSON.stringify({ html, sandbox }),
@@ -83,7 +84,7 @@ export function useIframeDocument(
83
84
  return;
84
85
  }
85
86
  let cancelled = false;
86
- void fetch(src, { method: 'HEAD' })
87
+ void workbenchFetch(src, { method: 'HEAD' })
87
88
  .then((res) => {
88
89
  if (cancelled) return;
89
90
  if (res.ok) {
@@ -0,0 +1,10 @@
1
+ import DOMPurify from 'dompurify';
2
+
3
+ /** Sanitize rendered Markdown before it enters the privileged workbench DOM. */
4
+ export function sanitizeMarkdownHtml(html: string): string {
5
+ return DOMPurify.sanitize(html, {
6
+ // Markdown does not need these active document surfaces. Keep regular
7
+ // inputs so marked's GFM task checkboxes remain interactive.
8
+ FORBID_TAGS: ['form', 'svg', 'math'],
9
+ });
10
+ }
@@ -2,6 +2,8 @@ import { canvasTheme } from '../state/canvas-store';
2
2
  import { openNodeInSystemBrowserRequest } from '../state/intent-bridge';
3
3
  import { canOpenNodeAsSurface } from '../../shared/surface.js';
4
4
  import type { CanvasNodeState } from '../types';
5
+ import { isHostedWorkbench } from '../state/workbench-transport';
6
+ import { showToast } from '../state/attention-bridge';
5
7
 
6
8
  /**
7
9
  * Stable content hash (djb2) used to cache-bust the surface iframe `src` when a
@@ -52,6 +54,14 @@ export function canOpenAsSite(node: CanvasNodeState): boolean {
52
54
  * and headless/disabled-browser environments.
53
55
  */
54
56
  export async function openNodeAsSite(node: CanvasNodeState): Promise<void> {
57
+ if (isHostedWorkbench()) {
58
+ showToast(
59
+ 'context',
60
+ 'Open in focus mode',
61
+ 'This surface has no public URL. Use Expand to view it here, or export the board to download it.',
62
+ );
63
+ return;
64
+ }
55
65
  const url = nodeSurfaceUrl(node.id);
56
66
  const res = await openNodeInSystemBrowserRequest(node.id, url);
57
67
  if (!res.opened) window.open(url, '_blank', 'noopener');
@@ -1,5 +1,5 @@
1
1
  import type { RefObject } from 'preact';
2
- import { useEffect } from 'preact/hooks';
2
+ import { useLayoutEffect } from 'preact/hooks';
3
3
  import { AX_SURFACE_ACK_SOURCE, AX_SURFACE_EMIT_SOURCE } from '../../shared/ax-surface-protocol.js';
4
4
  import { showToast } from '../state/attention-bridge';
5
5
  import { submitAxInteractionFromClient } from '../state/intent-bridge';
@@ -25,7 +25,9 @@ export function useAxSurfaceBridge(options: {
25
25
  iframeRef: RefObject<HTMLIFrameElement>;
26
26
  }): void {
27
27
  const { enabled, token, nodeId, sourceSurface, iframeRef } = options;
28
- useEffect(() => {
28
+ // Register before paint: a fast iframe can accept input before passive effects
29
+ // run, and its first emit must not be lost in that gap.
30
+ useLayoutEffect(() => {
29
31
  if (!enabled || !token) return;
30
32
  function onAxMessage(event: MessageEvent) {
31
33
  // Bind to THIS node's own iframe; the nonce + nodeId are a second gate,
@@ -1,7 +1,9 @@
1
1
  import { useEffect, useState } from 'preact/hooks';
2
2
  import { iframeMode } from '../state/iframe-mode';
3
+ import { isHostedWorkbench, workbenchFetch } from '../state/workbench-transport';
3
4
 
4
5
  function isSameOriginUrl(url: string): boolean {
6
+ if (isHostedWorkbench()) return url.startsWith('/') && !url.startsWith('//');
5
7
  try {
6
8
  return new URL(url, window.location.origin).origin === window.location.origin;
7
9
  } catch {
@@ -29,7 +31,7 @@ export function useSurfaceFrame(url: string): { src?: string; srcdoc?: string }
29
31
  let cancelled = false;
30
32
  // Ask the server to inline bundle assets (the mermaid renderer): hosts
31
33
  // that force srcdoc mode block sub-frame subresource requests too.
32
- void fetch(`${url}${url.includes('?') ? '&' : '?'}inline-assets=1`, { credentials: 'same-origin' })
34
+ void workbenchFetch(`${url}${url.includes('?') ? '&' : '?'}inline-assets=1`, { credentials: 'same-origin' })
33
35
  .then((res) => (res.ok ? res.text() : Promise.reject(new Error(`HTTP ${res.status}`))))
34
36
  .then((html) => {
35
37
  if (!cancelled) setDoc({ url, html });
@@ -0,0 +1,26 @@
1
+ import { useEffect, useState } from 'preact/hooks';
2
+ import { isHostedWorkbench, workbenchFetch } from '../state/workbench-transport';
3
+
4
+ /** Asset bytes travel through the host, not a localhost URL in the user's browser. */
5
+ export function useWorkbenchAsset(path: string): string | undefined {
6
+ const hosted = isHostedWorkbench() && path.startsWith('/');
7
+ const [asset, setAsset] = useState<{ path: string; url: string } | null>(null);
8
+ useEffect(() => {
9
+ if (!hosted) return;
10
+ let cancelled = false;
11
+ void workbenchFetch(path)
12
+ .then(async (response) => {
13
+ if (!response.ok) throw new Error(`Asset read failed: ${response.status}`);
14
+ const reader = new FileReader();
15
+ reader.onload = () => {
16
+ if (!cancelled && typeof reader.result === 'string') setAsset({ path, url: reader.result });
17
+ };
18
+ reader.readAsDataURL(await response.blob());
19
+ })
20
+ .catch((error: unknown) => console.error('[workbench-asset]', error));
21
+ return () => {
22
+ cancelled = true;
23
+ };
24
+ }, [path, hosted]);
25
+ return hosted ? (asset?.path === path ? asset.url : undefined) : path;
26
+ }
@@ -0,0 +1,78 @@
1
+ import { effect } from '@preact/signals';
2
+ import { JSON_VIEWER_STATE_SOURCE } from '../../shared/ax-surface-protocol.js';
3
+ import { canvasInteractionEpoch, nodes } from '../state/canvas-store';
4
+
5
+ type Snapshot = {
6
+ epoch: number;
7
+ version: unknown;
8
+ documentUrl: unknown;
9
+ state: Record<string, unknown> | null;
10
+ owners: Array<{ token: string; source: Window }>;
11
+ };
12
+
13
+ // Tab-local UI state only: never persist form drafts in canvas data or AX. Keep
14
+ // the receiver alive across the inline/expanded unmount so queued input messages
15
+ // cannot fall into a gap between component effects. Two owners cover a handover;
16
+ // removal, board navigation and authored spec changes discard the entire entry.
17
+ const snapshots = new Map<string, Snapshot>();
18
+ let listening = false;
19
+
20
+ export function restoreViewerRuntimeState(nodeId: string, token: string, source: Window): void {
21
+ if (!listening) {
22
+ listening = true;
23
+ effect(() => {
24
+ const current = nodes.value;
25
+ const epoch = canvasInteractionEpoch.value;
26
+ for (const [id, snapshot] of snapshots) {
27
+ const node = current.get(id);
28
+ if (
29
+ !node ||
30
+ snapshot.epoch !== epoch ||
31
+ snapshot.version !== node.data.specVersion ||
32
+ snapshot.documentUrl !== node.data.url
33
+ )
34
+ snapshots.delete(id);
35
+ }
36
+ });
37
+ window.addEventListener('message', (event: MessageEvent) => {
38
+ const message = event.data as {
39
+ source?: string;
40
+ type?: string;
41
+ token?: string;
42
+ nodeId?: string;
43
+ state?: unknown;
44
+ } | null;
45
+ if (
46
+ !message ||
47
+ message.source !== JSON_VIEWER_STATE_SOURCE ||
48
+ message.type !== 'snapshot' ||
49
+ typeof message.nodeId !== 'string'
50
+ )
51
+ return;
52
+ const snapshot = snapshots.get(message.nodeId);
53
+ const ownerIndex =
54
+ snapshot?.owners.findIndex((owner) => owner.token === message.token && owner.source === event.source) ?? -1;
55
+ if (!snapshot || ownerIndex < 0) return;
56
+ if (!message.state || typeof message.state !== 'object' || Array.isArray(message.state)) return;
57
+ const { ax: _ax, ...state } = message.state as Record<string, unknown>;
58
+ snapshot.state = state;
59
+ // Once the replacement emits, the retired document cannot overwrite it.
60
+ snapshot.owners = snapshot.owners.slice(ownerIndex);
61
+ });
62
+ }
63
+ const node = nodes.value.get(nodeId);
64
+ if (!node) return;
65
+ let snapshot = snapshots.get(nodeId);
66
+ if (!snapshot) {
67
+ snapshot = {
68
+ epoch: canvasInteractionEpoch.value,
69
+ version: node.data.specVersion,
70
+ documentUrl: node.data.url,
71
+ state: null,
72
+ owners: [],
73
+ };
74
+ snapshots.set(nodeId, snapshot);
75
+ }
76
+ snapshot.owners = [...snapshot.owners.filter((owner) => owner.source !== source).slice(-1), { token, source }];
77
+ source.postMessage({ source: JSON_VIEWER_STATE_SOURCE, type: 'restore', token, state: snapshot.state }, '*');
78
+ }
@@ -11,7 +11,6 @@ import {
11
11
  import {
12
12
  pushAttentionHistory,
13
13
  resetAttentionState,
14
- setAttentionFocus,
15
14
  setAttentionPulse,
16
15
  setAttentionToast,
17
16
  type AttentionEntry,
@@ -242,11 +241,6 @@ function entryFromEvent(event: SemanticWatchEvent): AttentionEntry | null {
242
241
  }
243
242
  }
244
243
 
245
- function applyAttentionSnapshot(): void {
246
- const snapshot = reducer.getAttentionSnapshot();
247
- setAttentionFocus(snapshot.primaryFocusNodeIds, snapshot.secondaryFocusNodeIds, snapshot.regions);
248
- }
249
-
250
244
  function flushToastQueue(): void {
251
245
  if (toastTimer !== null) return;
252
246
  const next = toastQueue.shift() ?? null;
@@ -322,8 +316,6 @@ export function syncAttentionFromSse(message: SseMessage): void {
322
316
  .map((event) => entryFromEvent(event))
323
317
  .filter((entry): entry is AttentionEntry => entry !== null);
324
318
 
325
- applyAttentionSnapshot();
326
-
327
319
  for (const entry of entries) {
328
320
  if (shouldSuppressEntry(entry)) continue;
329
321
  pushAttentionHistory(entry);
@@ -1,5 +1,4 @@
1
1
  import { signal } from '@preact/signals';
2
- import type { SemanticAttentionRegion } from '../../shared/semantic-attention.js';
3
2
 
4
3
  export type AttentionTone = 'context' | 'relationship' | 'group' | 'cluster' | 'neighborhood' | 'remove';
5
4
 
@@ -14,9 +13,6 @@ export interface AttentionEntry {
14
13
 
15
14
  export const attentionToast = signal<AttentionEntry | null>(null);
16
15
  export const attentionHistory = signal<AttentionEntry[]>([]);
17
- export const attentionPrimaryNodeIds = signal<Set<string>>(new Set());
18
- export const attentionSecondaryNodeIds = signal<Set<string>>(new Set());
19
- export const attentionRegions = signal<SemanticAttentionRegion[]>([]);
20
16
  export const attentionPulseNodeIds = signal<Set<string>>(new Set());
21
17
  export const attentionHistoryOpen = signal<boolean>(false);
22
18
  export const attentionHistoryUnread = signal<number>(0);
@@ -24,9 +20,6 @@ export const attentionHistoryUnread = signal<number>(0);
24
20
  export function resetAttentionState(): void {
25
21
  attentionToast.value = null;
26
22
  attentionHistory.value = [];
27
- attentionPrimaryNodeIds.value = new Set();
28
- attentionSecondaryNodeIds.value = new Set();
29
- attentionRegions.value = [];
30
23
  attentionPulseNodeIds.value = new Set();
31
24
  attentionHistoryOpen.value = false;
32
25
  attentionHistoryUnread.value = 0;
@@ -41,16 +34,6 @@ export function closeAttentionHistory(): void {
41
34
  attentionHistoryOpen.value = false;
42
35
  }
43
36
 
44
- export function setAttentionFocus(
45
- primaryNodeIds: string[],
46
- secondaryNodeIds: string[],
47
- regions: SemanticAttentionRegion[],
48
- ): void {
49
- attentionPrimaryNodeIds.value = new Set(primaryNodeIds);
50
- attentionSecondaryNodeIds.value = new Set(secondaryNodeIds);
51
- attentionRegions.value = regions;
52
- }
53
-
54
37
  export function setAttentionToast(entry: AttentionEntry | null): void {
55
38
  attentionToast.value = entry;
56
39
  }
@@ -11,6 +11,7 @@ import {
11
11
  import { computeAutoArrange } from '../../shared/auto-arrange';
12
12
  import { canvasArea, canvasAreaCenter, canvasFitInsets } from '../canvas/canvas-area';
13
13
  import { pushCanvasUpdate, requestBestEffort, requestOk, updateViewportFromClient } from './intent-bridge';
14
+ import { isHostedWorkbench } from './workbench-transport';
14
15
 
15
16
  function logCanvasStoreError(action: string, error: unknown): void {
16
17
  console.error(`[canvas-store] ${action} failed`, error);
@@ -39,7 +40,7 @@ export const reconnectAttempt = signal<number>(0);
39
40
  export const reconnectDelay = signal<number>(0);
40
41
  export const sessionId = signal<string>('');
41
42
  export const traceEnabled = signal<boolean>(false);
42
- export const canvasTheme = signal<string>('dark');
43
+ export const canvasTheme = signal<string>('harbor');
43
44
  export const hasInitialServerLayout = signal<boolean>(false);
44
45
  // Compact AX state snapshot (work items, focus, …) mirrored from the server and
45
46
  // pushed into AX-enabled surfaces so authored boards can render the live queue.
@@ -58,6 +59,8 @@ const EXCALIDRAW_CLOSE_MAX_WAIT_MS = 2500;
58
59
 
59
60
  // ── Pending edge connection (for context menu "Connect from") ─
60
61
  export const pendingConnection = signal<{ from: string } | null>(null);
62
+ /** Invalidates in-flight interaction replies when board navigation resets the canvas. */
63
+ export const canvasInteractionEpoch = signal(0);
61
64
 
62
65
  // ── Drag-to-connect (live edge preview) ─────────────────────
63
66
  export const draggingEdge = signal<{
@@ -151,6 +154,7 @@ export function resetCanvasInteractionState(): void {
151
154
  expandedCloseTimer = null;
152
155
  }
153
156
  batch(() => {
157
+ canvasInteractionEpoch.value += 1;
154
158
  activeNodeId.value = null;
155
159
  expandedNodeId.value = null;
156
160
  pendingExpandedNodeCloseId.value = null;
@@ -772,7 +776,8 @@ export function persistLayout(options: { recordHistory?: boolean } = {}): void {
772
776
  })),
773
777
  contextPinnedNodeIds: Array.from(contextPinnedNodeIds.value),
774
778
  };
775
- localStorage.setItem(STORAGE_KEY, JSON.stringify(layout));
779
+ // Opaque MCP App frames have no localStorage; the daemon persists layout.
780
+ if (!isHostedWorkbench()) localStorage.setItem(STORAGE_KEY, JSON.stringify(layout));
776
781
  void pushCanvasUpdate(nodeUpdates, options);
777
782
  } catch (error) {
778
783
  logCanvasStoreError('persistLayout', error);
@@ -0,0 +1,66 @@
1
+ import { signal } from '@preact/signals';
2
+ import { activeBoardId } from './boards-store';
3
+ import { requestJson } from './intent-bridge';
4
+
5
+ /**
6
+ * What agents did with the board's context (docs/design/AgentContext.dc.html):
7
+ * per node, the latest agent read of its content and the revision it read;
8
+ * per pin, who pinned it, when and why. Server truth from
9
+ * `GET /api/canvas/ax/context-status`, refetched when a read lands
10
+ * (`context-status-changed`), when pins change, and on (re)connect.
11
+ */
12
+
13
+ export interface NodeReadStatus {
14
+ nodeId: string;
15
+ lastReadAt: string;
16
+ lastReadBy: string;
17
+ readRevision: number;
18
+ readCount: number;
19
+ }
20
+
21
+ export interface ContextPinMeta {
22
+ pinnedBy: { actor: 'human' | 'agent' | 'system' | 'unknown'; source: string; agentId?: string };
23
+ pinnedAt: string;
24
+ reason?: string;
25
+ }
26
+
27
+ interface ContextStatusResponse {
28
+ ok?: boolean;
29
+ boardId?: string | null;
30
+ nodes?: NodeReadStatus[];
31
+ pins?: Record<string, ContextPinMeta>;
32
+ }
33
+
34
+ export const nodeReadStatus = signal<Map<string, NodeReadStatus>>(new Map());
35
+ export const contextPinMeta = signal<Record<string, ContextPinMeta>>({});
36
+
37
+ export function applyContextStatus(response: ContextStatusResponse): void {
38
+ nodeReadStatus.value = new Map((response.nodes ?? []).map((entry) => [entry.nodeId, entry]));
39
+ contextPinMeta.value = response.pins ?? {};
40
+ }
41
+
42
+ export async function refreshContextStatus(): Promise<void> {
43
+ const response = await requestJson<ContextStatusResponse>('fetchContextStatus', '/api/canvas/ax/context-status', {});
44
+ // A late answer for a board you have since left must not mark this one.
45
+ if (response.ok && response.boardId === activeBoardId.value) applyContextStatus(response);
46
+ }
47
+
48
+ /** How an agent's context relates to one pinned node — the mark its header shows. */
49
+ export type PinnedReadState =
50
+ | { kind: 'not-read' }
51
+ | { kind: 'read'; by: string; at: string; count: number }
52
+ | { kind: 'changed'; by: string; at: string };
53
+
54
+ export function pinnedReadState(nodeId: string, currentRevision: number): PinnedReadState {
55
+ const read = nodeReadStatus.value.get(nodeId);
56
+ const pinnedAt = contextPinMeta.value[nodeId]?.pinnedAt;
57
+ // Pinned after the latest read: the agent has not loaded it since you pinned it.
58
+ if (!read || (pinnedAt && read.lastReadAt < pinnedAt)) return { kind: 'not-read' };
59
+ if (currentRevision > read.readRevision) return { kind: 'changed', by: read.lastReadBy, at: read.lastReadAt };
60
+ return { kind: 'read', by: read.lastReadBy, at: read.lastReadAt, count: read.readCount };
61
+ }
62
+
63
+ /** Display name for an attributed writer: its agent id, else its transport label. */
64
+ export function writerName(actor: { source: string; agentId?: string }): string {
65
+ return actor.agentId?.trim() || actor.source;
66
+ }
@@ -6,6 +6,7 @@ import {
6
6
  } from '../../shared/human-presence.js';
7
7
  import { requestBestEffort, vetoGhostIntent } from './intent-bridge';
8
8
  import { intents, removeIntent } from './intent-store';
9
+ import { isHostedWorkbench } from './workbench-transport';
9
10
 
10
11
  /**
11
12
  * Human collaborator presence (rail-chrome-v2 phase 8). This tab reports its
@@ -64,6 +65,7 @@ let lastSentAt = 0;
64
65
  let pending: ReturnType<typeof setTimeout> | null = null;
65
66
 
66
67
  function post(body: Record<string, unknown>): void {
68
+ if (isHostedWorkbench()) return;
67
69
  void requestBestEffort('humanPresence', '/api/canvas/human-presence', {
68
70
  method: 'POST',
69
71
  headers: { 'Content-Type': 'application/json' },
@@ -23,6 +23,7 @@
23
23
  */
24
24
  import { signal } from '@preact/signals';
25
25
  import { IFRAME_PROBE_MESSAGE_SOURCE } from '../../shared/iframe-probe.js';
26
+ import { isHostedWorkbench } from './workbench-transport';
26
27
 
27
28
  export type IframeMode = 'src' | 'srcdoc';
28
29
 
@@ -105,6 +106,10 @@ function isAmpOrbHost(): boolean {
105
106
  export function resolveIframeMode(
106
107
  opts: { embedded?: boolean; ampOrb?: boolean; probeTimeoutMs?: number } = {},
107
108
  ): Promise<IframeMode> {
109
+ if (isHostedWorkbench()) {
110
+ iframeMode.value = 'srcdoc';
111
+ return Promise.resolve('srcdoc');
112
+ }
108
113
  if (iframeMode.value) return Promise.resolve(iframeMode.value);
109
114
  if (pending) return pending;
110
115
  const forced = forcedIframeMode();