pmx-canvas 0.6.4 → 0.7.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 (204) hide show
  1. package/.github/extensions/pmx-canvas/extension.mjs +67 -3
  2. package/CHANGELOG.md +73 -0
  3. package/Readme.md +35 -5
  4. package/dist/canvas/global.css +498 -28
  5. package/dist/canvas/index.js +110 -95
  6. package/dist/canvas/mermaid-entry.js +258 -258
  7. package/dist/types/cli/agent.d.ts +4 -0
  8. package/dist/types/cli/commands/backup.d.ts +1 -0
  9. package/dist/types/cli/commands/boards.d.ts +1 -0
  10. package/dist/types/cli/commands/export.d.ts +1 -0
  11. package/dist/types/cli/commands/record.d.ts +12 -0
  12. package/dist/types/client/canvas/BoardCopyDialog.d.ts +5 -0
  13. package/dist/types/client/canvas/BoardFolderDialog.d.ts +7 -0
  14. package/dist/types/client/canvas/BoardLinkDialog.d.ts +3 -0
  15. package/dist/types/client/canvas/BoardSwitcher.d.ts +8 -0
  16. package/dist/types/client/canvas/ExportDialog.d.ts +7 -0
  17. package/dist/types/client/canvas/HomeView.d.ts +9 -0
  18. package/dist/types/client/canvas/Presentation.d.ts +13 -0
  19. package/dist/types/client/canvas/use-node-drag.d.ts +2 -1
  20. package/dist/types/client/nodes/AttachmentNode.d.ts +6 -0
  21. package/dist/types/client/nodes/BoardNode.d.ts +5 -0
  22. package/dist/types/client/nodes/McpAppNode.d.ts +24 -1
  23. package/dist/types/client/state/boards-store.d.ts +79 -0
  24. package/dist/types/client/state/canvas-store.d.ts +2 -0
  25. package/dist/types/client/state/intent-bridge.d.ts +1 -0
  26. package/dist/types/client/state/presentation.d.ts +3 -0
  27. package/dist/types/client/state/session-store.d.ts +7 -0
  28. package/dist/types/client/types.d.ts +2 -2
  29. package/dist/types/json-render/catalog.d.ts +3 -3
  30. package/dist/types/json-render/charts/definitions.d.ts +1 -1
  31. package/dist/types/json-render/server.d.ts +8 -0
  32. package/dist/types/mcp/canvas-access.d.ts +10 -3
  33. package/dist/types/mcp/context-uri-template.d.ts +6 -0
  34. package/dist/types/server/agent-presence.d.ts +1 -1
  35. package/dist/types/server/attribution.d.ts +11 -0
  36. package/dist/types/server/ax-context.d.ts +3 -1
  37. package/dist/types/server/ax-interaction.d.ts +2 -2
  38. package/dist/types/server/ax-state.d.ts +13 -3
  39. package/dist/types/server/backup.d.ts +36 -0
  40. package/dist/types/server/board-export.d.ts +45 -0
  41. package/dist/types/server/canvas-db.d.ts +65 -9
  42. package/dist/types/server/canvas-operations.d.ts +45 -0
  43. package/dist/types/server/canvas-provenance.d.ts +1 -1
  44. package/dist/types/server/canvas-schema.d.ts +1 -0
  45. package/dist/types/server/canvas-state.d.ts +146 -3
  46. package/dist/types/server/canvas-validation.d.ts +3 -0
  47. package/dist/types/server/context-brief.d.ts +111 -0
  48. package/dist/types/server/context-reads.d.ts +55 -0
  49. package/dist/types/server/document-import.d.ts +66 -0
  50. package/dist/types/server/html-surface.d.ts +1 -1
  51. package/dist/types/server/index.d.ts +107 -4
  52. package/dist/types/server/mutation-history.d.ts +1 -0
  53. package/dist/types/server/operations/invoker.d.ts +2 -0
  54. package/dist/types/server/operations/mcp.d.ts +7 -1
  55. package/dist/types/server/operations/ops/app.d.ts +2 -0
  56. package/dist/types/server/operations/ops/ax-shared.d.ts +2 -2
  57. package/dist/types/server/operations/ops/backup.d.ts +2 -0
  58. package/dist/types/server/operations/ops/boards.d.ts +10 -0
  59. package/dist/types/server/operations/ops/export.d.ts +2 -0
  60. package/dist/types/server/operations/ops/imports.d.ts +2 -0
  61. package/dist/types/server/operations/ops/json-render.d.ts +2 -0
  62. package/dist/types/server/operations/ops/nodes.d.ts +4 -2
  63. package/dist/types/server/operations/ops/query.d.ts +2 -0
  64. package/dist/types/server/operations/registry.d.ts +9 -1
  65. package/dist/types/server/operations/types.d.ts +1 -1
  66. package/dist/types/server/server.d.ts +1 -0
  67. package/dist/types/server/trace-manager.d.ts +7 -0
  68. package/dist/types/server/web-artifacts.d.ts +3 -0
  69. package/dist/types/server/webpage-node.d.ts +22 -1
  70. package/dist/types/server/workbench-auth.d.ts +3 -0
  71. package/dist/types/shared/boards.d.ts +6 -0
  72. package/dist/types/shared/tour.d.ts +72 -0
  73. package/docs/RELEASE.md +5 -0
  74. package/docs/cli.md +157 -0
  75. package/docs/http-api.md +277 -6
  76. package/docs/mcp.md +96 -7
  77. package/docs/node-types.md +106 -0
  78. package/docs/plans/plan-010-attributed-ask-answers.md +39 -0
  79. package/docs/plans/plan-011-context-read-instrumentation.md +54 -0
  80. package/docs/plans/plan-012-many-boards-stage-1.md +98 -0
  81. package/docs/plans/plan-013-static-board-export.md +47 -0
  82. package/docs/plans/plan-014-connected-board-memory.md +140 -0
  83. package/docs/product-vision-2026-09.md +118 -18
  84. package/docs/sdk.md +50 -0
  85. package/package.json +1 -1
  86. package/skills/pmx-canvas/SKILL.md +62 -5
  87. package/skills/pmx-canvas/references/full-reference.md +44 -8
  88. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +3 -0
  89. package/skills/pmx-canvas/references/installing-pmx-canvas.md +4 -4
  90. package/skills/pmx-canvas-orchestration/SKILL.md +5 -0
  91. package/skills/pmx-canvas-testing/SKILL.md +4 -0
  92. package/src/cli/agent.ts +9 -0
  93. package/src/cli/commands/ax.ts +13 -0
  94. package/src/cli/commands/backup.ts +66 -0
  95. package/src/cli/commands/boards.ts +71 -0
  96. package/src/cli/commands/export.ts +30 -0
  97. package/src/cli/commands/nodes.ts +14 -1
  98. package/src/cli/commands/pins.ts +6 -2
  99. package/src/cli/commands/pump.ts +3 -0
  100. package/src/cli/commands/query.ts +33 -7
  101. package/src/cli/commands/record.ts +218 -0
  102. package/src/cli/commands/view.ts +7 -5
  103. package/src/cli/index.ts +21 -9
  104. package/src/client/App.tsx +28 -4
  105. package/src/client/canvas/AgentPresenceLayer.tsx +1 -1
  106. package/src/client/canvas/BoardCopyDialog.tsx +153 -0
  107. package/src/client/canvas/BoardFolderDialog.tsx +120 -0
  108. package/src/client/canvas/BoardLinkDialog.tsx +93 -0
  109. package/src/client/canvas/BoardSwitcher.tsx +149 -0
  110. package/src/client/canvas/CanvasNode.tsx +8 -0
  111. package/src/client/canvas/CanvasViewport.tsx +3 -0
  112. package/src/client/canvas/CommandPalette.tsx +107 -4
  113. package/src/client/canvas/ContextMenu.tsx +4 -0
  114. package/src/client/canvas/ExpandedNodeOverlay.tsx +3 -0
  115. package/src/client/canvas/ExportDialog.tsx +197 -0
  116. package/src/client/canvas/HomeView.tsx +277 -0
  117. package/src/client/canvas/IntentLayer.tsx +9 -2
  118. package/src/client/canvas/Presentation.tsx +108 -0
  119. package/src/client/canvas/SessionPanel.tsx +28 -1
  120. package/src/client/canvas/ToolRail.tsx +38 -3
  121. package/src/client/canvas/TopBar.tsx +49 -7
  122. package/src/client/canvas/import-files.ts +19 -1
  123. package/src/client/canvas/kind-colors.ts +1 -0
  124. package/src/client/canvas/use-node-drag.ts +8 -3
  125. package/src/client/icons.tsx +2 -0
  126. package/src/client/mermaid-entry.ts +39 -0
  127. package/src/client/nodes/AttachmentNode.tsx +148 -0
  128. package/src/client/nodes/BoardNode.tsx +35 -0
  129. package/src/client/nodes/FileNode.tsx +6 -0
  130. package/src/client/nodes/HtmlNode.tsx +12 -13
  131. package/src/client/nodes/MarkdownNode.tsx +47 -19
  132. package/src/client/nodes/McpAppNode.tsx +105 -13
  133. package/src/client/nodes/MermaidNode.tsx +3 -2
  134. package/src/client/nodes/ax-node-actions.ts +2 -5
  135. package/src/client/nodes/use-ax-surface-bridge.ts +1 -1
  136. package/src/client/state/boards-store.ts +167 -0
  137. package/src/client/state/canvas-store.ts +22 -0
  138. package/src/client/state/intent-bridge.ts +30 -2
  139. package/src/client/state/intent-store.ts +5 -1
  140. package/src/client/state/node-factory.ts +1 -0
  141. package/src/client/state/presentation.ts +7 -0
  142. package/src/client/state/session-store.ts +8 -0
  143. package/src/client/state/sse-bridge.ts +52 -5
  144. package/src/client/theme/global.css +498 -28
  145. package/src/client/types.ts +2 -0
  146. package/src/json-render/server.ts +45 -9
  147. package/src/mcp/canvas-access.ts +59 -22
  148. package/src/mcp/context-uri-template.ts +28 -0
  149. package/src/mcp/server.ts +122 -15
  150. package/src/server/agent-context.ts +116 -29
  151. package/src/server/agent-presence.ts +15 -9
  152. package/src/server/attribution.ts +29 -0
  153. package/src/server/ax-context.ts +4 -2
  154. package/src/server/ax-interaction.ts +1 -0
  155. package/src/server/ax-state-manager.ts +72 -3
  156. package/src/server/ax-state.ts +41 -0
  157. package/src/server/backup.ts +178 -0
  158. package/src/server/board-export.ts +686 -0
  159. package/src/server/canvas-db.ts +612 -105
  160. package/src/server/canvas-operations.ts +183 -31
  161. package/src/server/canvas-provenance.ts +1 -0
  162. package/src/server/canvas-schema.ts +22 -0
  163. package/src/server/canvas-state.ts +812 -48
  164. package/src/server/canvas-validation.ts +9 -1
  165. package/src/server/context-brief.ts +372 -0
  166. package/src/server/context-reads.ts +238 -0
  167. package/src/server/demo-state.json +670 -9
  168. package/src/server/document-import.ts +223 -0
  169. package/src/server/html-primitives.ts +199 -91
  170. package/src/server/html-surface.ts +8 -3
  171. package/src/server/index.ts +243 -5
  172. package/src/server/mutation-history.ts +10 -13
  173. package/src/server/operations/composites.ts +29 -5
  174. package/src/server/operations/http.ts +9 -3
  175. package/src/server/operations/index.ts +8 -0
  176. package/src/server/operations/invoker.ts +14 -3
  177. package/src/server/operations/mcp.ts +50 -11
  178. package/src/server/operations/ops/app.ts +15 -0
  179. package/src/server/operations/ops/ax-read.ts +10 -6
  180. package/src/server/operations/ops/ax-timeline.ts +75 -0
  181. package/src/server/operations/ops/backup.ts +134 -0
  182. package/src/server/operations/ops/batch.ts +20 -4
  183. package/src/server/operations/ops/boards.ts +351 -0
  184. package/src/server/operations/ops/export.ts +74 -0
  185. package/src/server/operations/ops/ext-app.ts +4 -0
  186. package/src/server/operations/ops/groups.ts +14 -7
  187. package/src/server/operations/ops/imports.ts +220 -0
  188. package/src/server/operations/ops/json-render.ts +37 -5
  189. package/src/server/operations/ops/nodes.ts +99 -21
  190. package/src/server/operations/ops/query.ts +206 -9
  191. package/src/server/operations/ops/viewport.ts +32 -0
  192. package/src/server/operations/registry.ts +129 -31
  193. package/src/server/operations/types.ts +1 -1
  194. package/src/server/scope-fence.ts +1 -1
  195. package/src/server/server.ts +155 -8
  196. package/src/server/spatial-analysis.ts +9 -17
  197. package/src/server/trace-manager.ts +19 -0
  198. package/src/server/web-artifacts.ts +11 -3
  199. package/src/server/webpage-node.ts +173 -13
  200. package/src/server/workbench-auth.ts +11 -0
  201. package/src/server/workboard.ts +3 -3
  202. package/src/shared/boards.ts +18 -0
  203. package/src/shared/node-sizes.ts +1 -0
  204. package/src/shared/tour.ts +87 -0
@@ -0,0 +1,197 @@
1
+ import { signal } from '@preact/signals';
2
+ import { useEffect, useState } from 'preact/hooks';
3
+ import { requestJson } from '../state/intent-bridge';
4
+ import { activeBoardId } from '../state/boards-store';
5
+
6
+ interface ExportManifest {
7
+ boardId: string;
8
+ boardName: string;
9
+ cards: number;
10
+ connections: number;
11
+ frames: number;
12
+ placeholders: Array<{ nodeId: string; title: string; reason: string }>;
13
+ files: Array<{ nodeId: string; path: string; included: boolean }>;
14
+ attachments: Array<{ nodeId: string; name: string }>;
15
+ sourceDerivedCards: Array<{ nodeId: string; included: boolean }>;
16
+ embeddedImages: number;
17
+ remoteImages: string[];
18
+ links: string[];
19
+ frameNetworkDestinations: string[];
20
+ embeddedCodeCanAccessNetwork: boolean;
21
+ }
22
+
23
+ interface ExportResult {
24
+ path: string;
25
+ url: string;
26
+ bytes: number;
27
+ }
28
+
29
+ export const exportDialogOpen = signal(false);
30
+
31
+ function sizeLabel(bytes: number): string {
32
+ return bytes >= 1024 * 1024 ? `${(bytes / 1024 / 1024).toFixed(1)} MB` : `${Math.ceil(bytes / 1024)} KB`;
33
+ }
34
+
35
+ /**
36
+ * Export the open board as one HTML file (plan 013). An export is a share, so
37
+ * the dialog lists what goes into the file before it is written; file contents
38
+ * stay out unless the owner ticks them in.
39
+ */
40
+ export function ExportDialog() {
41
+ const [includeFiles, setIncludeFiles] = useState(false);
42
+ const [includeDerivedText, setIncludeDerivedText] = useState(false);
43
+ const [manifest, setManifest] = useState<ExportManifest | null>(null);
44
+ const [result, setResult] = useState<ExportResult | null>(null);
45
+ const [busy, setBusy] = useState(false);
46
+ const open = exportDialogOpen.value;
47
+ const boardId = activeBoardId.value;
48
+
49
+ useEffect(() => {
50
+ if (!open || !boardId) return;
51
+ let current = true;
52
+ setResult(null);
53
+ setManifest(null);
54
+ void requestJson<{ manifest?: ExportManifest } | null>(
55
+ 'exportPreview',
56
+ `/api/canvas/export/preview?board=${encodeURIComponent(boardId)}&includeFiles=${includeFiles}&includeDerivedText=${includeDerivedText}`,
57
+ null,
58
+ ).then((body) => {
59
+ if (current) setManifest(body?.manifest ?? null);
60
+ });
61
+ return () => {
62
+ current = false;
63
+ };
64
+ }, [open, includeFiles, includeDerivedText, boardId]);
65
+
66
+ useEffect(() => {
67
+ setIncludeDerivedText(false);
68
+ }, [open, boardId]);
69
+
70
+ useEffect(() => {
71
+ if (!open) return;
72
+ const onKey = (e: KeyboardEvent) => {
73
+ if (e.key !== 'Escape') return;
74
+ e.stopPropagation();
75
+ exportDialogOpen.value = false;
76
+ };
77
+ window.addEventListener('keydown', onKey, { capture: true });
78
+ return () => window.removeEventListener('keydown', onKey, { capture: true });
79
+ }, [open]);
80
+
81
+ if (!open) return null;
82
+
83
+ const write = async () => {
84
+ if (!manifest || manifest.boardId !== activeBoardId.value) return;
85
+ setBusy(true);
86
+ const body = await requestJson<ExportResult | null>('exportBoard', '/api/canvas/export', null, {
87
+ method: 'POST',
88
+ headers: { 'Content-Type': 'application/json' },
89
+ body: JSON.stringify({ board: manifest.boardId, includeFiles, includeDerivedText }),
90
+ });
91
+ setBusy(false);
92
+ if (body?.path && activeBoardId.value === manifest.boardId) setResult(body);
93
+ };
94
+
95
+ return (
96
+ <div class="text-prompt-backdrop" onPointerDown={() => (exportDialogOpen.value = false)}>
97
+ <div
98
+ class="text-prompt export-dialog"
99
+ role="dialog"
100
+ aria-label="Export board"
101
+ data-testid="export-dialog"
102
+ onPointerDown={(e) => e.stopPropagation()}
103
+ >
104
+ <div class="text-prompt-title">Export {manifest ? `“${manifest.boardName}”` : 'board'}</div>
105
+ {!manifest ? (
106
+ <div class="export-dialog-note">Reading the board…</div>
107
+ ) : (
108
+ <>
109
+ <div class="export-dialog-note">
110
+ One HTML file anyone can open without installing anything. It will contain:
111
+ </div>
112
+ <ul class="export-dialog-list">
113
+ <li>
114
+ {manifest.cards} card{manifest.cards !== 1 ? 's' : ''} and {manifest.connections} connection
115
+ {manifest.connections !== 1 ? 's' : ''}
116
+ </li>
117
+ {manifest.frames > 0 && (
118
+ <li>{manifest.frames} embedded pages, charts or diagrams, with the code that draws them</li>
119
+ )}
120
+ {manifest.embeddedImages > 0 && <li>{manifest.embeddedImages} local images</li>}
121
+ {manifest.placeholders.length > 0 && (
122
+ <li>
123
+ Shown as placeholders:{' '}
124
+ {manifest.placeholders.map((entry) => `${entry.title} (${entry.reason})`).join(', ')}
125
+ </li>
126
+ )}
127
+ {manifest.remoteImages.length > 0 && (
128
+ <li>Web images loaded when opened: {manifest.remoteImages.join(', ')}</li>
129
+ )}
130
+ {manifest.links.length > 0 && <li>Links included in cards: {manifest.links.join(', ')}</li>}
131
+ {manifest.frameNetworkDestinations.length > 0 && (
132
+ <li>
133
+ Network destinations referenced by embedded pages: {manifest.frameNetworkDestinations.join(', ')}
134
+ </li>
135
+ )}
136
+ {manifest.embeddedCodeCanAccessNetwork && (
137
+ <li>
138
+ Warning: embedded code can access the network. Scripts may contact destinations that cannot be listed
139
+ in advance.
140
+ </li>
141
+ )}
142
+ </ul>
143
+ {(manifest.attachments?.length ?? 0) > 0 && (
144
+ <p>Original attachments are excluded. Their filenames remain visible.</p>
145
+ )}
146
+ {(manifest.sourceDerivedCards?.length ?? 0) > 0 && (
147
+ <label class="export-dialog-files">
148
+ <input
149
+ type="checkbox"
150
+ checked={includeDerivedText}
151
+ onChange={(event) => setIncludeDerivedText(event.currentTarget.checked)}
152
+ />
153
+ <span>Include imported document text ({manifest.sourceDerivedCards.length} cards)</span>
154
+ </label>
155
+ )}
156
+ {manifest.files.length > 0 && (
157
+ <label class="export-dialog-files">
158
+ <input
159
+ type="checkbox"
160
+ checked={includeFiles}
161
+ onChange={(e) => setIncludeFiles((e.target as HTMLInputElement).checked)}
162
+ />
163
+ <span>
164
+ Include the contents of {manifest.files.length} local file
165
+ {manifest.files.length !== 1 ? 's' : ''}: {manifest.files.map((file) => file.path).join(', ')}
166
+ </span>
167
+ </label>
168
+ )}
169
+ </>
170
+ )}
171
+ {result && (
172
+ <div class="export-dialog-result" data-testid="export-result">
173
+ <div>
174
+ Saved ({sizeLabel(result.bytes)}): <code>{result.path}</code>
175
+ </div>
176
+ <div class="export-dialog-links">
177
+ <a href={result.url} target="_blank" rel="noopener noreferrer">
178
+ Open
179
+ </a>
180
+ <a href={`${result.url}?download=1`}>Download</a>
181
+ </div>
182
+ </div>
183
+ )}
184
+ <div class="text-prompt-actions">
185
+ <button type="button" class="text-prompt-cancel" onClick={() => (exportDialogOpen.value = false)}>
186
+ {result ? 'Done' : 'Cancel'}
187
+ </button>
188
+ {!result && (
189
+ <button type="button" class="text-prompt-confirm" disabled={!manifest || busy} onClick={() => void write()}>
190
+ {busy ? 'Exporting…' : 'Export'}
191
+ </button>
192
+ )}
193
+ </div>
194
+ </div>
195
+ </div>
196
+ );
197
+ }
@@ -0,0 +1,277 @@
1
+ import { useEffect, useRef, useState } from 'preact/hooks';
2
+ import type { ComponentChildren } from 'preact';
3
+ import {
4
+ boardList,
5
+ activeBoardId,
6
+ createAndOpenBoard,
7
+ deleteBoard,
8
+ openBoard,
9
+ updateBoard,
10
+ type BoardSummary,
11
+ } from '../state/boards-store';
12
+ import { categoryAncestors, MIGRATED_SNAPSHOT_CATEGORY } from '../../shared/boards.js';
13
+ import { askText } from './TextPrompt';
14
+ import { BoardFolderDialog } from './BoardFolderDialog';
15
+ import { requestJson } from '../state/intent-bridge';
16
+ import { focusNode, nodes } from '../state/canvas-store';
17
+ import { BoardCopyDialog } from './BoardCopyDialog';
18
+
19
+ interface LibraryHit {
20
+ boardId: string;
21
+ boardTitle: string;
22
+ cardId: string;
23
+ title: string | null;
24
+ snippet: string;
25
+ }
26
+
27
+ function timeAgo(iso: string | null): string {
28
+ if (!iso) return 'never opened';
29
+ const mins = Math.floor((Date.now() - new Date(iso).getTime()) / 60000);
30
+ if (mins < 1) return 'just now';
31
+ if (mins < 60) return `${mins}m ago`;
32
+ const hrs = Math.floor(mins / 60);
33
+ if (hrs < 24) return `${hrs}h ago`;
34
+ const days = Math.floor(hrs / 24);
35
+ if (days < 30) return `${days}d ago`;
36
+ return new Date(iso).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
37
+ }
38
+
39
+ function nodesLabel(count: number): string {
40
+ return `${count} node${count !== 1 ? 's' : ''}`;
41
+ }
42
+
43
+ export async function promptNewBoard(): Promise<void> {
44
+ const name = await askText('New board', 'Board name', { confirm: 'Create' });
45
+ if (name) await createAndOpenBoard(name);
46
+ }
47
+
48
+ const COLLAPSED_KEY = 'pmx-canvas-home-collapsed';
49
+
50
+ /** Which categories this viewer folded away; the migrated-snapshot shelf starts folded. */
51
+ function readCollapsed(): Set<string> {
52
+ try {
53
+ const raw = localStorage.getItem(COLLAPSED_KEY);
54
+ if (raw) return new Set(JSON.parse(raw) as string[]);
55
+ } catch {
56
+ // Storage can be unavailable (private windows, embedded panes): fall back to the default.
57
+ }
58
+ return new Set([MIGRATED_SNAPSHOT_CATEGORY]);
59
+ }
60
+
61
+ function writeCollapsed(collapsed: Set<string>): void {
62
+ try {
63
+ localStorage.setItem(COLLAPSED_KEY, JSON.stringify([...collapsed]));
64
+ } catch {
65
+ // Folding still works for this visit without storage.
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Home (plan 012): the view when no board is open — every board, filed under
71
+ * its category (most recently opened first within each). Categories keep the
72
+ * library findable as it grows into the working memory of many sessions.
73
+ * Deleting asks in place, naming the board and what it holds; browser confirm
74
+ * dialogs are no-ops in agent panes.
75
+ */
76
+ export function HomeView() {
77
+ const boards = boardList.value;
78
+ const [confirmingDelete, setConfirmingDelete] = useState<string | null>(null);
79
+ const [moving, setMoving] = useState<string | null>(null);
80
+ const [copying, setCopying] = useState<string | null>(null);
81
+ const [search, setSearch] = useState('');
82
+ const [hits, setHits] = useState<LibraryHit[]>([]);
83
+ const [collapsed, setCollapsed] = useState<Set<string>>(readCollapsed);
84
+ const openHitSequence = useRef(0);
85
+ const folders = [
86
+ ...new Set(boards.flatMap((board) => (board.category ? categoryAncestors(board.category) : []))),
87
+ ].sort((a, b) => a.localeCompare(b));
88
+ const query = search.trim().toLocaleLowerCase();
89
+ const visible = boards.filter((board) => `${board.name} ${board.category ?? ''}`.toLocaleLowerCase().includes(query));
90
+ const visibleFolders = new Set(visible.flatMap((board) => (board.category ? categoryAncestors(board.category) : [])));
91
+ const movingBoard = boards.find((board) => board.id === moving);
92
+ const copyingBoard = boards.find((board) => board.id === copying);
93
+
94
+ useEffect(() => {
95
+ const q = search.trim();
96
+ if (!q) {
97
+ setHits([]);
98
+ return;
99
+ }
100
+ let cancelled = false;
101
+ const timer = window.setTimeout(() => {
102
+ void requestJson<{ results?: LibraryHit[] } | null>(
103
+ 'searchLibrary',
104
+ `/api/canvas/search?q=${encodeURIComponent(q)}&scope=library&limit=30`,
105
+ null,
106
+ ).then((result) => {
107
+ if (!cancelled) setHits(result?.results ?? []);
108
+ });
109
+ }, 150);
110
+ return () => {
111
+ cancelled = true;
112
+ window.clearTimeout(timer);
113
+ };
114
+ }, [search]);
115
+
116
+ const openHit = async (hit: LibraryHit) => {
117
+ const sequence = ++openHitSequence.current;
118
+ await openBoard(hit.boardId);
119
+ for (let attempt = 0; attempt < 40; attempt++) {
120
+ if (sequence !== openHitSequence.current || activeBoardId.value !== hit.boardId) return;
121
+ if (nodes.value.has(hit.cardId)) {
122
+ focusNode(hit.cardId);
123
+ return;
124
+ }
125
+ await new Promise((resolve) => window.setTimeout(resolve, 25));
126
+ }
127
+ };
128
+
129
+ const toggle = (name: string) => {
130
+ const next = new Set(collapsed);
131
+ if (next.has(name)) next.delete(name);
132
+ else next.add(name);
133
+ setCollapsed(next);
134
+ writeCollapsed(next);
135
+ };
136
+
137
+ const rename = async (board: BoardSummary) => {
138
+ const name = await askText('Rename board', 'Board name', { initial: board.name, confirm: 'Rename' });
139
+ if (name && name !== board.name) await updateBoard(board.id, { name });
140
+ };
141
+
142
+ const row = (board: BoardSummary) =>
143
+ confirmingDelete === board.id ? (
144
+ <li key={board.id} class="home-board is-confirming" data-testid="home-board">
145
+ <div class="home-board-confirm-text">
146
+ Delete <strong>{board.name}</strong> and its {nodesLabel(board.nodeCount)} and snapshots? This can't be
147
+ undone.
148
+ </div>
149
+ <div class="home-board-actions">
150
+ <button type="button" class="home-board-action" onClick={() => setConfirmingDelete(null)}>
151
+ Cancel
152
+ </button>
153
+ <button
154
+ type="button"
155
+ class="home-board-action is-danger"
156
+ onClick={() => {
157
+ setConfirmingDelete(null);
158
+ void deleteBoard(board.id);
159
+ }}
160
+ >
161
+ Delete board
162
+ </button>
163
+ </div>
164
+ </li>
165
+ ) : (
166
+ <li key={board.id} class="home-board" data-testid="home-board">
167
+ <button type="button" class="home-board-open" onClick={() => void openBoard(board.id)}>
168
+ <span class="home-board-name">{board.name}</span>
169
+ <span class="home-board-meta">
170
+ {nodesLabel(board.nodeCount)} · {timeAgo(board.lastOpenedAt)}
171
+ </span>
172
+ </button>
173
+ <div class="home-board-actions">
174
+ <button type="button" class="home-board-action" aria-haspopup="dialog" onClick={() => setMoving(board.id)}>
175
+ Move
176
+ </button>
177
+ <button type="button" class="home-board-action" aria-haspopup="dialog" onClick={() => setCopying(board.id)}>
178
+ Create from…
179
+ </button>
180
+ <button type="button" class="home-board-action" onClick={() => void rename(board)}>
181
+ Rename
182
+ </button>
183
+ <button type="button" class="home-board-action" onClick={() => setConfirmingDelete(board.id)}>
184
+ Delete
185
+ </button>
186
+ </div>
187
+ </li>
188
+ );
189
+
190
+ const folder = (path: string, depth: number): ComponentChildren => {
191
+ const children = folders.filter(
192
+ (candidate) => candidate.slice(0, candidate.lastIndexOf('/')) === path && candidate.includes('/'),
193
+ );
194
+ const direct = visible.filter((board) => board.category === path);
195
+ const count = visible.filter((board) => board.category === path || board.category?.startsWith(`${path}/`)).length;
196
+ const expanded = !!query || !collapsed.has(path);
197
+ return (
198
+ <section key={path} class="home-section" data-testid="home-section" data-folder={path} aria-label={path}>
199
+ <button type="button" class="home-section-head" aria-expanded={expanded} onClick={() => toggle(path)}>
200
+ <svg class="home-section-chevron" viewBox="0 0 20 20" width="18" height="18" fill="none" aria-hidden="true">
201
+ <path d="m7.5 4.75 5.25 5.25-5.25 5.25" />
202
+ </svg>
203
+ <svg class="home-section-folder" viewBox="0 0 20 20" width="18" height="18" fill="none" aria-hidden="true">
204
+ <path d="M2.75 5.75A1.75 1.75 0 0 1 4.5 4h3l1.75 2h6.25a1.75 1.75 0 0 1 1.75 1.75v6.5A1.75 1.75 0 0 1 15.5 16h-11a1.75 1.75 0 0 1-1.75-1.75v-8.5Z" />
205
+ </svg>
206
+ <span class="home-section-name">{path.split('/').at(-1)}</span>
207
+ <span class="home-section-count">{count}</span>
208
+ </button>
209
+ {expanded && (
210
+ <div class="home-folder-children" style={{ marginLeft: depth < 5 ? '14px' : '0' }}>
211
+ {children.filter((child) => visibleFolders.has(child)).map((child) => folder(child, depth + 1))}
212
+ {direct.length > 0 && (
213
+ <ul class="home-view-list" aria-label={`Boards in ${path}`}>
214
+ {direct.map(row)}
215
+ </ul>
216
+ )}
217
+ </div>
218
+ )}
219
+ </section>
220
+ );
221
+ };
222
+
223
+ return (
224
+ <div class="home-view" data-testid="home-view">
225
+ <div class="home-view-inner">
226
+ <div class="home-view-head">
227
+ <h1 class="home-view-title">Boards</h1>
228
+ <button type="button" class="home-view-new" onClick={() => void promptNewBoard()}>
229
+ New board
230
+ </button>
231
+ </div>
232
+ <input
233
+ class="text-prompt-input home-search"
234
+ type="search"
235
+ aria-label="Find boards and folders"
236
+ placeholder="Find boards and folders…"
237
+ value={search}
238
+ onInput={(event) => setSearch(event.currentTarget.value)}
239
+ />
240
+ {boards.length === 0 ? (
241
+ <div class="home-view-empty">No boards yet. Create one, or let an agent start writing — it opens one.</div>
242
+ ) : query && hits.length > 0 ? (
243
+ <ul class="home-view-list" aria-label="Library search results">
244
+ {hits.map((hit) => (
245
+ <li class="home-board" key={`${hit.boardId}:${hit.cardId}`}>
246
+ <button type="button" class="home-board-open" onClick={() => void openHit(hit)}>
247
+ <span class="home-board-name">{hit.title || hit.boardTitle}</span>
248
+ <span class="home-board-meta">
249
+ {hit.boardTitle} · {hit.snippet}
250
+ </span>
251
+ </button>
252
+ </li>
253
+ ))}
254
+ </ul>
255
+ ) : visible.length === 0 ? (
256
+ <div class="home-view-empty" role="status">
257
+ No boards match “{search}”.
258
+ </div>
259
+ ) : (
260
+ <>
261
+ {folders.filter((path) => !path.includes('/') && visibleFolders.has(path)).map((path) => folder(path, 0))}
262
+ {visible.some((board) => !board.category) && (
263
+ <section aria-label="Unfiled boards">
264
+ {folders.length > 0 && <div class="home-folder-hint">Unfiled boards</div>}
265
+ <ul class="home-view-list">{visible.filter((board) => !board.category).map(row)}</ul>
266
+ </section>
267
+ )}
268
+ </>
269
+ )}
270
+ </div>
271
+ {movingBoard && (
272
+ <BoardFolderDialog key={movingBoard.id} board={movingBoard} folders={folders} onClose={() => setMoving(null)} />
273
+ )}
274
+ {copyingBoard && <BoardCopyDialog key={copyingBoard.id} board={copyingBoard} onClose={() => setCopying(null)} />}
275
+ </div>
276
+ );
277
+ }
@@ -1,6 +1,6 @@
1
1
  import { useEffect } from 'preact/hooks';
2
2
  import { nodes } from '../state/canvas-store';
3
- import { hoveredIntentId, intents, type ClientIntent } from '../state/intent-store';
3
+ import { hoveredIntentId, intents, removeIntent, type ClientIntent } from '../state/intent-store';
4
4
  import { vetoGhostIntent } from '../state/intent-bridge';
5
5
  import { getNodeIcon } from '../icons';
6
6
  import { TYPE_LABELS } from '../types';
@@ -241,7 +241,14 @@ export function IntentLayer() {
241
241
  if (list.length === 0) return null;
242
242
 
243
243
  return (
244
- <div class="intent-layer">
244
+ <div
245
+ class="intent-layer"
246
+ onAnimationEnd={(event) => {
247
+ if (event.animationName !== 'intent-settle' && event.animationName !== 'intent-dissolve') return;
248
+ const id = (event.target as HTMLElement).dataset.intentId;
249
+ if (id && intents.value.get(id)?.phase !== 'forming') removeIntent(id);
250
+ }}
251
+ >
245
252
  <svg
246
253
  class="intent-line-layer"
247
254
  style={{
@@ -0,0 +1,108 @@
1
+ import { useEffect, useState } from 'preact/hooks';
2
+ import { cameraSchema, interpolateCamera, resolveStop, type Camera, type Tour } from '../../shared/tour.js';
3
+ import { canvasArea } from './canvas-area';
4
+ import { cancelViewportAnimation, hasInitialServerLayout, nodes, viewport } from '../state/canvas-store';
5
+ import { presenting, recordingCamera } from '../state/presentation';
6
+ import { requestJson } from '../state/intent-bridge';
7
+
8
+ declare global {
9
+ interface Window {
10
+ pmxCapture?: {
11
+ ready: () => boolean;
12
+ area: typeof canvasArea;
13
+ camera: () => Camera;
14
+ frame: (camera: Camera) => Promise<Camera>;
15
+ };
16
+ }
17
+ }
18
+
19
+ export function Presentation() {
20
+ const [tour, setTour] = useState<Tour>({ stops: [] });
21
+ const [index, setIndex] = useState(0);
22
+ const [error, setError] = useState('');
23
+ const active = presenting.value;
24
+ const ready = hasInitialServerLayout.value;
25
+ useEffect(() => {
26
+ const params = new URLSearchParams(location.search);
27
+ presenting.value = params.get('present') === '1';
28
+ recordingCamera.value = params.get('capture') === '1';
29
+ window.pmxCapture = {
30
+ ready: () => hasInitialServerLayout.value,
31
+ area: canvasArea,
32
+ camera: () => ({ ...viewport.value }),
33
+ frame: async (camera) => {
34
+ recordingCamera.value = true;
35
+ cancelViewportAnimation();
36
+ viewport.value = cameraSchema.parse(camera);
37
+ // Two animation frames: Preact has committed and the browser has had
38
+ // a paint opportunity before the screenshot command is acknowledged.
39
+ await new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())));
40
+ return { ...viewport.value };
41
+ },
42
+ };
43
+ return () => {
44
+ delete window.pmxCapture;
45
+ presenting.value = false;
46
+ recordingCamera.value = false;
47
+ };
48
+ }, []);
49
+ useEffect(() => {
50
+ if (!active || !ready) return;
51
+ let cancelled = false;
52
+ void requestJson('read tour', '/api/canvas/tour', { tour: { stops: [] } as Tour }).then((result) => {
53
+ if (!cancelled) {
54
+ setTour(result.tour);
55
+ setIndex(0);
56
+ }
57
+ });
58
+ return () => {
59
+ cancelled = true;
60
+ };
61
+ }, [active, ready]);
62
+ useEffect(() => {
63
+ if (!active || recordingCamera.value) return;
64
+ const stop = tour.stops[index];
65
+ if (!stop) return;
66
+ const area = canvasArea();
67
+ let target: Camera;
68
+ try {
69
+ target = resolveStop(stop, [...nodes.value.values()], area.width, area.height);
70
+ setError('');
71
+ } catch (error) {
72
+ setError(error instanceof Error ? error.message : String(error));
73
+ return;
74
+ }
75
+ cancelViewportAnimation();
76
+ const from = { ...viewport.value };
77
+ const start = performance.now();
78
+ const duration = (stop.duration ?? 1) * 1000;
79
+ let frame = 0;
80
+ const tick = (now: number) => {
81
+ if (recordingCamera.value) return;
82
+ const t = duration === 0 ? 1 : Math.min(1, (now - start) / duration);
83
+ viewport.value = interpolateCamera(from, target, t, area.width, area.height, stop.easing, stop.pullback);
84
+ if (t < 1) frame = requestAnimationFrame(tick);
85
+ };
86
+ frame = requestAnimationFrame(tick);
87
+ return () => cancelAnimationFrame(frame);
88
+ }, [active, index, tour]);
89
+ useEffect(() => {
90
+ if (!active) return;
91
+ const key = (event: KeyboardEvent) => {
92
+ event.stopImmediatePropagation();
93
+ if (['Escape', 'ArrowRight', 'ArrowDown', 'ArrowLeft', 'ArrowUp', ' '].includes(event.key))
94
+ event.preventDefault();
95
+ if (event.key === 'Escape') presenting.value = false;
96
+ if (['ArrowRight', 'ArrowDown', ' '].includes(event.key))
97
+ setIndex((i) => Math.max(0, Math.min(tour.stops.length - 1, i + 1)));
98
+ if (['ArrowLeft', 'ArrowUp'].includes(event.key)) setIndex((i) => Math.max(0, i - 1));
99
+ };
100
+ document.addEventListener('keydown', key, true);
101
+ return () => document.removeEventListener('keydown', key, true);
102
+ }, [active, tour]);
103
+ return active && error ? (
104
+ <div class="presentation-error" role="alert">
105
+ {error}
106
+ </div>
107
+ ) : null;
108
+ }
@@ -8,6 +8,7 @@ import {
8
8
  type ApprovalGateView,
9
9
  endSession,
10
10
  heldGates,
11
+ resolvedGates,
11
12
  pendingGates,
12
13
  refreshTimeline,
13
14
  reopenGate,
@@ -195,6 +196,28 @@ function HeldGateRow({ gate }: { gate: ApprovalGateView }) {
195
196
  );
196
197
  }
197
198
 
199
+ function answerLabel(gate: ApprovalGateView): string {
200
+ const by = gate.resolvedBy;
201
+ if (!by) return gate.status;
202
+ const decision = gate.status === 'approved' ? 'Approved' : 'Rejected';
203
+ if (by.actor === 'human') return `${decision} by you`;
204
+ if (by.actor === 'system') return `${decision} by system`;
205
+ const name = by.agentId || by.source || 'agent';
206
+ return `${gate.selfAnswer ? 'Self-' : ''}${decision.toLowerCase()} by ${name}`;
207
+ }
208
+
209
+ function ResolvedGateRow({ gate }: { gate: ApprovalGateView }) {
210
+ return (
211
+ <li class={`session-item status-${gate.status}`} data-gate-id={gate.id}>
212
+ <span class="session-glyph" aria-hidden="true" />
213
+ <div class="session-item-main">
214
+ <span class="session-item-title">{gate.title}</span>
215
+ <div class="session-item-detail">{answerLabel(gate)}</div>
216
+ </div>
217
+ </li>
218
+ );
219
+ }
220
+
198
221
  /**
199
222
  * Scope row under the header: the fence the human granted, or the affordance
200
223
  * to grant one from the current selection. The server refuses agent writes
@@ -296,6 +319,7 @@ export function SessionPanel() {
296
319
  const items = sessionWorkItems.value;
297
320
  const gates = pendingGates.value;
298
321
  const held = heldGates.value;
322
+ const resolved = resolvedGates.value;
299
323
  const entries = timelineEntries.value;
300
324
  const [workOpen, setWorkOpen] = useState(false);
301
325
  // Which filter chip the pointer/focus is on — drives the caption under the
@@ -435,7 +459,7 @@ export function SessionPanel() {
435
459
  </span>
436
460
  </button>
437
461
  {showList &&
438
- (gates.length === 0 && held.length === 0 && items.length === 0 ? (
462
+ (gates.length === 0 && held.length === 0 && resolved.length === 0 && items.length === 0 ? (
439
463
  <div class="session-empty">No work items yet — the agent's tasks and gates appear here.</div>
440
464
  ) : (
441
465
  <ul class="session-list" aria-live="polite" aria-label="Work items and gates">
@@ -445,6 +469,9 @@ export function SessionPanel() {
445
469
  {held.map((gate) => (
446
470
  <HeldGateRow key={gate.id} gate={gate} />
447
471
  ))}
472
+ {resolved.map((gate) => (
473
+ <ResolvedGateRow key={gate.id} gate={gate} />
474
+ ))}
448
475
  {items.map((item) => (
449
476
  <WorkItemRow key={item.id} item={item} />
450
477
  ))}