pmx-canvas 0.4.8 → 0.5.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 (212) hide show
  1. package/.github/extensions/pmx-canvas/extension.mjs +154 -5
  2. package/.github/extensions/pmx-canvas/steering-delivery.mjs +79 -0
  3. package/CHANGELOG.md +156 -0
  4. package/Readme.md +72 -23
  5. package/dist/canvas/global.css +3071 -796
  6. package/dist/canvas/index.js +122 -104
  7. package/dist/json-render/index.css +1 -1
  8. package/dist/types/cli/agent.d.ts +1 -0
  9. package/dist/types/cli/commands/pump.d.ts +9 -0
  10. package/dist/types/client/App.d.ts +1 -1
  11. package/dist/types/client/canvas/AgentPresenceLayer.d.ts +11 -0
  12. package/dist/types/client/canvas/BarHint.d.ts +22 -0
  13. package/dist/types/client/canvas/CanvasNode.d.ts +5 -0
  14. package/dist/types/client/canvas/CanvasViewport.d.ts +4 -4
  15. package/dist/types/client/canvas/CommandBar.d.ts +10 -0
  16. package/dist/types/client/canvas/ConnectionBanner.d.ts +13 -0
  17. package/dist/types/client/canvas/ContextMenu.d.ts +6 -0
  18. package/dist/types/client/canvas/EdgeLayer.d.ts +2 -1
  19. package/dist/types/client/canvas/EmptyState.d.ts +11 -0
  20. package/dist/types/client/canvas/ExternalWriters.d.ts +5 -0
  21. package/dist/types/client/canvas/HumanPresenceLayer.d.ts +8 -0
  22. package/dist/types/client/canvas/Minimap.d.ts +12 -1
  23. package/dist/types/client/canvas/ScopeFenceLayer.d.ts +8 -0
  24. package/dist/types/client/canvas/SelectionBar.d.ts +6 -0
  25. package/dist/types/client/canvas/SessionPanel.d.ts +1 -0
  26. package/dist/types/client/canvas/SessionReceipt.d.ts +17 -0
  27. package/dist/types/client/canvas/TextPrompt.d.ts +25 -0
  28. package/dist/types/client/canvas/ToolRail.d.ts +21 -0
  29. package/dist/types/client/canvas/TopBar.d.ts +7 -0
  30. package/dist/types/client/canvas/canvas-area.d.ts +39 -0
  31. package/dist/types/client/canvas/create-in-view.d.ts +18 -0
  32. package/dist/types/client/canvas/import-files.d.ts +8 -0
  33. package/dist/types/client/canvas/kind-colors.d.ts +3 -0
  34. package/dist/types/client/canvas/use-focus-trap.d.ts +14 -0
  35. package/dist/types/client/canvas/use-now.d.ts +6 -0
  36. package/dist/types/client/icons.d.ts +11 -4
  37. package/dist/types/client/nodes/ExtAppFrame.d.ts +2 -0
  38. package/dist/types/client/nodes/GroupNode.d.ts +6 -1
  39. package/dist/types/client/nodes/MarkdownNode.d.ts +2 -0
  40. package/dist/types/client/nodes/inline-editor-commands.d.ts +3 -4
  41. package/dist/types/client/state/canvas-store.d.ts +49 -5
  42. package/dist/types/client/state/human-store.d.ts +26 -0
  43. package/dist/types/client/state/iframe-mode.d.ts +1 -0
  44. package/dist/types/client/state/intent-bridge.d.ts +37 -5
  45. package/dist/types/client/state/node-factory.d.ts +0 -1
  46. package/dist/types/client/state/presence-store.d.ts +78 -0
  47. package/dist/types/client/state/session-store.d.ts +176 -0
  48. package/dist/types/client/types.d.ts +2 -1
  49. package/dist/types/client/utils/platform.d.ts +2 -0
  50. package/dist/types/server/agent-presence.d.ts +192 -0
  51. package/dist/types/server/ax-gate-ttl.d.ts +6 -0
  52. package/dist/types/server/ax-interaction.d.ts +4 -10
  53. package/dist/types/server/ax-state-manager.d.ts +21 -2
  54. package/dist/types/server/ax-state.d.ts +49 -3
  55. package/dist/types/server/ax-wait.d.ts +13 -0
  56. package/dist/types/server/canvas-db.d.ts +9 -1
  57. package/dist/types/server/canvas-operations.d.ts +11 -0
  58. package/dist/types/server/canvas-state.d.ts +27 -13
  59. package/dist/types/server/canvas-validation.d.ts +0 -14
  60. package/dist/types/server/html-surface.d.ts +1 -1
  61. package/dist/types/server/human-presence.d.ts +40 -0
  62. package/dist/types/server/index.d.ts +29 -3
  63. package/dist/types/server/intent-registry.d.ts +7 -0
  64. package/dist/types/server/mutation-history.d.ts +15 -2
  65. package/dist/types/server/operations/invoker.d.ts +13 -2
  66. package/dist/types/server/operations/ops/ax-presence.d.ts +2 -0
  67. package/dist/types/server/operations/ops/human-presence.d.ts +3 -0
  68. package/dist/types/server/operations/registry.d.ts +11 -0
  69. package/dist/types/server/operations/types.d.ts +1 -1
  70. package/dist/types/server/scope-fence.d.ts +62 -0
  71. package/dist/types/server/server.d.ts +0 -12
  72. package/dist/types/shared/agent-presence.d.ts +130 -0
  73. package/dist/types/shared/approval-gates.d.ts +19 -0
  74. package/dist/types/shared/auto-arrange.d.ts +0 -1
  75. package/dist/types/shared/ax-kinds.d.ts +11 -0
  76. package/dist/types/shared/human-presence.d.ts +31 -0
  77. package/dist/types/shared/iframe-probe.d.ts +8 -0
  78. package/dist/types/shared/scope-fence.d.ts +21 -0
  79. package/docs/RELEASE.md +20 -0
  80. package/docs/ax-host-adapter-contract.md +30 -14
  81. package/docs/cli.md +37 -1
  82. package/docs/environment.md +3 -0
  83. package/docs/http-api.md +222 -5
  84. package/docs/mcp.md +6 -5
  85. package/docs/node-types.md +3 -1
  86. package/docs/plans/plan-009-tech-debt-backlog.md +13 -0
  87. package/docs/screenshot.png +0 -0
  88. package/docs/screenshots/multi-agent-live.png +0 -0
  89. package/docs/screenshots/workbench-live-dark.png +0 -0
  90. package/docs/screenshots/workbench-live-light.png +0 -0
  91. package/docs/sdk.md +1 -1
  92. package/package.json +4 -4
  93. package/skills/pmx-canvas/SKILL.md +112 -17
  94. package/skills/pmx-canvas/references/codex-app-adapter.md +28 -0
  95. package/skills/pmx-canvas/references/full-reference.md +55 -16
  96. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +41 -24
  97. package/skills/pmx-canvas-orchestration/SKILL.md +56 -2
  98. package/skills/pmx-canvas-testing/SKILL.md +17 -0
  99. package/src/cli/agent.ts +1 -0
  100. package/src/cli/commands/copilot.ts +15 -8
  101. package/src/cli/commands/nodes.ts +1 -19
  102. package/src/cli/commands/pump.ts +263 -0
  103. package/src/cli/index.ts +2 -5
  104. package/src/cli/shared.ts +1 -1
  105. package/src/client/App.tsx +209 -585
  106. package/src/client/canvas/AgentPresenceLayer.tsx +74 -0
  107. package/src/client/canvas/AttentionHistory.tsx +0 -10
  108. package/src/client/canvas/BarHint.tsx +49 -0
  109. package/src/client/canvas/CanvasNode.tsx +488 -136
  110. package/src/client/canvas/CanvasViewport.tsx +190 -94
  111. package/src/client/canvas/CommandBar.tsx +140 -0
  112. package/src/client/canvas/CommandPalette.tsx +133 -58
  113. package/src/client/canvas/ConnectionBanner.tsx +65 -0
  114. package/src/client/canvas/ContextMenu.tsx +88 -27
  115. package/src/client/canvas/ContextPinBar.tsx +2 -2
  116. package/src/client/canvas/EdgeLayer.tsx +121 -38
  117. package/src/client/canvas/EmptyState.tsx +93 -0
  118. package/src/client/canvas/ExpandedNodeOverlay.tsx +64 -118
  119. package/src/client/canvas/ExternalWriters.tsx +308 -0
  120. package/src/client/canvas/FocusFieldLayer.tsx +12 -1
  121. package/src/client/canvas/HumanPresenceLayer.tsx +45 -0
  122. package/src/client/canvas/IntentLayer.tsx +1 -1
  123. package/src/client/canvas/Minimap.tsx +117 -190
  124. package/src/client/canvas/ScopeFenceLayer.tsx +38 -0
  125. package/src/client/canvas/SelectionBar.tsx +175 -23
  126. package/src/client/canvas/SessionPanel.tsx +481 -0
  127. package/src/client/canvas/SessionReceipt.tsx +141 -0
  128. package/src/client/canvas/ShortcutOverlay.tsx +21 -6
  129. package/src/client/canvas/SnapshotPanel.tsx +166 -80
  130. package/src/client/canvas/TextPrompt.tsx +128 -0
  131. package/src/client/canvas/ToolRail.tsx +447 -0
  132. package/src/client/canvas/TopBar.tsx +308 -0
  133. package/src/client/canvas/auto-fit.ts +1 -5
  134. package/src/client/canvas/canvas-area.ts +74 -0
  135. package/src/client/canvas/create-in-view.ts +48 -0
  136. package/src/client/canvas/import-files.ts +64 -0
  137. package/src/client/canvas/kind-colors.ts +22 -0
  138. package/src/client/canvas/snap-guides.ts +1 -1
  139. package/src/client/canvas/use-focus-trap.ts +54 -0
  140. package/src/client/canvas/use-now.ts +17 -0
  141. package/src/client/canvas/use-pan-zoom.ts +13 -2
  142. package/src/client/icons.tsx +57 -18
  143. package/src/client/nodes/ExtAppFrame.tsx +86 -20
  144. package/src/client/nodes/GroupNode.tsx +9 -28
  145. package/src/client/nodes/InlineFormatBar.tsx +4 -3
  146. package/src/client/nodes/InlineMarkdownEditor.tsx +27 -0
  147. package/src/client/nodes/MarkdownNode.tsx +59 -24
  148. package/src/client/nodes/MdFormatBar.tsx +2 -4
  149. package/src/client/nodes/inline-editor-commands.ts +17 -6
  150. package/src/client/nodes/use-surface-frame.ts +3 -1
  151. package/src/client/state/attention-bridge.ts +3 -0
  152. package/src/client/state/canvas-store.ts +285 -90
  153. package/src/client/state/human-store.ts +175 -0
  154. package/src/client/state/iframe-mode.ts +33 -9
  155. package/src/client/state/intent-bridge.ts +75 -13
  156. package/src/client/state/node-factory.ts +2 -8
  157. package/src/client/state/presence-store.ts +168 -0
  158. package/src/client/state/session-store.ts +419 -0
  159. package/src/client/state/sse-bridge.ts +93 -30
  160. package/src/client/theme/global.css +3071 -796
  161. package/src/client/types.ts +3 -1
  162. package/src/client/utils/platform.ts +6 -1
  163. package/src/mcp/canvas-access.ts +2 -2
  164. package/src/server/agent-presence.ts +755 -0
  165. package/src/server/ax-context.ts +45 -0
  166. package/src/server/ax-gate-ttl.ts +61 -0
  167. package/src/server/ax-interaction.ts +29 -1
  168. package/src/server/ax-state-manager.ts +78 -6
  169. package/src/server/ax-state.ts +82 -15
  170. package/src/server/ax-wait.ts +33 -0
  171. package/src/server/canvas-db.ts +103 -33
  172. package/src/server/canvas-operations.ts +58 -14
  173. package/src/server/canvas-state.ts +131 -58
  174. package/src/server/canvas-validation.ts +3 -53
  175. package/src/server/demo-state.json +2 -70
  176. package/src/server/html-surface.ts +11 -2
  177. package/src/server/human-presence.ts +160 -0
  178. package/src/server/index.ts +111 -6
  179. package/src/server/intent-registry.ts +13 -0
  180. package/src/server/mutation-history.ts +47 -2
  181. package/src/server/operations/composites.ts +6 -3
  182. package/src/server/operations/http.ts +10 -1
  183. package/src/server/operations/index.ts +4 -0
  184. package/src/server/operations/invoker.ts +33 -6
  185. package/src/server/operations/ops/ax-presence.ts +62 -0
  186. package/src/server/operations/ops/ax-read.ts +8 -0
  187. package/src/server/operations/ops/ax-state.ts +106 -1
  188. package/src/server/operations/ops/ax-timeline.ts +54 -5
  189. package/src/server/operations/ops/ax-work.ts +46 -0
  190. package/src/server/operations/ops/batch.ts +3 -1
  191. package/src/server/operations/ops/edges.ts +88 -2
  192. package/src/server/operations/ops/groups.ts +4 -3
  193. package/src/server/operations/ops/human-presence.ts +35 -0
  194. package/src/server/operations/ops/json-render.ts +11 -1
  195. package/src/server/operations/ops/nodes.ts +77 -16
  196. package/src/server/operations/ops/query.ts +1 -0
  197. package/src/server/operations/ops/snapshots.ts +7 -4
  198. package/src/server/operations/registry.ts +196 -1
  199. package/src/server/operations/types.ts +1 -1
  200. package/src/server/scope-fence.ts +207 -0
  201. package/src/server/server.ts +177 -79
  202. package/src/server/trace-manager.ts +0 -2
  203. package/src/server/web-artifacts.ts +0 -1
  204. package/src/shared/agent-presence.ts +162 -0
  205. package/src/shared/approval-gates.ts +35 -0
  206. package/src/shared/auto-arrange.ts +121 -13
  207. package/src/shared/ax-kinds.ts +23 -0
  208. package/src/shared/human-presence.ts +30 -0
  209. package/src/shared/iframe-probe.ts +8 -0
  210. package/src/shared/scope-fence.ts +31 -0
  211. package/dist/types/client/canvas/DockedNode.d.ts +0 -4
  212. package/src/client/canvas/DockedNode.tsx +0 -165
@@ -1,549 +1,66 @@
1
- import type { ComponentChildren } from 'preact';
2
1
  import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
3
2
  import { AttentionHistory } from './canvas/AttentionHistory';
4
3
  import { AttentionToast } from './canvas/AttentionToast';
4
+ import { registerCanvasArea, canvasArea } from './canvas/canvas-area';
5
5
  import { CanvasViewport } from './canvas/CanvasViewport';
6
+ import { CommandBar } from './canvas/CommandBar';
7
+ import { ConnectionBanner } from './canvas/ConnectionBanner';
6
8
  import { CommandPalette } from './canvas/CommandPalette';
7
9
  import { ContextMenu, useContextMenu } from './canvas/ContextMenu';
8
10
  import { ContextPinBar } from './canvas/ContextPinBar';
9
- import { DockedNode } from './canvas/DockedNode';
11
+ import { EmptyState } from './canvas/EmptyState';
12
+ import { createNodeInView } from './canvas/create-in-view';
13
+ import { undoFromKeyboard } from './state/session-store';
14
+ import { ActivityFeed, WritersSheet } from './canvas/ExternalWriters';
10
15
  import { ExpandedNodeOverlay } from './canvas/ExpandedNodeOverlay';
11
16
  import { Minimap } from './canvas/Minimap';
12
17
  import { SelectionBar } from './canvas/SelectionBar';
18
+ import { SessionPanel } from './canvas/SessionPanel';
19
+ import { SessionReceipt } from './canvas/SessionReceipt';
13
20
  import { ShortcutOverlay } from './canvas/ShortcutOverlay';
14
21
  import { SnapshotPanel } from './canvas/SnapshotPanel';
22
+ import { promptedCreate, ToolRail } from './canvas/ToolRail';
23
+ import { TextPrompt } from './canvas/TextPrompt';
24
+ import { TopBar } from './canvas/TopBar';
15
25
  import {
16
26
  activeNodeId,
17
27
  animateViewport,
18
- autoArrange,
19
- canvasTheme,
28
+ canvasTool,
20
29
  clearSelection,
21
30
  collapseExpandedNode,
22
- connectionStatus,
23
31
  contextPinnedNodeIds,
24
32
  cycleActiveNode,
25
33
  edges,
26
34
  expandedNodeId,
27
35
  fitAll,
28
- forceDirectedArrange,
29
36
  hasInitialServerLayout,
30
37
  nodes,
31
38
  pendingExpandedNodeCloseId,
32
- persistLayout,
39
+ removeNode,
40
+ selectedEdgeId,
33
41
  selectedNodeIds,
34
- sessionId,
35
- setViewport,
36
- traceEnabled,
42
+ spacePanHeld,
37
43
  viewport,
38
44
  walkGraph,
39
45
  zoomByFactor,
46
+ groupsOfSelection,
40
47
  } from './state/canvas-store';
41
48
  import { connectSSE } from './state/sse-bridge';
42
49
  import { intents } from './state/intent-store';
43
- import { reportClientViewportSize, saveCanvasTheme } from './state/intent-bridge';
50
+ import { sessionActive } from './state/presence-store';
44
51
  import {
45
- IconArrange,
46
- IconClearTrace,
47
- IconEraser,
48
- IconFitAll,
49
- IconLogo,
50
- IconMinimap,
51
- IconMoon,
52
- IconPen,
53
- IconResetView,
54
- IconSearch,
55
- IconShortcuts,
56
- IconSnapshot,
57
- IconSun,
58
- IconTextAnnotation,
59
- IconTrace,
60
- IconZoomIn,
61
- IconZoomOut,
62
- } from './icons';
63
- import { clearThemeOverride } from './state/theme-override';
64
- import { invalidateTokenCache } from './theme/tokens';
65
- import { MOD_KEY } from './utils/platform';
66
- import {
67
- CANVAS_THEMES,
68
- CANVAS_THEME_META,
69
- type CanvasThemeName,
70
- canvasThemeScheme,
71
- normalizeCanvasThemeName,
72
- } from '../shared/themes.js';
52
+ createGroupFromClient,
53
+ removeEdgeFromClient,
54
+ removeNodeFromClient,
55
+ reportClientViewportSize,
56
+ ungroupFromClient,
57
+ } from './state/intent-bridge';
58
+ import type { AnnotationTool } from './types';
73
59
 
74
60
  function logAppError(action: string, error: unknown): void {
75
61
  console.error(`[app] ${action} failed`, error);
76
62
  }
77
63
 
78
- function sendIntent(type: string, payload: Record<string, unknown> = {}): void {
79
- fetch(`/api/workbench/intent?_ts=${Date.now()}`, {
80
- method: 'POST',
81
- headers: { 'Content-Type': 'application/json' },
82
- body: JSON.stringify({ type, payload }),
83
- }).catch((error) => {
84
- logAppError('sendIntent', error);
85
- });
86
- }
87
-
88
- type AnnotationTool = 'pen' | 'eraser' | 'text' | null;
89
-
90
- function ToolbarHint({
91
- label,
92
- detail,
93
- shortcut,
94
- align = 'center',
95
- children,
96
- }: {
97
- label: string;
98
- detail?: string;
99
- shortcut?: string;
100
- align?: 'start' | 'center' | 'end';
101
- children: ComponentChildren;
102
- }) {
103
- return (
104
- <span class={`toolbar-tooltip-anchor toolbar-tooltip-anchor-${align}`}>
105
- {children}
106
- <span class="toolbar-tooltip" role="tooltip">
107
- <span class="toolbar-tooltip-label">{label}</span>
108
- {(detail || shortcut) && (
109
- <span class="toolbar-tooltip-meta">
110
- {detail && <span>{detail}</span>}
111
- {shortcut && <kbd class="toolbar-tooltip-shortcut">{shortcut}</kbd>}
112
- </span>
113
- )}
114
- </span>
115
- </span>
116
- );
117
- }
118
-
119
- function Toolbar({
120
- minimapVisible,
121
- onToggleMinimap,
122
- snapshotOpen,
123
- onToggleSnapshot,
124
- snapshotBtnRef,
125
- onOpenPalette,
126
- onOpenShortcuts,
127
- annotationTool,
128
- onToggleAnnotationMode,
129
- onToggleAnnotationEraser,
130
- onToggleTextAnnotation,
131
- }: {
132
- minimapVisible: boolean;
133
- onToggleMinimap: () => void;
134
- snapshotOpen: boolean;
135
- onToggleSnapshot: () => void;
136
- snapshotBtnRef: { current: HTMLButtonElement | null };
137
- onOpenPalette: () => void;
138
- onOpenShortcuts: () => void;
139
- annotationTool: AnnotationTool;
140
- onToggleAnnotationMode: () => void;
141
- onToggleAnnotationEraser: () => void;
142
- onToggleTextAnnotation: () => void;
143
- }) {
144
- const status = connectionStatus.value;
145
- const hasSynced = hasInitialServerLayout.value;
146
- const v = viewport.value;
147
- const nodeCount = nodes.value.size;
148
- const edgeCount = edges.value.size;
149
- const isTraceOn = traceEnabled.value;
150
- const traceNodeCount = Array.from(nodes.value.values()).filter((n) => n.type === 'trace').length;
151
- const statusTitle = status === 'connected' && !hasSynced ? 'syncing' : status;
152
- const statusLabel = statusTitle.charAt(0).toUpperCase() + statusTitle.slice(1);
153
- const countsLabel = hasSynced
154
- ? [
155
- `${nodeCount} node${nodeCount !== 1 ? 's' : ''}`,
156
- ...(edgeCount > 0 ? [`${edgeCount} edge${edgeCount !== 1 ? 's' : ''}`] : []),
157
- ...(traceNodeCount > 0
158
- ? [`${traceNodeCount} trace${traceNodeCount !== 1 ? 's' : ''}`]
159
- : isTraceOn
160
- ? ['trace armed']
161
- : []),
162
- ].join(' · ')
163
- : 'Syncing canvas…';
164
-
165
- // Popover state: the theme picker (desktop toolbar) and the mobile "More"
166
- // menu share one open-slot so at most one popover shows at a time.
167
- const [openMenu, setOpenMenu] = useState<null | 'theme' | 'more'>(null);
168
- const groupRef = useRef<HTMLDivElement>(null);
169
-
170
- useEffect(() => {
171
- if (!openMenu) return;
172
- const onPointerDown = (e: PointerEvent) => {
173
- if (groupRef.current && e.target instanceof Node && !groupRef.current.contains(e.target)) {
174
- setOpenMenu(null);
175
- }
176
- };
177
- const onKeyDown = (e: KeyboardEvent) => {
178
- if (e.key === 'Escape') setOpenMenu(null);
179
- };
180
- document.addEventListener('pointerdown', onPointerDown);
181
- document.addEventListener('keydown', onKeyDown);
182
- return () => {
183
- document.removeEventListener('pointerdown', onPointerDown);
184
- document.removeEventListener('keydown', onKeyDown);
185
- };
186
- }, [openMenu]);
187
-
188
- const applyTheme = (next: CanvasThemeName) => {
189
- // An explicit pick ends any ?theme= session override (host default) and
190
- // returns this client to the shared server-global theme it just saved.
191
- clearThemeOverride();
192
- document.documentElement.setAttribute('data-theme', next);
193
- invalidateTokenCache();
194
- canvasTheme.value = next;
195
- void saveCanvasTheme(next);
196
- setOpenMenu(null);
197
- };
198
-
199
- const menuAction = (fn: () => void) => () => {
200
- fn();
201
- setOpenMenu(null);
202
- };
203
-
204
- const activeTheme = normalizeCanvasThemeName(canvasTheme.value);
205
- const themeMenuItems = CANVAS_THEMES.map((name) => (
206
- <button
207
- key={name}
208
- type="button"
209
- role="menuitemradio"
210
- aria-checked={activeTheme === name}
211
- class={`toolbar-menu-item${activeTheme === name ? ' active' : ''}`}
212
- onClick={() => applyTheme(name)}
213
- >
214
- <span class="theme-swatch" style={{ background: CANVAS_THEME_META[name].swatchBg }}>
215
- <span class="theme-swatch-dot" style={{ background: CANVAS_THEME_META[name].swatchAccent }} />
216
- </span>
217
- <span>{CANVAS_THEME_META[name].label}</span>
218
- {activeTheme === name && (
219
- <span class="toolbar-menu-check" aria-hidden="true">
220
- ✓
221
- </span>
222
- )}
223
- </button>
224
- ));
225
-
226
- return (
227
- <div class="toolbar-group" ref={groupRef}>
228
- {/* ── Navigation Bar ──────────────────────────────────── */}
229
- <div class="canvas-toolbar">
230
- <ToolbarHint label="PMX Canvas" detail="Focus Field · spatial workbench for coding agents" align="start">
231
- <span class="canvas-brand" aria-label="PMX Canvas">
232
- <IconLogo size={22} />
233
- </span>
234
- </ToolbarHint>
235
-
236
- <div class="separator" />
237
-
238
- <ToolbarHint
239
- label="Canvas status"
240
- detail={hasSynced ? statusLabel : 'Syncing canvas from server'}
241
- align="start"
242
- >
243
- <span class={`connection-dot ${status}`} aria-label={`Canvas status: ${statusTitle}`} />
244
- </ToolbarHint>
245
- <span class="hud-collapsible-text" style={{ fontSize: '11px', color: 'var(--c-muted)' }}>
246
- {sessionId.value ? sessionId.value.slice(0, 12) : '…'}
247
- </span>
248
-
249
- <div class="separator" />
250
-
251
- <ToolbarHint label="Fit canvas" detail="Frame every node on screen">
252
- <button type="button" onClick={() => fitAll(window.innerWidth, window.innerHeight)} aria-label="Fit canvas">
253
- <IconFitAll />
254
- </button>
255
- </ToolbarHint>
256
- <ToolbarHint label="Reset view" shortcut={`${MOD_KEY}+0`}>
257
- <button type="button" onClick={() => animateViewport({ x: 0, y: 0, scale: 1 }, 250)} aria-label="Reset view">
258
- <IconResetView />
259
- </button>
260
- </ToolbarHint>
261
- <ToolbarHint label="Zoom in" shortcut={`${MOD_KEY}++`}>
262
- <button type="button" onClick={() => zoomByFactor(1.25)} aria-label="Zoom in">
263
- <IconZoomIn />
264
- </button>
265
- </ToolbarHint>
266
- <ToolbarHint label="Zoom out" shortcut={`${MOD_KEY}+-`}>
267
- <button type="button" onClick={() => zoomByFactor(1 / 1.25)} aria-label="Zoom out">
268
- <IconZoomOut />
269
- </button>
270
- </ToolbarHint>
271
- <span
272
- class="hud-collapsible-text"
273
- style={{ fontSize: '10px', color: 'var(--c-dim)', minWidth: '36px', textAlign: 'center' }}
274
- >
275
- {Math.round(v.scale * 100)}%
276
- </span>
277
-
278
- {/* Secondary controls collapse into the More menu on narrow screens. */}
279
- <span class="toolbar-collapsible">
280
- <div class="separator" />
281
-
282
- <ToolbarHint
283
- label="Arrange layout"
284
- detail={edgeCount > 0 ? 'Graph-aware layout for connected nodes' : 'Grid layout for loose nodes'}
285
- >
286
- <button
287
- type="button"
288
- onClick={() => (edgeCount > 0 ? forceDirectedArrange() : autoArrange())}
289
- aria-label="Arrange layout"
290
- >
291
- <IconArrange />
292
- </button>
293
- </ToolbarHint>
294
- <ToolbarHint
295
- label={minimapVisible ? 'Hide minimap' : 'Show minimap'}
296
- detail="Quickly navigate large canvases"
297
- >
298
- <button
299
- type="button"
300
- onClick={onToggleMinimap}
301
- aria-label={minimapVisible ? 'Hide minimap' : 'Show minimap'}
302
- style={{ color: minimapVisible ? 'var(--c-accent)' : undefined }}
303
- >
304
- <IconMinimap />
305
- </button>
306
- </ToolbarHint>
307
- {/* Anchor wrapper: the theme menu must open under THIS control, not at
308
- the toolbar-group's right edge (0.4.2 report Finding T). */}
309
- <span class="toolbar-menu-anchor">
310
- <ToolbarHint label="Theme" detail={`Current theme: ${CANVAS_THEME_META[activeTheme].label}`}>
311
- <button
312
- type="button"
313
- onClick={() => setOpenMenu(openMenu === 'theme' ? null : 'theme')}
314
- aria-label="Choose theme"
315
- aria-haspopup="menu"
316
- aria-expanded={openMenu === 'theme'}
317
- style={{ color: openMenu === 'theme' ? 'var(--c-accent)' : undefined }}
318
- >
319
- {canvasThemeScheme(activeTheme) === 'dark' ? <IconSun /> : <IconMoon />}
320
- </button>
321
- </ToolbarHint>
322
- {openMenu === 'theme' && (
323
- <div class="toolbar-menu" role="menu" aria-label="Theme">
324
- {themeMenuItems}
325
- </div>
326
- )}
327
- </span>
328
- <ToolbarHint label="Snapshots" detail="Capture and restore canvas states" align="end">
329
- <button
330
- ref={snapshotBtnRef}
331
- type="button"
332
- onClick={onToggleSnapshot}
333
- aria-label="Snapshots"
334
- style={{ color: snapshotOpen ? 'var(--c-accent)' : undefined }}
335
- >
336
- <IconSnapshot />
337
- </button>
338
- </ToolbarHint>
339
- </span>
340
-
341
- {/* Narrow screens: everything collapsed lives in this single menu. */}
342
- <span class="toolbar-more toolbar-menu-anchor">
343
- <ToolbarHint label="More" detail="All canvas actions" align="end">
344
- <button
345
- type="button"
346
- onClick={() => setOpenMenu(openMenu === 'more' ? null : 'more')}
347
- aria-label="More actions"
348
- aria-haspopup="menu"
349
- aria-expanded={openMenu === 'more'}
350
- style={{ color: openMenu === 'more' ? 'var(--c-accent)' : undefined }}
351
- >
352
- <span class="toolbar-more-glyph" aria-hidden="true">
353
- ⋯
354
- </span>
355
- </button>
356
- </ToolbarHint>
357
- {openMenu === 'more' && (
358
- <div class="toolbar-menu" role="menu" aria-label="Canvas actions">
359
- <button
360
- type="button"
361
- class="toolbar-menu-item"
362
- onClick={menuAction(() => (edgeCount > 0 ? forceDirectedArrange() : autoArrange()))}
363
- >
364
- <IconArrange />
365
- <span>Arrange layout</span>
366
- </button>
367
- <button type="button" class="toolbar-menu-item" onClick={menuAction(onToggleMinimap)}>
368
- <IconMinimap />
369
- <span>{minimapVisible ? 'Hide minimap' : 'Show minimap'}</span>
370
- </button>
371
- <button type="button" class="toolbar-menu-item" onClick={menuAction(onToggleSnapshot)}>
372
- <IconSnapshot />
373
- <span>Snapshots</span>
374
- </button>
375
- <div class="toolbar-menu-separator" />
376
- <button
377
- type="button"
378
- class="toolbar-menu-item"
379
- onClick={menuAction(() => sendIntent('trace-toggle', { enabled: !isTraceOn }))}
380
- >
381
- <IconTrace />
382
- <span>{isTraceOn ? 'Disable trace' : 'Enable trace'}</span>
383
- </button>
384
- {(isTraceOn || traceNodeCount > 0) && (
385
- <button type="button" class="toolbar-menu-item" onClick={menuAction(() => sendIntent('trace-clear'))}>
386
- <IconClearTrace />
387
- <span>Clear trace</span>
388
- </button>
389
- )}
390
- <button type="button" class="toolbar-menu-item" onClick={menuAction(onToggleAnnotationMode)}>
391
- <IconPen />
392
- <span>{annotationTool === 'pen' ? 'Stop annotating' : 'Annotate canvas'}</span>
393
- </button>
394
- <button type="button" class="toolbar-menu-item" onClick={menuAction(onToggleAnnotationEraser)}>
395
- <IconEraser />
396
- <span>{annotationTool === 'eraser' ? 'Stop erasing' : 'Erase annotations'}</span>
397
- </button>
398
- <button type="button" class="toolbar-menu-item" onClick={menuAction(onToggleTextAnnotation)}>
399
- <IconTextAnnotation />
400
- <span>{annotationTool === 'text' ? 'Stop text annotations' : 'Text annotations'}</span>
401
- </button>
402
- <div class="toolbar-menu-separator" />
403
- <button type="button" class="toolbar-menu-item" onClick={menuAction(onOpenPalette)}>
404
- <IconSearch />
405
- <span>Search nodes and actions</span>
406
- </button>
407
- <button type="button" class="toolbar-menu-item" onClick={menuAction(onOpenShortcuts)}>
408
- <IconShortcuts />
409
- <span>Keyboard shortcuts</span>
410
- </button>
411
- <div class="toolbar-menu-separator" />
412
- <div class="toolbar-menu-heading">Theme</div>
413
- {themeMenuItems}
414
- </div>
415
- )}
416
- </span>
417
- </div>
418
-
419
- {/* ── Action Bar (hidden on narrow screens — lives in the More menu) ── */}
420
- <div class="canvas-toolbar toolbar-actions-bar">
421
- <ToolbarHint
422
- label={isTraceOn ? 'Disable trace' : 'Enable trace'}
423
- detail={isTraceOn ? 'Stop collecting new trace nodes' : 'Capture agent execution on the canvas'}
424
- >
425
- <button
426
- type="button"
427
- onClick={() => sendIntent('trace-toggle', { enabled: !isTraceOn })}
428
- aria-label={isTraceOn ? 'Disable trace' : 'Enable trace'}
429
- style={{ color: isTraceOn ? 'var(--c-purple)' : undefined }}
430
- >
431
- <IconTrace />
432
- </button>
433
- </ToolbarHint>
434
- {(isTraceOn || traceNodeCount > 0) && (
435
- <ToolbarHint
436
- label="Clear trace"
437
- detail={
438
- traceNodeCount > 0
439
- ? `Remove ${traceNodeCount} trace node${traceNodeCount === 1 ? '' : 's'}`
440
- : 'Trace is enabled but still empty'
441
- }
442
- >
443
- <button type="button" onClick={() => sendIntent('trace-clear')} aria-label="Clear trace">
444
- <IconClearTrace />
445
- </button>
446
- </ToolbarHint>
447
- )}
448
-
449
- <div class="separator" />
450
-
451
- <ToolbarHint
452
- label={annotationTool === 'pen' ? 'Stop annotating' : 'Annotate canvas'}
453
- detail="Draw directly on the canvas for human-visible markup"
454
- >
455
- <button
456
- type="button"
457
- onClick={onToggleAnnotationMode}
458
- aria-label={annotationTool === 'pen' ? 'Stop annotating' : 'Annotate canvas'}
459
- aria-pressed={annotationTool === 'pen'}
460
- style={{ color: annotationTool === 'pen' ? 'var(--c-accent)' : undefined }}
461
- >
462
- <IconPen />
463
- </button>
464
- </ToolbarHint>
465
- <ToolbarHint
466
- label={annotationTool === 'eraser' ? 'Stop erasing' : 'Erase annotations'}
467
- detail="Click a drawn annotation to remove it"
468
- >
469
- <button
470
- type="button"
471
- onClick={onToggleAnnotationEraser}
472
- aria-label={annotationTool === 'eraser' ? 'Stop erasing' : 'Erase annotations'}
473
- aria-pressed={annotationTool === 'eraser'}
474
- style={{ color: annotationTool === 'eraser' ? 'var(--c-accent)' : undefined }}
475
- >
476
- <IconEraser />
477
- </button>
478
- </ToolbarHint>
479
- <ToolbarHint
480
- label={annotationTool === 'text' ? 'Stop text annotations' : 'Text annotations'}
481
- detail="Click anywhere to type an intent note"
482
- >
483
- <button
484
- type="button"
485
- onClick={onToggleTextAnnotation}
486
- aria-label={annotationTool === 'text' ? 'Stop text annotations' : 'Text annotations'}
487
- aria-pressed={annotationTool === 'text'}
488
- style={{ color: annotationTool === 'text' ? 'var(--c-accent)' : undefined }}
489
- >
490
- <IconTextAnnotation />
491
- </button>
492
- </ToolbarHint>
493
-
494
- <div class="separator" />
495
-
496
- <ToolbarHint label="Search nodes and actions" shortcut={`${MOD_KEY}+K`}>
497
- <button type="button" onClick={onOpenPalette} aria-label="Search nodes and actions">
498
- <IconSearch />
499
- </button>
500
- </ToolbarHint>
501
- <ToolbarHint label="Keyboard shortcuts" shortcut="?" align="end">
502
- <button type="button" onClick={onOpenShortcuts} aria-label="Keyboard shortcuts">
503
- <IconShortcuts />
504
- </button>
505
- </ToolbarHint>
506
-
507
- <span class="hud-collapsible-text" style={{ fontSize: '10px', color: 'var(--c-dim)' }}>
508
- {countsLabel}
509
- </span>
510
- </div>
511
- </div>
512
- );
513
- }
514
-
515
- function WelcomeCard({ onOpenPalette }: { onOpenPalette: () => void }) {
516
- return (
517
- <div class="welcome-card">
518
- <div class="welcome-icon">◇</div>
519
- <div class="welcome-title">Shape What The Agent Sees</div>
520
- <div class="welcome-subtitle">
521
- Lay out notes, files, and evidence. Bring related nodes together. Pin what matters. The board will reflect the
522
- active focus.
523
- </div>
524
- <div class="welcome-hints">
525
- <button type="button" class="welcome-hint" onClick={onOpenPalette}>
526
- <kbd>{MOD_KEY}+K</kbd>
527
- <span>Create a note</span>
528
- </button>
529
- <div class="welcome-hint">
530
- <kbd>Drop files</kbd>
531
- <span>Add evidence to the board</span>
532
- </div>
533
- <div class="welcome-hint">
534
- <kbd>{'\u2726'}</kbd>
535
- <span>Pin important nodes</span>
536
- </div>
537
- <div class="welcome-hint">
538
- <kbd>Move nearby</kbd>
539
- <span>Shape the focus field</span>
540
- </div>
541
- </div>
542
- <div class="welcome-footer">The canvas is a shared attention surface, not just an editor.</div>
543
- </div>
544
- );
545
- }
546
-
547
64
  export function App() {
548
65
  const [minimapVisible, setMinimapVisible] = useState(true);
549
66
  const [snapshotOpen, setSnapshotOpen] = useState(false);
@@ -551,24 +68,17 @@ export function App() {
551
68
  const [shortcutsOpen, setShortcutsOpen] = useState(false);
552
69
  const [annotationTool, setAnnotationTool] = useState<AnnotationTool>(null);
553
70
  const snapshotBtnRef = useRef<HTMLButtonElement>(null);
554
- const { menu, openNodeMenu, openCanvasMenu, closeMenu } = useContextMenu();
71
+ const { menu, openNodeMenu, openCanvasMenu, openEdgeMenu, closeMenu } = useContextMenu();
555
72
  const hasInitialLayout = hasInitialServerLayout.value;
73
+ // rail-chrome-v2: the one selector every agent surface mounts on. Exposed
74
+ // as a data attribute so styling and tests can key on it; the quiet board
75
+ // (no attached session) must stay byte-clean of agent chrome.
76
+ const sessionIsActive = sessionActive.value;
556
77
 
557
78
  const handleToggleMinimap = useCallback(() => setMinimapVisible((v) => !v), []);
558
79
  const handleToggleSnapshot = useCallback(() => setSnapshotOpen((v) => !v), []);
559
80
  const handleCloseSnapshot = useCallback(() => setSnapshotOpen(false), []);
560
- const handleToggleAnnotationMode = useCallback(
561
- () => setAnnotationTool((tool) => (tool === 'pen' ? null : 'pen')),
562
- [],
563
- );
564
- const handleToggleAnnotationEraser = useCallback(
565
- () => setAnnotationTool((tool) => (tool === 'eraser' ? null : 'eraser')),
566
- [],
567
- );
568
- const handleToggleTextAnnotation = useCallback(
569
- () => setAnnotationTool((tool) => (tool === 'text' ? null : 'text')),
570
- [],
571
- );
81
+ const handleSetAnnotationTool = useCallback((tool: AnnotationTool) => setAnnotationTool(tool), []);
572
82
 
573
83
  const handleMinimapNavigate = useCallback((x: number, y: number) => {
574
84
  animateViewport({ x, y, scale: viewport.value.scale }, 200);
@@ -580,7 +90,8 @@ export function App() {
580
90
 
581
91
  // Keep the server's idea of this window's size current (0.4.6 orb feedback
582
92
  // #2) so an agent `fit` computes a scale that actually fits the human's
583
- // window. Connect-time reporting lives in the SSE bridge; this covers resizes.
93
+ // canvas region. Connect-time reporting lives in the SSE bridge; this covers
94
+ // resizes. The report itself reads canvasArea(), so it reflects the region.
584
95
  useEffect(() => {
585
96
  let timer: number | null = null;
586
97
  const schedule = () => {
@@ -599,7 +110,9 @@ export function App() {
599
110
 
600
111
  useEffect(() => {
601
112
  const handleKeyDown = (e: KeyboardEvent) => {
602
- const mod = e.metaKey || e.ctrlKey;
113
+ // AltGr on European Windows layouts reports Ctrl+Alt — never treat it
114
+ // as the shortcut modifier, or AltGr+Z (ż, ź…) fires undo.
115
+ const mod = (e.metaKey || e.ctrlKey) && !e.altKey;
603
116
 
604
117
  // Cmd/Ctrl+K toggles command palette (works from anywhere, including inputs)
605
118
  if (mod && e.key === 'k') {
@@ -636,9 +149,20 @@ export function App() {
636
149
  return;
637
150
  }
638
151
 
639
- // Ignore other shortcuts when inside inputs
640
- const tag = (e.target as HTMLElement)?.tagName;
641
- if (tag === 'INPUT' || tag === 'TEXTAREA') return;
152
+ // Ignore other shortcuts when inside inputs or editable content — the
153
+ // single-key rail shortcuts below would otherwise eat characters typed
154
+ // into the inline markdown editor (contenteditable), which INPUT/TEXTAREA
155
+ // checks alone do not cover.
156
+ const target = e.target as HTMLElement | null;
157
+ const tag = target?.tagName;
158
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || target?.isContentEditable) return;
159
+
160
+ // Held Space = temporary pan tool (released on keyup below).
161
+ if (e.key === ' ' && !e.repeat && !mod) {
162
+ e.preventDefault();
163
+ spacePanHeld.value = true;
164
+ return;
165
+ }
642
166
 
643
167
  // ? toggles shortcut overlay
644
168
  if (e.key === '?' || (e.key === '/' && e.shiftKey)) {
@@ -647,12 +171,21 @@ export function App() {
647
171
  return;
648
172
  }
649
173
 
650
- if (mod && e.key === '0') {
174
+ if (mod && (e.key === 'z' || e.key === 'Z')) {
175
+ // One shared undo stack (item 10): Ctrl+Z undoes whichever op is on
176
+ // top, agent or human; Shift redoes.
177
+ e.preventDefault();
178
+ void undoFromKeyboard(e.shiftKey);
179
+ } else if (mod && (e.key === 'y' || e.key === 'Y')) {
180
+ // Ctrl+Y — the Windows redo convention.
181
+ e.preventDefault();
182
+ void undoFromKeyboard(true);
183
+ } else if (mod && e.key === '0') {
651
184
  e.preventDefault();
652
185
  animateViewport({ x: 0, y: 0, scale: 1 }, 250);
653
186
  } else if (mod && (e.key === '=' || e.key === '+')) {
654
187
  e.preventDefault();
655
- // Same centre-anchored zoom as the toolbar +/- buttons these shortcuts
188
+ // Same centre-anchored zoom as the top-bar +/- buttons these shortcuts
656
189
  // are advertised on (the tooltip names this key) — a raw scale change
657
190
  // is origin-anchored and drifts the board away under the cursor.
658
191
  zoomByFactor(1.25);
@@ -660,7 +193,7 @@ export function App() {
660
193
  e.preventDefault();
661
194
  zoomByFactor(1 / 1.25);
662
195
  } else if (e.key === 'Escape') {
663
- if (selectedNodeIds.value.size > 0) {
196
+ if (selectedNodeIds.value.size > 0 || selectedEdgeId.value !== null) {
664
197
  clearSelection();
665
198
  return;
666
199
  }
@@ -669,19 +202,117 @@ export function App() {
669
202
  } else if (e.key === 'Tab') {
670
203
  e.preventDefault();
671
204
  cycleActiveNode(e.shiftKey ? -1 : 1);
205
+ } else if (e.key === 'Delete' || e.key === 'Backspace') {
206
+ // A click-selected edge deletes first — keyboard parity with the edge
207
+ // context menu (draw with C, click it, press Delete).
208
+ if (selectedEdgeId.value !== null) {
209
+ const edgeId = selectedEdgeId.value;
210
+ e.preventDefault();
211
+ selectedEdgeId.value = null;
212
+ void removeEdgeFromClient(edgeId).catch((error) => logAppError('delete-edge', error));
213
+ return;
214
+ }
215
+ // Delete the selection, else the focused node — what the selection
216
+ // bar's delete and the card's × do, from the keyboard.
217
+ const ids =
218
+ selectedNodeIds.value.size > 0 ? [...selectedNodeIds.value] : activeNodeId.value ? [activeNodeId.value] : [];
219
+ if (ids.length === 0) return;
220
+ e.preventDefault();
221
+ for (const id of ids) {
222
+ removeNode(id);
223
+ void removeNodeFromClient(id).catch((error) => logAppError('delete', error));
224
+ }
225
+ clearSelection();
226
+ activeNodeId.value = null;
672
227
  } else if (activeNodeId.value && ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
673
228
  e.preventDefault();
674
229
  const dir = e.key.replace('Arrow', '').toLowerCase() as 'up' | 'down' | 'left' | 'right';
675
230
  walkGraph(dir);
231
+ } else if (!mod && !e.altKey) {
232
+ // Rail shortcuts — the rail buttons advertise these in their titles.
233
+ const key = e.key.toLowerCase();
234
+ if (key === 'v' && !e.shiftKey) {
235
+ canvasTool.value = 'select';
236
+ } else if (key === 'c' && !e.shiftKey && !mod) {
237
+ e.preventDefault();
238
+ canvasTool.value = canvasTool.value === 'connect' ? 'select' : 'connect';
239
+ } else if (key === 'm' && !e.shiftKey) {
240
+ e.preventDefault();
241
+ void createNodeInView({ type: 'markdown', title: 'New note', width: 520, height: 360 }).catch((error) =>
242
+ logAppError('create markdown', error),
243
+ );
244
+ } else if (key === 'a' && !e.shiftKey) {
245
+ e.preventDefault();
246
+ setAnnotationTool((tool) => (tool === 'pen' ? null : 'pen'));
247
+ } else if (key === 'g' && !e.shiftKey) {
248
+ // Groups v2: G groups the selection (≥2 nodes); otherwise a new empty frame.
249
+ e.preventDefault();
250
+ const ids = Array.from(selectedNodeIds.value);
251
+ if (ids.length >= 2) {
252
+ void createGroupFromClient({ title: 'Group', childIds: ids }).catch((error) =>
253
+ logAppError('group selection', error),
254
+ );
255
+ clearSelection();
256
+ } else {
257
+ void createNodeInView({ type: 'group', title: 'Group' }).catch((error) =>
258
+ logAppError('create group', error),
259
+ );
260
+ }
261
+ } else if (key === 'g' && e.shiftKey) {
262
+ // Shift+G dissolves every group the selection touches — the same
263
+ // `group.remove` op an agent runs (children released, frame gone).
264
+ e.preventDefault();
265
+ for (const groupId of groupsOfSelection()) {
266
+ removeNode(groupId);
267
+ void ungroupFromClient(groupId).catch((error) => logAppError('ungroup', error));
268
+ }
269
+ clearSelection();
270
+ } else if (key === 'i' && !e.shiftKey) {
271
+ e.preventDefault();
272
+ promptedCreate('image');
273
+ } else if (key === 'w' && !e.shiftKey) {
274
+ e.preventDefault();
275
+ promptedCreate('webpage');
276
+ } else if (key === 'h' && !e.shiftKey) {
277
+ e.preventDefault();
278
+ void createNodeInView({ type: 'html', title: 'HTML surface' }).catch((error) =>
279
+ logAppError('create html', error),
280
+ );
281
+ } else if (key === 'f' && e.shiftKey) {
282
+ e.preventDefault();
283
+ promptedCreate('file');
284
+ } else if (key === 'f' && !e.shiftKey) {
285
+ e.preventDefault();
286
+ const area = canvasArea();
287
+ fitAll(area.width, area.height);
288
+ }
676
289
  }
677
290
  };
678
291
 
292
+ const handleKeyUp = (e: KeyboardEvent) => {
293
+ if (e.key === ' ') spacePanHeld.value = false;
294
+ };
295
+ const handleBlur = () => {
296
+ spacePanHeld.value = false;
297
+ };
298
+
679
299
  document.addEventListener('keydown', handleKeyDown);
300
+ document.addEventListener('keyup', handleKeyUp);
301
+ window.addEventListener('blur', handleBlur);
680
302
  return () => {
681
303
  document.removeEventListener('keydown', handleKeyDown);
304
+ document.removeEventListener('keyup', handleKeyUp);
305
+ window.removeEventListener('blur', handleBlur);
682
306
  };
683
307
  }, [annotationTool, closeMenu, paletteOpen, shortcutsOpen]);
684
308
 
309
+ // The session panel takes 320px from the canvas region: re-report the
310
+ // region size so an agent `fit` keeps fitting the visible canvas.
311
+ useEffect(() => {
312
+ const timer = window.setTimeout(() => void reportClientViewportSize(), 250);
313
+ return () => window.clearTimeout(timer);
314
+ }, [sessionIsActive]);
315
+
685
316
  useEffect(() => {
686
317
  if (!hasInitialLayout) return;
687
318
  const ready = (window as Window & { __pmxCanvasBootstrapReady?: () => void }).__pmxCanvasBootstrapReady;
@@ -689,66 +320,59 @@ export function App() {
689
320
  }, [hasInitialLayout]);
690
321
 
691
322
  const allNodes = Array.from(nodes.value.values());
692
- const dockedLeft = allNodes.filter((n) => n.dockPosition === 'left');
693
- const dockedRight = allNodes
694
- .filter((n) => n.dockPosition === 'right')
695
- .sort((a, b) => {
696
- const order: Record<string, number> = { context: 0, ledger: 1 };
697
- return (order[a.type] ?? 2) - (order[b.type] ?? 2);
698
- });
323
+
324
+ const area = canvasArea();
699
325
 
700
326
  return (
701
- <div style={{ width: '100%', height: '100%', position: 'relative' }}>
702
- <div class="hud-layer">
703
- <div class="hud-left">
704
- {dockedLeft.map((n) => (
705
- <DockedNode key={n.id} node={n} />
706
- ))}
707
- </div>
708
- <Toolbar
709
- minimapVisible={minimapVisible}
710
- onToggleMinimap={handleToggleMinimap}
711
- snapshotOpen={snapshotOpen}
712
- onToggleSnapshot={handleToggleSnapshot}
713
- snapshotBtnRef={snapshotBtnRef}
714
- onOpenPalette={() => setPaletteOpen(true)}
715
- onOpenShortcuts={() => setShortcutsOpen((v) => !v)}
716
- annotationTool={annotationTool}
717
- onToggleAnnotationMode={handleToggleAnnotationMode}
718
- onToggleAnnotationEraser={handleToggleAnnotationEraser}
719
- onToggleTextAnnotation={handleToggleTextAnnotation}
720
- />
721
- <div class="hud-right">
722
- {dockedRight.map((n) => (
723
- <DockedNode key={n.id} node={n} />
724
- ))}
725
- </div>
726
- </div>
727
- <AttentionToast />
728
- <AttentionHistory />
729
- <CanvasViewport
730
- onNodeContextMenu={openNodeMenu}
731
- onCanvasContextMenu={openCanvasMenu}
732
- annotationMode={annotationTool !== null}
327
+ <div class="app-shell" data-session-active={sessionIsActive ? 'true' : 'false'}>
328
+ <ToolRail
329
+ minimapVisible={minimapVisible}
330
+ onToggleMinimap={handleToggleMinimap}
331
+ snapshotOpen={snapshotOpen}
332
+ onToggleSnapshot={handleToggleSnapshot}
333
+ snapshotBtnRef={snapshotBtnRef}
334
+ onOpenPalette={() => setPaletteOpen(true)}
335
+ onOpenShortcuts={() => setShortcutsOpen((v) => !v)}
733
336
  annotationTool={annotationTool}
337
+ onSetAnnotationTool={handleSetAnnotationTool}
734
338
  />
735
- {hasInitialLayout && allNodes.filter((n) => !n.dockPosition).length === 0 && intents.value.size === 0 && (
736
- <WelcomeCard onOpenPalette={() => setPaletteOpen(true)} />
737
- )}
738
- {selectedNodeIds.value.size > 0 && <SelectionBar />}
739
- {contextPinnedNodeIds.value.size > 0 && <ContextPinBar />}
740
- {expandedNodeId.value && <ExpandedNodeOverlay />}
741
- <SnapshotPanel open={snapshotOpen} onClose={handleCloseSnapshot} anchorRef={snapshotBtnRef} />
742
- {minimapVisible && (
743
- <Minimap
744
- viewport={viewport}
745
- nodes={nodes}
746
- edges={edges}
747
- onNavigate={handleMinimapNavigate}
748
- containerWidth={window.innerWidth}
749
- containerHeight={window.innerHeight}
750
- />
751
- )}
339
+ <div class="app-main">
340
+ <TopBar />
341
+ <ConnectionBanner />
342
+ <div class="canvas-region" ref={(el) => registerCanvasArea(el)}>
343
+ <CanvasViewport
344
+ onNodeContextMenu={openNodeMenu}
345
+ onCanvasContextMenu={openCanvasMenu}
346
+ onEdgeContextMenu={openEdgeMenu}
347
+ annotationMode={annotationTool !== null}
348
+ annotationTool={annotationTool}
349
+ />
350
+ <AttentionToast />
351
+ <AttentionHistory />
352
+ <ActivityFeed />
353
+ <WritersSheet />
354
+ {hasInitialLayout && allNodes.length === 0 && intents.value.size === 0 && (
355
+ <EmptyState onOpenPalette={() => setPaletteOpen(true)} />
356
+ )}
357
+ {selectedNodeIds.value.size > 0 && <SelectionBar />}
358
+ {sessionIsActive ? <CommandBar /> : contextPinnedNodeIds.value.size > 0 && <ContextPinBar />}
359
+ <SessionReceipt onOpenSnapshots={() => setSnapshotOpen(true)} />
360
+ <TextPrompt />
361
+ {expandedNodeId.value && <ExpandedNodeOverlay />}
362
+ <SnapshotPanel open={snapshotOpen} onClose={handleCloseSnapshot} anchorRef={snapshotBtnRef} />
363
+ {minimapVisible && (
364
+ <Minimap
365
+ viewport={viewport}
366
+ nodes={nodes}
367
+ edges={edges}
368
+ onNavigate={handleMinimapNavigate}
369
+ containerWidth={area.width}
370
+ containerHeight={area.height}
371
+ />
372
+ )}
373
+ </div>
374
+ </div>
375
+ {sessionIsActive && <SessionPanel />}
752
376
  {menu && <ContextMenu menu={menu} onClose={closeMenu} />}
753
377
  {paletteOpen && <CommandPalette onClose={() => setPaletteOpen(false)} onToggleMinimap={handleToggleMinimap} />}
754
378
  {shortcutsOpen && <ShortcutOverlay onClose={() => setShortcutsOpen(false)} />}