pmx-canvas 0.8.2 → 0.9.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 (335) hide show
  1. package/.github/extensions/pmx-canvas/runtime.json +1 -1
  2. package/.github/extensions/pmx-canvas/tools.json +601 -22
  3. package/CHANGELOG.md +49 -0
  4. package/Readme.md +131 -532
  5. package/dist/canvas/global.css +2724 -476
  6. package/dist/canvas/index.js +168 -107
  7. package/dist/canvas/mcp-app.html +2899 -596
  8. package/dist/json-render/index.js +20 -20
  9. package/dist/types/client/canvas/BarHint.d.ts +12 -2
  10. package/dist/types/client/canvas/BoardLinkDialog.d.ts +3 -1
  11. package/dist/types/client/canvas/BoardPinButton.d.ts +10 -0
  12. package/dist/types/client/canvas/BoardSwitcher.d.ts +1 -1
  13. package/dist/types/client/canvas/CanvasViewport.d.ts +1 -1
  14. package/dist/types/client/canvas/CommandBar.d.ts +3 -4
  15. package/dist/types/client/canvas/ConnectionBanner.d.ts +2 -1
  16. package/dist/types/client/canvas/ContextChip.d.ts +3 -0
  17. package/dist/types/client/canvas/CrossBoardReferences.d.ts +5 -0
  18. package/dist/types/client/canvas/EdgeLayer.d.ts +14 -3
  19. package/dist/types/client/canvas/EmptyState.d.ts +4 -4
  20. package/dist/types/client/canvas/HomeGraphView.d.ts +2 -0
  21. package/dist/types/client/canvas/HomeMapView.d.ts +1 -0
  22. package/dist/types/client/canvas/HomeReviewView.d.ts +6 -0
  23. package/dist/types/client/canvas/HomeView.d.ts +9 -7
  24. package/dist/types/client/canvas/KnowledgeDialog.d.ts +4 -0
  25. package/dist/types/client/canvas/LifecycleChrome.d.ts +5 -0
  26. package/dist/types/client/canvas/LifecycleFields.d.ts +7 -0
  27. package/dist/types/client/canvas/LifecyclePanel.d.ts +1 -0
  28. package/dist/types/client/canvas/MemoryInspector.d.ts +7 -0
  29. package/dist/types/client/canvas/NearPinLayer.d.ts +14 -0
  30. package/dist/types/client/canvas/NodeContextMark.d.ts +40 -7
  31. package/dist/types/client/canvas/RelationPresetsDialog.d.ts +4 -0
  32. package/dist/types/client/canvas/RelationsPanel.d.ts +4 -0
  33. package/dist/types/client/canvas/SessionPanel.d.ts +2 -0
  34. package/dist/types/client/canvas/canvas-area.d.ts +1 -1
  35. package/dist/types/client/canvas/graph-geometry.d.ts +27 -0
  36. package/dist/types/client/canvas/graph-model.d.ts +37 -0
  37. package/dist/types/client/canvas/memory-layout.d.ts +23 -0
  38. package/dist/types/client/canvas/use-chrome-height.d.ts +2 -0
  39. package/dist/types/client/canvas/use-focus-trap.d.ts +1 -0
  40. package/dist/types/client/canvas/use-memory-graph.d.ts +8 -0
  41. package/dist/types/client/canvas/use-pan-zoom.d.ts +4 -1
  42. package/dist/types/client/icons.d.ts +0 -10
  43. package/dist/types/client/nodes/AxStepControls.d.ts +1 -1
  44. package/dist/types/client/nodes/InlineMarkdownEditor.d.ts +4 -4
  45. package/dist/types/client/nodes/MarkdownNode.d.ts +5 -1
  46. package/dist/types/client/nodes/McpAppNode.d.ts +4 -4
  47. package/dist/types/client/nodes/viewer-runtime-state.d.ts +1 -1
  48. package/dist/types/client/state/boards-store.d.ts +81 -9
  49. package/dist/types/client/state/canvas-store.d.ts +14 -1
  50. package/dist/types/client/state/context-chip-store.d.ts +22 -0
  51. package/dist/types/client/state/context-status-store.d.ts +35 -0
  52. package/dist/types/client/state/intent-bridge.d.ts +11 -8
  53. package/dist/types/client/state/knowledge-store.d.ts +22 -0
  54. package/dist/types/client/state/near-pin-store.d.ts +44 -1
  55. package/dist/types/client/state/presence-store.d.ts +4 -4
  56. package/dist/types/client/state/presentation.d.ts +28 -0
  57. package/dist/types/client/state/relation-store.d.ts +10 -0
  58. package/dist/types/client/state/session-store.d.ts +41 -2
  59. package/dist/types/client/state/workspace-settings.d.ts +7 -0
  60. package/dist/types/client/types.d.ts +13 -1
  61. package/dist/types/client/utils/keyboard.d.ts +2 -0
  62. package/dist/types/client/utils/time-ago.d.ts +4 -0
  63. package/dist/types/mcp/context-uri-template.d.ts +3 -1
  64. package/dist/types/server/agent-presence.d.ts +10 -3
  65. package/dist/types/server/ax-context.d.ts +1 -1
  66. package/dist/types/server/canvas-db.d.ts +30 -7
  67. package/dist/types/server/canvas-operations.d.ts +24 -2
  68. package/dist/types/server/canvas-provenance.d.ts +22 -2
  69. package/dist/types/server/canvas-serialization.d.ts +2 -0
  70. package/dist/types/server/canvas-state.d.ts +93 -15
  71. package/dist/types/server/canvas-validation.d.ts +1 -1
  72. package/dist/types/server/context-brief.d.ts +94 -6
  73. package/dist/types/server/context-reads.d.ts +33 -7
  74. package/dist/types/server/edit-summary.d.ts +20 -0
  75. package/dist/types/server/index.d.ts +71 -10
  76. package/dist/types/server/knowledge-lifecycle.d.ts +221 -0
  77. package/dist/types/server/lifecycle-status.d.ts +10 -0
  78. package/dist/types/server/memory-graph.d.ts +9 -0
  79. package/dist/types/server/mutation-history.d.ts +2 -0
  80. package/dist/types/server/operations/ops/ax-shared.d.ts +1 -1
  81. package/dist/types/server/operations/ops/boards.d.ts +2 -1
  82. package/dist/types/server/operations/ops/lifecycle.d.ts +2 -0
  83. package/dist/types/server/operations/ops/memory-graph.d.ts +2 -0
  84. package/dist/types/server/operations/ops/nodes.d.ts +5 -0
  85. package/dist/types/server/operations/ops/query.d.ts +23 -0
  86. package/dist/types/server/operations/registry.d.ts +1 -0
  87. package/dist/types/server/placement.d.ts +7 -0
  88. package/dist/types/server/scope-fence.d.ts +2 -1
  89. package/dist/types/server/spatial-analysis.d.ts +6 -3
  90. package/dist/types/server/tour-control.d.ts +32 -0
  91. package/dist/types/server/wiki-links.d.ts +14 -0
  92. package/dist/types/server/workbench-auth.d.ts +2 -0
  93. package/dist/types/shared/agent-context.d.ts +2 -0
  94. package/dist/types/shared/agent-presence.d.ts +15 -12
  95. package/dist/types/shared/auto-arrange.d.ts +1 -0
  96. package/dist/types/shared/boards.d.ts +4 -4
  97. package/dist/types/shared/knowledge-lifecycle.d.ts +41 -0
  98. package/dist/types/shared/knowledge-presets.d.ts +6 -0
  99. package/dist/types/shared/lifecycle-status.d.ts +24 -0
  100. package/dist/types/shared/memory-graph.d.ts +53 -0
  101. package/dist/types/shared/relations.d.ts +68 -0
  102. package/dist/types/shared/semantic-attention.d.ts +2 -0
  103. package/dist/types/shared/tour.d.ts +38 -0
  104. package/dist/types/shared/workspace-settings.d.ts +11 -0
  105. package/docs/RELEASE.md +21 -3
  106. package/docs/adr-001-bun-only-runtime.md +7 -1
  107. package/docs/adr-002-shared-product-separate-runtimes.md +93 -0
  108. package/docs/api-reference.md +109 -0
  109. package/docs/ax-host-adapter-contract.md +1 -1
  110. package/docs/ax-state-contract.md +5 -4
  111. package/docs/cli.md +153 -15
  112. package/docs/demos/pmx-canvas-demo.mp4 +0 -0
  113. package/docs/design/AgentContext.dc.html +3 -3
  114. package/docs/design/BoardPins.dc.html +37 -22
  115. package/docs/design/Categories.dc.html +208 -0
  116. package/docs/design/CategoryEditor.dc.html +33 -0
  117. package/docs/design/Chrome.dc.html +52 -9
  118. package/docs/design/ContextChip.dc.html +224 -0
  119. package/docs/design/ContextChip600.dc.html +136 -0
  120. package/docs/design/EmptyState.dc.html +45 -0
  121. package/docs/design/Flows.dc.html +23 -1
  122. package/docs/design/HeaderMarks.dc.html +183 -0
  123. package/docs/design/Home.dc.html +19 -1
  124. package/docs/design/HomeGraph.dc.html +86 -202
  125. package/docs/design/HomeMap.dc.html +23 -1
  126. package/docs/design/HomeTeam.dc.html +32 -14
  127. package/docs/design/Lifecycle.dc.html +22 -0
  128. package/docs/design/Lifecycle600.dc.html +18 -0
  129. package/docs/design/LifecycleVariants.dc.html +157 -0
  130. package/docs/design/LinkAuthorship.dc.html +139 -0
  131. package/docs/design/LinksChanged.dc.html +203 -0
  132. package/docs/design/LinksOptions.dc.html +137 -0
  133. package/docs/design/LinksOptions600.dc.html +134 -0
  134. package/docs/design/NearPin.dc.html +9 -10
  135. package/docs/design/Pane600.dc.html +4 -3
  136. package/docs/design/Relations.dc.html +3 -2
  137. package/docs/design/Restore.dc.html +152 -0
  138. package/docs/design/SeeChange.dc.html +267 -0
  139. package/docs/design/Uses.dc.html +12 -6
  140. package/docs/design/canvas.json +146 -19
  141. package/docs/design.md +449 -16
  142. package/docs/environment.md +0 -1
  143. package/docs/evals/curation-effect.md +128 -0
  144. package/docs/http-api.md +459 -37
  145. package/docs/mcp.md +146 -18
  146. package/docs/node-types.md +58 -3
  147. package/docs/plans/design-gaps-2026-10-08.md +53 -0
  148. package/docs/plans/plan-009-tech-debt-backlog.md +379 -0
  149. package/docs/plans/plan-016-context-made-visible-wave-1.md +150 -5
  150. package/docs/plans/plan-017-knowledge-categories-and-lifecycle.md +175 -0
  151. package/docs/plans/plan-018-connected-memory-wave-2.md +594 -0
  152. package/docs/plans/plan-019-post-0.9-roadmap.md +198 -0
  153. package/docs/product-context-vision-2026-09.md +17 -1
  154. package/docs/product-vision-2026-09.md +1082 -43
  155. package/docs/product-vision-review-2026-09.md +13 -1
  156. package/docs/screenshots/workbench-live-dark.png +0 -0
  157. package/docs/screenshots/workbench-live-light.png +0 -0
  158. package/docs/sdk.md +62 -2
  159. package/package.json +1 -1
  160. package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/runtime.json +1 -1
  161. package/plugins/pmx-canvas/plugin.json +1 -1
  162. package/plugins/pmx-canvas/runtime.json +1 -1
  163. package/plugins/pmx-canvas/tools.json +601 -22
  164. package/skills/pmx-canvas/SKILL.md +216 -42
  165. package/skills/pmx-canvas/references/full-reference.md +120 -21
  166. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +2 -2
  167. package/skills/pmx-canvas/references/importing-context.md +132 -0
  168. package/skills/pmx-canvas/references/installing-pmx-canvas.md +6 -6
  169. package/skills/pmx-canvas/references/tours-and-recording.md +16 -1
  170. package/skills/pmx-canvas-testing/SKILL.md +20 -0
  171. package/src/cli/agent.ts +2 -0
  172. package/src/cli/commands/ax.ts +12 -0
  173. package/src/cli/commands/boards.ts +87 -11
  174. package/src/cli/commands/edges.ts +28 -0
  175. package/src/cli/commands/groups.ts +7 -0
  176. package/src/cli/commands/nodes.ts +1 -0
  177. package/src/cli/commands/query.ts +34 -2
  178. package/src/cli/commands/record.ts +199 -48
  179. package/src/cli/index.ts +6 -1
  180. package/src/client/App.tsx +177 -70
  181. package/src/client/canvas/AttentionHistory.tsx +0 -2
  182. package/src/client/canvas/BarHint.tsx +40 -2
  183. package/src/client/canvas/BoardCopyDialog.tsx +1 -1
  184. package/src/client/canvas/BoardFolderDialog.tsx +7 -7
  185. package/src/client/canvas/BoardLinkDialog.tsx +138 -33
  186. package/src/client/canvas/BoardPinButton.tsx +33 -0
  187. package/src/client/canvas/BoardSwitcher.tsx +26 -40
  188. package/src/client/canvas/CanvasNode.tsx +360 -237
  189. package/src/client/canvas/CanvasViewport.tsx +45 -7
  190. package/src/client/canvas/CommandBar.tsx +29 -23
  191. package/src/client/canvas/CommandPalette.tsx +5 -12
  192. package/src/client/canvas/ConnectionBanner.tsx +3 -3
  193. package/src/client/canvas/ContextChip.tsx +296 -0
  194. package/src/client/canvas/ContextMenu.tsx +63 -5
  195. package/src/client/canvas/CrossBoardReferences.tsx +60 -0
  196. package/src/client/canvas/EdgeLayer.tsx +339 -76
  197. package/src/client/canvas/EmptyState.tsx +33 -18
  198. package/src/client/canvas/ExpandedNodeOverlay.tsx +72 -40
  199. package/src/client/canvas/ExternalWriters.tsx +57 -42
  200. package/src/client/canvas/HomeGraphView.tsx +942 -0
  201. package/src/client/canvas/HomeMapView.tsx +641 -0
  202. package/src/client/canvas/HomeReviewView.tsx +167 -0
  203. package/src/client/canvas/HomeView.tsx +687 -160
  204. package/src/client/canvas/IntentLayer.tsx +19 -12
  205. package/src/client/canvas/KnowledgeDialog.tsx +488 -0
  206. package/src/client/canvas/LifecycleChrome.tsx +133 -0
  207. package/src/client/canvas/LifecycleFields.tsx +91 -0
  208. package/src/client/canvas/LifecyclePanel.tsx +533 -0
  209. package/src/client/canvas/MemoryInspector.tsx +104 -0
  210. package/src/client/canvas/Minimap.tsx +3 -3
  211. package/src/client/canvas/NearPinLayer.tsx +187 -0
  212. package/src/client/canvas/NodeContextMark.tsx +175 -97
  213. package/src/client/canvas/Presentation.tsx +23 -57
  214. package/src/client/canvas/RelationPresetsDialog.tsx +413 -0
  215. package/src/client/canvas/RelationsPanel.tsx +82 -0
  216. package/src/client/canvas/SelectionBar.tsx +3 -6
  217. package/src/client/canvas/SessionPanel.tsx +90 -50
  218. package/src/client/canvas/SessionReceipt.tsx +311 -39
  219. package/src/client/canvas/ShortcutOverlay.tsx +1 -1
  220. package/src/client/canvas/SnapshotPanel.tsx +27 -36
  221. package/src/client/canvas/ToolRail.tsx +367 -192
  222. package/src/client/canvas/TopBar.tsx +354 -124
  223. package/src/client/canvas/canvas-area.ts +10 -3
  224. package/src/client/canvas/graph-geometry.ts +195 -0
  225. package/src/client/canvas/graph-model.ts +254 -0
  226. package/src/client/canvas/memory-layout.ts +106 -0
  227. package/src/client/canvas/use-chrome-height.ts +20 -0
  228. package/src/client/canvas/use-focus-trap.ts +31 -5
  229. package/src/client/canvas/use-memory-graph.ts +58 -0
  230. package/src/client/canvas/use-node-drag.ts +2 -1
  231. package/src/client/canvas/use-pan-zoom.ts +64 -7
  232. package/src/client/icons.tsx +0 -63
  233. package/src/client/mcp-app/style.css +0 -6
  234. package/src/client/nodes/AxStepControls.tsx +74 -50
  235. package/src/client/nodes/BoardNode.tsx +5 -1
  236. package/src/client/nodes/ContextNode.tsx +18 -11
  237. package/src/client/nodes/ExtAppFrame.tsx +0 -1
  238. package/src/client/nodes/FileNode.tsx +46 -42
  239. package/src/client/nodes/HtmlNode.tsx +32 -0
  240. package/src/client/nodes/ImageNode.tsx +1 -1
  241. package/src/client/nodes/InlineFormatBar.tsx +6 -9
  242. package/src/client/nodes/InlineMarkdownEditor.tsx +56 -17
  243. package/src/client/nodes/MarkdownNode.tsx +377 -109
  244. package/src/client/nodes/McpAppNode.tsx +50 -20
  245. package/src/client/nodes/MdFormatBar.tsx +21 -18
  246. package/src/client/nodes/PromptNode.tsx +1 -1
  247. package/src/client/nodes/ResponseNode.tsx +1 -1
  248. package/src/client/nodes/viewer-runtime-state.ts +54 -22
  249. package/src/client/state/attention-bridge.ts +64 -15
  250. package/src/client/state/boards-store.ts +193 -39
  251. package/src/client/state/canvas-store.ts +99 -12
  252. package/src/client/state/context-chip-store.ts +70 -0
  253. package/src/client/state/context-status-store.ts +53 -0
  254. package/src/client/state/intent-bridge.ts +51 -30
  255. package/src/client/state/knowledge-store.ts +43 -0
  256. package/src/client/state/near-pin-store.ts +115 -6
  257. package/src/client/state/presence-store.ts +7 -10
  258. package/src/client/state/presentation.ts +91 -0
  259. package/src/client/state/relation-store.ts +81 -0
  260. package/src/client/state/session-store.ts +144 -18
  261. package/src/client/state/sse-bridge.ts +206 -29
  262. package/src/client/state/workspace-settings.ts +11 -0
  263. package/src/client/theme/global.css +2724 -476
  264. package/src/client/types.ts +12 -0
  265. package/src/client/utils/keyboard.ts +10 -0
  266. package/src/client/utils/time-ago.ts +14 -0
  267. package/src/json-render/renderer/index.tsx +31 -0
  268. package/src/mcp/canvas-app.ts +12 -2
  269. package/src/mcp/context-uri-template.ts +7 -4
  270. package/src/mcp/server.ts +63 -41
  271. package/src/mcp/workbench-app.ts +39 -6
  272. package/src/server/agent-presence.ts +128 -28
  273. package/src/server/ax-context.ts +27 -13
  274. package/src/server/board-export.ts +12 -1
  275. package/src/server/canvas-db.ts +331 -42
  276. package/src/server/canvas-operations.ts +195 -83
  277. package/src/server/canvas-provenance.ts +53 -22
  278. package/src/server/canvas-serialization.ts +2 -0
  279. package/src/server/canvas-state.ts +950 -122
  280. package/src/server/canvas-validation.ts +9 -2
  281. package/src/server/context-brief.ts +345 -38
  282. package/src/server/context-reads.ts +178 -30
  283. package/src/server/demo-state.json +355 -24
  284. package/src/server/edit-summary.ts +74 -0
  285. package/src/server/html-surface.ts +8 -1
  286. package/src/server/index.ts +181 -11
  287. package/src/server/knowledge-lifecycle.ts +304 -0
  288. package/src/server/lifecycle-status.ts +232 -0
  289. package/src/server/memory-graph.ts +166 -0
  290. package/src/server/mutation-history.ts +18 -6
  291. package/src/server/operations/composites.ts +32 -11
  292. package/src/server/operations/http.ts +7 -2
  293. package/src/server/operations/index.ts +4 -0
  294. package/src/server/operations/mcp.ts +26 -1
  295. package/src/server/operations/ops/ax-presence.ts +1 -1
  296. package/src/server/operations/ops/ax-read.ts +57 -6
  297. package/src/server/operations/ops/ax-timeline.ts +28 -2
  298. package/src/server/operations/ops/backup.ts +5 -3
  299. package/src/server/operations/ops/batch.ts +35 -4
  300. package/src/server/operations/ops/boards.ts +188 -47
  301. package/src/server/operations/ops/canvas-wire.ts +31 -1
  302. package/src/server/operations/ops/edges.ts +36 -2
  303. package/src/server/operations/ops/groups.ts +6 -1
  304. package/src/server/operations/ops/lifecycle.ts +262 -0
  305. package/src/server/operations/ops/memory-graph.ts +45 -0
  306. package/src/server/operations/ops/nodes.ts +107 -24
  307. package/src/server/operations/ops/query.ts +281 -68
  308. package/src/server/operations/ops/snapshots.ts +73 -1
  309. package/src/server/operations/ops/validate.ts +5 -1
  310. package/src/server/operations/ops/viewport.ts +97 -3
  311. package/src/server/operations/registry.ts +74 -25
  312. package/src/server/placement.ts +29 -0
  313. package/src/server/scope-fence.ts +78 -6
  314. package/src/server/server.ts +115 -16
  315. package/src/server/spatial-analysis.ts +48 -23
  316. package/src/server/tour-control.ts +82 -0
  317. package/src/server/wiki-links.ts +137 -0
  318. package/src/server/workbench-auth.ts +11 -0
  319. package/src/shared/agent-context.ts +3 -0
  320. package/src/shared/agent-presence.ts +15 -15
  321. package/src/shared/auto-arrange.ts +2 -0
  322. package/src/shared/boards.ts +4 -4
  323. package/src/shared/knowledge-lifecycle.ts +22 -0
  324. package/src/shared/knowledge-presets.ts +80 -0
  325. package/src/shared/lifecycle-status.ts +26 -0
  326. package/src/shared/memory-graph.ts +74 -0
  327. package/src/shared/relations.ts +98 -0
  328. package/src/shared/semantic-attention.ts +15 -1
  329. package/src/shared/tour.ts +23 -5
  330. package/src/shared/workspace-settings.ts +13 -0
  331. package/dist/types/client/canvas/ContextPinBar.d.ts +0 -1
  332. package/dist/types/client/canvas/FocusFieldLayer.d.ts +0 -1
  333. package/dist/types/client/canvas/kind-colors.d.ts +0 -3
  334. package/dist/types/server/agent-context.d.ts +0 -22
  335. package/src/client/canvas/ContextPinBar.tsx +0 -25
@@ -1,100 +1,239 @@
1
- import { signal } from '@preact/signals';
1
+ import { batch, effect, signal } from '@preact/signals';
2
+ import { knowledgeLifecycleEnabled } from './workspace-settings';
2
3
  import { requestJson } from './intent-bridge';
4
+ import { focusNode, hasInitialServerLayout, hiddenByCollapsedGroup, nodes, toggleCollapsed } from './canvas-store';
5
+ import type { MemoryTarget } from '../../shared/memory-graph.js';
6
+ import type { RelationDefinition } from '../../shared/relations.js';
3
7
 
4
8
  /**
5
9
  * Many boards (plan 012): the workspace's boards and which one is open. The
6
10
  * server is the authority; this mirrors GET /api/canvas/boards and the
7
- * `boards-changed` SSE frame. `activeBoardId === null` is Home.
11
+ * `boards-changed` SSE frame. Null means no server board is open; browsingMemory
12
+ * separately keeps a human in the library while agents open boards.
8
13
  */
9
14
  export interface BoardSummary {
10
15
  id: string;
11
16
  name: string;
12
- category: string | null;
17
+ folder: string | null;
13
18
  createdAt: string;
14
19
  lastOpenedAt: string | null;
15
20
  nodeCount: number;
16
21
  readmeNodeId?: string | null;
22
+ mapPosition?: { x: number; y: number } | null;
23
+ previousBoardId?: string | null;
24
+ relationVocabulary?: RelationDefinition[];
17
25
  summary?: string | null;
18
26
  pinnedTitles?: Array<{ nodeId: string; title: string }>;
19
- links?: Array<{ nodeId: string; boardId: string; title: string | null; missing: boolean }>;
20
- backlinks?: Array<{ boardId: string; title: string; nodeId: string }>;
27
+ links?: Array<{
28
+ id?: string;
29
+ nodeId: string;
30
+ boardId: string;
31
+ cardId?: string;
32
+ cardTitle?: string | null;
33
+ title: string | null;
34
+ missing: boolean;
35
+ kind?: string;
36
+ }>;
37
+ backlinks?: Array<{ id?: string; boardId: string; title: string; nodeId: string; cardId?: string; kind?: string }>;
38
+ /** Home thumbnail (docs/design/Home.dc.html): cards as 0–1 rectangles of the board's extent. */
39
+ preview?: Array<{ x: number; y: number; w: number; h: number; pinned?: true }>;
40
+ /** Roughly what this board's map costs in the brief when pinned (README + pinned card summaries). */
41
+ mapChars?: number;
42
+ /** In the agent's working set (vision move 0a); null when not pinned. */
43
+ pin?: { pinnedBy: { actor: string; source: string; agentId?: string }; pinnedAt: string; reason?: string } | null;
21
44
  }
22
45
 
23
46
  interface BoardsPayload {
47
+ catalogEpoch: string;
48
+ catalogRevision: number;
49
+ reloadRevision: number;
50
+ boardGeneration: number;
24
51
  activeBoardId: string | null;
25
52
  boards: BoardSummary[];
26
53
  }
27
54
 
28
55
  export const boardList = signal<BoardSummary[]>([]);
29
56
  export const activeBoardId = signal<string | null>(null);
57
+ /** An explicitly opened library view stays visible while agents switch boards. */
58
+ export const browsingMemory = signal(false);
59
+ /** Review belongs to this navigation incarnation, never merely a reused card ID. */
60
+ export const lifecycleTarget = signal<{ boardId: string; nodeId: string; incarnation: number } | null>(null);
61
+ export const lifecycleFilter = signal<'All' | 'To review' | 'Retired'>('All');
62
+ effect(() => {
63
+ activeBoardId.value;
64
+ lifecycleFilter.value = 'All';
65
+ });
66
+ /** Layout/pin/reconnect invalidation for the library's shared graph projection. */
67
+ export const memoryGraphRevision = signal(0);
30
68
  /** False until the first list arrives — Home must not flash before the server answers. */
31
69
  export const boardsLoaded = signal(false);
32
70
 
33
- let onBoardSwitched: (() => void) | null = null;
34
- let boardsRevision = 0;
71
+ let onBoardSwitched: ((initial: boolean) => void) | null = null;
72
+ export let catalogEpoch: string | null = null;
73
+ export let boardGeneration: number | null = null;
74
+ /** Invalidates suspended writes when the canonical board/library is replaced. */
75
+ export let boardIncarnation = 0;
76
+ let catalogRevision = 0;
77
+ let reloadRevision: number | null = null;
35
78
  let openRequestSequence = 0;
36
79
  let openRequestQueue: Promise<void> = Promise.resolve();
80
+ let navigationSequence = 0;
81
+ /** Invalidates lifecycle UI work across suspension, including an off/on round trip. */
82
+ export let lifecycleUiGeneration = 0;
83
+ effect(() => {
84
+ knowledgeLifecycleEnabled.value;
85
+ lifecycleUiGeneration++;
86
+ });
87
+
88
+ /** Only the current transport's connected handshake may replace the server epoch. */
89
+ export function connectBoardCatalog(epoch: string): void {
90
+ if (epoch === catalogEpoch) return;
91
+ catalogEpoch = epoch;
92
+ boardGeneration = null;
93
+ boardIncarnation++;
94
+ catalogRevision = 0;
95
+ reloadRevision = null;
96
+ openRequestSequence++;
97
+ navigationSequence++;
98
+ lifecycleTarget.value = null;
99
+ }
37
100
 
38
101
  /** The SSE bridge resyncs the canvas when the open board changes under it. */
39
- export function setBoardSwitchHandler(handler: (() => void) | null): void {
102
+ export function setBoardSwitchHandler(handler: ((initial: boolean) => void) | null): void {
40
103
  onBoardSwitched = handler;
41
104
  }
42
105
 
43
- export function applyBoards(data: { activeBoardId?: unknown; boards?: unknown; reloaded?: unknown } | null): void {
44
- if (!data || !Array.isArray(data.boards)) return;
45
- boardsRevision += 1;
106
+ export function applyBoards(
107
+ data: {
108
+ activeBoardId?: unknown;
109
+ boards?: unknown;
110
+ catalogEpoch?: unknown;
111
+ catalogRevision?: unknown;
112
+ reloadRevision?: unknown;
113
+ boardGeneration?: unknown;
114
+ } | null,
115
+ ): void {
116
+ if (
117
+ !data ||
118
+ !Array.isArray(data.boards) ||
119
+ data.catalogEpoch !== catalogEpoch ||
120
+ typeof data.catalogRevision !== 'number' ||
121
+ typeof data.reloadRevision !== 'number' ||
122
+ data.catalogRevision <= catalogRevision
123
+ )
124
+ return;
125
+ catalogRevision = data.catalogRevision;
46
126
  const nextActive = typeof data.activeBoardId === 'string' ? data.activeBoardId : null;
47
- // `reloaded`: the library was restored, so even the same board id holds new content.
48
- const switched = boardsLoaded.value && (nextActive !== activeBoardId.value || data.reloaded === true);
49
- boardList.value = data.boards as BoardSummary[];
50
- activeBoardId.value = nextActive;
51
- boardsLoaded.value = true;
52
- if (switched) onBoardSwitched?.();
127
+ const initial = !boardsLoaded.value;
128
+ const establishesGeneration = boardGeneration === null && typeof data.boardGeneration === 'number';
129
+ // A restore stays observable even when a later GET overtakes its notification.
130
+ const switched =
131
+ !initial &&
132
+ (nextActive !== activeBoardId.value ||
133
+ (reloadRevision !== null && data.reloadRevision !== reloadRevision) ||
134
+ (boardGeneration !== null &&
135
+ typeof data.boardGeneration === 'number' &&
136
+ data.boardGeneration !== boardGeneration));
137
+ if (switched) {
138
+ boardIncarnation++;
139
+ lifecycleTarget.value = null;
140
+ }
141
+ if (typeof data.boardGeneration === 'number') boardGeneration = data.boardGeneration;
142
+ reloadRevision = data.reloadRevision;
143
+ batch(() => {
144
+ boardList.value = data.boards as BoardSummary[];
145
+ activeBoardId.value = nextActive;
146
+ boardsLoaded.value = true;
147
+ // Never render old cards with the replacement board's identity/draft keys.
148
+ if (switched || initial) onBoardSwitched?.(initial);
149
+ // A same-board restart may deliver its tagged snapshot before the first
150
+ // catalog. Drain it without detaching the editor or losing its selection.
151
+ else if (establishesGeneration) onBoardSwitched?.(true);
152
+ });
53
153
  }
54
154
 
55
155
  export async function loadBoards(): Promise<void> {
56
- const revision = boardsRevision;
57
156
  const payload = await requestJson<BoardsPayload | null>('loadBoards', '/api/canvas/boards', null);
58
- if (revision === boardsRevision) applyBoards(payload);
157
+ applyBoards(payload);
59
158
  }
60
159
 
61
- async function post(
62
- action: string,
63
- url: string,
64
- method: string,
65
- body?: unknown,
66
- revision = boardsRevision,
67
- ): Promise<BoardsPayload | null> {
68
- const payload = await requestJson<BoardsPayload | null>(action, url, null, {
160
+ async function post(action: string, url: string, method: string, body?: unknown): Promise<BoardsPayload | null> {
161
+ return requestJson<BoardsPayload | null>(action, url, null, {
69
162
  method,
70
163
  headers: { 'Content-Type': 'application/json' },
71
164
  ...(body === undefined ? {} : { body: JSON.stringify(body) }),
72
165
  });
73
- return revision === boardsRevision ? payload : null;
166
+ }
167
+
168
+ /** Reserve navigation before an async save; later navigation cancels the pending jump. */
169
+ export function claimNavigation(): () => boolean {
170
+ const sequence = ++navigationSequence;
171
+ return () => sequence === navigationSequence;
74
172
  }
75
173
 
76
174
  /** Open a board, or Home with null. */
77
175
  export async function openBoard(id: string | null): Promise<void> {
78
176
  const sequence = ++openRequestSequence;
79
- const revision = boardsRevision;
177
+ navigationSequence++;
178
+ lifecycleTarget.value = null;
179
+ const epoch = catalogEpoch;
80
180
  let payload: BoardsPayload | null = null;
81
181
  const request = openRequestQueue.then(async () => {
82
- payload = await post('openBoard', '/api/canvas/boards/open', 'POST', { id }, revision);
182
+ if (epoch === catalogEpoch) payload = await post('openBoard', '/api/canvas/boards/open', 'POST', { id });
83
183
  });
84
184
  openRequestQueue = request.catch(() => undefined);
85
185
  await request;
86
- // post drops replies superseded by SSE; also ignore an older open request.
87
- if (sequence === openRequestSequence) applyBoards(payload);
186
+ if (sequence === openRequestSequence && epoch === catalogEpoch && payload) {
187
+ batch(() => {
188
+ applyBoards(payload);
189
+ if (id !== null) browsingMemory.value = false;
190
+ });
191
+ }
192
+ }
193
+
194
+ /** Used by wiki links, graph nodes and qualified library hits; never focus a stale board's same-ID card. */
195
+ export async function openMemoryTarget(target: MemoryTarget, options: { review?: boolean } = {}): Promise<boolean> {
196
+ if (options.review && !knowledgeLifecycleEnabled.value) return false;
197
+ const lifecycleGeneration = lifecycleUiGeneration;
198
+ const opening = openBoard(target.boardId);
199
+ const sequence = navigationSequence;
200
+ await opening;
201
+ const incarnation = boardIncarnation;
202
+ if (!target.cardId) return sequence === navigationSequence && activeBoardId.value === target.boardId;
203
+ // A slow layout fetch must not turn a card jump into only a board switch.
204
+ for (let attempt = 0; attempt < 400; attempt++) {
205
+ if (
206
+ sequence !== navigationSequence ||
207
+ activeBoardId.value !== target.boardId ||
208
+ boardIncarnation !== incarnation ||
209
+ browsingMemory.value ||
210
+ (options.review && (!knowledgeLifecycleEnabled.value || lifecycleGeneration !== lifecycleUiGeneration))
211
+ )
212
+ return false;
213
+ if (hasInitialServerLayout.value && nodes.value.has(target.cardId)) {
214
+ const group = hiddenByCollapsedGroup.value.get(target.cardId);
215
+ if (group) toggleCollapsed(group);
216
+ focusNode(target.cardId);
217
+ if (options.review) lifecycleTarget.value = { boardId: target.boardId, nodeId: target.cardId, incarnation };
218
+ return true;
219
+ }
220
+ await new Promise((resolve) => window.setTimeout(resolve, 25));
221
+ }
222
+ return false;
88
223
  }
89
224
 
90
- export async function createAndOpenBoard(name: string): Promise<void> {
225
+ /** Create a board (filed in `folder` when given) and open it. */
226
+ export async function createAndOpenBoard(name: string, folder?: string | null): Promise<void> {
91
227
  const sequence = ++openRequestSequence;
228
+ navigationSequence++;
229
+ lifecycleTarget.value = null;
230
+ const epoch = catalogEpoch;
92
231
  const created = await requestJson<{ board?: BoardSummary } | null>('createBoard', '/api/canvas/boards', null, {
93
232
  method: 'POST',
94
233
  headers: { 'Content-Type': 'application/json' },
95
- body: JSON.stringify({ name }),
234
+ body: JSON.stringify({ name, ...(folder ? { folder } : {}) }),
96
235
  });
97
- if (created?.board && sequence === openRequestSequence) await openBoard(created.board.id);
236
+ if (created?.board && sequence === openRequestSequence && epoch === catalogEpoch) await openBoard(created.board.id);
98
237
  }
99
238
 
100
239
  export interface BoardCopyCard {
@@ -122,7 +261,7 @@ export async function previewBoardCopy(sourceBoardId: string): Promise<BoardCopy
122
261
  export async function createBoardFrom(opts: {
123
262
  sourceBoardId: string;
124
263
  name: string;
125
- category?: string;
264
+ folder?: string;
126
265
  nodeIds: string[];
127
266
  includeReadme: boolean;
128
267
  includeStructure: boolean;
@@ -141,13 +280,28 @@ export async function createBoardFrom(opts: {
141
280
  return result?.board ?? null;
142
281
  }
143
282
 
144
- /** Rename and/or re-file a board; `category: null` removes it from its category. */
145
- export async function updateBoard(id: string, patch: { name?: string; category?: string | null }): Promise<void> {
283
+ /** Rename and/or re-file a board; `folder: null` unfiles it. */
284
+ export async function updateBoard(
285
+ id: string,
286
+ patch: {
287
+ name?: string;
288
+ folder?: string | null;
289
+ mapPosition?: { x: number; y: number } | null;
290
+ relationVocabulary?: RelationDefinition[];
291
+ },
292
+ ): Promise<boolean> {
146
293
  const payload = await post('updateBoard', `/api/canvas/boards/${encodeURIComponent(id)}`, 'PATCH', {
147
294
  ...patch,
148
- ...(patch.category === null ? { category: '' } : {}),
295
+ ...(patch.folder === null ? { folder: '' } : {}),
149
296
  });
150
297
  applyBoards(payload);
298
+ return payload !== null;
299
+ }
300
+
301
+ /** Pin a board into the agent's working set, or unpin it. */
302
+ export async function setBoardPinned(id: string, pinned: boolean): Promise<void> {
303
+ const url = `/api/canvas/boards/${encodeURIComponent(id)}/${pinned ? 'pin' : 'unpin'}`;
304
+ applyBoards(await post(pinned ? 'pinBoard' : 'unpinBoard', url, 'POST', {}));
151
305
  }
152
306
 
153
307
  export async function setBoardReadme(id: string, readmeNodeId: string | null): Promise<void> {
@@ -10,8 +10,10 @@ import {
10
10
  } from '../types';
11
11
  import { computeAutoArrange } from '../../shared/auto-arrange';
12
12
  import { canvasArea, canvasAreaCenter, canvasFitInsets } from '../canvas/canvas-area';
13
- import { pushCanvasUpdate, requestBestEffort, requestOk, updateViewportFromClient } from './intent-bridge';
13
+ import { activeBoardId, boardsLoaded } from './boards-store';
14
+ import { pushCanvasUpdate, requestBestEffort, requestJson, requestOk, updateViewportFromClient } from './intent-bridge';
14
15
  import { isHostedWorkbench } from './workbench-transport';
16
+ import { syncAttentionFromSse } from './attention-bridge';
15
17
 
16
18
  function logCanvasStoreError(action: string, error: unknown): void {
17
19
  console.error(`[canvas-store] ${action} failed`, error);
@@ -101,6 +103,7 @@ export function getNeighborNodeIds(nodeId: string | null, edgeMap: Map<string, C
101
103
 
102
104
  const neighborIds = new Set<string>();
103
105
  for (const edge of edgeMap.values()) {
106
+ if (edge.toBoardId !== undefined) continue;
104
107
  if (edge.from === nodeId) neighborIds.add(edge.to);
105
108
  if (edge.to === nodeId) neighborIds.add(edge.from);
106
109
  }
@@ -149,12 +152,15 @@ export function clearSelection(): void {
149
152
 
150
153
  /** Clear transient UI state that must never follow a node id onto another board. */
151
154
  export function resetCanvasInteractionState(): void {
155
+ cancelViewportAnimation();
152
156
  if (expandedCloseTimer !== null) {
153
157
  clearTimeout(expandedCloseTimer);
154
158
  expandedCloseTimer = null;
155
159
  }
156
160
  batch(() => {
157
161
  canvasInteractionEpoch.value += 1;
162
+ contextPinsRevision++;
163
+ contextPinsPendingWrites = 0;
158
164
  activeNodeId.value = null;
159
165
  expandedNodeId.value = null;
160
166
  pendingExpandedNodeCloseId.value = null;
@@ -394,6 +400,10 @@ export function getSelectedNodes(): CanvasNodeState[] {
394
400
  }
395
401
 
396
402
  // ── Context pin actions ──────────────────────────────────────
403
+ // Fence reads against both local writes and newer authoritative pin frames.
404
+ export let contextPinsRevision = 0;
405
+ export let contextPinsPendingWrites = 0;
406
+
397
407
  export function toggleContextPin(id: string): void {
398
408
  const next = new Set(contextPinnedNodeIds.value);
399
409
  if (next.has(id)) {
@@ -414,6 +424,14 @@ export function addContextPins(ids: string[]): void {
414
424
  syncContextPinsToServer(next);
415
425
  }
416
426
 
427
+ export function removeContextPins(ids: string[]): void {
428
+ const next = new Set(contextPinnedNodeIds.value);
429
+ for (const id of ids) next.delete(id);
430
+ contextPinnedNodeIds.value = next;
431
+ persistLayout();
432
+ syncContextPinsToServer(new Set(ids), 'remove');
433
+ }
434
+
417
435
  export function clearContextPins(): void {
418
436
  if (contextPinnedNodeIds.value.size === 0) return;
419
437
  contextPinnedNodeIds.value = new Set();
@@ -422,16 +440,51 @@ export function clearContextPins(): void {
422
440
  }
423
441
 
424
442
  export function replaceContextPinsFromServer(ids: string[]): void {
443
+ contextPinsRevision++;
425
444
  contextPinnedNodeIds.value = new Set(ids);
426
445
  }
427
446
 
428
- function syncContextPinsToServer(ids: Set<string>): void {
447
+ /** Reconcile dropped frames and refused/normalized writes without replaying old pins. */
448
+ export async function refreshContextPins(): Promise<void> {
449
+ if (contextPinsPendingWrites > 0) return;
450
+ const revision = contextPinsRevision;
451
+ const epoch = canvasInteractionEpoch.value;
452
+ // The agent brief filters retired cards. Only the raw event snapshot is authoritative
453
+ // for membership; consume its pin frame without replaying the rest of the snapshot.
454
+ const data = await requestJson<{
455
+ events?: Array<{ event: string; payload: { nodeIds?: string[]; boardId?: string | null } }>;
456
+ }>('refreshContextPins', '/api/workbench/poll', {});
457
+ const pins = data.events?.find((entry) => entry.event === 'context-pins-changed')?.payload;
458
+ if (
459
+ revision === contextPinsRevision &&
460
+ epoch === canvasInteractionEpoch.value &&
461
+ pins?.boardId === activeBoardId.value &&
462
+ Array.isArray(pins.nodeIds)
463
+ ) {
464
+ replaceContextPinsFromServer(pins.nodeIds);
465
+ syncAttentionFromSse({ event: 'context-pins-changed', data: pins });
466
+ }
467
+ }
468
+
469
+ function syncContextPinsToServer(ids: Set<string>, mode: 'set' | 'remove' = 'set'): void {
470
+ contextPinsRevision++;
471
+ contextPinsPendingWrites++;
472
+ const epoch = canvasInteractionEpoch.value;
429
473
  // Through the bridge so the write carries the workbench marker — a bare
430
474
  // fetch makes the human's pin look like an anonymous `api` agent.
431
475
  void requestBestEffort('syncContextPinsToServer', '/api/canvas/context-pins', {
432
476
  method: 'POST',
433
477
  headers: { 'Content-Type': 'application/json' },
434
- body: JSON.stringify({ nodeIds: Array.from(ids) }),
478
+ body: JSON.stringify({
479
+ nodeIds: Array.from(ids),
480
+ mode,
481
+ ...(boardsLoaded.value ? { expectedBoardId: activeBoardId.value } : {}),
482
+ }),
483
+ }).then(() => {
484
+ if (epoch !== canvasInteractionEpoch.value) return;
485
+ contextPinsPendingWrites--;
486
+ contextPinsRevision++;
487
+ void refreshContextPins();
435
488
  });
436
489
  }
437
490
 
@@ -535,7 +588,7 @@ export function removeEdgesForNode(nodeId: string): void {
535
588
  let changed = false;
536
589
  const next = new Map(edges.value);
537
590
  for (const [id, edge] of next) {
538
- if (edge.from === nodeId || edge.to === nodeId) {
591
+ if (edge.from === nodeId || (edge.toBoardId === undefined && edge.to === nodeId)) {
539
592
  next.delete(id);
540
593
  changed = true;
541
594
  }
@@ -617,11 +670,7 @@ export function replaceViewport(next: ViewportState): void {
617
670
  viewport.value = next;
618
671
  }
619
672
 
620
- export function commitViewport(next: ViewportState): void {
621
- commitViewportWithOptions(next);
622
- }
623
-
624
- function commitViewportWithOptions(next: ViewportState, options: { recordHistory?: boolean } = {}): void {
673
+ export function commitViewport(next: ViewportState, options: { recordHistory?: boolean } = {}): void {
625
674
  viewport.value = next;
626
675
  persistLayout(options);
627
676
  void updateViewportFromClient(next, options);
@@ -634,11 +683,22 @@ export function applyServerCanvasLayout(
634
683
  const nextNodes = new Map<string, CanvasNodeState>();
635
684
  let nextMaxZ = 1;
636
685
  for (const node of layout.nodes) {
637
- nextNodes.set(node.id, node);
686
+ const current = nodes.value.get(node.id);
687
+ // A poll captured before an acknowledged edit may arrive afterwards.
688
+ // Accept its geometry, but not older content or attribution. The initial
689
+ // layout belongs to a newly opened board, where IDs/revisions may repeat.
690
+ nextNodes.set(
691
+ node.id,
692
+ options.applyViewport !== true && current && (current.contentRevision ?? 0) > (node.contentRevision ?? 0)
693
+ ? { ...node, data: current.data, contentRevision: current.contentRevision, lastEditedBy: current.lastEditedBy }
694
+ : node,
695
+ );
638
696
  if (node.zIndex >= nextMaxZ) nextMaxZ = node.zIndex + 1;
639
697
  }
640
698
 
641
- const edgeSource = layout.edges.filter((edge) => nextNodes.has(edge.from) && nextNodes.has(edge.to));
699
+ const edgeSource = layout.edges.filter(
700
+ (edge) => nextNodes.has(edge.from) && (edge.toBoardId !== undefined || nextNodes.has(edge.to)),
701
+ );
642
702
  const nextEdges = new Map<string, CanvasEdge>();
643
703
  for (const edge of edgeSource) {
644
704
  nextEdges.set(edge.id, edge);
@@ -728,13 +788,39 @@ export function animateViewport(
728
788
  animationId = requestAnimationFrame(tick);
729
789
  } else {
730
790
  animationId = null;
731
- commitViewportWithOptions(target, options);
791
+ commitViewport(target, options);
732
792
  }
733
793
  }
734
794
 
735
795
  animationId = requestAnimationFrame(tick);
736
796
  }
737
797
 
798
+ /**
799
+ * Drive the camera along `frameAt(t)`, t in [0, 1], over `duration` ms — the
800
+ * tour/camera-move path. Shares animateViewport's slot, so a pan gesture or a
801
+ * capture frame (cancelViewportAnimation) interrupts it. Duration 0 jumps.
802
+ */
803
+ export function animateCameraPath(
804
+ frameAt: (t: number) => ViewportState,
805
+ duration: number,
806
+ onDone?: (end: ViewportState) => void,
807
+ ): void {
808
+ cancelViewportAnimation();
809
+ const start = performance.now();
810
+ const tick = (now: number) => {
811
+ const t = duration <= 0 ? 1 : Math.min(1, (now - start) / duration);
812
+ viewport.value = frameAt(t);
813
+ if (t < 1) {
814
+ animationId = requestAnimationFrame(tick);
815
+ } else {
816
+ animationId = null;
817
+ onDone?.(viewport.value);
818
+ }
819
+ };
820
+ if (duration <= 0) tick(start);
821
+ else animationId = requestAnimationFrame(tick);
822
+ }
823
+
738
824
  /** Cancel any in-flight viewport animation (e.g. when user starts dragging). */
739
825
  export function cancelViewportAnimation(): void {
740
826
  if (animationId !== null) {
@@ -769,6 +855,7 @@ export function persistLayout(options: { recordHistory?: boolean } = {}): void {
769
855
  id: e.id,
770
856
  from: e.from,
771
857
  to: e.to,
858
+ ...(e.toBoardId !== undefined ? { toBoardId: e.toBoardId } : {}),
772
859
  type: e.type,
773
860
  label: e.label,
774
861
  style: e.style,
@@ -0,0 +1,70 @@
1
+ import { signal } from '@preact/signals';
2
+ import { activeBoardId, boardList } from './boards-store';
3
+ import { requestJson } from './intent-bridge';
4
+
5
+ /**
6
+ * The context chip (docs/design/ContextChip.dc.html): what the agent's brief
7
+ * costs in tokens, and when each pinned board was last read. The brief fetch is a measurement, not a delivery: it says so with
8
+ * `x-pmx-proxied-read`, so it is never booked as an agent read — not even from
9
+ * an `?agent=` tab, whose requests carry no workbench marker. Otherwise the
10
+ * recorded read would announce itself and refetch the chip in a loop.
11
+ */
12
+
13
+ export interface BriefSize {
14
+ chars: number;
15
+ }
16
+
17
+ export interface BoardRead {
18
+ lastReadAt: string;
19
+ lastReadBy: string;
20
+ }
21
+
22
+ export const briefSize = signal<BriefSize | null>(null);
23
+ export const pinnedBoardReads = signal<Map<string, BoardRead | null>>(new Map());
24
+
25
+ /** A token count for display: 873, 1.1k. */
26
+ export function formatTokens(tokens: number): string {
27
+ return tokens >= 1000 ? `${(tokens / 1000).toFixed(1)}k` : `${Math.round(tokens)}`;
28
+ }
29
+
30
+ /** ≈ tokens for a character count (about four characters per token). */
31
+ export function approxTokens(chars: number): string {
32
+ return formatTokens(chars / 4);
33
+ }
34
+
35
+ let timer: ReturnType<typeof setTimeout> | null = null;
36
+ let generation = 0;
37
+
38
+ /** Debounced: pins, reads and board changes arrive in bursts; content edits wait longer. */
39
+ export function refreshContextChip(delayMs = 250): void {
40
+ if (timer) clearTimeout(timer);
41
+ timer = setTimeout(() => {
42
+ timer = null;
43
+ void load();
44
+ }, delayMs);
45
+ }
46
+
47
+ async function load(): Promise<void> {
48
+ const request = ++generation;
49
+ const boardId = activeBoardId.value;
50
+ // A late answer for a board you have since left, or an older request, must not paint the chip.
51
+ const current = () => request === generation && boardId === activeBoardId.value;
52
+ const brief = await requestJson<{ entries?: unknown[] } | null>('fetchBriefSize', '/api/canvas/context', null, {
53
+ headers: { 'x-pmx-proxied-read': '1' },
54
+ });
55
+ if (!current()) return;
56
+ if (brief?.entries) briefSize.value = { chars: JSON.stringify(brief).length };
57
+ const pinned = boardList.value.filter((board) => board.pin);
58
+ if (pinned.length === 0) {
59
+ if (current()) pinnedBoardReads.value = new Map();
60
+ return;
61
+ }
62
+ // One request for every pinned board's latest read.
63
+ const status = await requestJson<{ boards?: Record<string, BoardRead | null> } | null>(
64
+ 'fetchBoardReadStatus',
65
+ `/api/canvas/ax/context-status?boards=${pinned.map((board) => encodeURIComponent(board.id)).join(',')}`,
66
+ null,
67
+ );
68
+ if (current())
69
+ pinnedBoardReads.value = new Map(pinned.map((board) => [board.id, status?.boards?.[board.id] ?? null]));
70
+ }
@@ -1,5 +1,6 @@
1
1
  import { signal } from '@preact/signals';
2
2
  import { activeBoardId } from './boards-store';
3
+ import { edges } from './canvas-store';
3
4
  import { requestJson } from './intent-bridge';
4
5
 
5
6
  /**
@@ -28,14 +29,18 @@ interface ContextStatusResponse {
28
29
  ok?: boolean;
29
30
  boardId?: string | null;
30
31
  nodes?: NodeReadStatus[];
32
+ /** Per node, the newest links revision an agent read carried. */
33
+ links?: Record<string, number>;
31
34
  pins?: Record<string, ContextPinMeta>;
32
35
  }
33
36
 
34
37
  export const nodeReadStatus = signal<Map<string, NodeReadStatus>>(new Map());
35
38
  export const contextPinMeta = signal<Record<string, ContextPinMeta>>({});
39
+ export const seenLinks = signal<Record<string, number>>({});
36
40
 
37
41
  export function applyContextStatus(response: ContextStatusResponse): void {
38
42
  nodeReadStatus.value = new Map((response.nodes ?? []).map((entry) => [entry.nodeId, entry]));
43
+ seenLinks.value = response.links ?? {};
39
44
  contextPinMeta.value = response.pins ?? {};
40
45
  }
41
46
 
@@ -60,6 +65,54 @@ export function pinnedReadState(nodeId: string, currentRevision: number): Pinned
60
65
  return { kind: 'read', by: read.lastReadBy, at: read.lastReadAt, count: read.readCount };
61
66
  }
62
67
 
68
+ /**
69
+ * The marks a link's pill carries (docs/design/LinkAuthorship.dc.html):
70
+ * - `agent`: the agent that drew or last relabelled it — a ✦ that stays until
71
+ * a person edits, retypes or relabels the link. A read never clears it.
72
+ * - `notSeen`: a person's new or edited link that no read has carried yet —
73
+ * temporary, cleared by the agent's next read of either end. Quiet until an
74
+ * agent has read this board at all. An agent's link is never "not seen".
75
+ * The board's only person draws without a mark (another person's initial is
76
+ * Part 3, shared boards).
77
+ */
78
+ export interface LinkMarks {
79
+ agent: string | null;
80
+ notSeen: boolean;
81
+ }
82
+
83
+ export function linkMarks(edge: {
84
+ from: string;
85
+ to: string;
86
+ toBoardId?: string;
87
+ revision?: number;
88
+ changedBy?: { actor: string; source: string; agentId?: string };
89
+ }): LinkMarks {
90
+ if (edge.changedBy?.actor === 'agent') return { agent: writerName(edge.changedBy), notSeen: false };
91
+ const seen = seenLinks.value;
92
+ const notSeen =
93
+ !!edge.revision &&
94
+ Object.keys(seen).length > 0 &&
95
+ edge.revision > Math.max(seen[edge.from] ?? -1, edge.toBoardId === undefined ? (seen[edge.to] ?? -1) : -1);
96
+ return { agent: null, notSeen };
97
+ }
98
+
99
+ /**
100
+ * A card whose links changed after the last read that carried them (shown
101
+ * neutral, never amber). A link an agent drew does not count — that agent
102
+ * made it, and the link itself says "by <agent>". A removed link has no
103
+ * author left to check, so it counts.
104
+ */
105
+ export function linksChanged(nodeId: string, linksRevision: number): boolean {
106
+ const seen = seenLinks.value[nodeId];
107
+ if (seen === undefined || linksRevision <= seen) return false;
108
+ const unseen = [...edges.value.values()].filter(
109
+ (edge) =>
110
+ (edge.from === nodeId || (edge.toBoardId === undefined && edge.to === nodeId)) && (edge.revision ?? 0) > seen,
111
+ );
112
+ if (unseen.some((edge) => edge.changedBy?.actor !== 'agent')) return true;
113
+ return !unseen.some((edge) => edge.revision === linksRevision);
114
+ }
115
+
63
116
  /** Display name for an attributed writer: its agent id, else its transport label. */
64
117
  export function writerName(actor: { source: string; agentId?: string }): string {
65
118
  return actor.agentId?.trim() || actor.source;