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
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Agent presence — the contract behind every agent surface in the rail chrome
3
+ * (rail-chrome-v2 phase 2). Shared by server and client so the two cannot
4
+ * drift. See design/rail-chrome-v2/PLAN.md, "Phase 2 contract".
5
+ *
6
+ * Presence is DERIVED from feeds that already exist (AX activity ingest,
7
+ * agent-originated mutations) plus an explicit set for adapters with richer
8
+ * hooks. Nothing here is a second source of truth for work items, gates, or
9
+ * steering — those stay in the AX state.
10
+ */
11
+
12
+ export type AgentPhase = 'idle' | 'thinking' | 'tooling' | 'waiting-approval';
13
+
14
+ export const AGENT_PHASES: readonly AgentPhase[] = ['idle', 'thinking', 'tooling', 'waiting-approval'];
15
+
16
+ export interface AgentPresence {
17
+ /** Writer key: `agentId` when the host supplies one, else the source label. */
18
+ sessionId: string;
19
+ /** Host label (copilot, codex, mcp, api, cli, …). */
20
+ source: string;
21
+ agentId: string | null;
22
+ /** Display name. */
23
+ label: string;
24
+ phase: AgentPhase;
25
+ /** Tool or operation name while `tooling`. */
26
+ detail: string | null;
27
+ focusNodeId: string | null;
28
+ /** World coordinates. */
29
+ cursor: { x: number; y: number } | null;
30
+ /** A session is attached: `session-start` (or explicit attach) seen and no `session-end`. */
31
+ attached: boolean;
32
+ /** Fleet membership: the orchestrating writer's key this worker belongs to (chrome rolls workers up under it). */
33
+ parentAgentId?: string | null;
34
+ /** True when steering addressed to this writer can actually reach it: it is
35
+ * an attached session, or its consumer key has claimed deliveries before. */
36
+ steerable?: boolean;
37
+ /** Steers sitting unclaimed in this writer's delivery queue (pump-health:
38
+ * a growing count means the writer is not polling). Only set for steerable rows. */
39
+ pendingSteers?: number;
40
+ /** When this writer's consumer key last claimed its delivery queue — the
41
+ * proof-of-polling behind `steerable`. Absent for writers that never claimed
42
+ * (steerable only via an attached adapter loop). */
43
+ lastClaimAt?: string;
44
+ /** Agent writes observed for this writer — feeds the external-steering indicator. */
45
+ opCount: number;
46
+ /**
47
+ * The agent's REAL context window, when its host reports it (tokens used /
48
+ * window size). Null when no adapter reports — the top-bar meter then shows
49
+ * the pinned-context estimate against the configured budget instead, and
50
+ * says so.
51
+ */
52
+ contextUsage: { used: number; total: number } | null;
53
+ lastSeenAt: string;
54
+ }
55
+
56
+ export interface ContextBudget {
57
+ /** Estimated tokens of the pinned-context payload. */
58
+ used: number;
59
+ total: number;
60
+ }
61
+
62
+ /**
63
+ * One agent write, as the External Steering activity feed lists it. Derived
64
+ * from the same presence touch every agent-originated operation already
65
+ * makes — not a second log.
66
+ */
67
+ export interface AgentActivityEntry {
68
+ id: string;
69
+ at: string;
70
+ /** Writer key after attribution (the session's when the write was folded into it). */
71
+ sessionId: string;
72
+ label: string;
73
+ /** Registry operation name (node.add, edge.remove, ax.work.create, …). */
74
+ op: string;
75
+ /** Human sentence: "Created markdown “Release plan”". */
76
+ summary: string;
77
+ nodeId: string | null;
78
+ }
79
+
80
+ export interface AgentPresenceSnapshot {
81
+ presences: AgentPresence[];
82
+ budget: ContextBudget;
83
+ /** True when any presence is attached — the master gate for agent chrome. */
84
+ sessionActive: boolean;
85
+ /** Most recent agent writes, newest first (bounded by MAX_ACTIVITY_ENTRIES). */
86
+ activity: AgentActivityEntry[];
87
+ }
88
+
89
+ /** Activity entries kept for the feed — oldest fall off. */
90
+ export const MAX_ACTIVITY_ENTRIES = 50;
91
+
92
+ /** Unattached writers fade this long after their last write. */
93
+ // 5 min: 90s swept fleets and demos mid-run — a writer the human is looking
94
+ // at kept vanishing (joint-gaps #1 / orchestration #8).
95
+ export const PRESENCE_ACTIVITY_TTL_MS = 5 * 60_000;
96
+ /** Attached sessions expire after this much quiet without a `session-end`. */
97
+ export const PRESENCE_ATTACHED_IDLE_TTL_MS = 30 * 60_000;
98
+ /** `tooling` decays to `idle` after this much quiet. */
99
+ export const PRESENCE_TOOLING_SETTLE_MS = 4_000;
100
+ /** Explicit `thinking` without any fresh touch settles to idle — hosts set it
101
+ * at turn start and often never send the turn-end idle, leaving three
102
+ * "Thinking" chips on a silent board. */
103
+ export const PRESENCE_THINKING_SETTLE_MS = 3 * 60_000;
104
+ export const MAX_PRESENCES = 16;
105
+ /** Default `budget.total` when `PMX_CANVAS_CONTEXT_BUDGET_TOKENS` is unset. */
106
+ export const CONTEXT_BUDGET_DEFAULT_TOKENS = 32_000;
107
+
108
+ /**
109
+ * Source labels that name a TRANSPORT, not an agent. A write arriving under
110
+ * one of these while exactly one session is attached is the attached agent's
111
+ * own work reaching the board through that transport (Copilot/Codex/Claude
112
+ * Code all write via MCP or HTTP while their host holds the session).
113
+ */
114
+ export const TRANSPORT_SOURCES: readonly string[] = ['api', 'mcp', 'sdk', 'cli'];
115
+
116
+ /**
117
+ * The label a human-started session (*Start agent session* in the browser)
118
+ * carries until an agent writes into it — the session is a placeholder for
119
+ * whichever agent comes next, so the first identified writer names it.
120
+ */
121
+ export const HUMAN_STARTED_SESSION_LABEL = 'Agent session';
122
+
123
+ /** The one selector every agent surface reads. */
124
+ export function isSessionActive(presences: readonly AgentPresence[]): boolean {
125
+ return presences.some((presence) => presence.attached);
126
+ }
127
+
128
+ /** Writers that are live but not attached — the External Steering mode. */
129
+ export function externalWriters(presences: readonly AgentPresence[]): AgentPresence[] {
130
+ return presences.filter((presence) => !presence.attached);
131
+ }
132
+
133
+ /** Rough token estimate for a JSON payload (chars / 4) — good enough for a meter. */
134
+ export function estimateTokens(text: string): number {
135
+ return Math.ceil(text.length / 4);
136
+ }
137
+
138
+ /** Chip label per phase — shared by the top-bar chip and the on-canvas cursor chip. */
139
+ export function agentPhaseLabel(presence: Pick<AgentPresence, 'phase' | 'detail'>): string {
140
+ switch (presence.phase) {
141
+ case 'thinking':
142
+ return 'Thinking';
143
+ case 'tooling':
144
+ return presence.detail ? `Running ${presence.detail}` : 'Working';
145
+ case 'waiting-approval':
146
+ return 'Waiting on you';
147
+ default:
148
+ return 'Idle';
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Stable per-agent identity hue (0-359) from the writer key. Phase colors
154
+ * kept telling the human WHAT an agent is doing while erasing WHO — two
155
+ * thinking agents rendered identically. Identity rides the glyph/border;
156
+ * phase stays on dots and labels.
157
+ */
158
+ export function agentIdentityHue(key: string): number {
159
+ let hash = 5381;
160
+ for (let i = 0; i < key.length; i++) hash = ((hash << 5) + hash + key.charCodeAt(i)) | 0;
161
+ return ((hash % 360) + 360) % 360;
162
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Unattended approval policy (rail-chrome-v2 phase 4, design item 3).
3
+ *
4
+ * A pending approval gate carries a TTL. If the human does not answer in time
5
+ * the gate resolves to `held` — the safe default: the action does NOT proceed,
6
+ * a `policy` entry lands in the timeline, and the gate can be reopened from the
7
+ * session panel. Shared so the server's sweeper and the client's countdown read
8
+ * one clock.
9
+ */
10
+
11
+ export const DEFAULT_GATE_TTL_MS = 5 * 60_000;
12
+ export const MIN_GATE_TTL_MS = 1_000;
13
+ export const MAX_GATE_TTL_MS = 24 * 60 * 60_000;
14
+
15
+ export function clampGateTtlMs(value: unknown, fallback = DEFAULT_GATE_TTL_MS): number {
16
+ const ms = typeof value === 'number' ? value : Number(value);
17
+ if (!Number.isFinite(ms) || ms <= 0) return fallback;
18
+ return Math.min(MAX_GATE_TTL_MS, Math.max(MIN_GATE_TTL_MS, Math.floor(ms)));
19
+ }
20
+
21
+ /** Milliseconds until a gate auto-holds (0 when expired, null when it has no TTL). */
22
+ export function gateRemainingMs(gate: { expiresAt: string | null }, now = Date.now()): number | null {
23
+ if (!gate.expiresAt) return null;
24
+ const expires = Date.parse(gate.expiresAt);
25
+ if (!Number.isFinite(expires)) return null;
26
+ return Math.max(0, expires - now);
27
+ }
28
+
29
+ /** `M:SS` for the gate countdown ("auto-holds in 4:31"). */
30
+ export function formatCountdown(ms: number): string {
31
+ const totalSeconds = Math.max(0, Math.ceil(ms / 1000));
32
+ const minutes = Math.floor(totalSeconds / 60);
33
+ const seconds = totalSeconds % 60;
34
+ return `${minutes}:${String(seconds).padStart(2, '0')}`;
35
+ }
@@ -14,7 +14,6 @@ export interface ArrangeNode {
14
14
  position: ArrangePosition;
15
15
  size: ArrangeSize;
16
16
  pinned: boolean;
17
- dockPosition: 'left' | 'right' | null;
18
17
  data: Record<string, unknown>;
19
18
  }
20
19
 
@@ -53,6 +52,8 @@ const UNIT_GAP_Y = 72;
53
52
  const COMPONENT_GAP_X = 220;
54
53
  const COMPONENT_GAP_Y = 180;
55
54
  const MAX_ROW_WIDTH = 3200;
55
+ /** Units whose rects sit within this distance belong to one spatial cluster. */
56
+ const CLUSTER_JOIN_DISTANCE = 320;
56
57
  const GROUP_PAD = 40;
57
58
  const GROUP_TITLEBAR_HEIGHT = 32;
58
59
 
@@ -88,7 +89,7 @@ function buildArrangeUnits(allNodes: ArrangeNode[]): {
88
89
  nodesById: Map<string, ArrangeNode>;
89
90
  nodeToUnit: Map<string, string>;
90
91
  } {
91
- const movable = allNodes.filter((node) => !node.pinned && node.dockPosition === null);
92
+ const movable = allNodes.filter((node) => !node.pinned);
92
93
  const nodesById = new Map(movable.map((node) => [node.id, node]));
93
94
  const nodeToUnit = new Map<string, string>();
94
95
  const units: ArrangeUnit[] = [];
@@ -362,15 +363,70 @@ function computeGraphComponent(
362
363
  };
363
364
  }
364
365
 
365
- function placeComponents(components: ComponentLayout[]): Map<string, ArrangePosition> {
366
+ /**
367
+ * Partition units into SPATIAL clusters of the current layout: units whose
368
+ * rects sit within CLUSTER_JOIN_DISTANCE of each other, plus units joined by
369
+ * an edge (a component must lay out together). Arranging then works per
370
+ * cluster, anchored at the cluster's own neighborhood — a global repack
371
+ * interleaved unrelated areas of the board (the QA cards and a demo cluster
372
+ * parked 10k units away) and destroyed spatial memory.
373
+ */
374
+ function collectSpatialClusters(units: ArrangeUnit[], undirected: Map<string, Set<string>>): ArrangeUnit[][] {
375
+ const parent = new Map<string, string>(units.map((unit) => [unit.id, unit.id]));
376
+ const find = (id: string): string => {
377
+ let root = id;
378
+ while (parent.get(root) !== root) root = parent.get(root)!;
379
+ let cursor = id;
380
+ while (parent.get(cursor) !== cursor) {
381
+ const next = parent.get(cursor)!;
382
+ parent.set(cursor, root);
383
+ cursor = next;
384
+ }
385
+ return root;
386
+ };
387
+ const union = (a: string, b: string): void => {
388
+ const ra = find(a);
389
+ const rb = find(b);
390
+ if (ra !== rb) parent.set(ra, rb);
391
+ };
392
+
393
+ const pad = CLUSTER_JOIN_DISTANCE / 2;
394
+ for (let i = 0; i < units.length; i++) {
395
+ for (let j = i + 1; j < units.length; j++) {
396
+ const a = units[i];
397
+ const b = units[j];
398
+ const near =
399
+ a.origin.x - pad < b.origin.x + b.size.width + pad &&
400
+ a.origin.x + a.size.width + pad > b.origin.x - pad &&
401
+ a.origin.y - pad < b.origin.y + b.size.height + pad &&
402
+ a.origin.y + a.size.height + pad > b.origin.y - pad;
403
+ if (near) union(a.id, b.id);
404
+ }
405
+ }
406
+ for (const [from, neighbors] of undirected) {
407
+ for (const to of neighbors) if (parent.has(from) && parent.has(to)) union(from, to);
408
+ }
409
+
410
+ const byRoot = new Map<string, ArrangeUnit[]>();
411
+ for (const unit of units) {
412
+ const root = find(unit.id);
413
+ if (!byRoot.has(root)) byRoot.set(root, []);
414
+ byRoot.get(root)!.push(unit);
415
+ }
416
+ const clusters = Array.from(byRoot.values());
417
+ for (const cluster of clusters) cluster.sort((a, b) => a.sortKey.y - b.sortKey.y || a.sortKey.x - b.sortKey.x);
418
+ return clusters.sort((a, b) => a[0].sortKey.y - b[0].sortKey.y || a[0].sortKey.x - b[0].sortKey.x);
419
+ }
420
+
421
+ function placeComponents(components: ComponentLayout[], origin: ArrangePosition): Map<string, ArrangePosition> {
366
422
  const absolute = new Map<string, ArrangePosition>();
367
- let cursorX = START_X;
368
- let cursorY = START_Y;
423
+ let cursorX = origin.x;
424
+ let cursorY = origin.y;
369
425
  let rowHeight = 0;
370
426
 
371
427
  for (const component of components) {
372
- if (cursorX > START_X && cursorX + component.size.width > MAX_ROW_WIDTH) {
373
- cursorX = START_X;
428
+ if (cursorX > origin.x && cursorX - origin.x + component.size.width > MAX_ROW_WIDTH) {
429
+ cursorX = origin.x;
374
430
  cursorY += rowHeight + COMPONENT_GAP_Y;
375
431
  rowHeight = 0;
376
432
  }
@@ -403,12 +459,64 @@ export function computeAutoArrange(
403
459
  }
404
460
 
405
461
  const { outgoing, undirected, indegree, outdegree } = buildUnitGraphs(units, nodeToUnit, allEdges);
406
- const components = collectComponents(units, undirected).map((component) =>
407
- mode === 'graph'
408
- ? computeGraphComponent(component, outgoing, undirected, indegree, outdegree)
409
- : computeGridComponent(component),
410
- );
411
- const absoluteUnitPositions = placeComponents(components);
462
+ const clusters = collectSpatialClusters(units, undirected);
463
+ const absoluteUnitPositions = new Map<string, ArrangePosition>();
464
+ // Already-packed cluster rects: a cluster that grew past its old bounds is
465
+ // shifted below earlier clusters instead of overlapping them. Clusters are
466
+ // processed top-left first, so the cascade is deterministic.
467
+ const packedRects: Array<ArrangePosition & ArrangeSize> = [];
468
+
469
+ for (const clusterUnits of clusters) {
470
+ const components = collectComponents(clusterUnits, undirected).map((component) =>
471
+ mode === 'graph'
472
+ ? computeGraphComponent(component, outgoing, undirected, indegree, outdegree)
473
+ : computeGridComponent(component),
474
+ );
475
+ // Anchor the cluster at its own neighborhood, not a global origin.
476
+ const origin = {
477
+ x: Math.min(...clusterUnits.map((unit) => unit.origin.x)),
478
+ y: Math.min(...clusterUnits.map((unit) => unit.origin.y)),
479
+ };
480
+ const placed = placeComponents(components, origin);
481
+
482
+ const unitSizes = new Map(clusterUnits.map((unit) => [unit.id, unit.size]));
483
+ const boundsOf = (positions: Map<string, ArrangePosition>): ArrangePosition & ArrangeSize => {
484
+ let minX = Number.POSITIVE_INFINITY;
485
+ let minY = Number.POSITIVE_INFINITY;
486
+ let maxX = Number.NEGATIVE_INFINITY;
487
+ let maxY = Number.NEGATIVE_INFINITY;
488
+ for (const [unitId, position] of positions) {
489
+ const size = unitSizes.get(unitId);
490
+ if (!size) continue;
491
+ minX = Math.min(minX, position.x);
492
+ minY = Math.min(minY, position.y);
493
+ maxX = Math.max(maxX, position.x + size.width);
494
+ maxY = Math.max(maxY, position.y + size.height);
495
+ }
496
+ return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
497
+ };
498
+
499
+ let shifted = placed;
500
+ for (let attempt = 0; attempt <= packedRects.length; attempt++) {
501
+ const bounds = boundsOf(shifted);
502
+ const blockers = packedRects.filter(
503
+ (rect) =>
504
+ bounds.x < rect.x + rect.width + COMPONENT_GAP_X &&
505
+ bounds.x + bounds.width + COMPONENT_GAP_X > rect.x &&
506
+ bounds.y < rect.y + rect.height + COMPONENT_GAP_Y &&
507
+ bounds.y + bounds.height + COMPONENT_GAP_Y > rect.y,
508
+ );
509
+ if (blockers.length === 0) break;
510
+ const deltaY = Math.max(...blockers.map((rect) => rect.y + rect.height)) + COMPONENT_GAP_Y - bounds.y;
511
+ if (deltaY <= 0) break;
512
+ const next = new Map<string, ArrangePosition>();
513
+ for (const [unitId, position] of shifted) next.set(unitId, { x: position.x, y: position.y + deltaY });
514
+ shifted = next;
515
+ }
516
+
517
+ for (const [unitId, position] of shifted) absoluteUnitPositions.set(unitId, position);
518
+ packedRects.push(boundsOf(shifted));
519
+ }
412
520
 
413
521
  for (const unit of units) {
414
522
  const targetOrigin = absoluteUnitPositions.get(unit.id);
@@ -0,0 +1,23 @@
1
+ /**
2
+ * AX status and kind unions shared by the server's state model and the
3
+ * client's session panel, so a new status cannot be added on one side and
4
+ * silently render unrecognized on the other.
5
+ */
6
+ export type AxWorkItemStatus = 'todo' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
7
+
8
+ /** `held` = auto-held by the unattended-approval policy: the action did NOT proceed. */
9
+ export type AxApprovalStatus = 'pending' | 'approved' | 'rejected' | 'held';
10
+
11
+ export type AxEventKind =
12
+ | 'prompt'
13
+ | 'assistant-message'
14
+ | 'tool-start'
15
+ | 'tool-result'
16
+ | 'failure'
17
+ | 'approval'
18
+ | 'steering'
19
+ | 'command'
20
+ | 'note'
21
+ | 'policy'
22
+ /** A human took over a node the agent was editing; the agent yielded (rail-chrome-v2 item 6). */
23
+ | 'yield';
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Human collaborator presence (rail-chrome-v2 phase 8, design items 5 and 6).
3
+ * Every open workbench tab is a client; the server fans the set out over SSE
4
+ * so tabs see each other's cursors, and an agent write to a node a human is
5
+ * holding is refused until the grab ends (user wins). In-memory, TTL-swept,
6
+ * never persisted — like agent presence.
7
+ */
8
+ export interface HumanPresence {
9
+ /** Per-tab id (sessionStorage), never an identity. */
10
+ clientId: string;
11
+ /** Display name shown on the cursor tag. */
12
+ name: string;
13
+ /** World coordinates of the pointer, null when it left the canvas. */
14
+ cursor: { x: number; y: number } | null;
15
+ /** The node this human is dragging or editing right now. */
16
+ grabbingNodeId: string | null;
17
+ lastSeenAt: string;
18
+ }
19
+
20
+ export interface HumanPresenceSnapshot {
21
+ humans: HumanPresence[];
22
+ }
23
+
24
+ /** A tab that stops reporting is gone after this. */
25
+ export const HUMAN_PRESENCE_TTL_MS = 30_000;
26
+ /** A grab the tab stops renewing (crash, tab closed mid-drag) releases after this. */
27
+ export const HUMAN_GRAB_TTL_MS = 8_000;
28
+ /** Cursor updates are throttled to this on the client. */
29
+ export const HUMAN_CURSOR_THROTTLE_MS = 80;
30
+ export const MAX_HUMANS = 32;
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Handshake contract between /api/canvas/iframe-probe (server document) and
3
+ * probeSrcIframes (client). The probe document posts this marker to its parent;
4
+ * receiving it is the ONLY proof that src-URL iframes truly load — hosts that
5
+ * block sub-frame requests (the Claude Code desktop browser) still fire `load`
6
+ * on the error page they commit instead.
7
+ */
8
+ export const IFRAME_PROBE_MESSAGE_SOURCE = 'pmx-canvas-iframe-probe';
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Scope fence geometry shared by the server's enforcement and the client's
3
+ * rendering so both draw the same box: the bounding box of the fenced nodes
4
+ * plus `padding` px. Null when none of the fenced nodes exist.
5
+ */
6
+ export interface FenceRect {
7
+ x: number;
8
+ y: number;
9
+ width: number;
10
+ height: number;
11
+ }
12
+
13
+ export function fenceRectFromNodes(
14
+ nodes: Iterable<{ position: { x: number; y: number }; size: { width: number; height: number } }>,
15
+ padding: number,
16
+ ): FenceRect | null {
17
+ let minX = Number.POSITIVE_INFINITY;
18
+ let minY = Number.POSITIVE_INFINITY;
19
+ let maxX = Number.NEGATIVE_INFINITY;
20
+ let maxY = Number.NEGATIVE_INFINITY;
21
+ let count = 0;
22
+ for (const node of nodes) {
23
+ count += 1;
24
+ minX = Math.min(minX, node.position.x);
25
+ minY = Math.min(minY, node.position.y);
26
+ maxX = Math.max(maxX, node.position.x + node.size.width);
27
+ maxY = Math.max(maxY, node.position.y + node.size.height);
28
+ }
29
+ if (count === 0) return null;
30
+ return { x: minX - padding, y: minY - padding, width: maxX - minX + padding * 2, height: maxY - minY + padding * 2 };
31
+ }
@@ -1,4 +0,0 @@
1
- import type { CanvasNodeState } from '../types';
2
- export declare function DockedNode({ node }: {
3
- node: CanvasNodeState;
4
- }): import("preact/src").JSX.Element;
@@ -1,165 +0,0 @@
1
- import { ContextNode } from '../nodes/ContextNode';
2
- import { LedgerNode } from '../nodes/LedgerNode';
3
- import { StatusNode } from '../nodes/StatusNode';
4
- import { StatusSummary } from '../nodes/StatusSummary';
5
- import { closeAttentionHistory } from '../state/attention-store';
6
- import { getContextPinnedNodes, toggleCollapsed, undockNode } from '../state/canvas-store';
7
- import { TYPE_LABELS } from '../types';
8
- import type { CanvasNodeState } from '../types';
9
-
10
- function renderDockedContent(node: CanvasNodeState) {
11
- switch (node.type) {
12
- case 'status':
13
- return <StatusNode node={node} />;
14
- case 'ledger':
15
- return <LedgerNode node={node} />;
16
- case 'context':
17
- return <ContextNode node={node} />;
18
- default:
19
- return null;
20
- }
21
- }
22
-
23
- function getContextItemCount(node: CanvasNodeState): number {
24
- const cards = Array.isArray(node.data.cards) ? (node.data.cards as unknown[]) : [];
25
- const auxTabs = Array.isArray(node.data.auxTabs) ? (node.data.auxTabs as unknown[]) : [];
26
- return cards.length + auxTabs.length;
27
- }
28
-
29
- function ContextDockedNode({ node }: { node: CanvasNodeState }) {
30
- const pinnedNodes = getContextPinnedNodes();
31
- const count = pinnedNodes.length > 0 ? pinnedNodes.length : getContextItemCount(node);
32
- const hasItems = count > 0;
33
- const collapsed = node.collapsed === true;
34
-
35
- const expand = () => {
36
- // Mutual exclusion with the Updates panel — only one side panel open at a
37
- // time. They share the same right-edge anchor, so opening both at once
38
- // would visually collide.
39
- closeAttentionHistory();
40
- toggleCollapsed(node.id);
41
- };
42
-
43
- if (collapsed) {
44
- // Collapsed = a menu-height pill in the right of the top HUD row, mirroring
45
- // the docked status widget on the left so the bar reads as one continuous menu.
46
- return (
47
- <div class="docked-node docked-node--collapsed" data-docked-node="true">
48
- <div class="docked-node-header">
49
- <span class="node-type-badge">Context</span>
50
- {hasItems && (
51
- <span class="docked-node-count" aria-hidden="true">
52
- {count > 99 ? '99+' : count}
53
- </span>
54
- )}
55
- <div class="docked-node-controls">
56
- <button
57
- type="button"
58
- onClick={(e) => {
59
- e.stopPropagation();
60
- expand();
61
- }}
62
- title={
63
- hasItems ? `${count} item${count === 1 ? '' : 's'} in agent context — expand` : 'Expand agent context'
64
- }
65
- aria-label={hasItems ? `Context — ${count} item${count === 1 ? '' : 's'}` : 'Expand agent context'}
66
- >
67
- {'▸'}
68
- </button>
69
- <button
70
- type="button"
71
- onClick={(e) => {
72
- e.stopPropagation();
73
- undockNode(node.id);
74
- }}
75
- title="Undock to canvas"
76
- aria-label="Undock to canvas"
77
- >
78
- {'⊙'}
79
- </button>
80
- </div>
81
- </div>
82
- </div>
83
- );
84
- }
85
-
86
- return (
87
- <aside class="context-dock-panel" data-docked-node="true" aria-label="Agent context">
88
- <div class="context-dock-header">
89
- <div class="context-dock-header-text">
90
- <span class="context-dock-title">Context</span>
91
- <span class="context-dock-subtitle">
92
- {hasItems ? `${count} item${count === 1 ? '' : 's'} in agent context` : 'Active agent context'}
93
- </span>
94
- </div>
95
- <div class="context-dock-controls">
96
- <button
97
- type="button"
98
- class="context-dock-icon-button"
99
- onClick={(e) => {
100
- e.stopPropagation();
101
- undockNode(node.id);
102
- }}
103
- aria-label="Undock to canvas"
104
- title="Undock to canvas"
105
- >
106
- {'\u2299'}
107
- </button>
108
- <button
109
- type="button"
110
- class="context-dock-icon-button"
111
- onClick={(e) => {
112
- e.stopPropagation();
113
- toggleCollapsed(node.id);
114
- }}
115
- aria-label="Collapse context panel"
116
- title="Collapse"
117
- >
118
- ×
119
- </button>
120
- </div>
121
- </div>
122
- <div class="context-dock-body">
123
- <ContextNode node={node} pinnedNodes={pinnedNodes} />
124
- </div>
125
- </aside>
126
- );
127
- }
128
-
129
- export function DockedNode({ node }: { node: CanvasNodeState }) {
130
- if (node.type === 'context') {
131
- return <ContextDockedNode node={node} />;
132
- }
133
-
134
- return (
135
- <div class={`docked-node${node.collapsed ? ' docked-node--collapsed' : ''}`} data-docked-node="true">
136
- <div class="docked-node-header">
137
- <span class="node-type-badge">{TYPE_LABELS[node.type] ?? node.type}</span>
138
- {node.type === 'status' && node.collapsed && <StatusSummary node={node} />}
139
- <div class="docked-node-controls">
140
- <button
141
- type="button"
142
- onClick={(e) => {
143
- e.stopPropagation();
144
- toggleCollapsed(node.id);
145
- }}
146
- title={node.collapsed ? 'Expand' : 'Collapse'}
147
- >
148
- {node.collapsed ? '\u25B8' : '\u25BE'}
149
- </button>
150
- <button
151
- type="button"
152
- onClick={(e) => {
153
- e.stopPropagation();
154
- undockNode(node.id);
155
- }}
156
- title="Undock to canvas"
157
- >
158
- {'\u2299'}
159
- </button>
160
- </div>
161
- </div>
162
- {!node.collapsed && <div class="docked-node-body">{renderDockedContent(node)}</div>}
163
- </div>
164
- );
165
- }