pmx-canvas 0.4.7 → 0.5.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 (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 +3069 -796
  6. package/dist/canvas/index.js +104 -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 +25 -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 +32 -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 +23 -1
  57. package/dist/types/server/canvas-operations.d.ts +1 -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 +54 -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 +1 -1
  93. package/skills/pmx-canvas/SKILL.md +109 -18
  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 +57 -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 +252 -0
  103. package/src/cli/index.ts +1 -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 +41 -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 +79 -2
  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 +273 -84
  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 +3069 -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 +237 -61
  172. package/src/server/canvas-operations.ts +17 -8
  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 +107 -4
  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 +65 -16
  196. package/src/server/operations/ops/query.ts +1 -0
  197. package/src/server/operations/ops/snapshots.ts +16 -0
  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
@@ -0,0 +1,308 @@
1
+ import type { ComponentChildren } from 'preact';
2
+ import { useEffect, useState } from 'preact/hooks';
3
+ import { IconFitAll, IconZoomIn, IconZoomOut } from '../icons';
4
+ import {
5
+ animateViewport,
6
+ connectionStatus,
7
+ edges,
8
+ fitAll,
9
+ hasInitialServerLayout,
10
+ nodes,
11
+ sessionId,
12
+ traceEnabled,
13
+ viewport,
14
+ zoomByFactor,
15
+ } from '../state/canvas-store';
16
+ import { modChord } from '../utils/platform';
17
+ import { canvasArea } from './canvas-area';
18
+ import { BarHint } from './BarHint';
19
+ import { degradedState } from './ConnectionBanner';
20
+ import { ExternalWriterIndicator } from './ExternalWriters';
21
+ import { useNow } from './use-now';
22
+ import { agentPhaseLabel } from '../../shared/agent-presence.js';
23
+ import { agentIdentityHue } from '../../shared/agent-presence.js';
24
+ import { activeSession, agentPresences, attachedSessions, contextBudget } from '../state/presence-store';
25
+ import { endSession, pendingGates, startSession } from '../state/session-store';
26
+ import { formatCountdown, gateRemainingMs } from '../../shared/approval-gates.js';
27
+
28
+ /**
29
+ * The attached session's chip (rail-chrome-v2 phase 3): phase-colored, with a
30
+ * dot that pulses while the agent thinks or runs a tool. Renders ONLY while a
31
+ * session is attached — the quiet board shows nothing here until phase 5
32
+ * lands the "Start agent session" affordance.
33
+ */
34
+ function AgentChip() {
35
+ // Every attached session gets its chip — with several agents on the board,
36
+ // showing only the first hid the rest from the top bar entirely.
37
+ const sessions = attachedSessions.value;
38
+ if (sessions.length === 0) return null;
39
+ return (
40
+ <>
41
+ {sessions.map((session) => {
42
+ // Fleet roll-up: workers declaring this session as their parent count
43
+ // into its chip instead of growing the bar one chip per worker.
44
+ const workers = agentPresences.value.filter(
45
+ (presence) =>
46
+ presence.parentAgentId != null &&
47
+ (presence.parentAgentId === session.sessionId || presence.parentAgentId === session.source),
48
+ ).length;
49
+ return (
50
+ <BarHint
51
+ key={session.sessionId}
52
+ label={`Agent session — ${session.label}`}
53
+ tapToOpen
54
+ body="What this attached agent is doing right now: idle, thinking, running a tool, or waiting on your approval. Steer it from the composer below."
55
+ >
56
+ <span
57
+ class={`agent-chip phase-${session.phase}`}
58
+ data-phase={session.phase}
59
+ style={{ '--identity-color': `hsl(${agentIdentityHue(session.sessionId)} 65% 62%)` }}
60
+ >
61
+ <span class="agent-chip-dot" aria-hidden="true" />
62
+ <span class="agent-chip-label">{agentPhaseLabel(session)}</span>
63
+ <span class="agent-chip-who hud-collapsible-text">{session.label}</span>
64
+ {workers > 0 && (
65
+ <span class="agent-chip-workers">
66
+ +{workers} worker{workers === 1 ? '' : 's'}
67
+ </span>
68
+ )}
69
+ <button
70
+ type="button"
71
+ class="agent-chip-end"
72
+ aria-label={`End ${session.label} session`}
73
+ title={`End ${session.label}'s session`}
74
+ onClick={(e) => {
75
+ // The chip sits inside a tap-to-open hint — ending a session
76
+ // must not also open it.
77
+ e.stopPropagation();
78
+ void endSession({ source: session.source, agentId: session.agentId });
79
+ }}
80
+ >
81
+ ×
82
+ </button>
83
+ </span>
84
+ </BarHint>
85
+ );
86
+ })}
87
+ </>
88
+ );
89
+ }
90
+
91
+ /**
92
+ * Amber escalation badge while any approval gate is pending in an attached
93
+ * session: "1 gate · 4:31" — the countdown is the soonest auto-hold.
94
+ */
95
+ function GateBadge() {
96
+ const count = pendingGates.value.length;
97
+ const active = activeSession.value !== null && count > 0;
98
+ const now = useNow(active ? 1000 : 0);
99
+ if (!active) return null;
100
+ const soonest = pendingGates.value
101
+ .map((gate) => gateRemainingMs(gate, now))
102
+ .filter((ms): ms is number => ms !== null)
103
+ .sort((a, b) => a - b)[0];
104
+ return (
105
+ <BarHint
106
+ label="Approval gates waiting on you"
107
+ tapToOpen
108
+ body="The agent is blocked until you approve or reject in the session panel; unanswered gates auto-hold when the countdown runs out."
109
+ >
110
+ <span class="gate-badge">
111
+ {count} gate{count === 1 ? '' : 's'}
112
+ {soonest !== undefined && ` · ${formatCountdown(soonest)}`}
113
+ </span>
114
+ </BarHint>
115
+ );
116
+ }
117
+
118
+ function budgetTone(ratio: number): 'ok' | 'warn' | 'danger' {
119
+ if (ratio > 0.9) return 'danger';
120
+ if (ratio >= 0.7) return 'warn';
121
+ return 'ok';
122
+ }
123
+
124
+ function formatTokens(value: number): string {
125
+ return value >= 1000 ? `${(value / 1000).toFixed(value >= 10_000 ? 0 : 1)}k` : String(value);
126
+ }
127
+
128
+ /**
129
+ * Context meter (rail-chrome-v2 phase 5). Two honest modes:
130
+ * - the host reported the agent's REAL window (`contextUsage` on its presence)
131
+ * → "Context": used / window, with the pinned payload as a note;
132
+ * - nothing reported (no adapter does yet) → "Pins": the pinned-context
133
+ * payload estimate against the configured budget
134
+ * (PMX_CANVAS_CONTEXT_BUDGET_TOKENS). Never presented as the agent's window.
135
+ * Session-only, like the chip; the estimate moves within a frame of a pin toggle.
136
+ */
137
+ function ContextBudget() {
138
+ const session = activeSession.value;
139
+ if (!session) return null;
140
+ const budget = contextBudget.value;
141
+ const real = session.contextUsage;
142
+ const used = real ? real.used : budget.used;
143
+ const total = real ? real.total : budget.total;
144
+ const ratio = total > 0 ? Math.min(1, used / total) : 0;
145
+ const pct = Math.round(ratio * 100);
146
+ const hint = real
147
+ ? {
148
+ label: `Context window — reported by ${session.label}`,
149
+ body: `${formatTokens(real.used)} of ${formatTokens(real.total)} tokens of the agent's actual context window are used. Your ✦-pinned nodes account for ≈ ${formatTokens(budget.used)} tokens of it.`,
150
+ }
151
+ : {
152
+ label: 'Pins — pinned-context size (estimate)',
153
+ body: `The nodes you pinned (✦) are what the agent is asked to carry as context: ≈ ${formatTokens(budget.used)} of a ${formatTokens(budget.total)}-token budget. When the agent's host reports its real context window, this meter switches to showing that instead.`,
154
+ };
155
+ return (
156
+ <BarHint label={hint.label} body={hint.body} tapToOpen>
157
+ <span class={`context-budget tone-${budgetTone(ratio)}`} data-mode={real ? 'window' : 'pins'}>
158
+ <span class="context-budget-caption hud-collapsible-text">{real ? 'Context' : 'Pins'}</span>
159
+ <span class="context-budget-track" aria-hidden="true">
160
+ <span class="context-budget-fill" style={{ width: `${pct}%` }} />
161
+ </span>
162
+ <span class="context-budget-label" data-testid="budget-label">
163
+ {used > 0 && pct === 0 ? '<1' : pct}%
164
+ </span>
165
+ </span>
166
+ </BarHint>
167
+ );
168
+ }
169
+
170
+ /**
171
+ * Quiet-board affordance (rail-chrome-v2 phase 5): attach a human-started
172
+ * session. The agent's subsequent MCP/HTTP writes are attributed to it, which
173
+ * is what turns the quiet board into a Focus Session with a cursor and panel.
174
+ */
175
+ function StartSessionButton() {
176
+ const [busy, setBusy] = useState(false);
177
+ if (activeSession.value) return null;
178
+ return (
179
+ <BarHint
180
+ label="Start an agent session"
181
+ body="Attaches a session to this board: the next agent that writes here is adopted into it, and you get the panel, composer, and receipt."
182
+ >
183
+ <button
184
+ type="button"
185
+ class="start-session-btn"
186
+ disabled={busy}
187
+ onClick={() => {
188
+ setBusy(true);
189
+ void startSession().finally(() => setBusy(false));
190
+ }}
191
+ >
192
+ <span class="start-session-dot" aria-hidden="true" />
193
+ Start agent session
194
+ <span class="beta-tag">beta</span>
195
+ </button>
196
+ </BarHint>
197
+ );
198
+ }
199
+
200
+ /**
201
+ * The slim 44px top bar (rail-chrome-v2 phase 1): connection state, board
202
+ * identity on the left, view controls on the right. `overflow:hidden` +
203
+ * `min-width:0` discipline throughout — the title ellipsizes, meta collapses
204
+ * below 1180px, and nothing ever wraps to a second row.
205
+ */
206
+ export function TopBar() {
207
+ const status = connectionStatus.value;
208
+ const hasSynced = hasInitialServerLayout.value;
209
+ const v = viewport.value;
210
+ const nodeCount = nodes.value.size;
211
+ const edgeCount = edges.value.size;
212
+ const isTraceOn = traceEnabled.value;
213
+ const traceNodeCount = Array.from(nodes.value.values()).filter((n) => n.type === 'trace').length;
214
+
215
+ // Board identity = workspace basename, fetched once from /health. The
216
+ // canvas has no board-name concept — the workspace IS the board.
217
+ const [workspaceName, setWorkspaceName] = useState<string>('');
218
+ useEffect(() => {
219
+ let cancelled = false;
220
+ fetch('/health')
221
+ .then((r) => (r.ok ? r.json() : null))
222
+ .then((data: { workspace?: string } | null) => {
223
+ if (cancelled || typeof data?.workspace !== 'string' || !data.workspace) return;
224
+ const base = data.workspace
225
+ .replace(/[\\/]+$/, '')
226
+ .split(/[\\/]/)
227
+ .pop();
228
+ if (base) setWorkspaceName(base);
229
+ })
230
+ .catch(() => {});
231
+ return () => {
232
+ cancelled = true;
233
+ };
234
+ }, []);
235
+
236
+ const degraded = degradedState.value;
237
+ const statusTitle = degraded ?? (status === 'connected' && !hasSynced ? 'syncing' : status);
238
+ const countsLabel = hasSynced
239
+ ? [
240
+ `${nodeCount} node${nodeCount !== 1 ? 's' : ''}`,
241
+ ...(edgeCount > 0 ? [`${edgeCount} edge${edgeCount !== 1 ? 's' : ''}`] : []),
242
+ ...(traceNodeCount > 0
243
+ ? [`${traceNodeCount} trace${traceNodeCount !== 1 ? 's' : ''}`]
244
+ : isTraceOn
245
+ ? ['trace armed']
246
+ : []),
247
+ ].join(' · ')
248
+ : 'Syncing canvas…';
249
+
250
+ const handleFit = () => {
251
+ const area = canvasArea();
252
+ fitAll(area.width, area.height);
253
+ };
254
+
255
+ return (
256
+ <div class="top-bar">
257
+ <BarHint
258
+ label={`Canvas status: ${statusTitle}`}
259
+ tapToOpen
260
+ body="Live updates stream in over SSE; amber means reconnecting."
261
+ align="start"
262
+ >
263
+ <span class={`connection-dot ${degraded ?? status}`} aria-label={`Canvas status: ${statusTitle}`} />
264
+ </BarHint>
265
+ <span class="top-bar-title" title={workspaceName || 'PMX Canvas'}>
266
+ {workspaceName || 'PMX Canvas'}
267
+ </span>
268
+ <span class="top-bar-meta hud-collapsible-text">{sessionId.value ? sessionId.value.slice(0, 12) : '…'}</span>
269
+ <span class="top-bar-meta hud-collapsible-text">{countsLabel}</span>
270
+
271
+ <span class="top-bar-spacer" />
272
+
273
+ <AgentChip />
274
+ <GateBadge />
275
+ <ContextBudget />
276
+ <ExternalWriterIndicator />
277
+ <StartSessionButton />
278
+
279
+ <div class="top-bar-sep" />
280
+
281
+ <BarHint label="Zoom out" shortcut={modChord('\u2212')}>
282
+ <button type="button" class="top-bar-btn" onClick={() => zoomByFactor(1 / 1.25)} aria-label="Zoom out">
283
+ <IconZoomOut />
284
+ </button>
285
+ </BarHint>
286
+ <BarHint label="Reset zoom" shortcut={modChord('0')}>
287
+ <button
288
+ type="button"
289
+ class="top-bar-zoom-label"
290
+ onClick={() => animateViewport({ x: 0, y: 0, scale: 1 }, 250)}
291
+ aria-label="Reset view"
292
+ >
293
+ {Math.round(v.scale * 100)}%
294
+ </button>
295
+ </BarHint>
296
+ <BarHint label="Zoom in" shortcut={modChord('+')}>
297
+ <button type="button" class="top-bar-btn" onClick={() => zoomByFactor(1.25)} aria-label="Zoom in">
298
+ <IconZoomIn />
299
+ </button>
300
+ </BarHint>
301
+ <BarHint label="Fit all" shortcut="F" align="end">
302
+ <button type="button" class="top-bar-btn" onClick={handleFit} aria-label="Fit canvas">
303
+ <IconFitAll />
304
+ </button>
305
+ </BarHint>
306
+ </div>
307
+ );
308
+ }
@@ -45,11 +45,7 @@ function isContentFitSurface(node: CanvasNodeState): boolean {
45
45
  * whose height is controlled elsewhere. */
46
46
  function isAutoSizeExempt(node: CanvasNodeState): boolean {
47
47
  return (
48
- node.collapsed === true ||
49
- node.dockPosition != null ||
50
- node.data.strictSize === true ||
51
- node.data.userResized === true ||
52
- node.type === 'group'
48
+ node.collapsed === true || node.data.strictSize === true || node.data.userResized === true || node.type === 'group'
53
49
  );
54
50
  }
55
51
 
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The canvas region's box in screen coordinates. With the rail + top-bar shell
3
+ * the canvas no longer spans the window, so every "centre of the view" and
4
+ * "how big is the view" computation must use this region — anchoring zoom at
5
+ * `window.innerWidth / 2` would drift the board toward the rail on every step,
6
+ * and reporting the window to the server makes an agent `fit` compute a scale
7
+ * that hides nodes under the chrome.
8
+ *
9
+ * The shell registers its canvas-region element on mount; everything else asks
10
+ * for the rect. The window fallback keeps unit tests and any chromeless embed
11
+ * working unchanged.
12
+ */
13
+
14
+ export interface CanvasAreaRect {
15
+ left: number;
16
+ top: number;
17
+ width: number;
18
+ height: number;
19
+ }
20
+
21
+ let canvasAreaEl: HTMLElement | null = null;
22
+
23
+ export function registerCanvasArea(el: HTMLElement | null): void {
24
+ canvasAreaEl = el;
25
+ }
26
+
27
+ export function canvasArea(): CanvasAreaRect {
28
+ if (canvasAreaEl?.isConnected) {
29
+ const rect = canvasAreaEl.getBoundingClientRect();
30
+ if (rect.width > 0 && rect.height > 0) {
31
+ return { left: rect.left, top: rect.top, width: rect.width, height: rect.height };
32
+ }
33
+ }
34
+ return { left: 0, top: 0, width: window.innerWidth, height: window.innerHeight };
35
+ }
36
+
37
+ /** Screen-space centre of the canvas region — the anchor for centred zoom/focus. */
38
+ export function canvasAreaCenter(): { x: number; y: number } {
39
+ const rect = canvasArea();
40
+ return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
41
+ }
@@ -0,0 +1,48 @@
1
+ import { focusNode, viewport } from '../state/canvas-store';
2
+ import { createNodeFromClient } from '../state/intent-bridge';
3
+ import { canvasAreaCenter } from './canvas-area';
4
+
5
+ /** Default frames for human creates that pass no size (the server clamps to the type floor). */
6
+ const DEFAULT_FRAME: Record<string, { width: number; height: number }> = {
7
+ markdown: { width: 520, height: 360 },
8
+ group: { width: 600, height: 400 },
9
+ html: { width: 720, height: 640 },
10
+ image: { width: 720, height: 520 },
11
+ file: { width: 720, height: 600 },
12
+ webpage: { width: 520, height: 420 },
13
+ };
14
+
15
+ /**
16
+ * A human create from the rail, keyboard, palette, or empty state lands
17
+ * centred in the CURRENT viewport — where the human is looking — instead of
18
+ * at the server's board-relative auto-placement, which on a busy board can
19
+ * be far off-screen (the rail's Group landed out of view). Explicit x/y win.
20
+ */
21
+ export async function createNodeInView(opts: {
22
+ type: string;
23
+ title?: string;
24
+ content?: string;
25
+ x?: number;
26
+ y?: number;
27
+ width?: number;
28
+ height?: number;
29
+ }): Promise<{ ok: boolean; id?: string }> {
30
+ if (opts.x !== undefined && opts.y !== undefined) return createNodeFromClient(opts);
31
+ const frame = DEFAULT_FRAME[opts.type] ?? { width: 360, height: 200 };
32
+ const width = opts.width ?? frame.width;
33
+ const height = opts.height ?? frame.height;
34
+ const centre = canvasAreaCenter();
35
+ const v = viewport.value;
36
+ const result = await createNodeFromClient({
37
+ ...opts,
38
+ width,
39
+ height,
40
+ x: Math.round((centre.x - v.x) / v.scale - width / 2),
41
+ y: Math.round((centre.y - v.y) / v.scale - height / 2),
42
+ });
43
+ // The shared viewport can move between the ask (a prompted dialog) and the
44
+ // create — focus the node so it can never land off-screen (0.5.0 readiness,
45
+ // Codex: Open image… required a palette hunt to find its node).
46
+ if (result.ok && result.id) focusNode(result.id);
47
+ return result;
48
+ }
@@ -0,0 +1,64 @@
1
+ import { createNodeFromClient } from '../state/intent-bridge';
2
+
3
+ const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'bmp', 'ico', 'avif']);
4
+ const MD_EXTS = new Set(['md', 'mdx', 'markdown']);
5
+
6
+ export function nodeTypeFromFilename(name: string): 'image' | 'markdown' | 'file' {
7
+ const ext = name.split('.').pop()?.toLowerCase() ?? '';
8
+ if (IMAGE_EXTS.has(ext)) return 'image';
9
+ if (MD_EXTS.has(ext)) return 'markdown';
10
+ return 'file';
11
+ }
12
+
13
+ /**
14
+ * Turn local files into nodes laid out in a grid around a world point — the
15
+ * one implementation behind the viewport's drop zone and the empty state's
16
+ * file picker. Images become image nodes (data URI), markdown becomes
17
+ * markdown, everything else a file node with the text inlined.
18
+ */
19
+ export async function importFiles(files: File[], baseWx: number, baseWy: number): Promise<void> {
20
+ const nodeW = 400;
21
+ const nodeH = 300;
22
+ const spacing = 20;
23
+ const cols = Math.ceil(Math.sqrt(files.length));
24
+
25
+ for (let i = 0; i < files.length; i++) {
26
+ const file = files[i]!;
27
+ const col = i % cols;
28
+ const row = Math.floor(i / cols);
29
+ const wx = baseWx - (cols * (nodeW + spacing)) / 2 + col * (nodeW + spacing);
30
+ const wy = baseWy - nodeH / 2 + row * (nodeH + spacing);
31
+
32
+ const type = nodeTypeFromFilename(file.name);
33
+ const fileName = file.name;
34
+
35
+ if (type === 'image') {
36
+ const reader = new FileReader();
37
+ const dataUri: string = await new Promise((resolve) => {
38
+ reader.onload = () => resolve(reader.result as string);
39
+ reader.readAsDataURL(file);
40
+ });
41
+ await createNodeFromClient({
42
+ type: 'image',
43
+ title: fileName,
44
+ content: dataUri,
45
+ x: wx,
46
+ y: wy,
47
+ width: nodeW,
48
+ height: nodeH,
49
+ });
50
+ } else {
51
+ const text = await file.text();
52
+ const isWide = type === 'markdown' || type === 'file';
53
+ await createNodeFromClient({
54
+ type,
55
+ title: fileName,
56
+ content: text,
57
+ x: wx,
58
+ y: wy,
59
+ width: isWide ? 720 : nodeW,
60
+ height: isWide ? 500 : nodeH,
61
+ });
62
+ }
63
+ }
64
+ }
@@ -0,0 +1,22 @@
1
+ import type { CanvasNodeState } from '../types';
2
+
3
+ /** Kind color per node type, as CSS tokens so every theme keeps its palette. */
4
+ export const KIND_COLOR: Record<CanvasNodeState['type'], string> = {
5
+ markdown: 'var(--c-accent)',
6
+ 'mcp-app': 'var(--c-ok)',
7
+ webpage: 'var(--c-warn)',
8
+ 'json-render': 'var(--c-ok)',
9
+ graph: 'var(--c-purple)',
10
+ prompt: 'var(--c-accent)',
11
+ response: 'var(--c-ok)',
12
+ status: 'var(--c-warn)',
13
+ context: 'var(--c-muted)',
14
+ ledger: 'var(--c-dim)',
15
+ trace: 'var(--c-purple)',
16
+ file: 'var(--c-accent)',
17
+ diff: 'var(--c-ok)',
18
+ mermaid: 'var(--c-purple)',
19
+ image: 'var(--c-ok)',
20
+ html: 'var(--c-warn)',
21
+ group: 'var(--c-accent)',
22
+ };
@@ -81,7 +81,7 @@ export function buildSnapCache(dragId: string, allNodes: Iterable<CanvasNodeStat
81
81
  const nodeList = Array.from(allNodes);
82
82
  const excludedIds = collectExcludedReferenceIds(dragId, nodeList);
83
83
  for (const n of nodeList) {
84
- if (excludedIds.has(n.id) || n.dockPosition !== null) continue;
84
+ if (excludedIds.has(n.id)) continue;
85
85
  const l = n.position.x;
86
86
  const r = n.position.x + n.size.width;
87
87
  const cx = n.position.x + n.size.width / 2;
@@ -0,0 +1,54 @@
1
+ import { useEffect } from 'preact/hooks';
2
+
3
+ const FOCUSABLE =
4
+ 'a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
5
+
6
+ /**
7
+ * Focus trap for modal overlays (rail-chrome-v2 item 18): while `active`, Tab
8
+ * cycles inside `ref`, the first focusable takes focus on open unless
9
+ * `initial` names one, and the element focused before the overlay opened gets
10
+ * focus back on close.
11
+ */
12
+ export function useFocusTrap(
13
+ ref: { current: HTMLElement | null },
14
+ active: boolean,
15
+ options: { initial?: { current: HTMLElement | null }; restoreTo?: () => HTMLElement | null } = {},
16
+ ): void {
17
+ useEffect(() => {
18
+ if (!active) return;
19
+ const root = ref.current;
20
+ if (!root) return;
21
+ const previous = document.activeElement as HTMLElement | null;
22
+ const restoreTo = options.restoreTo;
23
+ const focusables = () => Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE));
24
+ const initial = options.initial?.current ?? focusables()[0];
25
+ if (initial && !root.contains(document.activeElement)) initial.focus();
26
+ const onKey = (e: KeyboardEvent) => {
27
+ if (e.key !== 'Tab') return;
28
+ const items = focusables();
29
+ if (items.length === 0) return;
30
+ const first = items[0]!;
31
+ const last = items[items.length - 1]!;
32
+ const current = document.activeElement as HTMLElement | null;
33
+ if (e.shiftKey && (current === first || !root.contains(current))) {
34
+ e.preventDefault();
35
+ last.focus();
36
+ } else if (!e.shiftKey && current === last) {
37
+ e.preventDefault();
38
+ first.focus();
39
+ }
40
+ };
41
+ root.addEventListener('keydown', onKey);
42
+ return () => {
43
+ root.removeEventListener('keydown', onKey);
44
+ // `restoreTo` wins: an overlay that replaced the element it was opened
45
+ // from (focus mode unmounts the node while expanded) names the element
46
+ // to return to, which may only exist after the next frame.
47
+ if (restoreTo) {
48
+ requestAnimationFrame(() => restoreTo()?.focus());
49
+ } else if (previous && document.contains(previous) && previous !== document.body) {
50
+ previous.focus();
51
+ }
52
+ };
53
+ }, [ref, active, options.initial, options.restoreTo]);
54
+ }
@@ -0,0 +1,17 @@
1
+ import { useEffect, useState } from 'preact/hooks';
2
+
3
+ /**
4
+ * A ticking "now" for countdowns and relative ages. `intervalMs` 0 stops the
5
+ * ticker (the value still reads as the mount time) — pass 0 while the surface
6
+ * that needs it is hidden so an idle page runs no timers.
7
+ */
8
+ export function useNow(intervalMs = 1000): number {
9
+ const [now, setNow] = useState(() => Date.now());
10
+ useEffect(() => {
11
+ if (intervalMs <= 0) return;
12
+ setNow(Date.now());
13
+ const timer = window.setInterval(() => setNow(Date.now()), intervalMs);
14
+ return () => window.clearInterval(timer);
15
+ }, [intervalMs]);
16
+ return now;
17
+ }
@@ -1,4 +1,5 @@
1
1
  import type { Signal } from '@preact/signals';
2
+ import { isPanModeActive } from '../state/canvas-store';
2
3
  import { useCallback, useEffect, useRef } from 'preact/hooks';
3
4
  import type { ViewportState } from '../types';
4
5
 
@@ -100,9 +101,19 @@ export function usePanZoom({ viewport, onViewportChange, onViewportCommit, disab
100
101
  const handlePointerDown = useCallback(
101
102
  (e: PointerEvent) => {
102
103
  if (disabled) return;
103
- // Only pan when clicking the canvas background (not nodes)
104
104
  const container = containerRef.current;
105
- if (!container || e.target !== container) return;
105
+ if (!container) return;
106
+ // Middle-drag pans from anywhere, any tool — standard canvas UX.
107
+ if (e.button === 1) {
108
+ e.preventDefault();
109
+ } else if (isPanModeActive()) {
110
+ // Pan tool / held Space: the world layer is pointer-inert (CSS), so
111
+ // the event target is the container even over nodes.
112
+ if (e.target !== container) return;
113
+ } else {
114
+ // Select tool: background drag belongs to the lasso (CanvasViewport).
115
+ return;
116
+ }
106
117
  isPanning.current = true;
107
118
  lastPointer.current = { x: e.clientX, y: e.clientY };
108
119
  container.setPointerCapture(e.pointerId);