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
@@ -4,12 +4,15 @@
4
4
  * When the canvas page itself runs inside an iframe (ampcode.com embeds the
5
5
  * portal URL on *.onamp.dev), Chrome refuses to load child iframes from `src`
6
6
  * URLs — even same-origin relative ones — leaving every iframe-backed node a
7
- * gray "refused to connect" placeholder. Inline documents (`srcdoc`) still
8
- * render. This module probes the real behavior once per boot: a hidden 1px
9
- * iframe navigates to the tiny /api/canvas/iframe-probe document; if `load`
10
- * fires the host is normal (`src` mode), otherwise same-origin surfaces fall
11
- * back to fetch() + `srcdoc` (see nodes/use-surface-frame.ts and
12
- * nodes/iframe-document-url.ts).
7
+ * gray "refused to connect" placeholder. Some TOP-LEVEL hosts (the Claude
8
+ * Code desktop browser) block sub-frame requests the same way. Inline
9
+ * documents (`srcdoc`) still render. This module probes the real behavior
10
+ * once per boot: a hidden 1px iframe navigates to the tiny
11
+ * /api/canvas/iframe-probe document, which posts a handshake message back —
12
+ * `load` alone is no signal, because blocked frames fire it on their error
13
+ * page. Embedded pages block on the probe; top-level pages default to `src`
14
+ * and self-heal to fetch() + `srcdoc` when the background probe fails (see
15
+ * nodes/use-surface-frame.ts and nodes/iframe-document-url.ts).
13
16
  *
14
17
  * `src` stays the default everywhere it works: URL-loaded surfaces keep real
15
18
  * document URLs, and the frame-document transport exists precisely because
@@ -19,6 +22,7 @@
19
22
  * Force a mode with `?iframe-mode=srcdoc` (or `=src`) for debugging.
20
23
  */
21
24
  import { signal } from '@preact/signals';
25
+ import { IFRAME_PROBE_MESSAGE_SOURCE } from '../../shared/iframe-probe.js';
22
26
 
23
27
  export type IframeMode = 'src' | 'srcdoc';
24
28
 
@@ -48,11 +52,22 @@ export function probeSrcIframes(timeoutMs = PROBE_TIMEOUT_MS): Promise<boolean>
48
52
  test.setAttribute('sandbox', 'allow-scripts');
49
53
  const done = (ok: boolean) => {
50
54
  window.clearTimeout(timer);
55
+ window.removeEventListener('message', onMessage);
51
56
  test.remove();
52
57
  resolve(ok);
53
58
  };
59
+ // `load` proves nothing: a host that blocks the request (the Claude Code
60
+ // desktop browser cancels sub-frame loads with ERR_BLOCKED_BY_CLIENT)
61
+ // still fires `load` on the error page it commits. Only the probe
62
+ // document executing and posting its handshake back proves src works —
63
+ // and only from OUR frame, so a surface iframe cannot spoof the probe.
64
+ const onMessage = (event: MessageEvent) => {
65
+ if (event.source !== test.contentWindow) return;
66
+ if ((event.data as { source?: unknown } | null)?.source !== IFRAME_PROBE_MESSAGE_SOURCE) return;
67
+ done(true);
68
+ };
54
69
  const timer = window.setTimeout(() => done(false), timeoutMs);
55
- test.addEventListener('load', () => done(true));
70
+ window.addEventListener('message', onMessage);
56
71
  test.addEventListener('error', () => done(false));
57
72
  test.src = IFRAME_PROBE_PATH;
58
73
  (document.body ?? document.documentElement).appendChild(test);
@@ -87,7 +102,9 @@ function isAmpOrbHost(): boolean {
87
102
  * normal path pays zero probe latency before surfaces mount. Only embedded
88
103
  * documents run the probe (`embedded` is overridable for tests).
89
104
  */
90
- export function resolveIframeMode(opts: { embedded?: boolean; ampOrb?: boolean } = {}): Promise<IframeMode> {
105
+ export function resolveIframeMode(
106
+ opts: { embedded?: boolean; ampOrb?: boolean; probeTimeoutMs?: number } = {},
107
+ ): Promise<IframeMode> {
91
108
  if (iframeMode.value) return Promise.resolve(iframeMode.value);
92
109
  if (pending) return pending;
93
110
  const forced = forcedIframeMode();
@@ -96,14 +113,21 @@ export function resolveIframeMode(opts: { embedded?: boolean; ampOrb?: boolean }
96
113
  return Promise.resolve(forced);
97
114
  }
98
115
  if (!(opts.embedded ?? isEmbeddedDocument())) {
116
+ // Top-level pages default to src with zero latency — but some top-level
117
+ // hosts (the Claude Code desktop browser) block sub-frame requests
118
+ // outright, so verify in the background and self-heal: surfaces read the
119
+ // signal and re-render into fetch()+srcdoc when it flips.
99
120
  iframeMode.value = 'src';
121
+ void probeSrcIframes(opts.probeTimeoutMs).then((srcWorks) => {
122
+ if (!srcWorks && iframeMode.value === 'src') iframeMode.value = 'srcdoc';
123
+ });
100
124
  return Promise.resolve('src');
101
125
  }
102
126
  if (opts.ampOrb ?? isAmpOrbHost()) {
103
127
  iframeMode.value = 'srcdoc';
104
128
  return Promise.resolve('srcdoc');
105
129
  }
106
- pending = probeSrcIframes().then((srcWorks) => {
130
+ pending = probeSrcIframes(opts.probeTimeoutMs).then((srcWorks) => {
107
131
  const mode: IframeMode = srcWorks ? 'src' : 'srcdoc';
108
132
  iframeMode.value = mode;
109
133
  return mode;
@@ -1,17 +1,41 @@
1
+ import { canvasArea } from '../canvas/canvas-area';
2
+ import type { AgentPresenceSnapshot } from '../../shared/agent-presence.js';
1
3
  function logRequestError(action: string, error: unknown): void {
2
4
  console.error(`[intent-bridge] ${action} failed`, error);
3
5
  }
4
6
 
7
+ /**
8
+ * Per-tab identity: an AGENT driving the workbench UI from its own pane opens
9
+ * `/workbench?agent=<key>` and every write from that tab is booked as that
10
+ * agent — before this, UI-driven agent tests carried the human marker and the
11
+ * human was told "you rejected a gate" they never saw (0.5.0 readiness).
12
+ */
13
+ export const tabAgentKey: string | null = (() => {
14
+ try {
15
+ const value = new URLSearchParams(window.location.search).get('agent')?.trim();
16
+ return value ? value.slice(0, 64) : null;
17
+ } catch {
18
+ return null;
19
+ }
20
+ })();
21
+
5
22
  /**
6
23
  * Every workbench-originated request carries this marker so the server never
7
24
  * synthesizes an auto-ghost for it — a human dragging or editing in the
8
- * browser is not agent activity (the ghost cursor shows AGENT moves).
25
+ * browser is not agent activity (the ghost cursor shows AGENT moves). A tab
26
+ * opened with `?agent=<key>` identifies as that agent instead.
9
27
  */
10
28
  function withWorkbenchMarker(init?: RequestInit): RequestInit {
11
- return { ...init, headers: { ...(init?.headers as Record<string, string> | undefined), 'X-PMX-Workbench': '1' } };
29
+ const headers = { ...(init?.headers as Record<string, string> | undefined) };
30
+ if (tabAgentKey) {
31
+ headers['x-pmx-source'] = tabAgentKey;
32
+ } else {
33
+ headers['X-PMX-Workbench'] = '1';
34
+ }
35
+ return { ...init, headers };
12
36
  }
13
37
 
14
- async function requestJson<T>(action: string, url: string, fallback: T, init?: RequestInit): Promise<T> {
38
+ export async function requestJson<T>(action: string, url: string, fallback: T, init?: RequestInit): Promise<T> {
15
39
  try {
16
40
  const res = await fetch(url, withWorkbenchMarker(init));
17
41
  return (await res.json()) as T;
@@ -21,7 +45,7 @@ async function requestJson<T>(action: string, url: string, fallback: T, init?: R
21
45
  }
22
46
  }
23
47
 
24
- async function requestOk(action: string, url: string, init?: RequestInit): Promise<{ ok: boolean }> {
48
+ export async function requestOk(action: string, url: string, init?: RequestInit): Promise<{ ok: boolean }> {
25
49
  try {
26
50
  const res = await fetch(url, withWorkbenchMarker(init));
27
51
  return { ok: res.ok };
@@ -31,7 +55,7 @@ async function requestOk(action: string, url: string, init?: RequestInit): Promi
31
55
  }
32
56
  }
33
57
 
34
- async function requestBestEffort(action: string, url: string, init?: RequestInit): Promise<void> {
58
+ export async function requestBestEffort(action: string, url: string, init?: RequestInit): Promise<void> {
35
59
  try {
36
60
  await fetch(url, withWorkbenchMarker(init));
37
61
  } catch (error) {
@@ -200,7 +224,6 @@ export async function pushCanvasUpdate(
200
224
  position?: { x: number; y: number };
201
225
  size?: { width: number; height: number };
202
226
  collapsed?: boolean;
203
- dockPosition?: 'left' | 'right' | null;
204
227
  }>,
205
228
  options: { recordHistory?: boolean } = {},
206
229
  ): Promise<void> {
@@ -260,10 +283,11 @@ export async function updateNodeFromClient(
260
283
  size?: { width: number; height: number };
261
284
  collapsed?: boolean;
262
285
  pinned?: boolean;
263
- dockPosition?: 'left' | 'right' | null;
264
286
  title?: string;
265
287
  content?: string;
266
288
  data?: Record<string, unknown>;
289
+ /** Groups: replace the membership list (positions preserved). */
290
+ children?: string[];
267
291
  },
268
292
  ): Promise<{ ok: boolean; id?: string }> {
269
293
  return requestJson(
@@ -331,6 +355,11 @@ export async function fetchAxSurfaceState(): Promise<unknown> {
331
355
  return requestJson<unknown>('fetchAxSurfaceState', '/api/canvas/ax/surface-snapshot', null);
332
356
  }
333
357
 
358
+ /** Connect-time agent-presence read; live updates arrive as `agent-presence` SSE frames. */
359
+ export async function fetchAgentPresence(): Promise<Partial<AgentPresenceSnapshot> | null> {
360
+ return requestJson<Partial<AgentPresenceSnapshot> | null>('fetchAgentPresence', '/api/canvas/ax/presence', null);
361
+ }
362
+
334
363
  /** Ask the server to open a node's surface in the system browser. */
335
364
  export async function openNodeInSystemBrowserRequest(
336
365
  nodeId: string,
@@ -390,8 +419,11 @@ export async function updateViewportFromClient(
390
419
  */
391
420
  function clientWindowSize(): { clientWidth: number; clientHeight: number } | Record<string, never> {
392
421
  if (typeof window === 'undefined') return {};
393
- const clientWidth = window.innerWidth;
394
- const clientHeight = window.innerHeight;
422
+ // The CANVAS REGION, not the window — with the rail + top-bar shell a
423
+ // window-sized fit would hide nodes under the chrome.
424
+ const area = canvasArea();
425
+ const clientWidth = Math.round(area.width);
426
+ const clientHeight = Math.round(area.height);
395
427
  if (!clientWidth || !clientHeight) return {};
396
428
  return { clientWidth, clientHeight };
397
429
  }
@@ -438,8 +470,12 @@ export async function createGroupFromClient(opts: {
438
470
  );
439
471
  }
440
472
 
441
- /** Add nodes to an existing group. */
442
- export async function addToGroupFromClient(groupId: string, childIds: string[]): Promise<{ ok: boolean }> {
473
+ /** Add nodes to an existing group; `childLayout` packs the children (the header's auto-arrange). */
474
+ export async function addToGroupFromClient(
475
+ groupId: string,
476
+ childIds: string[],
477
+ childLayout?: 'grid' | 'column' | 'flow',
478
+ ): Promise<{ ok: boolean }> {
443
479
  return requestJson(
444
480
  'addToGroupFromClient',
445
481
  '/api/canvas/group/add',
@@ -447,12 +483,21 @@ export async function addToGroupFromClient(groupId: string, childIds: string[]):
447
483
  {
448
484
  method: 'POST',
449
485
  headers: { 'Content-Type': 'application/json' },
450
- body: JSON.stringify({ groupId, childIds }),
486
+ body: JSON.stringify({ groupId, childIds, ...(childLayout ? { childLayout } : {}) }),
451
487
  },
452
488
  );
453
489
  }
454
490
 
455
- /** Ungroup all children from a group. */
491
+ /** Replace a group's membership (positions preserved) — how a single child leaves. */
492
+ export async function setGroupChildrenFromClient(groupId: string, children: string[]): Promise<{ ok: boolean }> {
493
+ return updateNodeFromClient(groupId, { children });
494
+ }
495
+
496
+ /**
497
+ * Ungroup = dissolve (children released, frame removed). The SAME server op
498
+ * an agent's `canvas_group { action: "ungroup" }` runs, so both sides get the
499
+ * identical board and one undo step.
500
+ */
456
501
  export async function ungroupFromClient(groupId: string): Promise<{ ok: boolean }> {
457
502
  return requestJson(
458
503
  'ungroupFromClient',
@@ -501,6 +546,23 @@ export async function deleteSnapshot(id: string): Promise<{ ok: boolean }> {
501
546
  return requestJson('deleteSnapshot', `/api/canvas/snapshots/${id}`, { ok: false }, { method: 'DELETE' });
502
547
  }
503
548
 
549
+ /** Patch an edge in place (label/type/style/animated) via the server. */
550
+ export async function updateEdgeFromClient(
551
+ edgeId: string,
552
+ patch: { label?: string; type?: string; style?: string; animated?: boolean },
553
+ ): Promise<{ ok: boolean }> {
554
+ return requestJson(
555
+ 'updateEdgeFromClient',
556
+ `/api/canvas/edge/${encodeURIComponent(edgeId)}`,
557
+ { ok: false },
558
+ {
559
+ method: 'PATCH',
560
+ headers: { 'Content-Type': 'application/json' },
561
+ body: JSON.stringify(patch),
562
+ },
563
+ );
564
+ }
565
+
504
566
  /** Remove a canvas edge via the server. */
505
567
  export async function removeEdgeFromClient(edgeId: string): Promise<{ ok: boolean }> {
506
568
  return requestJson(
@@ -13,12 +13,8 @@ import type { CanvasNodeState } from '../types';
13
13
  // /api/canvas/node) and get the SERVER's `defaultNodeSize`
14
14
  // (src/server/operations/ops/nodes.ts), which differs from this table for
15
15
  // most types (e.g. its fallback is 360x200). The server half of H5
16
- // (`syncEventToCanvasState`, `ensureDefaultDockedNodes`) is deliberately
17
- // deferred — do not "fix" that mismatch from this file.
18
- // - The server seeds `status-main`/`context-main` docked AND collapsed
19
- // (`ensureDefaultDockedNodes` in src/server/server.ts); the SSE bridge
20
- // creates the same ids expanded, with `context-main` undocked. Client
21
- // behavior is preserved as-is.
16
+ // (`syncEventToCanvasState`) is deliberately deferred — do not "fix" that
17
+ // mismatch from this file.
22
18
  // - sse-bridge's prompt literal hardcoded `height: 400` beside a table entry
23
19
  // of the same value; both now read the table (no rendering change).
24
20
 
@@ -56,7 +52,6 @@ export function makeNodeState(
56
52
  options: {
57
53
  position?: { x: number; y: number };
58
54
  size?: { width: number; height: number };
59
- dockPosition?: 'left' | 'right' | null;
60
55
  } = {},
61
56
  ): CanvasNodeState {
62
57
  const defaults = DEFAULT_POSITIONS[type];
@@ -68,7 +63,6 @@ export function makeNodeState(
68
63
  zIndex: type === 'status' ? 0 : 1,
69
64
  collapsed: false,
70
65
  pinned: false,
71
- dockPosition: options.dockPosition ?? null,
72
66
  data,
73
67
  };
74
68
  }
@@ -0,0 +1,168 @@
1
+ import { computed, signal } from '@preact/signals';
2
+ import type { PmxAxIntent } from '../../shared/ax-intent.js';
3
+ import { intents } from './intent-store';
4
+ import {
5
+ HUMAN_STARTED_SESSION_LABEL,
6
+ type AgentActivityEntry,
7
+ type AgentPresence,
8
+ type AgentPresenceSnapshot,
9
+ CONTEXT_BUDGET_DEFAULT_TOKENS,
10
+ type ContextBudget,
11
+ externalWriters,
12
+ isSessionActive,
13
+ } from '../../shared/agent-presence.js';
14
+
15
+ /**
16
+ * Agent presence (rail-chrome-v2 phase 2). Fed by the server's `agent-presence`
17
+ * SSE snapshot (every change, including TTL expiry) and the connect-time read,
18
+ * so this store never runs its own expiry ticker.
19
+ *
20
+ * `sessionActive` is the ONE selector every agent surface reads — the session
21
+ * panel, command bar, presence layer, and top-bar chip all mount on it.
22
+ */
23
+ export const agentPresences = signal<AgentPresence[]>([]);
24
+ export const contextBudget = signal<ContextBudget>({ used: 0, total: CONTEXT_BUDGET_DEFAULT_TOKENS });
25
+ /** Recent agent writes, newest first — the External Steering activity feed. */
26
+ export const agentActivity = signal<AgentActivityEntry[]>([]);
27
+
28
+ /** External Steering chrome (phase 6): the feed popover and the writers sheet. */
29
+ export const activityFeedOpen = signal(false);
30
+ export const writersSheetOpen = signal(false);
31
+ /** Feed filter: a writer's sessionId, or null for all. */
32
+ export const activityFilter = signal<string | null>(null);
33
+
34
+ /**
35
+ * Per-writer identity colors from the accent set, agent-violet first,
36
+ * assigned in order of first appearance and stable for the page's life — a
37
+ * writer that fades and returns keeps its color.
38
+ */
39
+ const WRITER_PALETTE = ['var(--c-purple)', 'var(--c-accent)', 'var(--c-ok)', 'var(--c-warn)', 'var(--c-danger)'];
40
+ const writerColors = new Map<string, string>();
41
+ export function writerColor(sessionId: string): string {
42
+ const known = writerColors.get(sessionId);
43
+ if (known) return known;
44
+ const color = WRITER_PALETTE[writerColors.size % WRITER_PALETTE.length]!;
45
+ writerColors.set(sessionId, color);
46
+ return color;
47
+ }
48
+
49
+ /** Avatar initial: first letter of the label, upper-cased. */
50
+ export function writerInitial(label: string): string {
51
+ const first = label
52
+ .trim()
53
+ .replace(/^[^a-z0-9]+/i, '')
54
+ .charAt(0);
55
+ return (first || label.trim().charAt(0) || '?').toUpperCase();
56
+ }
57
+
58
+ /** Compact relative age for feed rows and the writers sheet: now · 12s · 4m · 2h. */
59
+ export function relativeAge(iso: string, now = Date.now()): string {
60
+ const ms = now - Date.parse(iso);
61
+ if (!Number.isFinite(ms) || ms < 5_000) return 'now';
62
+ if (ms < 60_000) return `${Math.floor(ms / 1000)}s`;
63
+ if (ms < 3_600_000) return `${Math.floor(ms / 60_000)}m`;
64
+ return `${Math.floor(ms / 3_600_000)}h`;
65
+ }
66
+
67
+ /** Master gate for every agent surface: an attached session exists. */
68
+ export const sessionActive = computed(() => isSessionActive(agentPresences.value));
69
+
70
+ /** Live writers with no attached session — the External Steering mode. */
71
+ export const externalWriterPresences = computed(() => externalWriters(agentPresences.value));
72
+
73
+ /**
74
+ * Connected agents the human can ADDRESS from the composer: every live
75
+ * presence (attached sessions first). `label` is the display name; `value` is
76
+ * the CONSUMER key the steer must be targeted at — the identity the agent
77
+ * claims deliveries with. For an adapter session that is its source key
78
+ * (Copilot attaches as source "copilot" labelled "GitHub Copilot"); for an
79
+ * adopted human-started session the sessionId is "browser", so the writer's
80
+ * label IS its consumer key. Addressing the display label instead would
81
+ * produce steering nobody can ever claim. The un-adopted placeholder is
82
+ * excluded: no consumer will ever claim as "Agent session".
83
+ */
84
+ export const steerableAgents = computed(() => {
85
+ const seen = new Set<string>();
86
+ return (
87
+ [...agentPresences.value]
88
+ .filter((presence) => presence.label !== HUMAN_STARTED_SESSION_LABEL)
89
+ .sort((a, b) => Number(b.attached) - Number(a.attached))
90
+ // The picker is the connected-writer ROSTER: every live writer is listed,
91
+ // but only those a steer can actually REACH are selectable — attached
92
+ // sessions, or external writers whose consumer key has claimed
93
+ // deliveries. A one-shot writer (curl, the CLI under Codex) is presence
94
+ // without an inbox: shown, disabled, with the reason.
95
+ .map((presence) => ({
96
+ value: presence.sessionId === 'browser' ? presence.label : presence.sessionId,
97
+ label: presence.label,
98
+ attached: presence.attached,
99
+ steerable: presence.attached || presence.steerable === true,
100
+ pendingSteers: presence.pendingSteers ?? 0,
101
+ // Proof-of-polling: its consumer key claimed the queue within the last
102
+ // 5 minutes. Attached ≠ polling — a desktop session can look steerable
103
+ // while nothing reads its inbox (round-2 Codex finding).
104
+ polling: presence.lastClaimAt !== undefined && Date.now() - Date.parse(presence.lastClaimAt) < 5 * 60_000,
105
+ }))
106
+ .filter((agent) => (seen.has(agent.value) ? false : (seen.add(agent.value), true)))
107
+ );
108
+ });
109
+
110
+ /** The attached session's presence (first if several hosts attached). */
111
+ /** Every attached session, in presence order — the top bar shows one chip each. */
112
+ export const attachedSessions = computed<AgentPresence[]>(() =>
113
+ agentPresences.value.filter((presence) => presence.attached),
114
+ );
115
+
116
+ export const activeSession = computed<AgentPresence | null>(
117
+ () => agentPresences.value.find((presence) => presence.attached) ?? null,
118
+ );
119
+
120
+ /**
121
+ * Nodes an agent is mutating RIGHT NOW — drives the shimmer. Derived from the
122
+ * intent store's in-flight ghosts (move / edit / remove target an existing
123
+ * node) rather than a parallel source; gated on `sessionActive` at the use
124
+ * site so the quiet board never shimmers.
125
+ */
126
+ export function mutatingNodeIdsFrom(intentList: Iterable<Pick<PmxAxIntent, 'kind' | 'nodeId'>>): Set<string> {
127
+ const ids = new Set<string>();
128
+ for (const intent of intentList) {
129
+ if (intent.nodeId && intent.kind !== 'create') ids.add(intent.nodeId);
130
+ }
131
+ return ids;
132
+ }
133
+
134
+ export const mutatingNodeIds = computed(() => mutatingNodeIdsFrom(intents.value.values()));
135
+
136
+ /**
137
+ * Where a presence's cursor sits in WORLD coordinates: an explicit cursor
138
+ * wins; otherwise the node the agent last touched (anchored near its title
139
+ * bar's right end, like a collaborator hovering the node); otherwise null.
140
+ */
141
+ export function presenceWorldPosition(
142
+ presence: Pick<AgentPresence, 'cursor' | 'focusNodeId'>,
143
+ nodeById: (id: string) => { position: { x: number; y: number }; size: { width: number } } | undefined,
144
+ ): { x: number; y: number } | null {
145
+ if (presence.cursor) return { x: presence.cursor.x, y: presence.cursor.y };
146
+ if (!presence.focusNodeId) return null;
147
+ const node = nodeById(presence.focusNodeId);
148
+ if (!node) return null;
149
+ return { x: node.position.x + Math.max(24, node.size.width - 28), y: node.position.y + 16 };
150
+ }
151
+
152
+ export function applyPresenceSnapshot(snapshot: Partial<AgentPresenceSnapshot> | null | undefined): void {
153
+ if (!snapshot) return;
154
+ if (Array.isArray(snapshot.presences)) agentPresences.value = snapshot.presences;
155
+ if (snapshot.budget && Number.isFinite(snapshot.budget.used) && Number.isFinite(snapshot.budget.total)) {
156
+ contextBudget.value = { used: snapshot.budget.used, total: snapshot.budget.total };
157
+ }
158
+ if (Array.isArray(snapshot.activity)) agentActivity.value = snapshot.activity;
159
+ }
160
+
161
+ export function resetPresence(): void {
162
+ agentPresences.value = [];
163
+ contextBudget.value = { used: 0, total: CONTEXT_BUDGET_DEFAULT_TOKENS };
164
+ agentActivity.value = [];
165
+ activityFeedOpen.value = false;
166
+ writersSheetOpen.value = false;
167
+ activityFilter.value = null;
168
+ }