pmx-canvas 0.2.1 → 0.2.2

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 (45) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/Readme.md +2 -2
  3. package/dist/canvas/global.css +260 -0
  4. package/dist/canvas/index.js +76 -76
  5. package/dist/json-render/index.js +2 -2
  6. package/dist/types/client/canvas/IntentLayer.d.ts +1 -0
  7. package/dist/types/client/state/intent-bridge.d.ts +10 -0
  8. package/dist/types/client/state/intent-store.d.ts +25 -0
  9. package/dist/types/json-render/server.d.ts +1 -1
  10. package/dist/types/server/index.d.ts +34 -4
  11. package/dist/types/server/intent-registry.d.ts +45 -0
  12. package/dist/types/server/operations/ops/intent.d.ts +2 -0
  13. package/dist/types/shared/ax-intent.d.ts +58 -0
  14. package/docs/mcp.md +21 -2
  15. package/docs/screenshot.png +0 -0
  16. package/package.json +1 -1
  17. package/skills/pmx-canvas/SKILL.md +197 -1305
  18. package/skills/pmx-canvas/evals/evals.json +199 -0
  19. package/skills/pmx-canvas/references/full-reference.md +1441 -0
  20. package/src/cli/index.ts +21 -4
  21. package/src/client/canvas/CanvasNode.tsx +13 -13
  22. package/src/client/canvas/CanvasViewport.tsx +2 -0
  23. package/src/client/canvas/ContextMenu.tsx +25 -19
  24. package/src/client/canvas/IntentLayer.tsx +278 -0
  25. package/src/client/nodes/ExtAppFrame.tsx +31 -22
  26. package/src/client/state/intent-bridge.ts +31 -0
  27. package/src/client/state/intent-store.ts +107 -0
  28. package/src/client/state/sse-bridge.ts +31 -0
  29. package/src/client/theme/global.css +260 -0
  30. package/src/json-render/charts/components.tsx +18 -4
  31. package/src/json-render/renderer/index.tsx +11 -2
  32. package/src/json-render/server.ts +1 -1
  33. package/src/server/index.ts +240 -158
  34. package/src/server/intent-registry.ts +324 -0
  35. package/src/server/operations/composites.ts +11 -0
  36. package/src/server/operations/index.ts +2 -0
  37. package/src/server/operations/ops/edges.ts +1 -0
  38. package/src/server/operations/ops/groups.ts +3 -0
  39. package/src/server/operations/ops/intent.ts +132 -0
  40. package/src/server/operations/ops/json-render.ts +3 -0
  41. package/src/server/operations/ops/nodes.ts +3 -0
  42. package/src/server/operations/registry.ts +68 -3
  43. package/src/server/server.ts +40 -12
  44. package/src/shared/ax-intent.ts +64 -0
  45. package/src/shared/surface.ts +5 -1
package/src/cli/index.ts CHANGED
@@ -122,15 +122,30 @@ function removePidFile(path: string): void {
122
122
  }
123
123
  }
124
124
 
125
- async function isHealthy(url: string): Promise<boolean> {
125
+ interface HealthStatus {
126
+ responsive: boolean;
127
+ workspace: string | null;
128
+ }
129
+
130
+ async function readHealthStatus(url: string): Promise<HealthStatus> {
126
131
  try {
127
132
  const response = await fetch(url);
128
- return response.ok;
133
+ if (!response.ok) return { responsive: false, workspace: null };
134
+ const payload = await response.json().catch(() => null) as unknown;
135
+ const workspace = payload && typeof payload === 'object' && 'workspace' in payload
136
+ && typeof payload.workspace === 'string'
137
+ ? payload.workspace
138
+ : null;
139
+ return { responsive: true, workspace };
129
140
  } catch {
130
- return false;
141
+ return { responsive: false, workspace: null };
131
142
  }
132
143
  }
133
144
 
145
+ async function isHealthy(url: string): Promise<boolean> {
146
+ return (await readHealthStatus(url)).responsive;
147
+ }
148
+
134
149
  function readLogTail(path: string, maxLines = 20): string | null {
135
150
  try {
136
151
  if (!existsSync(path)) return null;
@@ -254,7 +269,8 @@ async function showServeStatus(options: {
254
269
  const url = `http://localhost:${options.port}/workbench`;
255
270
  const pid = readPidFile(options.pidFile);
256
271
  const pidRunning = pid ? isProcessRunning(pid) : false;
257
- const responsive = await isHealthy(healthUrl);
272
+ const health = await readHealthStatus(healthUrl);
273
+ const responsive = health.responsive;
258
274
  const running = responsive || pidRunning;
259
275
  if (!running && existsSync(options.pidFile) && !pidRunning) {
260
276
  removePidFile(options.pidFile);
@@ -265,6 +281,7 @@ async function showServeStatus(options: {
265
281
  daemon: true,
266
282
  running,
267
283
  responsive,
284
+ workspace: health.workspace,
268
285
  pid,
269
286
  pidRunning,
270
287
  url,
@@ -324,19 +324,19 @@ export function CanvasNode({ node, children, onContextMenu }: CanvasNodeProps) {
324
324
  >
325
325
  {node.collapsed ? '▸' : '▾'}
326
326
  </button>
327
- {node.type !== 'status' && (
328
- <button
329
- type="button"
330
- onClick={(e) => {
331
- e.stopPropagation();
332
- removeNode(node.id);
333
- void removeNodeFromClient(node.id);
334
- }}
335
- title="Close"
336
- >
337
- ×
338
- </button>
339
- )}
327
+ {/* Report #64: status nodes get the same remove control as every other
328
+ node type (backend removal + undo/history handle status uniformly). */}
329
+ <button
330
+ type="button"
331
+ onClick={(e) => {
332
+ e.stopPropagation();
333
+ removeNode(node.id);
334
+ void removeNodeFromClient(node.id);
335
+ }}
336
+ title="Close"
337
+ >
338
+ ×
339
+ </button>
340
340
  </div>
341
341
  </div>
342
342
  {!node.collapsed && (
@@ -31,6 +31,7 @@ import {
31
31
  import { createEdgeFromClient, createNodeFromClient } from '../state/intent-bridge';
32
32
  import type { CanvasAnnotation, CanvasNodeState } from '../types';
33
33
  import { FocusFieldLayer } from './FocusFieldLayer';
34
+ import { IntentLayer } from './IntentLayer';
34
35
  import { CanvasNode } from './CanvasNode';
35
36
  import { EdgeLayer } from './EdgeLayer';
36
37
  import { AnnotationLayer } from './AnnotationLayer';
@@ -772,6 +773,7 @@ export function CanvasViewport({ onNodeContextMenu, onCanvasContextMenu, annotat
772
773
  }}
773
774
  >
774
775
  <FocusFieldLayer />
776
+ <IntentLayer />
775
777
  <EdgeLayer nodes={nodes} edges={edges} />
776
778
  <AnnotationLayer annotations={Array.from(annotations.value.values())} />
777
779
  {draftAnnotation && draftAnnotation.points.length >= 2 && <AnnotationLayer annotations={[draftAnnotation]} />}
@@ -8,6 +8,7 @@ import {
8
8
  focusNode,
9
9
  nodes,
10
10
  pendingConnection,
11
+ persistLayout,
11
12
  removeNode,
12
13
  toggleCollapsed,
13
14
  toggleContextPin,
@@ -424,23 +425,29 @@ function buildNodeMenuItems(node: CanvasNodeState): MenuItem[] {
424
425
  action: () => toggleCollapsed(node.id),
425
426
  });
426
427
 
427
- // Pin/Unpin
428
+ // Context pin — add/remove from the human-curated agent context (report #63).
429
+ // This is the PRIMARY "pin" in PMX's model ("pin nodes to curate context"); it
430
+ // matches the SelectionBar's "Pin as context" and updates the context count + the
431
+ // node's ctx-pin indicator. Listed first so the obvious "Pin" verb maps to context.
432
+ const isCtxPinned = contextPinnedNodeIds.value.has(node.id);
433
+ items.push({
434
+ label: isCtxPinned ? 'Unpin from context' : 'Pin as context',
435
+ action: () => toggleContextPin(node.id),
436
+ });
437
+
438
+ // Position lock — a distinct, secondary feature (exclude from auto-arrange). Renamed
439
+ // off the word "Pin" so it no longer collides with context pinning (report #63), and
440
+ // now persists like every other layout mutation.
428
441
  items.push({
429
- label: node.pinned ? 'Unpin' : 'Pin (exclude from auto-arrange)',
442
+ label: node.pinned ? 'Unlock position' : 'Lock position (no auto-arrange)',
430
443
  action: () => {
431
444
  const pinned = !node.pinned;
432
445
  updateNode(node.id, { pinned });
433
446
  void updateNodeFromClient(node.id, { pinned });
447
+ persistLayout();
434
448
  },
435
449
  });
436
450
 
437
- // Context pin — add/remove from persistent agent context
438
- const isCtxPinned = contextPinnedNodeIds.value.has(node.id);
439
- items.push({
440
- label: isCtxPinned ? 'Remove from context' : 'Add to context',
441
- action: () => toggleContextPin(node.id),
442
- });
443
-
444
451
  // ── Edge connection ──
445
452
  const pending = pendingConnection.value;
446
453
  if (pending && pending.from !== node.id) {
@@ -586,16 +593,15 @@ function buildNodeMenuItems(node: CanvasNodeState): MenuItem[] {
586
593
  }
587
594
  }
588
595
 
589
- if (node.type !== 'status') {
590
- items.push({ separator: true });
591
- items.push({
592
- label: 'Close',
593
- action: () => {
594
- removeNode(node.id);
595
- void removeNodeFromClient(node.id);
596
- },
597
- });
598
- }
596
+ // Report #64: status nodes are removable like any other node type.
597
+ items.push({ separator: true });
598
+ items.push({
599
+ label: 'Close',
600
+ action: () => {
601
+ removeNode(node.id);
602
+ void removeNodeFromClient(node.id);
603
+ },
604
+ });
599
605
 
600
606
  return items;
601
607
  }
@@ -0,0 +1,278 @@
1
+ import { useEffect } from 'preact/hooks';
2
+ import { nodes } from '../state/canvas-store';
3
+ import {
4
+ hoveredIntentId,
5
+ intents,
6
+ type ClientIntent,
7
+ } from '../state/intent-store';
8
+ import { vetoGhostIntent } from '../state/intent-bridge';
9
+ import { getNodeIcon } from '../icons';
10
+ import { TYPE_LABELS } from '../types';
11
+ import type { CanvasNodeState } from '../types';
12
+
13
+ /**
14
+ * Ghost Cursor of Intent overlay. Renders the agent's pre-commit moves as faint
15
+ * placeholders in world space (it lives inside the canvas transform, like
16
+ * FocusFieldLayer, so positions are world coords). Five kinds:
17
+ * create → dashed ghost node with icon + type badge
18
+ * move → ghost at the destination + a dashed trail from the current node
19
+ * connect → dashed bezier in the edge-type color
20
+ * remove → red crosshatch tombstone over the target
21
+ * edit → shimmer bar over the target
22
+ * Each ghost carries a label/confidence chip, its reason, a seq badge (staged
23
+ * batches), and a ✕ veto (also Esc while hovered).
24
+ */
25
+
26
+ interface Rect {
27
+ left: number;
28
+ top: number;
29
+ width: number;
30
+ height: number;
31
+ }
32
+
33
+ const DEFAULT_GHOST_SIZE = { width: 260, height: 150 };
34
+
35
+ const GHOST_SIZE: Partial<Record<string, { width: number; height: number }>> = {
36
+ markdown: { width: 300, height: 170 },
37
+ status: { width: 280, height: 110 },
38
+ context: { width: 300, height: 200 },
39
+ trace: { width: 220, height: 64 },
40
+ file: { width: 300, height: 190 },
41
+ image: { width: 260, height: 200 },
42
+ webpage: { width: 320, height: 210 },
43
+ html: { width: 320, height: 210 },
44
+ group: { width: 340, height: 210 },
45
+ graph: { width: 320, height: 210 },
46
+ 'json-render': { width: 320, height: 210 },
47
+ 'mcp-app': { width: 340, height: 230 },
48
+ };
49
+
50
+ function isKnownNodeType(value: string | undefined): value is CanvasNodeState['type'] {
51
+ return !!value && value in TYPE_LABELS;
52
+ }
53
+
54
+ function getNodeRect(nodeId: string | undefined): Rect | null {
55
+ if (!nodeId) return null;
56
+ const node = nodes.value.get(nodeId);
57
+ if (!node || node.dockPosition !== null) return null;
58
+ return { left: node.position.x, top: node.position.y, width: node.size.width, height: node.size.height };
59
+ }
60
+
61
+ function center(rect: Rect): { x: number; y: number } {
62
+ return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
63
+ }
64
+
65
+ function ghostOpacity(intent: ClientIntent): number {
66
+ if (typeof intent.confidence !== 'number') return 0.82;
67
+ return 0.4 + Math.max(0, Math.min(1, intent.confidence)) * 0.55;
68
+ }
69
+
70
+ function bezierPath(a: { x: number; y: number }, b: { x: number; y: number }): string {
71
+ const dx = Math.max(40, Math.abs(b.x - a.x) / 2);
72
+ return `M ${a.x} ${a.y} C ${a.x + dx} ${a.y}, ${b.x - dx} ${b.y}, ${b.x} ${b.y}`;
73
+ }
74
+
75
+ function GhostInfo({ intent }: { intent: ClientIntent }) {
76
+ const NodeIcon = isKnownNodeType(intent.nodeType) ? getNodeIcon(intent.nodeType) : null;
77
+ const label = intent.label || intent.kind;
78
+ const confidencePct =
79
+ typeof intent.confidence === 'number' ? `${Math.round(intent.confidence * 100)}%` : null;
80
+ return (
81
+ <div
82
+ class="intent-info"
83
+ onMouseEnter={() => (hoveredIntentId.value = intent.id)}
84
+ onMouseLeave={() => {
85
+ if (hoveredIntentId.value === intent.id) hoveredIntentId.value = null;
86
+ }}
87
+ >
88
+ <div class="intent-chip">
89
+ {typeof intent.seq === 'number' && <span class="intent-seq">{intent.seq}</span>}
90
+ {NodeIcon && (
91
+ <span class="intent-chip-icon" aria-hidden="true">
92
+ <NodeIcon size={12} />
93
+ </span>
94
+ )}
95
+ <span class="intent-chip-label">{label}</span>
96
+ {confidencePct && <span class="intent-confidence">{confidencePct}</span>}
97
+ {intent.phase === 'forming' && (
98
+ <button
99
+ type="button"
100
+ class="intent-veto"
101
+ title="Veto this move (Esc)"
102
+ aria-label="Veto this move"
103
+ onClick={(e) => {
104
+ e.stopPropagation();
105
+ void vetoGhostIntent(intent);
106
+ }}
107
+ >
108
+
109
+ </button>
110
+ )}
111
+ </div>
112
+ {intent.reason && <div class="intent-reason">{intent.reason}</div>}
113
+ </div>
114
+ );
115
+ }
116
+
117
+ function GhostBox({ intent, rect }: { intent: ClientIntent; rect: Rect }) {
118
+ const NodeIcon = isKnownNodeType(intent.nodeType) ? getNodeIcon(intent.nodeType) : null;
119
+ const typeLabel = isKnownNodeType(intent.nodeType) ? TYPE_LABELS[intent.nodeType] : 'Node';
120
+ return (
121
+ <div
122
+ class={`intent-ghost intent-ghost-box is-${intent.phase}`}
123
+ data-intent-id={intent.id}
124
+ style={{
125
+ left: `${rect.left}px`,
126
+ top: `${rect.top}px`,
127
+ width: `${rect.width}px`,
128
+ height: `${rect.height}px`,
129
+ opacity: ghostOpacity(intent),
130
+ }}
131
+ >
132
+ <div class="intent-ghost-titlebar">
133
+ <span class="intent-ghost-icon" aria-hidden="true">
134
+ {NodeIcon ? <NodeIcon size={13} /> : '◇'}
135
+ </span>
136
+ <span class="intent-ghost-badge">{typeLabel}</span>
137
+ </div>
138
+ <GhostInfo intent={intent} />
139
+ </div>
140
+ );
141
+ }
142
+
143
+ function GhostOverlay({ intent, rect, variant }: { intent: ClientIntent; rect: Rect; variant: 'remove' | 'edit' }) {
144
+ return (
145
+ <div
146
+ class={`intent-ghost intent-ghost-${variant} is-${intent.phase}`}
147
+ data-intent-id={intent.id}
148
+ style={{
149
+ left: `${rect.left}px`,
150
+ top: `${rect.top}px`,
151
+ width: `${rect.width}px`,
152
+ height: `${rect.height}px`,
153
+ opacity: ghostOpacity(intent),
154
+ }}
155
+ >
156
+ {variant === 'edit' && <div class="intent-edit-bar" />}
157
+ <GhostInfo intent={intent} />
158
+ </div>
159
+ );
160
+ }
161
+
162
+ function renderGhost(intent: ClientIntent) {
163
+ const settledRect = intent.phase === 'settling'
164
+ ? getNodeRect(intent.settledNodeId)
165
+ : null;
166
+ switch (intent.kind) {
167
+ case 'create': {
168
+ if (!intent.position) return null;
169
+ const size = (intent.nodeType && GHOST_SIZE[intent.nodeType]) || DEFAULT_GHOST_SIZE;
170
+ const rect: Rect = settledRect ?? { left: intent.position.x, top: intent.position.y, ...size };
171
+ return <GhostBox key={intent.id} intent={intent} rect={rect} />;
172
+ }
173
+ case 'move': {
174
+ if (!intent.position) return null;
175
+ const source = getNodeRect(intent.nodeId);
176
+ const size = source ?? DEFAULT_GHOST_SIZE;
177
+ const rect: Rect = settledRect ?? { left: intent.position.x, top: intent.position.y, width: size.width, height: size.height };
178
+ return <GhostBox key={intent.id} intent={intent} rect={rect} />;
179
+ }
180
+ case 'remove': {
181
+ const rect = settledRect ?? getNodeRect(intent.nodeId);
182
+ if (!rect) return null;
183
+ return <GhostOverlay key={intent.id} intent={intent} rect={rect} variant="remove" />;
184
+ }
185
+ case 'edit': {
186
+ const rect = settledRect ?? getNodeRect(intent.nodeId);
187
+ if (!rect) return null;
188
+ return <GhostOverlay key={intent.id} intent={intent} rect={rect} variant="edit" />;
189
+ }
190
+ case 'connect': {
191
+ if (!intent.edge) return null;
192
+ const from = getNodeRect(intent.edge.from);
193
+ const to = getNodeRect(intent.edge.to);
194
+ if (!from || !to) return null;
195
+ const mid = { x: (from.left + from.width / 2 + to.left + to.width / 2) / 2, y: (from.top + from.height / 2 + to.top + to.height / 2) / 2 };
196
+ const rect: Rect = { left: mid.x - 110, top: mid.y - 18, width: 220, height: 36 };
197
+ // The bezier itself is drawn in the shared SVG layer; here we anchor the info card.
198
+ return (
199
+ <div
200
+ key={intent.id}
201
+ class={`intent-ghost intent-ghost-connect is-${intent.phase}`}
202
+ data-intent-id={intent.id}
203
+ style={{ left: `${rect.left}px`, top: `${rect.top}px`, width: `${rect.width}px`, opacity: ghostOpacity(intent) }}
204
+ >
205
+ <GhostInfo intent={intent} />
206
+ </div>
207
+ );
208
+ }
209
+ default:
210
+ return null;
211
+ }
212
+ }
213
+
214
+ export function IntentLayer() {
215
+ const list = Array.from(intents.value.values());
216
+
217
+ // Esc vetoes the hovered ghost before App's hierarchical Esc handler runs.
218
+ useEffect(() => {
219
+ function onKeyDown(e: KeyboardEvent): void {
220
+ if (e.key !== 'Escape') return;
221
+ const id = hoveredIntentId.value;
222
+ if (!id) return;
223
+ const intent = intents.value.get(id);
224
+ if (!intent || intent.phase !== 'forming') return;
225
+ e.stopImmediatePropagation();
226
+ e.preventDefault();
227
+ void vetoGhostIntent(intent);
228
+ }
229
+ window.addEventListener('keydown', onKeyDown, true);
230
+ return () => window.removeEventListener('keydown', onKeyDown, true);
231
+ }, []);
232
+
233
+ if (list.length === 0) return null;
234
+
235
+ return (
236
+ <div class="intent-layer">
237
+ <svg class="intent-line-layer" style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', overflow: 'visible', pointerEvents: 'none' }}>
238
+ <defs>
239
+ <marker id="intent-arrow" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">
240
+ <path d="M0,0 L6,3 L0,6 Z" class="intent-arrow-head" />
241
+ </marker>
242
+ </defs>
243
+ {list.map((intent) => {
244
+ if (intent.kind === 'connect' && intent.edge) {
245
+ const from = getNodeRect(intent.edge.from);
246
+ const to = getNodeRect(intent.edge.to);
247
+ if (!from || !to) return null;
248
+ return (
249
+ <path
250
+ key={`line-${intent.id}`}
251
+ d={bezierPath(center(from), center(to))}
252
+ class={`intent-edge type-${intent.edge.type}`}
253
+ style={{ opacity: ghostOpacity(intent) }}
254
+ />
255
+ );
256
+ }
257
+ if (intent.kind === 'move' && intent.position) {
258
+ const source = getNodeRect(intent.nodeId);
259
+ if (!source) return null;
260
+ const size = source;
261
+ const dest = { x: intent.position.x + size.width / 2, y: intent.position.y + size.height / 2 };
262
+ return (
263
+ <path
264
+ key={`line-${intent.id}`}
265
+ d={bezierPath(center(source), dest)}
266
+ class="intent-trail"
267
+ markerEnd="url(#intent-arrow)"
268
+ style={{ opacity: ghostOpacity(intent) }}
269
+ />
270
+ );
271
+ }
272
+ return null;
273
+ })}
274
+ </svg>
275
+ {list.map(renderGhost)}
276
+ </div>
277
+ );
278
+ }
@@ -643,29 +643,38 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
643
643
  if (iframeRef.current) {
644
644
  iframeRef.current.style.height = '100%';
645
645
  }
646
- if (!bridge || !bridgeReadyRef.current) return;
647
- bridge.setHostContext?.({
648
- theme: toMcpTheme(canvasTheme.value),
649
- platform: 'web',
650
- containerDimensions: resolveExtAppContainerDimensions(iframeRef.current, {
651
- width: node.size.width,
652
- height: maxHeight,
653
- }),
654
- displayMode: isExpanded ? 'fullscreen' : 'inline',
655
- locale: navigator.language,
656
- timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
657
- });
658
- void bridge.sendHostContextChange?.({
659
- theme: toMcpTheme(canvasTheme.value),
660
- platform: 'web',
661
- containerDimensions: resolveExtAppContainerDimensions(iframeRef.current, {
662
- width: node.size.width,
663
- height: maxHeight,
664
- }),
665
- displayMode: isExpanded ? 'fullscreen' : 'inline',
666
- locale: navigator.language,
667
- timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
646
+ if (!bridge || !bridgeReadyRef.current) return undefined;
647
+ // Measure + send AFTER the expand/collapse overlay has laid out (double rAF).
648
+ // Measuring synchronously here reads the iframe at its OLD inline size, so an app
649
+ // like Excalidraw reflows bound text against stale dimensions and clips the start
650
+ // of labels in expanded mode (report #62). A double rAF lands after layout+paint so
651
+ // resolveExtAppContainerDimensions reads the real expanded frame.
652
+ let raf1: number | null = null;
653
+ let raf2: number | null = null;
654
+ raf1 = requestAnimationFrame(() => {
655
+ raf1 = null;
656
+ raf2 = requestAnimationFrame(() => {
657
+ raf2 = null;
658
+ if (!bridgeReadyRef.current) return;
659
+ const hostContext = {
660
+ theme: toMcpTheme(canvasTheme.value),
661
+ platform: 'web' as const,
662
+ containerDimensions: resolveExtAppContainerDimensions(iframeRef.current, {
663
+ width: node.size.width,
664
+ height: maxHeight,
665
+ }),
666
+ displayMode: isExpanded ? ('fullscreen' as const) : ('inline' as const),
667
+ locale: navigator.language,
668
+ timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
669
+ };
670
+ bridge.setHostContext?.(hostContext);
671
+ void bridge.sendHostContextChange?.(hostContext);
672
+ });
668
673
  });
674
+ return () => {
675
+ if (raf1 !== null) cancelAnimationFrame(raf1);
676
+ if (raf2 !== null) cancelAnimationFrame(raf2);
677
+ };
669
678
  }, [isExpanded, maxHeight]);
670
679
 
671
680
  // Loading state — HTML not yet fetched
@@ -55,6 +55,37 @@ export async function sendIntent(
55
55
  });
56
56
  }
57
57
 
58
+ /**
59
+ * Veto a forming ghost intent at the mutation gate, then queue steering for the
60
+ * active agent session only when the server accepted the veto.
61
+ */
62
+ export async function vetoGhostIntent(intent: {
63
+ id: string;
64
+ kind: string;
65
+ label?: string;
66
+ reason?: string;
67
+ }): Promise<boolean> {
68
+ const what = intent.label?.trim() || `${intent.kind} intent`;
69
+ const message = `Veto: do not ${what}${intent.reason ? ` — ${intent.reason}` : ''}.`;
70
+ const cleared = await requestJson<{ ok?: boolean; cleared?: boolean }>(
71
+ 'vetoGhostIntent',
72
+ `/api/canvas/ax/intent/${encodeURIComponent(intent.id)}`,
73
+ { ok: false, cleared: false },
74
+ {
75
+ method: 'DELETE',
76
+ headers: { 'Content-Type': 'application/json' },
77
+ body: JSON.stringify({ vetoed: true }),
78
+ },
79
+ );
80
+ if (cleared.cleared !== true) return false;
81
+ await requestBestEffort('vetoGhostSteering', '/api/canvas/ax/steer', {
82
+ method: 'POST',
83
+ headers: { 'Content-Type': 'application/json' },
84
+ body: JSON.stringify({ message, source: 'browser' }),
85
+ });
86
+ return true;
87
+ }
88
+
58
89
  /** Fetch rendered markdown HTML from the server. */
59
90
  export async function renderMarkdown(markdown: string): Promise<string> {
60
91
  const data = await requestJson<{ html?: string }>('renderMarkdown', '/api/render', {}, {
@@ -0,0 +1,107 @@
1
+ import { signal } from '@preact/signals';
2
+ import type { PmxAxIntent } from '../../shared/ax-intent.js';
3
+
4
+ /**
5
+ * Client-side store for the Ghost Cursor of Intent. Ghosts are ephemeral
6
+ * presence pushed over SSE (`ax-intent` / `ax-intent-clear`); this store mirrors
7
+ * them into a signal the IntentLayer renders, tracks a short exit phase so
8
+ * settle/dissolve can animate, and prunes anything the server's TTL frame did
9
+ * not reach (SSE backstop). Nothing here is ever persisted.
10
+ */
11
+
12
+ export type IntentPhase = 'forming' | 'settling' | 'dissolving';
13
+
14
+ export interface ClientIntent extends PmxAxIntent {
15
+ phase: IntentPhase;
16
+ /** The real node a settled intent became — seeds the settle morph. */
17
+ settledNodeId?: string;
18
+ }
19
+
20
+ export const intents = signal<Map<string, ClientIntent>>(new Map());
21
+ /** The ghost currently hovered — drives Esc-to-veto. */
22
+ export const hoveredIntentId = signal<string | null>(null);
23
+
24
+ const SETTLE_MS = 480;
25
+ const DISSOLVE_MS = 320;
26
+
27
+ const exitTimers = new Map<string, ReturnType<typeof setTimeout>>();
28
+ let pruneTimer: ReturnType<typeof setInterval> | null = null;
29
+
30
+ function writeIntents(next: Map<string, ClientIntent>): void {
31
+ intents.value = next;
32
+ ensurePrune();
33
+ }
34
+
35
+ function clearExitTimer(id: string): void {
36
+ const timer = exitTimers.get(id);
37
+ if (timer) {
38
+ clearTimeout(timer);
39
+ exitTimers.delete(id);
40
+ }
41
+ }
42
+
43
+ /** A live `ax-intent` frame: (re)place the ghost in its forming state. */
44
+ export function upsertIntent(intent: PmxAxIntent): void {
45
+ clearExitTimer(intent.id);
46
+ const next = new Map(intents.value);
47
+ next.set(intent.id, { ...intent, phase: 'forming' });
48
+ writeIntents(next);
49
+ }
50
+
51
+ export function removeIntent(id: string): void {
52
+ clearExitTimer(id);
53
+ if (!intents.value.has(id)) return;
54
+ const next = new Map(intents.value);
55
+ next.delete(id);
56
+ writeIntents(next);
57
+ }
58
+
59
+ function setPhase(id: string, phase: IntentPhase, ms: number, settledNodeId?: string): void {
60
+ const current = intents.value.get(id);
61
+ if (!current || current.phase === phase) return;
62
+ const next = new Map(intents.value);
63
+ next.set(id, { ...current, phase, ...(settledNodeId ? { settledNodeId } : {}) });
64
+ writeIntents(next);
65
+ clearExitTimer(id);
66
+ exitTimers.set(id, setTimeout(() => removeIntent(id), ms));
67
+ }
68
+
69
+ /** Resolve a ghost into a real node — the settle morph, then removal. */
70
+ export function settleIntent(id: string, settledNodeId?: string): void {
71
+ setPhase(id, 'settling', SETTLE_MS, settledNodeId);
72
+ }
73
+
74
+ /** Dissolve a ghost (expired / vetoed / evicted / abandoned), then remove it. */
75
+ export function dissolveIntent(id: string): void {
76
+ setPhase(id, 'dissolving', DISSOLVE_MS);
77
+ }
78
+
79
+ export function resetIntents(): void {
80
+ for (const timer of exitTimers.values()) clearTimeout(timer);
81
+ exitTimers.clear();
82
+ if (pruneTimer) {
83
+ clearInterval(pruneTimer);
84
+ pruneTimer = null;
85
+ }
86
+ hoveredIntentId.value = null;
87
+ intents.value = new Map();
88
+ }
89
+
90
+ // SSE backstop: if a clear frame is dropped, expired forming ghosts still go
91
+ // away on their own TTL. Runs only while ghosts are present.
92
+ function ensurePrune(): void {
93
+ if (pruneTimer || intents.value.size === 0) return;
94
+ pruneTimer = setInterval(() => {
95
+ const now = Date.now();
96
+ for (const intent of intents.value.values()) {
97
+ if (intent.phase === 'forming' && intent.expiresAt <= now) {
98
+ dissolveIntent(intent.id);
99
+ }
100
+ }
101
+ if (intents.value.size === 0 && pruneTimer) {
102
+ clearInterval(pruneTimer);
103
+ pruneTimer = null;
104
+ }
105
+ }, 1000);
106
+ (pruneTimer as { unref?: () => void }).unref?.();
107
+ }