pmx-canvas 0.6.5 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (181) hide show
  1. package/.github/extensions/pmx-canvas/extension.mjs +139 -14
  2. package/CHANGELOG.md +70 -0
  3. package/Readme.md +32 -6
  4. package/dist/canvas/global.css +510 -31
  5. package/dist/canvas/index.js +92 -77
  6. package/dist/types/cli/agent.d.ts +3 -0
  7. package/dist/types/cli/commands/backup.d.ts +1 -0
  8. package/dist/types/cli/commands/boards.d.ts +1 -0
  9. package/dist/types/cli/commands/export.d.ts +1 -0
  10. package/dist/types/cli/daemon.d.ts +5 -5
  11. package/dist/types/cli/shared.d.ts +3 -1
  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/nodes/AttachmentNode.d.ts +6 -0
  19. package/dist/types/client/nodes/BoardNode.d.ts +5 -0
  20. package/dist/types/client/state/boards-store.d.ts +79 -0
  21. package/dist/types/client/state/canvas-store.d.ts +2 -0
  22. package/dist/types/client/state/intent-bridge.d.ts +1 -0
  23. package/dist/types/client/state/session-store.d.ts +7 -0
  24. package/dist/types/client/types.d.ts +2 -2
  25. package/dist/types/json-render/catalog.d.ts +1 -1
  26. package/dist/types/json-render/charts/definitions.d.ts +1 -1
  27. package/dist/types/json-render/server.d.ts +6 -0
  28. package/dist/types/mcp/canvas-access.d.ts +10 -3
  29. package/dist/types/mcp/context-uri-template.d.ts +6 -0
  30. package/dist/types/server/agent-presence.d.ts +1 -1
  31. package/dist/types/server/attribution.d.ts +11 -0
  32. package/dist/types/server/ax-context.d.ts +3 -1
  33. package/dist/types/server/ax-interaction.d.ts +2 -2
  34. package/dist/types/server/ax-state.d.ts +13 -3
  35. package/dist/types/server/backup.d.ts +36 -0
  36. package/dist/types/server/board-export.d.ts +45 -0
  37. package/dist/types/server/canvas-db.d.ts +63 -9
  38. package/dist/types/server/canvas-operations.d.ts +23 -0
  39. package/dist/types/server/canvas-provenance.d.ts +1 -1
  40. package/dist/types/server/canvas-state.d.ts +140 -3
  41. package/dist/types/server/context-brief.d.ts +111 -0
  42. package/dist/types/server/context-reads.d.ts +55 -0
  43. package/dist/types/server/document-import.d.ts +66 -0
  44. package/dist/types/server/index.d.ts +103 -4
  45. package/dist/types/server/mutation-history.d.ts +1 -0
  46. package/dist/types/server/operations/invoker.d.ts +2 -0
  47. package/dist/types/server/operations/mcp.d.ts +7 -1
  48. package/dist/types/server/operations/ops/ax-shared.d.ts +2 -2
  49. package/dist/types/server/operations/ops/backup.d.ts +2 -0
  50. package/dist/types/server/operations/ops/boards.d.ts +10 -0
  51. package/dist/types/server/operations/ops/export.d.ts +2 -0
  52. package/dist/types/server/operations/ops/imports.d.ts +2 -0
  53. package/dist/types/server/operations/ops/nodes.d.ts +1 -1
  54. package/dist/types/server/operations/ops/query.d.ts +2 -0
  55. package/dist/types/server/operations/registry.d.ts +9 -1
  56. package/dist/types/server/operations/types.d.ts +1 -1
  57. package/dist/types/server/server.d.ts +1 -0
  58. package/dist/types/server/trace-manager.d.ts +7 -0
  59. package/dist/types/server/webpage-node.d.ts +22 -1
  60. package/dist/types/server/workbench-auth.d.ts +3 -0
  61. package/dist/types/shared/boards.d.ts +6 -0
  62. package/docs/RELEASE.md +5 -0
  63. package/docs/cli.md +73 -0
  64. package/docs/http-api.md +198 -2
  65. package/docs/mcp.md +71 -6
  66. package/docs/node-types.md +26 -0
  67. package/docs/plans/plan-010-attributed-ask-answers.md +39 -0
  68. package/docs/plans/plan-011-context-read-instrumentation.md +54 -0
  69. package/docs/plans/plan-012-many-boards-stage-1.md +98 -0
  70. package/docs/plans/plan-013-static-board-export.md +47 -0
  71. package/docs/plans/plan-014-connected-board-memory.md +140 -0
  72. package/docs/product-vision-2026-09.md +118 -18
  73. package/docs/sdk.md +50 -0
  74. package/package.json +1 -1
  75. package/skills/pmx-canvas/SKILL.md +68 -11
  76. package/skills/pmx-canvas/evals/evals.json +130 -0
  77. package/skills/pmx-canvas/references/full-reference.md +43 -7
  78. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +3 -0
  79. package/skills/pmx-canvas/references/installing-pmx-canvas.md +4 -4
  80. package/skills/pmx-canvas/references/tours-and-recording.md +69 -0
  81. package/skills/pmx-canvas-orchestration/SKILL.md +5 -0
  82. package/skills/pmx-canvas-testing/SKILL.md +11 -2
  83. package/src/cli/agent.ts +4 -0
  84. package/src/cli/commands/apps.ts +1 -1
  85. package/src/cli/commands/ax.ts +13 -0
  86. package/src/cli/commands/backup.ts +66 -0
  87. package/src/cli/commands/boards.ts +71 -0
  88. package/src/cli/commands/export.ts +37 -0
  89. package/src/cli/commands/nodes.ts +4 -1
  90. package/src/cli/commands/pins.ts +6 -2
  91. package/src/cli/commands/pump.ts +3 -0
  92. package/src/cli/commands/query.ts +33 -7
  93. package/src/cli/commands/view.ts +7 -5
  94. package/src/cli/daemon.ts +37 -7
  95. package/src/cli/index.ts +19 -9
  96. package/src/cli/shared.ts +11 -2
  97. package/src/client/App.tsx +21 -4
  98. package/src/client/canvas/AttentionToast.tsx +5 -3
  99. package/src/client/canvas/BoardCopyDialog.tsx +153 -0
  100. package/src/client/canvas/BoardFolderDialog.tsx +120 -0
  101. package/src/client/canvas/BoardLinkDialog.tsx +93 -0
  102. package/src/client/canvas/BoardSwitcher.tsx +149 -0
  103. package/src/client/canvas/CanvasNode.tsx +0 -1
  104. package/src/client/canvas/CanvasViewport.tsx +3 -0
  105. package/src/client/canvas/CommandPalette.tsx +107 -4
  106. package/src/client/canvas/ContextMenu.tsx +4 -1
  107. package/src/client/canvas/ExpandedNodeOverlay.tsx +3 -0
  108. package/src/client/canvas/ExportDialog.tsx +199 -0
  109. package/src/client/canvas/HomeView.tsx +277 -0
  110. package/src/client/canvas/Presentation.tsx +39 -9
  111. package/src/client/canvas/SelectionBar.tsx +0 -2
  112. package/src/client/canvas/SessionPanel.tsx +28 -1
  113. package/src/client/canvas/ToolRail.tsx +38 -3
  114. package/src/client/canvas/TopBar.tsx +34 -18
  115. package/src/client/canvas/import-files.ts +19 -1
  116. package/src/client/canvas/kind-colors.ts +1 -0
  117. package/src/client/icons.tsx +2 -0
  118. package/src/client/nodes/AttachmentNode.tsx +145 -0
  119. package/src/client/nodes/BoardNode.tsx +35 -0
  120. package/src/client/nodes/FileNode.tsx +6 -0
  121. package/src/client/nodes/MarkdownNode.tsx +47 -19
  122. package/src/client/nodes/ax-node-actions.ts +2 -5
  123. package/src/client/nodes/use-ax-surface-bridge.ts +1 -1
  124. package/src/client/state/attention-bridge.ts +3 -1
  125. package/src/client/state/boards-store.ts +167 -0
  126. package/src/client/state/canvas-store.ts +22 -0
  127. package/src/client/state/intent-bridge.ts +30 -2
  128. package/src/client/state/node-factory.ts +1 -0
  129. package/src/client/state/session-store.ts +5 -0
  130. package/src/client/state/sse-bridge.ts +52 -6
  131. package/src/client/theme/global.css +510 -31
  132. package/src/client/types.ts +2 -0
  133. package/src/json-render/server.ts +16 -9
  134. package/src/mcp/canvas-access.ts +59 -22
  135. package/src/mcp/context-uri-template.ts +28 -0
  136. package/src/mcp/server.ts +122 -15
  137. package/src/server/agent-context.ts +116 -29
  138. package/src/server/attribution.ts +29 -0
  139. package/src/server/ax-context.ts +4 -2
  140. package/src/server/ax-interaction.ts +1 -0
  141. package/src/server/ax-state-manager.ts +72 -3
  142. package/src/server/ax-state.ts +41 -0
  143. package/src/server/backup.ts +178 -0
  144. package/src/server/board-export.ts +686 -0
  145. package/src/server/canvas-db.ts +599 -107
  146. package/src/server/canvas-operations.ts +68 -9
  147. package/src/server/canvas-provenance.ts +1 -0
  148. package/src/server/canvas-schema.ts +12 -0
  149. package/src/server/canvas-state.ts +774 -45
  150. package/src/server/context-brief.ts +372 -0
  151. package/src/server/context-reads.ts +238 -0
  152. package/src/server/demo-state.json +659 -2
  153. package/src/server/document-import.ts +223 -0
  154. package/src/server/index.ts +231 -5
  155. package/src/server/mutation-history.ts +10 -13
  156. package/src/server/operations/composites.ts +26 -4
  157. package/src/server/operations/http.ts +9 -3
  158. package/src/server/operations/index.ts +8 -0
  159. package/src/server/operations/invoker.ts +14 -3
  160. package/src/server/operations/mcp.ts +50 -11
  161. package/src/server/operations/ops/app.ts +5 -0
  162. package/src/server/operations/ops/ax-read.ts +10 -6
  163. package/src/server/operations/ops/ax-timeline.ts +75 -0
  164. package/src/server/operations/ops/backup.ts +134 -0
  165. package/src/server/operations/ops/batch.ts +8 -1
  166. package/src/server/operations/ops/boards.ts +351 -0
  167. package/src/server/operations/ops/export.ts +74 -0
  168. package/src/server/operations/ops/ext-app.ts +4 -0
  169. package/src/server/operations/ops/imports.ts +236 -0
  170. package/src/server/operations/ops/nodes.ts +57 -5
  171. package/src/server/operations/ops/query.ts +206 -9
  172. package/src/server/operations/registry.ts +123 -31
  173. package/src/server/operations/types.ts +1 -1
  174. package/src/server/server.ts +146 -2
  175. package/src/server/spatial-analysis.ts +9 -17
  176. package/src/server/trace-manager.ts +19 -0
  177. package/src/server/web-artifacts.ts +2 -1
  178. package/src/server/webpage-node.ts +173 -13
  179. package/src/server/workbench-auth.ts +11 -0
  180. package/src/shared/boards.ts +18 -0
  181. package/src/shared/node-sizes.ts +1 -0
@@ -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,9 +1,10 @@
1
- import { useEffect, useState } from 'preact/hooks';
1
+ import { useEffect, useRef, useState } from 'preact/hooks';
2
2
  import { cameraSchema, interpolateCamera, resolveStop, type Camera, type Tour } from '../../shared/tour.js';
3
3
  import { canvasArea } from './canvas-area';
4
4
  import { cancelViewportAnimation, hasInitialServerLayout, nodes, viewport } from '../state/canvas-store';
5
5
  import { presenting, recordingCamera } from '../state/presentation';
6
6
  import { requestJson } from '../state/intent-bridge';
7
+ import { usePanZoom } from './use-pan-zoom';
7
8
 
8
9
  declare global {
9
10
  interface Window {
@@ -22,6 +23,17 @@ export function Presentation() {
22
23
  const [error, setError] = useState('');
23
24
  const active = presenting.value;
24
25
  const ready = hasInitialServerLayout.value;
26
+ const frame = useRef(0);
27
+ const moveCamera = (camera: Camera) => {
28
+ cancelAnimationFrame(frame.current);
29
+ viewport.value = camera;
30
+ };
31
+ // A separate gesture surface keeps cards read-only and camera changes local.
32
+ const navigationRef = usePanZoom({
33
+ viewport,
34
+ onViewportChange: moveCamera,
35
+ onViewportCommit: moveCamera,
36
+ });
25
37
  useEffect(() => {
26
38
  const params = new URLSearchParams(location.search);
27
39
  presenting.value = params.get('present') === '1';
@@ -76,20 +88,20 @@ export function Presentation() {
76
88
  const from = { ...viewport.value };
77
89
  const start = performance.now();
78
90
  const duration = (stop.duration ?? 1) * 1000;
79
- let frame = 0;
80
91
  const tick = (now: number) => {
81
92
  if (recordingCamera.value) return;
82
93
  const t = duration === 0 ? 1 : Math.min(1, (now - start) / duration);
83
94
  viewport.value = interpolateCamera(from, target, t, area.width, area.height, stop.easing, stop.pullback);
84
- if (t < 1) frame = requestAnimationFrame(tick);
95
+ if (t < 1) frame.current = requestAnimationFrame(tick);
85
96
  };
86
- frame = requestAnimationFrame(tick);
87
- return () => cancelAnimationFrame(frame);
97
+ frame.current = requestAnimationFrame(tick);
98
+ return () => cancelAnimationFrame(frame.current);
88
99
  }, [active, index, tour]);
89
100
  useEffect(() => {
90
101
  if (!active) return;
91
102
  const key = (event: KeyboardEvent) => {
92
103
  event.stopImmediatePropagation();
104
+ if (event.target instanceof HTMLButtonElement && ['Enter', ' '].includes(event.key)) return;
93
105
  if (['Escape', 'ArrowRight', 'ArrowDown', 'ArrowLeft', 'ArrowUp', ' '].includes(event.key))
94
106
  event.preventDefault();
95
107
  if (event.key === 'Escape') presenting.value = false;
@@ -100,9 +112,27 @@ export function Presentation() {
100
112
  document.addEventListener('keydown', key, true);
101
113
  return () => document.removeEventListener('keydown', key, true);
102
114
  }, [active, tour]);
103
- return active && error ? (
104
- <div class="presentation-error" role="alert">
105
- {error}
106
- </div>
115
+ return active ? (
116
+ <>
117
+ {!recordingCamera.value && (
118
+ <div
119
+ class="presentation-navigation"
120
+ ref={navigationRef}
121
+ onPointerDown={(event) => {
122
+ if (event.button === 1 || event.button === 2) cancelAnimationFrame(frame.current);
123
+ }}
124
+ onContextMenu={(event) => event.preventDefault()}
125
+ >
126
+ <button type="button" class="presentation-exit" onClick={() => (presenting.value = false)}>
127
+ Exit presentation
128
+ </button>
129
+ </div>
130
+ )}
131
+ {error && (
132
+ <div class="presentation-error" role="alert">
133
+ {error}
134
+ </div>
135
+ )}
136
+ </>
107
137
  ) : null;
108
138
  }
@@ -8,7 +8,6 @@ import {
8
8
  clearSelection,
9
9
  contextPinnedNodeIds,
10
10
  distributeSelection,
11
- removeNode,
12
11
  selectedNodeIds,
13
12
  } from '../state/canvas-store';
14
13
  import { createEdgeFromClient, createGroupFromClient, removeNodeFromClient } from '../state/intent-bridge';
@@ -51,7 +50,6 @@ export function SelectionBar() {
51
50
  const handleDelete = useCallback(() => {
52
51
  const ids = Array.from(selectedNodeIds.value);
53
52
  for (const id of ids) {
54
- removeNode(id);
55
53
  void removeNodeFromClient(id);
56
54
  }
57
55
  clearSelection();
@@ -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
  ))}
@@ -30,8 +30,12 @@ import {
30
30
  forceDirectedArrange,
31
31
  nodes,
32
32
  traceEnabled,
33
+ viewport,
33
34
  } from '../state/canvas-store';
35
+ import { importFiles } from './import-files';
36
+ import { canvasArea } from './canvas-area';
34
37
  import { saveCanvasTheme } from '../state/intent-bridge';
38
+ import { openBoard } from '../state/boards-store';
35
39
  import { createNodeInView } from './create-in-view';
36
40
  import { askText } from './TextPrompt';
37
41
  import { FeedbackDialog } from './FeedbackDialog';
@@ -175,6 +179,7 @@ export function ToolRail({
175
179
  const [openMenu, setOpenMenu] = useState<null | 'theme' | 'annotate'>(null);
176
180
  const [menuAnchor, setMenuAnchor] = useState<{ top: number; bottom: number; right: number } | null>(null);
177
181
  const railRef = useRef<HTMLDivElement>(null);
182
+ const documentInput = useRef<HTMLInputElement>(null);
178
183
 
179
184
  const toggleMenu = (menu: 'theme' | 'annotate') => (e: MouseEvent) => {
180
185
  if (openMenu === menu) {
@@ -240,9 +245,18 @@ export function ToolRail({
240
245
  return (
241
246
  <>
242
247
  <div class="tool-rail" ref={railRef} role="toolbar" aria-label="Canvas tools" aria-orientation="vertical">
243
- <span class="rail-brand" title="PMX Canvas" aria-label="PMX Canvas">
244
- <IconLogo size={22} />
245
- </span>
248
+ <RailButton
249
+ label="Home"
250
+ ariaLabel="PMX Canvas — Home"
251
+ onClick={() => {
252
+ setOpenMenu(null);
253
+ void openBoard(null).catch((error) => logRailError('openHome', error));
254
+ }}
255
+ >
256
+ <span class="rail-brand">
257
+ <IconLogo size={22} />
258
+ </span>
259
+ </RailButton>
246
260
 
247
261
  <RailButton
248
262
  label="Bug and feedback"
@@ -322,6 +336,27 @@ export function ToolRail({
322
336
  <RailButton label="File" shortcut="Shift+F" onClick={() => promptedCreate('file')}>
323
337
  <IconNodeFile size={15} />
324
338
  </RailButton>
339
+ <RailButton label="Attach document" onClick={() => documentInput.current?.click()}>
340
+ <span aria-hidden="true">↥</span>
341
+ </RailButton>
342
+ <input
343
+ ref={documentInput}
344
+ type="file"
345
+ hidden
346
+ multiple
347
+ accept=".pdf,.ppt,.pptx,.xls,.xlsx,.doc,.docx,.odt,.ods,.odp"
348
+ aria-label="Attach documents"
349
+ onChange={(event) => {
350
+ const area = canvasArea();
351
+ const view = viewport.value;
352
+ void importFiles(
353
+ Array.from(event.currentTarget.files ?? []),
354
+ (area.width / 2 - view.x) / view.scale,
355
+ (area.height / 2 - view.y) / view.scale,
356
+ );
357
+ event.currentTarget.value = '';
358
+ }}
359
+ />
325
360
  <RailButton label="Webpage" shortcut="W" onClick={() => promptedCreate('webpage')}>
326
361
  <IconNodeWebpage size={15} />
327
362
  </RailButton>
@@ -1,3 +1,6 @@
1
+ import { exportDialogOpen } from './ExportDialog';
2
+ import { activeBoardId } from '../state/boards-store';
3
+ import { BoardSwitcher } from './BoardSwitcher';
1
4
  import type { ComponentChildren } from 'preact';
2
5
  import { presenting } from '../state/presentation';
3
6
  import { useEffect, useState } from 'preact/hooks';
@@ -264,8 +267,8 @@ export function TopBar() {
264
267
  const isTraceOn = traceEnabled.value;
265
268
  const traceNodeCount = Array.from(nodes.value.values()).filter((n) => n.type === 'trace').length;
266
269
 
267
- // Board identity = workspace basename, fetched once from /health. The
268
- // canvas has no board-name concept — the workspace IS the board.
270
+ // Until the board list arrives, the switcher shows the workspace basename
271
+ // (fetched once from /health).
269
272
  const [workspaceName, setWorkspaceName] = useState<string>('');
270
273
  useEffect(() => {
271
274
  let cancelled = false;
@@ -314,9 +317,7 @@ export function TopBar() {
314
317
  >
315
318
  <span class={`connection-dot ${degraded ?? status}`} aria-label={`Canvas status: ${statusTitle}`} />
316
319
  </BarHint>
317
- <span class="top-bar-title" title={workspaceName || 'PMX Canvas'}>
318
- {workspaceName || 'PMX Canvas'}
319
- </span>
320
+ <BoardSwitcher fallbackName={workspaceName || 'PMX Canvas'} />
320
321
  <span class="top-bar-meta hud-collapsible-text">{sessionId.value ? sessionId.value.slice(0, 12) : '…'}</span>
321
322
  <span class="top-bar-meta hud-collapsible-text">{countsLabel}</span>
322
323
 
@@ -330,20 +331,35 @@ export function TopBar() {
330
331
 
331
332
  <div class="top-bar-sep" />
332
333
 
333
- <BarHint
334
- label="Present board"
335
- body="Arrow keys or Space step through stops; Esc exits. Without a saved tour, groups are read top-to-bottom, left-to-right."
336
- >
337
- <button
338
- type="button"
339
- class="present-button"
340
- onClick={() => {
341
- presenting.value = true;
342
- }}
334
+ {activeBoardId.value && (
335
+ <BarHint label="Export board" body="One HTML file of this board that anyone can open, no install needed.">
336
+ <button
337
+ type="button"
338
+ class="present-button"
339
+ onClick={() => {
340
+ exportDialogOpen.value = true;
341
+ }}
342
+ >
343
+ Export
344
+ </button>
345
+ </BarHint>
346
+ )}
347
+ {activeBoardId.value && (
348
+ <BarHint
349
+ label="Present board"
350
+ body="Arrow keys or Space step through stops; Esc exits. Without a saved tour, groups are read top-to-bottom, left-to-right."
343
351
  >
344
- Present
345
- </button>
346
- </BarHint>
352
+ <button
353
+ type="button"
354
+ class="present-button"
355
+ onClick={() => {
356
+ presenting.value = true;
357
+ }}
358
+ >
359
+ Present
360
+ </button>
361
+ </BarHint>
362
+ )}
347
363
  <BarHint label="Zoom out" shortcut={modChord('\u2212')}>
348
364
  <button type="button" class="top-bar-btn" onClick={() => zoomByFactor(1 / 1.25)} aria-label="Zoom out">
349
365
  <IconZoomOut />
@@ -1,4 +1,6 @@
1
- import { createNodeFromClient } from '../state/intent-bridge';
1
+ import { createNodeFromClient, requestJson } from '../state/intent-bridge';
2
+ import { activeBoardId } from '../state/boards-store';
3
+ import { showToast } from '../state/attention-bridge';
2
4
 
3
5
  const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'bmp', 'ico', 'avif']);
4
6
  const MD_EXTS = new Set(['md', 'mdx', 'markdown']);
@@ -17,6 +19,7 @@ export function nodeTypeFromFilename(name: string): 'image' | 'markdown' | 'file
17
19
  * markdown, everything else a file node with the text inlined.
18
20
  */
19
21
  export async function importFiles(files: File[], baseWx: number, baseWy: number): Promise<void> {
22
+ const boardId = activeBoardId.value;
20
23
  const nodeW = 400;
21
24
  const nodeH = 300;
22
25
  const spacing = 20;
@@ -32,12 +35,24 @@ export async function importFiles(files: File[], baseWx: number, baseWy: number)
32
35
  const type = nodeTypeFromFilename(file.name);
33
36
  const fileName = file.name;
34
37
 
38
+ if (/\.(pdf|pptx?|xlsx?|docx?|od[pts])$/i.test(fileName)) {
39
+ if (!boardId || file.size === 0 || file.size > 20 * 1024 * 1024) {
40
+ showToast('remove', 'File not attached', 'Open a board and choose a nonempty file under 20 MiB.');
41
+ continue;
42
+ }
43
+ const query = new URLSearchParams({ boardId, name: fileName, mime: file.type, x: String(wx), y: String(wy) });
44
+ showToast('context', 'Attaching document', fileName);
45
+ await requestJson('attachDocument', `/api/canvas/attachments?${query}`, null, { method: 'POST', body: file });
46
+ continue;
47
+ }
48
+ if (activeBoardId.value !== boardId) return;
35
49
  if (type === 'image') {
36
50
  const reader = new FileReader();
37
51
  const dataUri: string = await new Promise((resolve) => {
38
52
  reader.onload = () => resolve(reader.result as string);
39
53
  reader.readAsDataURL(file);
40
54
  });
55
+ if (activeBoardId.value !== boardId) return;
41
56
  await createNodeFromClient({
42
57
  type: 'image',
43
58
  title: fileName,
@@ -46,9 +61,11 @@ export async function importFiles(files: File[], baseWx: number, baseWy: number)
46
61
  y: wy,
47
62
  width: nodeW,
48
63
  height: nodeH,
64
+ ...(boardId ? { boardId } : {}),
49
65
  });
50
66
  } else {
51
67
  const text = await file.text();
68
+ if (activeBoardId.value !== boardId) return;
52
69
  const isWide = type === 'markdown' || type === 'file';
53
70
  await createNodeFromClient({
54
71
  type,
@@ -58,6 +75,7 @@ export async function importFiles(files: File[], baseWx: number, baseWy: number)
58
75
  y: wy,
59
76
  width: isWide ? 720 : nodeW,
60
77
  height: isWide ? 500 : nodeH,
78
+ ...(boardId ? { boardId } : {}),
61
79
  });
62
80
  }
63
81
  }
@@ -7,6 +7,7 @@ export const KIND_COLOR: Record<CanvasNodeState['type'], string> = {
7
7
  webpage: 'var(--c-warn)',
8
8
  'json-render': 'var(--c-ok)',
9
9
  graph: 'var(--c-purple)',
10
+ board: 'var(--c-accent)',
10
11
  prompt: 'var(--c-accent)',
11
12
  response: 'var(--c-ok)',
12
13
  status: 'var(--c-warn)',
@@ -538,6 +538,8 @@ export function getNodeIcon(type: string): (p: IconProps) => JSX.Element {
538
538
  return IconNodeJsonRender;
539
539
  case 'graph':
540
540
  return IconNodeGraph;
541
+ case 'board':
542
+ return IconNodeGraph;
541
543
  case 'html':
542
544
  return IconNodeWebpage;
543
545
  default: