pmx-canvas 0.6.5 → 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 (169) hide show
  1. package/.github/extensions/pmx-canvas/extension.mjs +67 -3
  2. package/CHANGELOG.md +54 -0
  3. package/Readme.md +29 -5
  4. package/dist/canvas/global.css +440 -24
  5. package/dist/canvas/index.js +90 -75
  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/client/canvas/BoardCopyDialog.d.ts +5 -0
  11. package/dist/types/client/canvas/BoardFolderDialog.d.ts +7 -0
  12. package/dist/types/client/canvas/BoardLinkDialog.d.ts +3 -0
  13. package/dist/types/client/canvas/BoardSwitcher.d.ts +8 -0
  14. package/dist/types/client/canvas/ExportDialog.d.ts +7 -0
  15. package/dist/types/client/canvas/HomeView.d.ts +9 -0
  16. package/dist/types/client/nodes/AttachmentNode.d.ts +6 -0
  17. package/dist/types/client/nodes/BoardNode.d.ts +5 -0
  18. package/dist/types/client/state/boards-store.d.ts +79 -0
  19. package/dist/types/client/state/canvas-store.d.ts +2 -0
  20. package/dist/types/client/state/intent-bridge.d.ts +1 -0
  21. package/dist/types/client/state/session-store.d.ts +7 -0
  22. package/dist/types/client/types.d.ts +2 -2
  23. package/dist/types/json-render/catalog.d.ts +1 -1
  24. package/dist/types/json-render/charts/definitions.d.ts +1 -1
  25. package/dist/types/json-render/server.d.ts +6 -0
  26. package/dist/types/mcp/canvas-access.d.ts +10 -3
  27. package/dist/types/mcp/context-uri-template.d.ts +6 -0
  28. package/dist/types/server/agent-presence.d.ts +1 -1
  29. package/dist/types/server/attribution.d.ts +11 -0
  30. package/dist/types/server/ax-context.d.ts +3 -1
  31. package/dist/types/server/ax-interaction.d.ts +2 -2
  32. package/dist/types/server/ax-state.d.ts +13 -3
  33. package/dist/types/server/backup.d.ts +36 -0
  34. package/dist/types/server/board-export.d.ts +45 -0
  35. package/dist/types/server/canvas-db.d.ts +63 -9
  36. package/dist/types/server/canvas-operations.d.ts +23 -0
  37. package/dist/types/server/canvas-provenance.d.ts +1 -1
  38. package/dist/types/server/canvas-state.d.ts +140 -3
  39. package/dist/types/server/context-brief.d.ts +111 -0
  40. package/dist/types/server/context-reads.d.ts +55 -0
  41. package/dist/types/server/document-import.d.ts +66 -0
  42. package/dist/types/server/index.d.ts +103 -4
  43. package/dist/types/server/mutation-history.d.ts +1 -0
  44. package/dist/types/server/operations/invoker.d.ts +2 -0
  45. package/dist/types/server/operations/mcp.d.ts +7 -1
  46. package/dist/types/server/operations/ops/ax-shared.d.ts +2 -2
  47. package/dist/types/server/operations/ops/backup.d.ts +2 -0
  48. package/dist/types/server/operations/ops/boards.d.ts +10 -0
  49. package/dist/types/server/operations/ops/export.d.ts +2 -0
  50. package/dist/types/server/operations/ops/imports.d.ts +2 -0
  51. package/dist/types/server/operations/ops/nodes.d.ts +1 -1
  52. package/dist/types/server/operations/ops/query.d.ts +2 -0
  53. package/dist/types/server/operations/registry.d.ts +9 -1
  54. package/dist/types/server/operations/types.d.ts +1 -1
  55. package/dist/types/server/server.d.ts +1 -0
  56. package/dist/types/server/trace-manager.d.ts +7 -0
  57. package/dist/types/server/webpage-node.d.ts +22 -1
  58. package/dist/types/server/workbench-auth.d.ts +3 -0
  59. package/dist/types/shared/boards.d.ts +6 -0
  60. package/docs/RELEASE.md +5 -0
  61. package/docs/cli.md +68 -0
  62. package/docs/http-api.md +198 -2
  63. package/docs/mcp.md +71 -6
  64. package/docs/node-types.md +26 -0
  65. package/docs/plans/plan-010-attributed-ask-answers.md +39 -0
  66. package/docs/plans/plan-011-context-read-instrumentation.md +54 -0
  67. package/docs/plans/plan-012-many-boards-stage-1.md +98 -0
  68. package/docs/plans/plan-013-static-board-export.md +47 -0
  69. package/docs/plans/plan-014-connected-board-memory.md +140 -0
  70. package/docs/product-vision-2026-09.md +118 -18
  71. package/docs/sdk.md +50 -0
  72. package/package.json +1 -1
  73. package/skills/pmx-canvas/SKILL.md +53 -4
  74. package/skills/pmx-canvas/references/full-reference.md +43 -7
  75. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +3 -0
  76. package/skills/pmx-canvas/references/installing-pmx-canvas.md +4 -4
  77. package/skills/pmx-canvas-orchestration/SKILL.md +5 -0
  78. package/skills/pmx-canvas-testing/SKILL.md +4 -0
  79. package/src/cli/agent.ts +4 -0
  80. package/src/cli/commands/ax.ts +13 -0
  81. package/src/cli/commands/backup.ts +66 -0
  82. package/src/cli/commands/boards.ts +71 -0
  83. package/src/cli/commands/export.ts +30 -0
  84. package/src/cli/commands/nodes.ts +4 -1
  85. package/src/cli/commands/pins.ts +6 -2
  86. package/src/cli/commands/pump.ts +3 -0
  87. package/src/cli/commands/query.ts +33 -7
  88. package/src/cli/commands/view.ts +7 -5
  89. package/src/cli/index.ts +19 -9
  90. package/src/client/App.tsx +21 -3
  91. package/src/client/canvas/BoardCopyDialog.tsx +153 -0
  92. package/src/client/canvas/BoardFolderDialog.tsx +120 -0
  93. package/src/client/canvas/BoardLinkDialog.tsx +93 -0
  94. package/src/client/canvas/BoardSwitcher.tsx +149 -0
  95. package/src/client/canvas/CanvasViewport.tsx +3 -0
  96. package/src/client/canvas/CommandPalette.tsx +107 -4
  97. package/src/client/canvas/ContextMenu.tsx +4 -0
  98. package/src/client/canvas/ExpandedNodeOverlay.tsx +3 -0
  99. package/src/client/canvas/ExportDialog.tsx +197 -0
  100. package/src/client/canvas/HomeView.tsx +277 -0
  101. package/src/client/canvas/SessionPanel.tsx +28 -1
  102. package/src/client/canvas/ToolRail.tsx +38 -3
  103. package/src/client/canvas/TopBar.tsx +34 -18
  104. package/src/client/canvas/import-files.ts +19 -1
  105. package/src/client/canvas/kind-colors.ts +1 -0
  106. package/src/client/icons.tsx +2 -0
  107. package/src/client/nodes/AttachmentNode.tsx +148 -0
  108. package/src/client/nodes/BoardNode.tsx +35 -0
  109. package/src/client/nodes/FileNode.tsx +6 -0
  110. package/src/client/nodes/MarkdownNode.tsx +47 -19
  111. package/src/client/nodes/ax-node-actions.ts +2 -5
  112. package/src/client/nodes/use-ax-surface-bridge.ts +1 -1
  113. package/src/client/state/boards-store.ts +167 -0
  114. package/src/client/state/canvas-store.ts +22 -0
  115. package/src/client/state/intent-bridge.ts +30 -2
  116. package/src/client/state/node-factory.ts +1 -0
  117. package/src/client/state/session-store.ts +5 -0
  118. package/src/client/state/sse-bridge.ts +49 -5
  119. package/src/client/theme/global.css +440 -24
  120. package/src/client/types.ts +2 -0
  121. package/src/json-render/server.ts +16 -9
  122. package/src/mcp/canvas-access.ts +59 -22
  123. package/src/mcp/context-uri-template.ts +28 -0
  124. package/src/mcp/server.ts +122 -15
  125. package/src/server/agent-context.ts +116 -29
  126. package/src/server/attribution.ts +29 -0
  127. package/src/server/ax-context.ts +4 -2
  128. package/src/server/ax-interaction.ts +1 -0
  129. package/src/server/ax-state-manager.ts +72 -3
  130. package/src/server/ax-state.ts +41 -0
  131. package/src/server/backup.ts +178 -0
  132. package/src/server/board-export.ts +686 -0
  133. package/src/server/canvas-db.ts +599 -107
  134. package/src/server/canvas-operations.ts +68 -9
  135. package/src/server/canvas-provenance.ts +1 -0
  136. package/src/server/canvas-schema.ts +12 -0
  137. package/src/server/canvas-state.ts +774 -45
  138. package/src/server/context-brief.ts +372 -0
  139. package/src/server/context-reads.ts +238 -0
  140. package/src/server/demo-state.json +659 -2
  141. package/src/server/document-import.ts +223 -0
  142. package/src/server/index.ts +231 -5
  143. package/src/server/mutation-history.ts +10 -13
  144. package/src/server/operations/composites.ts +26 -4
  145. package/src/server/operations/http.ts +9 -3
  146. package/src/server/operations/index.ts +8 -0
  147. package/src/server/operations/invoker.ts +14 -3
  148. package/src/server/operations/mcp.ts +50 -11
  149. package/src/server/operations/ops/app.ts +5 -0
  150. package/src/server/operations/ops/ax-read.ts +10 -6
  151. package/src/server/operations/ops/ax-timeline.ts +75 -0
  152. package/src/server/operations/ops/backup.ts +134 -0
  153. package/src/server/operations/ops/batch.ts +8 -1
  154. package/src/server/operations/ops/boards.ts +351 -0
  155. package/src/server/operations/ops/export.ts +74 -0
  156. package/src/server/operations/ops/ext-app.ts +4 -0
  157. package/src/server/operations/ops/imports.ts +220 -0
  158. package/src/server/operations/ops/nodes.ts +57 -5
  159. package/src/server/operations/ops/query.ts +206 -9
  160. package/src/server/operations/registry.ts +123 -31
  161. package/src/server/operations/types.ts +1 -1
  162. package/src/server/server.ts +146 -2
  163. package/src/server/spatial-analysis.ts +9 -17
  164. package/src/server/trace-manager.ts +19 -0
  165. package/src/server/web-artifacts.ts +2 -1
  166. package/src/server/webpage-node.ts +173 -13
  167. package/src/server/workbench-auth.ts +11 -0
  168. package/src/shared/boards.ts +18 -0
  169. package/src/shared/node-sizes.ts +1 -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
+ }
@@ -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>