pmx-canvas 0.2.0 → 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.
- package/CHANGELOG.md +124 -0
- package/Readme.md +2 -2
- package/dist/canvas/global.css +260 -0
- package/dist/canvas/index.js +76 -76
- package/dist/json-render/index.js +2 -2
- package/dist/types/client/canvas/IntentLayer.d.ts +1 -0
- package/dist/types/client/state/intent-bridge.d.ts +10 -0
- package/dist/types/client/state/intent-store.d.ts +25 -0
- package/dist/types/json-render/server.d.ts +1 -1
- package/dist/types/server/ax-state-manager.d.ts +11 -0
- package/dist/types/server/ax-state.d.ts +2 -0
- package/dist/types/server/canvas-db.d.ts +13 -0
- package/dist/types/server/canvas-state.d.ts +5 -0
- package/dist/types/server/index.d.ts +34 -4
- package/dist/types/server/intent-registry.d.ts +45 -0
- package/dist/types/server/operations/ops/intent.d.ts +2 -0
- package/dist/types/shared/ax-intent.d.ts +58 -0
- package/docs/ax-host-adapter-contract.md +19 -1
- package/docs/http-api.md +4 -0
- package/docs/mcp.md +22 -3
- package/docs/screenshot.png +0 -0
- package/package.json +1 -1
- package/skills/pmx-canvas/SKILL.md +197 -1283
- package/skills/pmx-canvas/evals/evals.json +199 -0
- package/skills/pmx-canvas/references/ax-html-control-surface.md +93 -0
- package/skills/pmx-canvas/references/full-reference.md +1441 -0
- package/skills/pmx-canvas/references/github-copilot-app-adapter.md +23 -7
- package/src/cli/index.ts +21 -4
- package/src/client/canvas/CanvasNode.tsx +13 -13
- package/src/client/canvas/CanvasViewport.tsx +2 -0
- package/src/client/canvas/ContextMenu.tsx +25 -19
- package/src/client/canvas/IntentLayer.tsx +278 -0
- package/src/client/nodes/ExtAppFrame.tsx +31 -22
- package/src/client/state/intent-bridge.ts +31 -0
- package/src/client/state/intent-store.ts +107 -0
- package/src/client/state/sse-bridge.ts +31 -0
- package/src/client/theme/global.css +260 -0
- package/src/json-render/charts/components.tsx +18 -4
- package/src/json-render/renderer/index.tsx +11 -2
- package/src/json-render/server.ts +1 -1
- package/src/server/ax-context.ts +8 -1
- package/src/server/ax-state-manager.ts +18 -0
- package/src/server/ax-state.ts +8 -0
- package/src/server/canvas-db.ts +35 -0
- package/src/server/canvas-state.ts +8 -0
- package/src/server/index.ts +240 -158
- package/src/server/intent-registry.ts +324 -0
- package/src/server/operations/composites.ts +11 -0
- package/src/server/operations/index.ts +2 -0
- package/src/server/operations/ops/edges.ts +1 -0
- package/src/server/operations/ops/groups.ts +3 -0
- package/src/server/operations/ops/intent.ts +132 -0
- package/src/server/operations/ops/json-render.ts +3 -0
- package/src/server/operations/ops/nodes.ts +3 -0
- package/src/server/operations/registry.ts +68 -3
- package/src/server/server.ts +40 -12
- package/src/shared/ax-intent.ts +64 -0
- package/src/shared/surface.ts +5 -1
|
@@ -52,20 +52,36 @@ panel.
|
|
|
52
52
|
### Agent behavior — steering is gated, not pushed
|
|
53
53
|
|
|
54
54
|
`onUserPromptSubmitted` injects the whole `/api/canvas/ax/context` (pins, focus, work
|
|
55
|
-
items, approval gates, and `
|
|
56
|
-
when the **pin/focus gate is open** (`pinned.count > 0 || focus.nodeIds.length > 0`),
|
|
57
|
-
and it is clipped to a char budget.
|
|
55
|
+
items, approval gates, and the compact `delivery` lead block) as hidden context — but
|
|
56
|
+
only when the **pin/focus gate is open** (`pinned.count > 0 || focus.nodeIds.length > 0`),
|
|
57
|
+
and it is clipped to a char budget. Read steering from **`delivery.pendingSteering`**
|
|
58
|
+
(the compact, count-bearing block — newest-first, capped at 10), not the full
|
|
59
|
+
`timeline.pendingSteering`. Three consequences the adapter/agent must honor:
|
|
58
60
|
|
|
59
61
|
1. A steering board must **stay pinned** (or its button must also emit `ax.focus.set`
|
|
60
62
|
on the board node) to hold the gate open.
|
|
61
63
|
2. A sandbox button click does **not** wake a turn — a human message does. The click
|
|
62
64
|
only enqueues the steer.
|
|
63
|
-
3. The agent must **act on injected `pendingSteering` / `pendingActivity` and
|
|
64
|
-
(`canvas_ax_delivery { action: "mark" }`), or it re-injects every gated turn.
|
|
65
|
+
3. The agent must **act on injected `delivery.pendingSteering` / `pendingActivity` and
|
|
66
|
+
then ack** (`canvas_ax_delivery { action: "mark" }`), or it re-injects every gated turn.
|
|
65
67
|
|
|
66
|
-
To be robust to the char clip, prefer injecting
|
|
68
|
+
To be robust to the char clip, prefer injecting that compact loop-safe lead block from
|
|
67
69
|
`GET /api/canvas/ax/context?consumer=copilot` (`delivery.pendingSteering` +
|
|
68
|
-
`delivery.
|
|
70
|
+
`delivery.totalPending` / `delivery.omittedPending` + `delivery.pendingActivity`)
|
|
71
|
+
**above** the full dump. When `omittedPending > 0`, drain the full FIFO backlog from
|
|
72
|
+
`canvas_ax_delivery { action: "claim", consumer: "copilot" }` (oldest-first).
|
|
73
|
+
|
|
74
|
+
#### Waking the agent from a canvas steer (#59) — adapter-owned
|
|
75
|
+
|
|
76
|
+
Recording a browser-origin `ax.steer` does **not** wake the active session by itself
|
|
77
|
+
(report #59); PMX only queues it (the `ok:true` emit ack = "recorded", not "delivered").
|
|
78
|
+
To make a canvas **Steer** button actually create a visible turn, the adapter must, on
|
|
79
|
+
its own cadence (e.g. an SSE subscription or poll), **drain**
|
|
80
|
+
`canvas_ax_delivery { action: "claim", consumer: "copilot" }`, call the host's native
|
|
81
|
+
send (`copilotSession.send` / the working `send_instruction` path) with each steer, then
|
|
82
|
+
`canvas_ax_delivery { action: "mark" }` it (loop-safe). This wake is intentionally
|
|
83
|
+
host-owned — PMX never imports the host SDK. Until the adapter wires it, a steering
|
|
84
|
+
button must be labeled "queued for the agent's next turn", not "steer now".
|
|
69
85
|
|
|
70
86
|
### Closing the loop (optional, recommended)
|
|
71
87
|
|
package/src/cli/index.ts
CHANGED
|
@@ -122,15 +122,30 @@ function removePidFile(path: string): void {
|
|
|
122
122
|
}
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
{
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
//
|
|
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 ? '
|
|
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
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
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
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
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', {}, {
|