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 { useEffect, useRef } from 'preact/hooks';
2
+ import type { AgentPresence } from '../../shared/agent-presence.js';
3
+ import { vetoGhostIntent } from '../state/intent-bridge';
4
+ import { type ClientIntent, intents, removeIntent } from '../state/intent-store';
5
+ import {
6
+ activityFeedOpen,
7
+ activityFilter,
8
+ agentActivity,
9
+ agentPresences,
10
+ externalWriterPresences,
11
+ relativeAge,
12
+ sessionActive,
13
+ writerColor,
14
+ writerInitial,
15
+ writersSheetOpen,
16
+ } from '../state/presence-store';
17
+ import { scopeFence, pendingGates, startSession } from '../state/session-store';
18
+ import { sessionId } from '../state/canvas-store';
19
+ import { useFocusTrap } from './use-focus-trap';
20
+ import { useNow } from './use-now';
21
+
22
+ /**
23
+ * External Steering surfaces (rail-chrome-v2 phase 6, design items 1, 9, 17):
24
+ * an external agent writes to the board with NO session attached. The chrome
25
+ * stays quiet; the only additions are a passive top-bar indicator (writers +
26
+ * op count), a click-to-open activity feed with per-writer filters and inline
27
+ * Veto on pending intents, and a connected-writers sheet that is pure
28
+ * visibility — pmx-canvas is local-first, veto/ghosting is the safety model,
29
+ * not permissions. Every surface here mounts only while external writers are
30
+ * live and no session is attached.
31
+ */
32
+
33
+ function Avatar({ presence, size = 18 }: { presence: Pick<AgentPresence, 'sessionId' | 'label'>; size?: number }) {
34
+ return (
35
+ <span
36
+ class="writer-avatar"
37
+ style={{ background: writerColor(presence.sessionId), width: `${size}px`, height: `${size}px` }}
38
+ aria-hidden="true"
39
+ >
40
+ {writerInitial(presence.label)}
41
+ </span>
42
+ );
43
+ }
44
+
45
+ /** Top-bar indicator: avatar cluster, "3 writers", op count, pulsing dot. */
46
+ export function ExternalWriterIndicator() {
47
+ const writers = externalWriterPresences.value;
48
+ if (sessionActive.value || writers.length === 0) return null;
49
+ const ops = writers.reduce((sum, writer) => sum + writer.opCount, 0);
50
+ const label = writers.length === 1 ? writers[0]!.label : `${writers.length} writers`;
51
+ const open = activityFeedOpen.value;
52
+ return (
53
+ <button
54
+ type="button"
55
+ class="external-indicator"
56
+ data-testid="external-indicator"
57
+ aria-expanded={open}
58
+ title={`${writers.length} external writer${writers.length === 1 ? '' : 's'} on this board — click for activity`}
59
+ onClick={() => {
60
+ activityFeedOpen.value = !open;
61
+ }}
62
+ >
63
+ <span class="external-indicator-avatars">
64
+ {writers.slice(0, 3).map((writer) => (
65
+ <Avatar key={writer.sessionId} presence={writer} />
66
+ ))}
67
+ </span>
68
+ <span class="external-indicator-label">{label}</span>
69
+ <span class="external-indicator-ops hud-collapsible-text">
70
+ {ops} op{ops === 1 ? '' : 's'}
71
+ </span>
72
+ <span class="external-indicator-dot" aria-hidden="true" />
73
+ </button>
74
+ );
75
+ }
76
+
77
+ function opGlyph(op: string): { glyph: string; tone: string } {
78
+ if (op === 'node.add' || op === 'jsonrender.add' || op === 'graph.add' || op === 'group.create') {
79
+ return { glyph: '+', tone: 'ok' };
80
+ }
81
+ if (op === 'node.remove' || op === 'edge.remove' || op === 'canvas.clear') return { glyph: '×', tone: 'danger' };
82
+ if (op === 'edge.add') return { glyph: '⟶', tone: 'accent' };
83
+ if (op.startsWith('ax.')) return { glyph: '◆', tone: 'purple' };
84
+ return { glyph: '✎', tone: 'accent' };
85
+ }
86
+
87
+ /** ISO timestamp of the most recent write across the writers. */
88
+ function latestWrite(writers: readonly AgentPresence[]): string {
89
+ return writers.reduce((latest, writer) => (writer.lastSeenAt > latest ? writer.lastSeenAt : latest), '');
90
+ }
91
+
92
+ /** Pending explicit intents from external writers — the rows that carry an inline Veto. */
93
+ function pendingProposals(): ClientIntent[] {
94
+ return [...intents.value.values()].filter((intent) => !intent.auto && intent.phase === 'forming');
95
+ }
96
+
97
+ export function ActivityFeed() {
98
+ const open = activityFeedOpen.value;
99
+ const writers = externalWriterPresences.value;
100
+ const now = useNow(open ? 10_000 : 0);
101
+ // Esc closes the top-most surface: the sheet first, then the feed.
102
+ useEffect(() => {
103
+ if (!open) return;
104
+ const onKey = (e: KeyboardEvent) => {
105
+ if (e.key !== 'Escape') return;
106
+ if (writersSheetOpen.value) writersSheetOpen.value = false;
107
+ else activityFeedOpen.value = false;
108
+ };
109
+ window.addEventListener('keydown', onKey);
110
+ return () => window.removeEventListener('keydown', onKey);
111
+ }, [open]);
112
+ if (!open || sessionActive.value || writers.length === 0) return null;
113
+
114
+ const filter = activityFilter.value;
115
+ const proposals = pendingProposals().filter((intent) => !filter || (intent.source ?? '') === filter);
116
+ const rows = agentActivity.value.filter((entry) => !filter || entry.sessionId === filter);
117
+ const filterChip = (id: string | null, label: string) => (
118
+ <button
119
+ type="button"
120
+ class={`activity-filter${filter === id ? ' is-active' : ''}`}
121
+ style={id ? { '--writer': writerColor(id) } : undefined}
122
+ aria-pressed={filter === id}
123
+ onClick={() => {
124
+ activityFilter.value = id;
125
+ }}
126
+ >
127
+ {label}
128
+ </button>
129
+ );
130
+
131
+ return (
132
+ <div class="activity-feed" data-testid="activity-feed" role="dialog" aria-label="External activity">
133
+ <div class="activity-feed-head">
134
+ <span class="external-indicator-dot" aria-hidden="true" />
135
+ <div class="activity-feed-titles">
136
+ <div class="activity-feed-title">
137
+ External activity — {writers.length} writer{writers.length === 1 ? '' : 's'}
138
+ </div>
139
+ <div class="activity-feed-sub">
140
+ {writers.reduce((sum, writer) => sum + writer.opCount, 0)} writes · last{' '}
141
+ {relativeAge(latestWrite(writers), now)} · no session attached
142
+ </div>
143
+ </div>
144
+ <button
145
+ type="button"
146
+ class="activity-feed-close"
147
+ aria-label="Close activity feed"
148
+ onClick={() => {
149
+ activityFeedOpen.value = false;
150
+ }}
151
+ >
152
+ ×
153
+ </button>
154
+ </div>
155
+ <div class="activity-filters">
156
+ {filterChip(null, 'All')}
157
+ {writers.map((writer) => filterChip(writer.sessionId, writer.label))}
158
+ </div>
159
+ <div class="activity-rows">
160
+ {proposals.map((intent) => (
161
+ <div key={intent.id} class="activity-row is-pending" data-testid="activity-proposal">
162
+ <span class="activity-glyph tone-purple" aria-hidden="true">
163
+ ▢
164
+ </span>
165
+ <div class="activity-main">
166
+ <div class="activity-text">
167
+ Proposing: <b>{intent.label?.trim() || `${intent.kind} intent`}</b>
168
+ </div>
169
+ <div class="activity-meta">
170
+ {intent.reason?.trim() || 'Intent signalled — a real veto window before the mutation'}
171
+ {intent.source ? ` · ${intent.source}` : ''}
172
+ </div>
173
+ <div class="activity-actions">
174
+ <button
175
+ type="button"
176
+ class="activity-veto"
177
+ onClick={() => {
178
+ void vetoGhostIntent(intent).then((ok) => {
179
+ if (ok) removeIntent(intent.id);
180
+ });
181
+ }}
182
+ >
183
+ Veto
184
+ </button>
185
+ <span class="activity-hint">or hover ghost + Esc</span>
186
+ </div>
187
+ </div>
188
+ <span class="activity-age">now</span>
189
+ </div>
190
+ ))}
191
+ {rows.length === 0 && proposals.length === 0 && <div class="activity-empty">No writes yet.</div>}
192
+ {rows.map((entry) => {
193
+ const { glyph, tone } = opGlyph(entry.op);
194
+ return (
195
+ <div key={entry.id} class="activity-row" data-testid="activity-row" data-op={entry.op}>
196
+ <span class={`activity-glyph tone-${tone}`} aria-hidden="true">
197
+ {glyph}
198
+ </span>
199
+ <div class="activity-main">
200
+ <div class="activity-text">{entry.summary}</div>
201
+ <div class="activity-writer" style={{ color: writerColor(entry.sessionId) }}>
202
+ {entry.label}
203
+ </div>
204
+ </div>
205
+ <span class="activity-age">{relativeAge(entry.at, now)}</span>
206
+ </div>
207
+ );
208
+ })}
209
+ </div>
210
+ <div class="activity-feed-foot">
211
+ <span class="activity-feed-note">All writes ghost first · Esc vetoes</span>
212
+ <button
213
+ type="button"
214
+ class="activity-feed-link"
215
+ onClick={() => {
216
+ writersSheetOpen.value = true;
217
+ }}
218
+ >
219
+ Writers
220
+ </button>
221
+ <button
222
+ type="button"
223
+ class="activity-feed-link is-accent"
224
+ onClick={() => {
225
+ activityFeedOpen.value = false;
226
+ void startSession();
227
+ }}
228
+ >
229
+ Start session ↗
230
+ </button>
231
+ </div>
232
+ </div>
233
+ );
234
+ }
235
+
236
+ function sessionConfig(): string {
237
+ const parts: string[] = [];
238
+ if (scopeFence.value) parts.push('scoped');
239
+ if (pendingGates.value.length > 0) parts.push('gated');
240
+ return parts.length > 0 ? parts.join(' + ') : 'open';
241
+ }
242
+
243
+ /** Connected writers — pure visibility, no permissions. */
244
+ export function WritersSheet() {
245
+ const open = writersSheetOpen.value;
246
+ const now = useNow(open ? 10_000 : 0);
247
+ const sheetRef = useRef<HTMLDivElement>(null);
248
+ useFocusTrap(sheetRef, open);
249
+ if (!open) return null;
250
+ const sessions = agentPresences.value.filter((presence) => presence.attached);
251
+ const external = agentPresences.value.filter((presence) => !presence.attached);
252
+ const close = () => {
253
+ writersSheetOpen.value = false;
254
+ };
255
+ return (
256
+ <>
257
+ <div class="writers-scrim" onClick={close} aria-hidden="true" />
258
+ <div
259
+ ref={sheetRef}
260
+ class="writers-sheet"
261
+ data-testid="writers-sheet"
262
+ role="dialog"
263
+ aria-modal="true"
264
+ aria-label="Connected writers"
265
+ >
266
+ <div class="writers-head">
267
+ <span class="writers-title">Connected writers</span>
268
+ <span class="writers-board">{sessionId.value.slice(0, 12)}</span>
269
+ <button type="button" class="activity-feed-close" aria-label="Close writers" onClick={close}>
270
+ ×
271
+ </button>
272
+ </div>
273
+ <div class="writers-body">
274
+ <div class="writers-section">Agent sessions</div>
275
+ {sessions.length === 0 && <div class="writers-empty">None attached — start one from the top bar.</div>}
276
+ {sessions.map((presence) => (
277
+ <div key={presence.sessionId} class="writers-row">
278
+ <Avatar presence={presence} size={20} />
279
+ <span class="writers-name">{presence.label}</span>
280
+ <span class="writers-config">{sessionConfig()}</span>
281
+ </div>
282
+ ))}
283
+ <div class="writers-section">External writers · MCP</div>
284
+ {external.length === 0 && <div class="writers-empty">No external writers right now.</div>}
285
+ {external.map((presence) => (
286
+ <div key={presence.sessionId} class="writers-row">
287
+ <Avatar presence={presence} size={20} />
288
+ <span class="writers-name is-mono">{presence.label}</span>
289
+ <span class="writers-meta">
290
+ {presence.source !== presence.label ? `${presence.source} · ` : ''}wrote{' '}
291
+ {relativeAge(presence.lastSeenAt, now)} ago
292
+ </span>
293
+ </div>
294
+ ))}
295
+ </div>
296
+ <div class="writers-foot">
297
+ <span class="writers-note">
298
+ Local-first: any process on this machine can connect and write. Every write ghosts first — veto is the
299
+ control, not permissions.
300
+ </span>
301
+ <button type="button" class="writers-done" onClick={close}>
302
+ Done
303
+ </button>
304
+ </div>
305
+ </div>
306
+ </>
307
+ );
308
+ }
@@ -10,7 +10,7 @@ interface Rect {
10
10
 
11
11
  function getNodeRect(nodeId: string): Rect | null {
12
12
  const node = nodes.value.get(nodeId);
13
- if (!node || node.dockPosition !== null) return null;
13
+ if (!node) return null;
14
14
  return {
15
15
  left: node.position.x,
16
16
  top: node.position.y,
@@ -55,11 +55,22 @@ export function FocusFieldLayer() {
55
55
 
56
56
  if (primaryNodeIds.length === 0 && secondaryNodeIds.length === 0) return null;
57
57
 
58
+ // A region that spans (nearly) the whole populated board distinguishes
59
+ // nothing — pins far apart degrade the glow into a border around
60
+ // everything. Skip those; the per-node halos still mark the pins.
61
+ const all = [...nodes.value.values()];
62
+ const boardArea =
63
+ all.length > 0
64
+ ? (Math.max(...all.map((n) => n.position.x + n.size.width)) - Math.min(...all.map((n) => n.position.x))) *
65
+ (Math.max(...all.map((n) => n.position.y + n.size.height)) - Math.min(...all.map((n) => n.position.y)))
66
+ : 0;
67
+
58
68
  return (
59
69
  <div class="attention-field-layer" aria-hidden="true">
60
70
  {regions.map((region) => {
61
71
  const rect = getRegionRect(region.nodeIds);
62
72
  if (!rect) return null;
73
+ if (boardArea > 0 && rect.width * rect.height >= boardArea * 0.75) return null;
63
74
  return <div key={region.id} class="attention-field-region" style={rectStyle(rect, 42)} />;
64
75
  })}
65
76
  {secondaryNodeIds.map((nodeId) => {
@@ -0,0 +1,45 @@
1
+ import { HUMAN_PRESENCE_TTL_MS } from '../../shared/human-presence.js';
2
+ import { viewport } from '../state/canvas-store';
3
+ import { otherHumans } from '../state/human-store';
4
+ import { useNow } from './use-now';
5
+
6
+ /**
7
+ * Human collaborator cursors (rail-chrome-v2 phase 8, item 5): the other
8
+ * render style of the presence layer — green arrow + name tag, never a phase
9
+ * chip. Mounts whenever another tab is on the board, session or not (humans
10
+ * collaborate on quiet boards too). World-space like the agent layer, glyph
11
+ * counter-scaled so it keeps a constant screen size.
12
+ */
13
+ export function HumanPresenceLayer() {
14
+ // Staleness guard (0.5.0 readiness): an embedded pane whose stream stalls
15
+ // (background throttling, dead SSE) kept painting ancient guest cursors from
16
+ // its last frame. Cursors past the server TTL drop on the local clock even
17
+ // when no fresh frame ever arrives.
18
+ const now = useNow(otherHumans.value.length > 0 ? 5_000 : 0);
19
+ const humans = otherHumans.value.filter(
20
+ (human) =>
21
+ human.cursor !== null &&
22
+ (!human.lastSeenAt || now - Date.parse(human.lastSeenAt) < HUMAN_PRESENCE_TTL_MS + 5_000),
23
+ );
24
+ if (humans.length === 0) return null;
25
+ const counterScale = 1 / Math.max(0.05, viewport.value.scale);
26
+ return (
27
+ <div class="human-presence-layer" aria-hidden="true">
28
+ {humans.map((human) => (
29
+ <div
30
+ key={human.clientId}
31
+ class={`human-cursor${human.grabbingNodeId ? ' is-grabbing' : ''}`}
32
+ data-client-id={human.clientId}
33
+ style={{ transform: `translate(${human.cursor!.x}px, ${human.cursor!.y}px)` }}
34
+ >
35
+ <div class="human-cursor-inner" style={{ transform: `scale(${counterScale})` }}>
36
+ <svg class="human-cursor-glyph" width="16" height="16" viewBox="0 0 16 16" aria-hidden="true">
37
+ <path d="M3 2 L13 8 L8.5 9.5 L6.5 14 Z" fill="currentColor" stroke="var(--c-bg)" stroke-width="1" />
38
+ </svg>
39
+ <span class="human-cursor-tag">{human.name}</span>
40
+ </div>
41
+ </div>
42
+ ))}
43
+ </div>
44
+ );
45
+ }
@@ -50,7 +50,7 @@ function isKnownNodeType(value: string | undefined): value is CanvasNodeState['t
50
50
  function getNodeRect(nodeId: string | undefined): Rect | null {
51
51
  if (!nodeId) return null;
52
52
  const node = nodes.value.get(nodeId);
53
- if (!node || node.dockPosition !== null) return null;
53
+ if (!node) return null;
54
54
  return { left: node.position.x, top: node.position.y, width: node.size.width, height: node.size.height };
55
55
  }
56
56