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,4 +1,5 @@
1
1
  import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
2
+ import { canvasArea } from './canvas-area';
2
3
  import {
3
4
  autoArrange,
4
5
  canvasTheme,
@@ -8,12 +9,17 @@ import {
8
9
  nodes,
9
10
  searchHighlightIds,
10
11
  } from '../state/canvas-store';
11
- import { createNodeFromClient, saveCanvasTheme } from '../state/intent-bridge';
12
+ import { saveCanvasTheme } from '../state/intent-bridge';
13
+ import { createNodeInView } from './create-in-view';
14
+ import { sessionActive } from '../state/presence-store';
15
+ import { startSession } from '../state/session-store';
12
16
  import { TYPE_LABELS, type CanvasNodeState } from '../types';
13
17
  import { invalidateTokenCache } from '../theme/tokens';
14
18
  import { clearThemeOverride } from '../state/theme-override';
19
+ import { getNodeIcon, IconArrange, IconFitAll, IconMinimap, IconMoon, IconNodeMarkdown, IconSteer } from '../icons';
15
20
 
16
- import { MOD_KEY } from '../utils/platform';
21
+ import { modChord } from '../utils/platform';
22
+ import { useFocusTrap } from './use-focus-trap';
17
23
 
18
24
  // ── Types ───────────────────────────────────────────────────
19
25
  interface PaletteItem {
@@ -21,8 +27,10 @@ interface PaletteItem {
21
27
  kind: 'node' | 'action';
22
28
  label: string;
23
29
  description?: string;
24
- badge: string;
25
- badgeClass?: string;
30
+ /** Keyboard shortcut shown as a kbd on action rows. */
31
+ shortcut?: string;
32
+ icon: (p: { size?: number; class?: string }) => preact.JSX.Element;
33
+ iconTone?: 'accent' | 'purple' | 'muted';
26
34
  nodeType?: CanvasNodeState['type'];
27
35
  action: () => void;
28
36
  }
@@ -100,6 +108,8 @@ export function CommandPalette({ onClose, onToggleMinimap }: { onClose: () => vo
100
108
  const [selectedIndex, setSelectedIndex] = useState(0);
101
109
  const inputRef = useRef<HTMLInputElement>(null);
102
110
  const listRef = useRef<HTMLDivElement>(null);
111
+ const dialogRef = useRef<HTMLDivElement>(null);
112
+ useFocusTrap(dialogRef, true, { initial: inputRef });
103
113
 
104
114
  // Autofocus
105
115
  useEffect(() => {
@@ -114,12 +124,20 @@ export function CommandPalette({ onClose, onToggleMinimap }: { onClose: () => vo
114
124
  for (const node of nodes.value.values()) {
115
125
  const title = (node.data.title as string) || TYPE_LABELS[node.type];
116
126
  const content = (node.data.content as string) || (node.data.path as string) || '';
127
+ const childCount = node.type === 'group' && Array.isArray(node.data.children) ? node.data.children.length : 0;
117
128
  items.push({
118
129
  id: `node:${node.id}`,
119
130
  kind: 'node',
120
131
  label: title,
121
- description: content.length > 80 ? content.slice(0, 80) + '...' : content || undefined,
122
- badge: TYPE_LABELS[node.type],
132
+ description:
133
+ node.type === 'group'
134
+ ? `group · ${childCount} node${childCount === 1 ? '' : 's'}`
135
+ : content.length > 60
136
+ ? `${TYPE_LABELS[node.type].toLowerCase()} · ${content.slice(0, 60)}…`
137
+ : content
138
+ ? `${TYPE_LABELS[node.type].toLowerCase()} · ${content}`
139
+ : TYPE_LABELS[node.type].toLowerCase(),
140
+ icon: getNodeIcon(node.type),
123
141
  nodeType: node.type,
124
142
  action: () => {
125
143
  focusNode(node.id);
@@ -129,26 +147,50 @@ export function CommandPalette({ onClose, onToggleMinimap }: { onClose: () => vo
129
147
  }
130
148
 
131
149
  // Action items
132
- const actions: Array<{ label: string; badge: string; action: () => void }> = [
150
+ const actions: Array<{
151
+ label: string;
152
+ shortcut?: string;
153
+ icon: PaletteItem['icon'];
154
+ iconTone?: PaletteItem['iconTone'];
155
+ action: () => void;
156
+ }> = [
133
157
  {
134
- label: 'New note (markdown node)',
135
- badge: 'CREATE',
158
+ label: 'New markdown note',
159
+ shortcut: 'M',
160
+ icon: IconNodeMarkdown,
161
+ iconTone: 'accent',
136
162
  action: () => {
137
- createNodeFromClient({ type: 'markdown', title: 'New note', width: 520, height: 360 });
163
+ void createNodeInView({ type: 'markdown', title: 'New note', width: 520, height: 360 });
138
164
  onClose();
139
165
  },
140
166
  },
167
+ ...(sessionActive.value
168
+ ? []
169
+ : [
170
+ {
171
+ label: 'Start agent session',
172
+ icon: IconSteer,
173
+ iconTone: 'purple' as const,
174
+ action: () => {
175
+ void startSession();
176
+ onClose();
177
+ },
178
+ },
179
+ ]),
141
180
  {
142
- label: 'Fit all nodes',
143
- badge: 'VIEW',
181
+ label: 'Fit view',
182
+ shortcut: 'F',
183
+ icon: IconFitAll,
184
+ iconTone: 'muted',
144
185
  action: () => {
145
- fitAll(window.innerWidth, window.innerHeight);
186
+ fitAll(canvasArea().width, canvasArea().height);
146
187
  onClose();
147
188
  },
148
189
  },
149
190
  {
150
191
  label: 'Auto-arrange (grid)',
151
- badge: 'LAYOUT',
192
+ icon: IconArrange,
193
+ iconTone: 'muted',
152
194
  action: () => {
153
195
  autoArrange();
154
196
  onClose();
@@ -156,7 +198,8 @@ export function CommandPalette({ onClose, onToggleMinimap }: { onClose: () => vo
156
198
  },
157
199
  {
158
200
  label: 'Auto-arrange (graph-aware)',
159
- badge: 'LAYOUT',
201
+ icon: IconArrange,
202
+ iconTone: 'muted',
160
203
  action: () => {
161
204
  forceDirectedArrange();
162
205
  onClose();
@@ -164,7 +207,8 @@ export function CommandPalette({ onClose, onToggleMinimap }: { onClose: () => vo
164
207
  },
165
208
  {
166
209
  label: 'Toggle minimap',
167
- badge: 'VIEW',
210
+ icon: IconMinimap,
211
+ iconTone: 'muted',
168
212
  action: () => {
169
213
  onToggleMinimap();
170
214
  onClose();
@@ -172,7 +216,8 @@ export function CommandPalette({ onClose, onToggleMinimap }: { onClose: () => vo
172
216
  },
173
217
  {
174
218
  label: 'Toggle theme (dark/light)',
175
- badge: 'THEME',
219
+ icon: IconMoon,
220
+ iconTone: 'muted',
176
221
  action: () => {
177
222
  const next = canvasTheme.value === 'dark' ? 'light' : 'dark';
178
223
  // An explicit pick ends any ?theme= session override, same as the
@@ -192,8 +237,9 @@ export function CommandPalette({ onClose, onToggleMinimap }: { onClose: () => vo
192
237
  id: `action:${a.label}`,
193
238
  kind: 'action',
194
239
  label: a.label,
195
- badge: a.badge,
196
- badgeClass: 'command-palette-badge--action',
240
+ ...(a.shortcut ? { shortcut: a.shortcut } : {}),
241
+ icon: a.icon,
242
+ ...(a.iconTone ? { iconTone: a.iconTone } : {}),
197
243
  action: a.action,
198
244
  });
199
245
  }
@@ -229,6 +275,9 @@ export function CommandPalette({ onClose, onToggleMinimap }: { onClose: () => vo
229
275
  // Sort by score descending
230
276
  filtered.sort((a, b) => b.score - a.score);
231
277
  }
278
+ // Grouped render order (design item 7): Actions first, then Jump to.
279
+ filtered = [...filtered.filter((item) => item.kind === 'action'), ...filtered.filter((item) => item.kind === 'node')];
280
+ const firstNodeIndex = filtered.findIndex((item) => item.kind === 'node');
232
281
 
233
282
  // ── Sync spatial search highlights to canvas ──────────────
234
283
  useEffect(() => {
@@ -292,48 +341,74 @@ export function CommandPalette({ onClose, onToggleMinimap }: { onClose: () => vo
292
341
 
293
342
  return (
294
343
  <div class="command-palette-backdrop" onMouseDown={onClose}>
295
- <div class="command-palette" onMouseDown={(e) => e.stopPropagation()}>
296
- <input
297
- ref={inputRef}
298
- type="text"
299
- class="command-palette-input"
300
- value={query}
301
- onInput={(e) => setQuery((e.target as HTMLInputElement).value)}
302
- onKeyDown={handleKeyDown}
303
- placeholder={`Search nodes and actions... (${MOD_KEY}+K)`}
304
- />
305
- <div class="command-palette-hint">
306
- <span>
307
- <kbd>{'\u2191'}</kbd>
308
- <kbd>{'\u2193'}</kbd> navigate
309
- </span>
310
- <span>
311
- <kbd>{'\u21B5'}</kbd> select
312
- </span>
313
- <span>
314
- <kbd>esc</kbd> close
315
- </span>
316
- <span>
317
- <kbd>type:</kbd> filter by type
318
- </span>
344
+ <div
345
+ ref={dialogRef}
346
+ class="command-palette"
347
+ role="dialog"
348
+ aria-modal="true"
349
+ aria-label="Search and commands"
350
+ onMouseDown={(e) => e.stopPropagation()}
351
+ >
352
+ <div class="command-palette-search">
353
+ <svg
354
+ width="15"
355
+ height="15"
356
+ viewBox="0 0 16 16"
357
+ fill="none"
358
+ stroke="currentColor"
359
+ stroke-width="1.5"
360
+ stroke-linecap="round"
361
+ aria-hidden="true"
362
+ >
363
+ <circle cx="7" cy="7" r="4.5" />
364
+ <line x1="10.5" y1="10.5" x2="14.5" y2="14.5" />
365
+ </svg>
366
+ <input
367
+ ref={inputRef}
368
+ type="text"
369
+ class="command-palette-input"
370
+ value={query}
371
+ onInput={(e) => setQuery((e.target as HTMLInputElement).value)}
372
+ onKeyDown={handleKeyDown}
373
+ placeholder="Search nodes, run actions, jump to…"
374
+ aria-label="Search nodes, run actions, jump to"
375
+ />
376
+ <kbd class="command-palette-esc">esc</kbd>
319
377
  </div>
320
378
  <div class="command-palette-results" ref={listRef}>
321
379
  {filtered.length === 0 && <div class="command-palette-empty">No matching nodes or actions</div>}
322
- {filtered.map((item, i) => (
323
- <button
324
- key={item.id}
325
- type="button"
326
- class={`command-palette-item${i === clampedIndex ? ' selected' : ''}`}
327
- onMouseEnter={() => setSelectedIndex(i)}
328
- onClick={() => item.action()}
329
- >
330
- <span class={`command-palette-badge${item.badgeClass ? ` ${item.badgeClass}` : ''}`}>{item.badge}</span>
331
- <span class="command-palette-label">
332
- {item.indices.length > 0 ? highlightMatch(item.label, item.indices) : item.label}
333
- </span>
334
- {item.description && <span class="command-palette-desc">{item.description}</span>}
335
- </button>
336
- ))}
380
+ {filtered.map((item, i) => {
381
+ const Icon = item.icon;
382
+ const heading = i === 0 && item.kind === 'action' ? 'Actions' : i === firstNodeIndex ? 'Jump to' : null;
383
+ return (
384
+ <div key={item.id} class="command-palette-row">
385
+ {heading && <div class="command-palette-group">{heading}</div>}
386
+ <button
387
+ type="button"
388
+ class={`command-palette-item${i === clampedIndex ? ' selected' : ''}`}
389
+ onMouseEnter={() => setSelectedIndex(i)}
390
+ onClick={() => item.action()}
391
+ >
392
+ <span class={`command-palette-icon tone-${item.iconTone ?? 'kind'}`} aria-hidden="true">
393
+ <Icon size={14} />
394
+ </span>
395
+ <span class="command-palette-label">
396
+ {item.indices.length > 0 ? highlightMatch(item.label, item.indices) : item.label}
397
+ {item.description && <span class="command-palette-desc"> — {item.description}</span>}
398
+ </span>
399
+ {item.shortcut && <kbd class="command-palette-kbd">{item.shortcut}</kbd>}
400
+ </button>
401
+ </div>
402
+ );
403
+ })}
404
+ </div>
405
+ <div class="command-palette-hint">
406
+ <span>↑↓ navigate</span>
407
+ <span>↵ open</span>
408
+ <span>esc close</span>
409
+ <span>type: filter</span>
410
+ <span class="command-palette-hint-spacer" />
411
+ <span>{modChord('K')}</span>
337
412
  </div>
338
413
  </div>
339
414
  </div>
@@ -0,0 +1,65 @@
1
+ import { computed } from '@preact/signals';
2
+ import {
3
+ connectionStatus,
4
+ hasInitialServerLayout,
5
+ reconnectAttempt,
6
+ reconnectDelay,
7
+ workbenchConnectionEpoch,
8
+ } from '../state/canvas-store';
9
+
10
+ /**
11
+ * Degraded connection states (rail-chrome-v2 phase 7, design item 14), mapped
12
+ * onto the transport the bridge already runs:
13
+ * - reconnecting: the stream (SSE or the polling fallback) is down and the
14
+ * bridge is backing off. Edits still go over HTTP; the board may be stale.
15
+ * - resyncing: a transport came back after a drop and the full `connected`
16
+ * snapshot is being applied — the seq cursor was dropped, the board reloads
17
+ * from the server's snapshot without touching local edits.
18
+ * Neither shows on first boot; the top-bar dot carries the same state.
19
+ */
20
+ export type DegradedState = 'reconnecting' | 'resyncing' | null;
21
+
22
+ export const degradedState = computed<DegradedState>(() => {
23
+ if (connectionStatus.value === 'disconnected') return 'reconnecting';
24
+ if (connectionStatus.value === 'connected' && !hasInitialServerLayout.value && workbenchConnectionEpoch.value > 1) {
25
+ return 'resyncing';
26
+ }
27
+ return null;
28
+ });
29
+
30
+ export function ConnectionBanner() {
31
+ const state = degradedState.value;
32
+ if (!state) return null;
33
+ if (state === 'reconnecting') {
34
+ const attempt = reconnectAttempt.value;
35
+ const delay = reconnectDelay.value;
36
+ return (
37
+ <div class="connection-banner is-reconnecting" role="status" data-testid="connection-banner">
38
+ <span class="connection-banner-glyph" aria-hidden="true">
39
+ ↻
40
+ </span>
41
+ <span class="connection-banner-text">
42
+ <b>Reconnecting…</b> live updates are paused; edits still save, the board may be stale until the stream
43
+ returns.
44
+ </span>
45
+ <span class="connection-banner-meta">
46
+ {attempt > 0 ? `retry ${attempt}` : 'retrying'}
47
+ {delay > 0 ? ` · ${Math.max(1, Math.round(delay / 1000))}s` : ''}
48
+ </span>
49
+ </div>
50
+ );
51
+ }
52
+ return (
53
+ <div class="connection-banner is-resyncing" role="status" data-testid="connection-banner">
54
+ <span class="connection-banner-glyph" aria-hidden="true">
55
+ ⟳
56
+ </span>
57
+ <span class="connection-banner-text">
58
+ <b>Resyncing from snapshot</b> — the event cursor went stale; the board reloads without losing local edits.
59
+ </span>
60
+ <span class="connection-banner-progress" aria-hidden="true">
61
+ <span class="connection-banner-progress-fill" />
62
+ </span>
63
+ </div>
64
+ );
65
+ }
@@ -2,7 +2,6 @@ import type { ComponentChildren } from 'preact';
2
2
  import { useCallback, useEffect, useState } from 'preact/hooks';
3
3
  import {
4
4
  contextPinnedNodeIds,
5
- dockNode,
6
5
  edges,
7
6
  expandNode,
8
7
  focusNode,
@@ -12,7 +11,6 @@ import {
12
11
  removeNode,
13
12
  toggleCollapsed,
14
13
  toggleContextPin,
15
- undockNode,
16
14
  updateNode,
17
15
  } from '../state/canvas-store';
18
16
  import {
@@ -20,11 +18,15 @@ import {
20
18
  createGroupFromClient,
21
19
  createNodeFromClient,
22
20
  refreshWebpageNodeFromClient,
21
+ removeEdgeFromClient,
23
22
  removeNodeFromClient,
24
23
  sendIntent,
24
+ setGroupChildrenFromClient,
25
25
  ungroupFromClient,
26
+ updateEdgeFromClient,
26
27
  updateNodeFromClient,
27
28
  } from '../state/intent-bridge';
29
+ import { askText } from './TextPrompt';
28
30
  import { EXPANDABLE_TYPES } from '../types';
29
31
  import type { CanvasNodeState } from '../types';
30
32
 
@@ -41,6 +43,12 @@ export type MenuState =
41
43
  y: number;
42
44
  canvasX: number;
43
45
  canvasY: number;
46
+ }
47
+ | {
48
+ kind: 'edge';
49
+ x: number;
50
+ y: number;
51
+ edgeId: string;
44
52
  };
45
53
 
46
54
  export function useContextMenu() {
@@ -58,6 +66,12 @@ export function useContextMenu() {
58
66
  setMenu({ kind: 'canvas', x: e.clientX, y: e.clientY, canvasX, canvasY });
59
67
  }, []);
60
68
 
69
+ const openEdgeMenu = useCallback((e: MouseEvent, edgeId: string) => {
70
+ e.preventDefault();
71
+ e.stopPropagation();
72
+ setMenu({ kind: 'edge', x: e.clientX, y: e.clientY, edgeId });
73
+ }, []);
74
+
61
75
  const closeMenu = useCallback(() => setMenu(null), []);
62
76
 
63
77
  useEffect(() => {
@@ -74,7 +88,7 @@ export function useContextMenu() {
74
88
  };
75
89
  }, [menu]);
76
90
 
77
- return { menu, openNodeMenu, openCanvasMenu, closeMenu };
91
+ return { menu, openNodeMenu, openCanvasMenu, openEdgeMenu, closeMenu };
78
92
  }
79
93
 
80
94
  interface ContextMenuProps {
@@ -99,6 +113,44 @@ export function ContextMenu({ menu, onClose }: ContextMenuProps) {
99
113
  const node = nodes.value.get(menu.nodeId);
100
114
  if (!node) return null;
101
115
  items = buildNodeMenuItems(node);
116
+ } else if (menu.kind === 'edge') {
117
+ // The full edge lifecycle from one menu: relabel, retype, restyle, delete —
118
+ // a drawn edge used to be frozen the moment it existed.
119
+ const edgeId = menu.edgeId;
120
+ const edge = edges.value.get(edgeId);
121
+ items = [
122
+ {
123
+ label: edge?.label
124
+ ? `Edit label “${edge.label.slice(0, 18)}${edge.label.length > 18 ? '…' : ''}”`
125
+ : 'Add label…',
126
+ action: () => {
127
+ void askText('Edge label (empty clears it)', 'label text', {
128
+ initial: edge?.label ?? '',
129
+ allowEmpty: true,
130
+ confirm: 'Save',
131
+ }).then((next) => {
132
+ if (next === null) return;
133
+ void updateEdgeFromClient(edgeId, { label: next });
134
+ });
135
+ },
136
+ },
137
+ { separator: true },
138
+ ...(['flow', 'depends-on', 'relation', 'references'] as const).map((type) => ({
139
+ label: `${edge?.type === type ? '✓' : ' '} ${type}`,
140
+ action: () => void updateEdgeFromClient(edgeId, { type }),
141
+ })),
142
+ { separator: true },
143
+ ...(['solid', 'dashed', 'dotted'] as const).map((style) => ({
144
+ label: `${(edge?.style ?? 'solid') === style ? '✓' : ' '} ${style}`,
145
+ action: () => void updateEdgeFromClient(edgeId, { style }),
146
+ })),
147
+ { separator: true },
148
+ {
149
+ label: 'Delete edge',
150
+ danger: true,
151
+ action: () => void removeEdgeFromClient(edgeId),
152
+ },
153
+ ];
102
154
  } else {
103
155
  items = buildCanvasMenuItems(menu.canvasX, menu.canvasY);
104
156
  }
@@ -147,7 +199,7 @@ export function ContextMenu({ menu, onClose }: ContextMenuProps) {
147
199
  <button
148
200
  key={itemKey}
149
201
  type="button"
150
- class="context-menu-item"
202
+ class={`context-menu-item${item.danger ? ' is-danger' : ''}`}
151
203
  onClick={() => {
152
204
  item.action?.();
153
205
  onClose();
@@ -167,6 +219,8 @@ interface MenuItem {
167
219
  shortcut?: string;
168
220
  action?: () => void;
169
221
  separator?: boolean;
222
+ /** Destructive item — rendered in the danger color. */
223
+ danger?: boolean;
170
224
  render?: (onClose: () => void) => ComponentChildren;
171
225
  }
172
226
 
@@ -294,7 +348,9 @@ async function createPromptedNode(opts: {
294
348
  titleFallback: string;
295
349
  errorMessage: string;
296
350
  }): Promise<void> {
297
- const value = window.prompt(opts.promptLabel, opts.placeholder ?? '');
351
+ // In-canvas prompt: `window.prompt` (and `window.alert`) are silent no-ops
352
+ // in embedded browser panes — these menu items "did nothing" there.
353
+ const value = await askText(opts.promptLabel, opts.placeholder ?? '');
298
354
  if (!value) return;
299
355
  const trimmed = value.trim();
300
356
  if (!trimmed) return;
@@ -311,7 +367,7 @@ async function createPromptedNode(opts: {
311
367
  });
312
368
 
313
369
  if (!result.ok) {
314
- window.alert(opts.errorMessage);
370
+ console.error(`[canvas-menu] ${opts.errorMessage}`);
315
371
  }
316
372
  }
317
373
 
@@ -561,35 +617,40 @@ function buildNodeMenuItems(node: CanvasNodeState): MenuItem[] {
561
617
  if (childIds.length > 0) {
562
618
  items.push({
563
619
  label: `Ungroup (${childIds.length} node${childIds.length !== 1 ? 's' : ''})`,
564
- action: () => ungroupFromClient(node.id),
620
+ // Dissolve — the same op an agent's ungroup runs.
621
+ action: () => {
622
+ removeNode(node.id);
623
+ void ungroupFromClient(node.id);
624
+ },
565
625
  });
566
626
  }
567
627
  }
568
628
 
569
- // Dock/undock for status and ledger nodes
570
- if (node.type === 'status' || node.type === 'ledger') {
571
- items.push({ separator: true });
572
- if (node.dockPosition !== null) {
573
- items.push({
574
- label: 'Undock to canvas',
575
- action: () => undockNode(node.id),
576
- });
577
- } else {
578
- items.push({
579
- label: 'Dock left of toolbar',
580
- action: () => dockNode(node.id, 'left'),
581
- });
582
- items.push({
583
- label: 'Dock right of toolbar',
584
- action: () => dockNode(node.id, 'right'),
585
- });
586
- }
629
+ // A member of a frame can leave it alone — the drag-out gesture ("release
630
+ // to remove") exists but is easy to miss; this is the discoverable path.
631
+ const parentGroupId = typeof node.data.parentGroup === 'string' ? node.data.parentGroup : null;
632
+ const parentGroup = parentGroupId ? nodes.value.get(parentGroupId) : undefined;
633
+ if (parentGroup && parentGroup.type === 'group') {
634
+ const parentTitle =
635
+ typeof parentGroup.data.title === 'string' && parentGroup.data.title ? parentGroup.data.title : 'frame';
636
+ const siblings = Array.isArray(parentGroup.data.children)
637
+ ? (parentGroup.data.children as string[]).filter((childId) => childId !== node.id)
638
+ : [];
639
+ items.push({
640
+ label: `Remove from “${parentTitle}”`,
641
+ action: () => {
642
+ void setGroupChildrenFromClient(parentGroup.id, siblings);
643
+ },
644
+ });
587
645
  }
588
646
 
589
- // Report #64: status nodes are removable like any other node type.
647
+ // Report #64: status nodes are removable like any other node type. Named
648
+ // "Delete" — "Close" did not read as destructive, and a frame's delete
649
+ // deserves the honest caveat that its members stay.
590
650
  items.push({ separator: true });
591
651
  items.push({
592
- label: 'Close',
652
+ label: node.type === 'group' ? 'Delete frame (children stay)' : 'Delete',
653
+ danger: true,
593
654
  action: () => {
594
655
  removeNode(node.id);
595
656
  void removeNodeFromClient(node.id);
@@ -1,9 +1,9 @@
1
- import { clearContextPins, contextPinnedNodeIds, hasOpenDockedContextPanel } from '../state/canvas-store';
1
+ import { clearContextPins, contextPinnedNodeIds } from '../state/canvas-store';
2
2
  import { attentionHistoryOpen } from '../state/attention-store';
3
3
 
4
4
  export function ContextPinBar() {
5
5
  const count = contextPinnedNodeIds.value.size;
6
- if (count === 0 || attentionHistoryOpen.value || hasOpenDockedContextPanel.value) return null;
6
+ if (count === 0 || attentionHistoryOpen.value) return null;
7
7
 
8
8
  return (
9
9
  <div class="context-pin-bar">