pmx-canvas 0.5.1 → 0.6.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.
- package/CHANGELOG.md +28 -0
- package/Readme.md +9 -6
- package/dist/canvas/global.css +79 -4
- package/dist/canvas/index.js +171 -128
- package/dist/canvas/mermaid-entry.js +1303 -1303
- package/dist/json-render/index.js +101 -102
- package/docs/adr-001-bun-only-runtime.md +1 -1
- package/docs/bun-webview-integration.md +2 -2
- package/docs/product-context-vision-2026-09.md +207 -0
- package/docs/product-review-2026-09.md +174 -0
- package/docs/product-vision-2026-09.md +200 -0
- package/docs/screenshot.png +0 -0
- package/package.json +2 -2
- package/skills/pmx-canvas/SKILL.md +52 -29
- package/skills/pmx-canvas/references/installing-pmx-canvas.md +126 -18
- package/skills/web-artifacts-builder/scripts/bundle-artifact.sh +12 -1
- package/src/client/canvas/CanvasViewport.tsx +11 -0
- package/src/client/canvas/TopBar.tsx +85 -45
- package/src/client/canvas/use-pan-zoom.ts +7 -0
- package/src/client/nodes/ExtAppFrame.tsx +90 -27
- package/src/client/state/canvas-store.ts +17 -5
- package/src/client/theme/global.css +79 -4
- package/src/server/operations/mcp.ts +45 -31
- package/src/server/operations/ops/ax-presence.ts +3 -0
- package/src/server/scope-fence.ts +1 -1
|
@@ -31,59 +31,99 @@ import { formatCountdown, gateRemainingMs } from '../../shared/approval-gates.js
|
|
|
31
31
|
* session is attached — the quiet board shows nothing here until phase 5
|
|
32
32
|
* lands the "Start agent session" affordance.
|
|
33
33
|
*/
|
|
34
|
+
const AGENT_CHIP_LIMIT = 3;
|
|
35
|
+
const AGENT_PHASE_RANK: Record<string, number> = { 'waiting-approval': 0, thinking: 1, tooling: 2, idle: 3 };
|
|
36
|
+
|
|
34
37
|
function AgentChip() {
|
|
35
38
|
// Every attached session gets its chip — with several agents on the board,
|
|
36
|
-
// showing only the first hid the rest from the top bar entirely.
|
|
39
|
+
// showing only the first hid the rest from the top bar entirely. But one
|
|
40
|
+
// chip PER agent floods the bar on a many-agent board (user feedback,
|
|
41
|
+
// 0.5.1 cycle: 16 unshrinkable chips shoved the zoom cluster out of the
|
|
42
|
+
// bar and over the session panel, and the total was invisible): the most
|
|
43
|
+
// active few wear their own chips and the rest fold into a census chip
|
|
44
|
+
// that always names the total.
|
|
37
45
|
const sessions = attachedSessions.value;
|
|
38
46
|
if (sessions.length === 0) return null;
|
|
47
|
+
const ranked = [...sessions].sort((a, b) => (AGENT_PHASE_RANK[a.phase] ?? 4) - (AGENT_PHASE_RANK[b.phase] ?? 4));
|
|
48
|
+
const visible = ranked.slice(0, AGENT_CHIP_LIMIT);
|
|
49
|
+
const overflow = ranked.slice(AGENT_CHIP_LIMIT);
|
|
50
|
+
const phaseCounts = sessions.reduce<Record<string, number>>((acc, s) => {
|
|
51
|
+
acc[s.phase] = (acc[s.phase] ?? 0) + 1;
|
|
52
|
+
return acc;
|
|
53
|
+
}, {});
|
|
54
|
+
const breakdown = [
|
|
55
|
+
phaseCounts['waiting-approval'] && `${phaseCounts['waiting-approval']} waiting on approval`,
|
|
56
|
+
phaseCounts.thinking && `${phaseCounts.thinking} thinking`,
|
|
57
|
+
phaseCounts.tooling && `${phaseCounts.tooling} running tools`,
|
|
58
|
+
phaseCounts.idle && `${phaseCounts.idle} idle`,
|
|
59
|
+
]
|
|
60
|
+
.filter(Boolean)
|
|
61
|
+
.join(', ');
|
|
39
62
|
return (
|
|
40
63
|
<>
|
|
41
|
-
{
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
64
|
+
{/* The chips live in their own clipping flex wrapper: each chip keeps
|
|
65
|
+
its natural readable width and the WRAPPER absorbs the squeeze,
|
|
66
|
+
clipping at its edge — no per-child shrink cascade, and chip
|
|
67
|
+
content can never paint over the census, meter, or zoom cluster. */}
|
|
68
|
+
<span class="agent-chips">
|
|
69
|
+
{visible.map((session) => {
|
|
70
|
+
// Fleet roll-up: workers declaring this session as their parent count
|
|
71
|
+
// into its chip instead of growing the bar one chip per worker.
|
|
72
|
+
const workers = agentPresences.value.filter(
|
|
73
|
+
(presence) =>
|
|
74
|
+
presence.parentAgentId != null &&
|
|
75
|
+
(presence.parentAgentId === session.sessionId || presence.parentAgentId === session.source),
|
|
76
|
+
).length;
|
|
77
|
+
return (
|
|
78
|
+
<BarHint
|
|
79
|
+
key={session.sessionId}
|
|
80
|
+
label={`Agent session — ${session.label}`}
|
|
81
|
+
tapToOpen
|
|
82
|
+
body="What this attached agent is doing right now: idle, thinking, running a tool, or waiting on your approval. Steer it from the composer below."
|
|
60
83
|
>
|
|
61
|
-
<span
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
<span class="agent-chip-workers">
|
|
66
|
-
+{workers} worker{workers === 1 ? '' : 's'}
|
|
67
|
-
</span>
|
|
68
|
-
)}
|
|
69
|
-
<button
|
|
70
|
-
type="button"
|
|
71
|
-
class="agent-chip-end"
|
|
72
|
-
aria-label={`End ${session.label} session`}
|
|
73
|
-
title={`End ${session.label}'s session`}
|
|
74
|
-
onClick={(e) => {
|
|
75
|
-
// The chip sits inside a tap-to-open hint — ending a session
|
|
76
|
-
// must not also open it.
|
|
77
|
-
e.stopPropagation();
|
|
78
|
-
void endSession({ source: session.source, agentId: session.agentId });
|
|
79
|
-
}}
|
|
84
|
+
<span
|
|
85
|
+
class={`agent-chip phase-${session.phase}`}
|
|
86
|
+
data-phase={session.phase}
|
|
87
|
+
style={{ '--identity-color': `hsl(${agentIdentityHue(session.sessionId)} 65% 62%)` }}
|
|
80
88
|
>
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
89
|
+
<span class="agent-chip-dot" aria-hidden="true" />
|
|
90
|
+
<span class="agent-chip-label">{agentPhaseLabel(session)}</span>
|
|
91
|
+
<span class="agent-chip-who hud-collapsible-text">{session.label}</span>
|
|
92
|
+
{workers > 0 && (
|
|
93
|
+
<span class="agent-chip-workers">
|
|
94
|
+
+{workers} worker{workers === 1 ? '' : 's'}
|
|
95
|
+
</span>
|
|
96
|
+
)}
|
|
97
|
+
<button
|
|
98
|
+
type="button"
|
|
99
|
+
class="agent-chip-end"
|
|
100
|
+
aria-label={`End ${session.label} session`}
|
|
101
|
+
title={`End ${session.label}'s session`}
|
|
102
|
+
onClick={(e) => {
|
|
103
|
+
// The chip sits inside a tap-to-open hint — ending a session
|
|
104
|
+
// must not also open it.
|
|
105
|
+
e.stopPropagation();
|
|
106
|
+
void endSession({ source: session.source, agentId: session.agentId });
|
|
107
|
+
}}
|
|
108
|
+
>
|
|
109
|
+
×
|
|
110
|
+
</button>
|
|
111
|
+
</span>
|
|
112
|
+
</BarHint>
|
|
113
|
+
);
|
|
114
|
+
})}
|
|
115
|
+
</span>
|
|
116
|
+
{overflow.length > 0 && (
|
|
117
|
+
<BarHint
|
|
118
|
+
label={`${sessions.length} agent sessions attached`}
|
|
119
|
+
tapToOpen
|
|
120
|
+
body={`${sessions.length} agents on this board — ${breakdown}. The most active wear their own chips; the rest are here, and idle agents' cursors leave the board until they work again.`}
|
|
121
|
+
>
|
|
122
|
+
<span class="agent-chip agent-chip-more" data-testid="agent-chip-more">
|
|
123
|
+
+{overflow.length} · {sessions.length} agents
|
|
124
|
+
</span>
|
|
125
|
+
</BarHint>
|
|
126
|
+
)}
|
|
87
127
|
</>
|
|
88
128
|
);
|
|
89
129
|
}
|
|
@@ -110,6 +110,13 @@ export function usePanZoom({ viewport, onViewportChange, onViewportCommit, disab
|
|
|
110
110
|
// Pan tool / held Space: the world layer is pointer-inert (CSS), so
|
|
111
111
|
// the event target is the container even over nodes.
|
|
112
112
|
if (e.target !== container) return;
|
|
113
|
+
// A pan gesture must not double as the browser's drag-select: without
|
|
114
|
+
// preventDefault the sweep still extends a text selection through the
|
|
115
|
+
// pointer-inert world, and a later pointerdown inside that selection
|
|
116
|
+
// starts a native text drag the canvas mis-reads as a URL drop —
|
|
117
|
+
// panning from a node minted webpage nodes from card text (0.5.1 Amp
|
|
118
|
+
// finding D).
|
|
119
|
+
e.preventDefault();
|
|
113
120
|
} else {
|
|
114
121
|
// Select tool: background drag belongs to the lasso (CanvasViewport).
|
|
115
122
|
return;
|
|
@@ -137,7 +137,7 @@ export function extAppRecoveryLog(nodeId: string, event: string): void {
|
|
|
137
137
|
// panel without devtools access — the Copilot WKWebView, exactly where the
|
|
138
138
|
// black tiles happen — still yields an actionable trace: agents read it back
|
|
139
139
|
// via GET /api/canvas/debug/ext-app-recovery (0.3.2 report Finding N).
|
|
140
|
-
|
|
140
|
+
const extAppReportQueue: Array<{ t: number; nodeId: string; event: string; visibility?: string }> = [];
|
|
141
141
|
let extAppReportTimer: ReturnType<typeof setTimeout> | null = null;
|
|
142
142
|
function queueExtAppRecoveryReport(entry: { t: number; nodeId: string; event: string }): void {
|
|
143
143
|
extAppReportQueue.push({
|
|
@@ -287,28 +287,72 @@ export function buildExtAppBootBeaconScript(frameToken: string, nodeId: string):
|
|
|
287
287
|
// rendering pipeline is actually running — a frame whose layer WebKit never
|
|
288
288
|
// composites stays silent, and that silence drives the parent's recovery
|
|
289
289
|
// ladder. One unsolicited tick at boot, then on-demand answers to probes.
|
|
290
|
-
// Content oracle (Finding N, 0.5.
|
|
291
|
-
//
|
|
292
|
-
//
|
|
293
|
-
//
|
|
294
|
-
//
|
|
295
|
-
|
|
290
|
+
// Content oracle v2 (Finding N, 0.5.1 reopen): a LAID-OUT canvas passed the
|
|
291
|
+
// 0.5.1 oracle even when nothing was ever drawn into it — Excalidraw mounts
|
|
292
|
+
// its canvas element the moment React renders, so blank cream tiles read
|
|
293
|
+
// paint-ok (content-verified), the ladder concluded success, and Retry
|
|
294
|
+
// never engaged (Copilot 0.5.1 report: A/C/E cream with a green trail).
|
|
295
|
+
// The oracle now samples the canvas's PIXELS: a readable main drawing
|
|
296
|
+
// surface with uniform pixels is not content, whatever shell chrome or
|
|
297
|
+
// boilerplate text the document laid out around it.
|
|
298
|
+
function canvasInk(el) {
|
|
296
299
|
try {
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
300
|
+
if (!el.width || !el.height) return 'blank';
|
|
301
|
+
var sw = Math.min(96, el.width);
|
|
302
|
+
var sh = Math.min(64, el.height);
|
|
303
|
+
var probe = document.createElement('canvas');
|
|
304
|
+
probe.width = sw;
|
|
305
|
+
probe.height = sh;
|
|
306
|
+
var ctx = probe.getContext('2d', { willReadFrequently: true });
|
|
307
|
+
if (!ctx) return 'unknown';
|
|
308
|
+
ctx.drawImage(el, 0, 0, sw, sh);
|
|
309
|
+
var d = ctx.getImageData(0, 0, sw, sh).data;
|
|
310
|
+
var r = d[0], g = d[1], b = d[2], a = d[3];
|
|
311
|
+
for (var i = 4; i < d.length; i += 4) {
|
|
312
|
+
if (Math.abs(d[i] - r) > 3 || Math.abs(d[i + 1] - g) > 3 || Math.abs(d[i + 2] - b) > 3 || Math.abs(d[i + 3] - a) > 3) return 'ink';
|
|
313
|
+
}
|
|
314
|
+
return 'blank';
|
|
315
|
+
} catch (e) {
|
|
316
|
+
return 'unknown'; // tainted or WebGL-unreadable: pixels cannot be judged
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
function contentVerdict() {
|
|
320
|
+
try {
|
|
321
|
+
var canvases = document.querySelectorAll('canvas');
|
|
322
|
+
var blankMain = false;
|
|
323
|
+
var unreadable = false;
|
|
324
|
+
for (var i = 0; i < canvases.length; i++) {
|
|
325
|
+
var r = canvases[i].getBoundingClientRect();
|
|
326
|
+
if (r.width <= 8 || r.height <= 8) continue;
|
|
327
|
+
var ink = canvasInk(canvases[i]);
|
|
328
|
+
if (ink === 'ink') return { content: true, detail: 'ink' };
|
|
329
|
+
if (ink === 'unknown') unreadable = true;
|
|
330
|
+
else if (r.width >= 160 && r.height >= 120) blankMain = true;
|
|
331
|
+
}
|
|
332
|
+
// A readable, never-painted main surface vetoes everything below:
|
|
333
|
+
// toolbar icons and shell text around a blank drawing surface is
|
|
334
|
+
// exactly the false green the human reports as an empty cream tile.
|
|
335
|
+
if (blankMain) return { content: false, detail: 'blank-canvas' };
|
|
336
|
+
if (unreadable) return { content: true, detail: 'canvas-unreadable' };
|
|
337
|
+
var svgs = document.querySelectorAll('svg');
|
|
338
|
+
for (var j = 0; j < svgs.length; j++) {
|
|
339
|
+
var sr = svgs[j].getBoundingClientRect();
|
|
340
|
+
if (sr.width > 8 && sr.height > 8 && svgs[j].querySelector('path, rect, circle, ellipse, line, polyline, polygon, text, image, use')) {
|
|
341
|
+
return { content: true, detail: 'svg' };
|
|
342
|
+
}
|
|
301
343
|
}
|
|
302
344
|
var body = document.body;
|
|
303
|
-
|
|
345
|
+
if (body && body.innerText && body.innerText.trim().length > 0) return { content: true, detail: 'text' };
|
|
346
|
+
return { content: false, detail: 'empty' };
|
|
304
347
|
} catch (e) {
|
|
305
|
-
return false;
|
|
348
|
+
return { content: false, detail: 'probe-error' };
|
|
306
349
|
}
|
|
307
350
|
}
|
|
308
351
|
function sendPaintTick() {
|
|
309
352
|
requestAnimationFrame(function () {
|
|
310
353
|
requestAnimationFrame(function () {
|
|
311
|
-
|
|
354
|
+
var v = contentVerdict();
|
|
355
|
+
window.parent.postMessage({ source: '${EXT_APP_BOOT_BEACON_SOURCE}', token: TOKEN, nodeId: NODE_ID, kind: 'paint-tick', content: v.content, detail: v.detail }, '*');
|
|
312
356
|
});
|
|
313
357
|
});
|
|
314
358
|
}
|
|
@@ -388,8 +432,11 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
388
432
|
const themeUnsubRef = useRef<(() => void) | null>(null);
|
|
389
433
|
const webkitRemountAttemptsRef = useRef(0);
|
|
390
434
|
// Paint oracle (Finding N): resolvers waiting for the frame's next paint-tick,
|
|
391
|
-
// each handed the tick's content verdict.
|
|
392
|
-
const paintTickWaitersRef = useRef<Array<(content: boolean) => void>>([]);
|
|
435
|
+
// each handed the tick's content verdict + the oracle's reason for it.
|
|
436
|
+
const paintTickWaitersRef = useRef<Array<(tick: { content: boolean; detail: string }) => void>>([]);
|
|
437
|
+
// Last tick's oracle detail (ink / blank-canvas / svg / text / …) for the
|
|
438
|
+
// recovery trail — the label is the diagnostic the field reports read.
|
|
439
|
+
const lastPaintDetailRef = useRef<string>('');
|
|
393
440
|
// Genuine boot signal: set ONLY when the app completes the ui/initialize
|
|
394
441
|
// handshake (bridge.oninitialized) — NOT by the 1200ms bootstrap fallback,
|
|
395
442
|
// which flips status via notifications that resolve even into a dead iframe.
|
|
@@ -471,8 +518,12 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
471
518
|
return;
|
|
472
519
|
appScriptsRanRef.current = true;
|
|
473
520
|
if (data.kind === 'paint-tick') {
|
|
474
|
-
const
|
|
475
|
-
|
|
521
|
+
const tickData = data as { content?: unknown; detail?: unknown };
|
|
522
|
+
const tick = {
|
|
523
|
+
content: tickData.content === true,
|
|
524
|
+
detail: typeof tickData.detail === 'string' ? tickData.detail : '',
|
|
525
|
+
};
|
|
526
|
+
for (const waiter of paintTickWaitersRef.current.splice(0)) waiter(tick);
|
|
476
527
|
}
|
|
477
528
|
}
|
|
478
529
|
window.addEventListener('message', onBootBeacon);
|
|
@@ -590,13 +641,14 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
590
641
|
settled = true;
|
|
591
642
|
resolve('timeout');
|
|
592
643
|
}, PAINT_TICK_TIMEOUT_MS);
|
|
593
|
-
paintTickWaitersRef.current.push((
|
|
644
|
+
paintTickWaitersRef.current.push((tick) => {
|
|
594
645
|
if (settled) return;
|
|
595
646
|
settled = true;
|
|
596
647
|
window.clearTimeout(timer);
|
|
648
|
+
lastPaintDetailRef.current = tick.detail;
|
|
597
649
|
// A tick without content is NOT paint-ok: the rAF pipeline runs while
|
|
598
650
|
// the document is empty cream / never composited (Finding N reopen).
|
|
599
|
-
resolve(content ? 'content-verified' : 'tick-no-content');
|
|
651
|
+
resolve(tick.content ? 'content-verified' : 'tick-no-content');
|
|
600
652
|
});
|
|
601
653
|
contentWindow.postMessage({ source: EXT_APP_PAINT_PROBE_SOURCE, token: axToken }, '*');
|
|
602
654
|
});
|
|
@@ -672,10 +724,14 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
672
724
|
// A paint-ok collected while the document claims hidden proves only that the
|
|
673
725
|
// app's rAF pipeline is alive — NOT that anything composited (Finding N,
|
|
674
726
|
// 0.4.7). Say so in the trail instead of recording an unqualified success.
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
727
|
+
// The [detail] bracket names WHAT the oracle verified (ink / svg / text) so
|
|
728
|
+
// field reports can distinguish pixel-verified greens from weaker ones.
|
|
729
|
+
const paintOkLabel = (suffix = ''): string => {
|
|
730
|
+
const detail = lastPaintDetailRef.current ? ` [${lastPaintDetailRef.current}]` : '';
|
|
731
|
+
return typeof document !== 'undefined' && document.visibilityState === 'hidden'
|
|
732
|
+
? `paint-ok (content-verified)${detail}${suffix} (host hidden)`
|
|
733
|
+
: `paint-ok (content-verified)${detail}${suffix}`;
|
|
734
|
+
};
|
|
679
735
|
|
|
680
736
|
const runPaintLadder = async (): Promise<void> => {
|
|
681
737
|
// Runs for the EXPANDED instance too (Finding N reopen: expanded C was a
|
|
@@ -692,7 +748,9 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
692
748
|
}
|
|
693
749
|
extAppRecoveryLog(
|
|
694
750
|
nodeId,
|
|
695
|
-
verdict === 'tick-no-content'
|
|
751
|
+
verdict === 'tick-no-content'
|
|
752
|
+
? `paint-fail (tick without content: ${lastPaintDetailRef.current || 'unknown'})`
|
|
753
|
+
: 'paint-fail (no tick)',
|
|
696
754
|
);
|
|
697
755
|
await runSoftExpandCycle();
|
|
698
756
|
verdict = await probePaint();
|
|
@@ -777,7 +835,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
777
835
|
() => () => {
|
|
778
836
|
unmountedRef.current = true;
|
|
779
837
|
for (const waiter of bootWaitersRef.current.splice(0)) waiter();
|
|
780
|
-
for (const waiter of paintTickWaitersRef.current.splice(0)) waiter(false);
|
|
838
|
+
for (const waiter of paintTickWaitersRef.current.splice(0)) waiter({ content: false, detail: 'unmounted' });
|
|
781
839
|
},
|
|
782
840
|
[],
|
|
783
841
|
);
|
|
@@ -1096,7 +1154,12 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
1096
1154
|
};
|
|
1097
1155
|
if (typeof navigator !== 'undefined' && isWebKitOnlyHost(navigator.userAgent)) {
|
|
1098
1156
|
void awaitContentVerified(4_000).then((verified) => {
|
|
1099
|
-
extAppRecoveryLog(
|
|
1157
|
+
extAppRecoveryLog(
|
|
1158
|
+
nodeId,
|
|
1159
|
+
verified
|
|
1160
|
+
? 'settled (content-verified)'
|
|
1161
|
+
: `settled (content-timeout${lastPaintDetailRef.current ? `: ${lastPaintDetailRef.current}` : ''})`,
|
|
1162
|
+
);
|
|
1100
1163
|
releaseQueue();
|
|
1101
1164
|
});
|
|
1102
1165
|
} else {
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
type ViewportState,
|
|
10
10
|
} from '../types';
|
|
11
11
|
import { computeAutoArrange } from '../../shared/auto-arrange';
|
|
12
|
-
import { canvasAreaCenter, canvasFitInsets } from '../canvas/canvas-area';
|
|
12
|
+
import { canvasArea, canvasAreaCenter, canvasFitInsets } from '../canvas/canvas-area';
|
|
13
13
|
import { pushCanvasUpdate, requestBestEffort, requestOk, updateViewportFromClient } from './intent-bridge';
|
|
14
14
|
|
|
15
15
|
function logCanvasStoreError(action: string, error: unknown): void {
|
|
@@ -833,14 +833,26 @@ export function focusNode(id: string, options: { recordHistory?: boolean } = {})
|
|
|
833
833
|
const node = nodes.value.get(id);
|
|
834
834
|
if (!node) return;
|
|
835
835
|
const v = viewport.value;
|
|
836
|
+
// Same screen-space frame as fitAll: viewport x/y are CONTAINER-relative
|
|
837
|
+
// (screen = world*scale + viewport inside the canvas region), so the centre
|
|
838
|
+
// is built from the region's dimensions + chrome insets — the old
|
|
839
|
+
// window-coordinate canvasAreaCenter() over-shifted focus by the region
|
|
840
|
+
// origin, and centring without insets parked tall nodes (the 960×600
|
|
841
|
+
// mcp-app open) under the command bar (0.5.1 Amp finding C).
|
|
842
|
+
const area = canvasArea();
|
|
843
|
+
const insets = canvasFitInsets();
|
|
844
|
+
const usableW = Math.max(80, area.width - insets.left - insets.right);
|
|
845
|
+
const usableH = Math.max(80, area.height - insets.top - insets.bottom);
|
|
846
|
+
// Shrink (never grow) so the whole node — resize corner included — fits the
|
|
847
|
+
// usable rect clear of the floating chrome.
|
|
848
|
+
const scale = Math.min(v.scale, usableW / node.size.width, usableH / node.size.height);
|
|
836
849
|
const cx = node.position.x + node.size.width / 2;
|
|
837
850
|
const cy = node.position.y + node.size.height / 2;
|
|
838
|
-
const centre = canvasAreaCenter();
|
|
839
851
|
animateViewport(
|
|
840
852
|
{
|
|
841
|
-
x:
|
|
842
|
-
y:
|
|
843
|
-
scale
|
|
853
|
+
x: insets.left + usableW / 2 - cx * scale,
|
|
854
|
+
y: insets.top + usableH / 2 - cy * scale,
|
|
855
|
+
scale,
|
|
844
856
|
},
|
|
845
857
|
300,
|
|
846
858
|
options,
|
|
@@ -986,10 +986,21 @@ body,
|
|
|
986
986
|
/* NOT overflow:hidden — the BarHint tooltips hang below the bar and were
|
|
987
987
|
clipped invisible for everyone (the e2e asserted opacity, which cannot
|
|
988
988
|
see ancestor clipping). Children ellipsize themselves; the narrow-screen
|
|
989
|
-
collapse is media-query display, not clipping.
|
|
989
|
+
collapse is media-query display, not clipping. overflow-x: clip is the
|
|
990
|
+
one safe axis (visible/clip is the valid mixed pair): tooltips still
|
|
991
|
+
hang below, but bar content can never paint over the session panel
|
|
992
|
+
again (0.5.1 user feedback — the agent-chip flood pushed the zoom
|
|
993
|
+
cluster out of the bar). */
|
|
994
|
+
overflow-x: clip;
|
|
990
995
|
min-width: 0;
|
|
991
996
|
z-index: 9998;
|
|
992
997
|
}
|
|
998
|
+
/* CHIP BarHint anchors must pass shrink through to their chip (flex min-auto
|
|
999
|
+
sizing) or an unshrinkable anchor re-creates the overflow. Chip anchors
|
|
1000
|
+
ONLY — a blanket min-width: 0 crushed the zoom buttons to slivers. */
|
|
1001
|
+
.top-bar .toolbar-tooltip-anchor:has(.agent-chip) {
|
|
1002
|
+
min-width: 0;
|
|
1003
|
+
}
|
|
993
1004
|
|
|
994
1005
|
.top-bar-title {
|
|
995
1006
|
font-size: 13px;
|
|
@@ -1104,14 +1115,46 @@ body,
|
|
|
1104
1115
|
font-size: 11px;
|
|
1105
1116
|
font-weight: 600;
|
|
1106
1117
|
white-space: nowrap;
|
|
1118
|
+
/* Rigid: the chip never shrinks and never lies about its min size (a
|
|
1119
|
+
min-width: 0 here let BarHint anchors collapse under their chips, and
|
|
1120
|
+
neighboring chips painted over each other). The .agent-chips wrapper is
|
|
1121
|
+
the ONE squeeze point — it clips whole trailing chips at its edge. */
|
|
1107
1122
|
flex-shrink: 0;
|
|
1108
|
-
min-width: 0;
|
|
1109
1123
|
max-width: 280px;
|
|
1110
1124
|
}
|
|
1125
|
+
/* Many-agent bar (0.5.1 user feedback): the visible chips sit in ONE
|
|
1126
|
+
clipping flex wrapper. Chips keep their natural readable width; when the
|
|
1127
|
+
bar is overconstrained the WRAPPER shrinks and clips at its edge, so chip
|
|
1128
|
+
content can never shove or paint over the census chip, the context meter,
|
|
1129
|
+
or the zoom cluster (16 unshrinkable chips used to push the zoom controls
|
|
1130
|
+
out of the bar and over the session panel). */
|
|
1131
|
+
.agent-chips {
|
|
1132
|
+
display: inline-flex;
|
|
1133
|
+
align-items: center;
|
|
1134
|
+
gap: 12px;
|
|
1135
|
+
min-width: 0;
|
|
1136
|
+
overflow: hidden;
|
|
1137
|
+
}
|
|
1138
|
+
/* Rigid anchors: nested-flex min-content lets a BarHint anchor collapse
|
|
1139
|
+
under its chip (ellipsizing spans zero the chip's min-content), and the
|
|
1140
|
+
chip then paints into the next anchor. Only the wrapper above may give. */
|
|
1141
|
+
.agent-chips .toolbar-tooltip-anchor {
|
|
1142
|
+
flex-shrink: 0;
|
|
1143
|
+
}
|
|
1111
1144
|
.agent-chip.phase-idle {
|
|
1112
1145
|
background: none;
|
|
1113
1146
|
border-color: var(--c-line);
|
|
1114
1147
|
}
|
|
1148
|
+
/* The census chip is the "how many" answer — it never compresses away. Its
|
|
1149
|
+
BarHint anchor must hold width too: a shrinking anchor around an
|
|
1150
|
+
unshrinkable chip lets the chip paint over the next bar item. */
|
|
1151
|
+
.agent-chip-more {
|
|
1152
|
+
flex-shrink: 0;
|
|
1153
|
+
}
|
|
1154
|
+
.top-bar .toolbar-tooltip-anchor:has(.agent-chip-more) {
|
|
1155
|
+
flex-shrink: 0;
|
|
1156
|
+
min-width: max-content;
|
|
1157
|
+
}
|
|
1115
1158
|
.agent-chip-dot {
|
|
1116
1159
|
width: 7px;
|
|
1117
1160
|
height: 7px;
|
|
@@ -1134,6 +1177,11 @@ body,
|
|
|
1134
1177
|
font-weight: 400;
|
|
1135
1178
|
color: var(--c-muted);
|
|
1136
1179
|
white-space: nowrap;
|
|
1180
|
+
/* Leaf-level truncation: a compressed chip must ellipsize the agent name,
|
|
1181
|
+
never paint it past the pill (0.5.1 user feedback, many-agent bar). */
|
|
1182
|
+
overflow: hidden;
|
|
1183
|
+
text-overflow: ellipsis;
|
|
1184
|
+
min-width: 0;
|
|
1137
1185
|
}
|
|
1138
1186
|
|
|
1139
1187
|
/* On-canvas cursor + phase chip (world layer; counter-scaled inner) */
|
|
@@ -1146,7 +1194,9 @@ body,
|
|
|
1146
1194
|
top: 0;
|
|
1147
1195
|
z-index: 9000;
|
|
1148
1196
|
pointer-events: none;
|
|
1149
|
-
transition:
|
|
1197
|
+
transition:
|
|
1198
|
+
transform 220ms cubic-bezier(0.22, 1, 0.36, 1),
|
|
1199
|
+
opacity 320ms ease;
|
|
1150
1200
|
color: var(--presence-color);
|
|
1151
1201
|
}
|
|
1152
1202
|
.agent-cursor.is-worker {
|
|
@@ -1160,6 +1210,20 @@ body,
|
|
|
1160
1210
|
font-size: 9px;
|
|
1161
1211
|
padding: 1px 6px;
|
|
1162
1212
|
}
|
|
1213
|
+
/* Idle cursors leave the board (user feedback, 0.5.1 cycle): with many agents
|
|
1214
|
+
attached, a wall of parked "Idle" pills covers real node content. The
|
|
1215
|
+
element stays MOUNTED — counts, twin-merge, and cursor-follow logic are
|
|
1216
|
+
untouched, and visibility (not display) keeps its layout box so position
|
|
1217
|
+
polls still resolve — but it paints nothing; any non-idle phase fades it
|
|
1218
|
+
back in. Placed after .is-worker so opacity: 0 wins that collision. */
|
|
1219
|
+
.agent-cursor.phase-idle {
|
|
1220
|
+
opacity: 0;
|
|
1221
|
+
visibility: hidden;
|
|
1222
|
+
transition:
|
|
1223
|
+
transform 220ms cubic-bezier(0.22, 1, 0.36, 1),
|
|
1224
|
+
opacity 320ms ease,
|
|
1225
|
+
visibility 0s linear 320ms;
|
|
1226
|
+
}
|
|
1163
1227
|
.agent-chip-workers {
|
|
1164
1228
|
margin-left: 4px;
|
|
1165
1229
|
padding: 0 5px;
|
|
@@ -2830,9 +2894,20 @@ body,
|
|
|
2830
2894
|
}
|
|
2831
2895
|
|
|
2832
2896
|
/* Pan tool / held Space: the world layer goes pointer-inert so drags land on
|
|
2833
|
-
the viewport container itself and pan — nodes cannot swallow the drag.
|
|
2897
|
+
the viewport container itself and pan — nodes cannot swallow the drag.
|
|
2898
|
+
user-select too: text selection sweeps straight through pointer-inert
|
|
2899
|
+
content, and a selection made mid-pan later becomes a native text drag the
|
|
2900
|
+
drop handler mints webpage nodes from (0.5.1 Amp finding D). */
|
|
2834
2901
|
.canvas-viewport.pan-mode .canvas-world {
|
|
2835
2902
|
pointer-events: none;
|
|
2903
|
+
-webkit-user-select: none;
|
|
2904
|
+
user-select: none;
|
|
2905
|
+
}
|
|
2906
|
+
/* The resize handle and connect ports carry their own pointer-events: auto,
|
|
2907
|
+
which punches through the inert world — a pan started on them never pans. */
|
|
2908
|
+
.canvas-viewport.pan-mode .node-resize-handle,
|
|
2909
|
+
.canvas-viewport.pan-mode .node-port {
|
|
2910
|
+
pointer-events: none;
|
|
2836
2911
|
}
|
|
2837
2912
|
.canvas-viewport.pan-mode:active {
|
|
2838
2913
|
cursor: grabbing;
|
|
@@ -27,10 +27,18 @@ export function registerOperationTools(server: McpServer, getHost: () => Promise
|
|
|
27
27
|
if (foldedOpNames.has(op.name)) continue;
|
|
28
28
|
const tool = op.mcp;
|
|
29
29
|
if (!tool) continue;
|
|
30
|
-
server.
|
|
30
|
+
server.registerTool(
|
|
31
31
|
tool.toolName,
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
{
|
|
33
|
+
description: tool.description,
|
|
34
|
+
// A LOOSE object, not a raw shape: the SDK wraps raw shapes in a
|
|
35
|
+
// stripping z.object, which silently dropped unknown args before the
|
|
36
|
+
// op layer could reject them — a removed field (dockPosition) looked
|
|
37
|
+
// successful over MCP while the same HTTP call 400'd (0.5.1 Amp
|
|
38
|
+
// finding A). Loose parsing forwards unknown keys to the op layer,
|
|
39
|
+
// the single authority on dead-field rejection, HTTP and MCP alike.
|
|
40
|
+
inputSchema: z.looseObject({ ...op.inputShape, ...(tool.extraShape ?? {}) }),
|
|
41
|
+
},
|
|
34
42
|
async (input: Record<string, unknown>) => {
|
|
35
43
|
try {
|
|
36
44
|
const host = await getHost();
|
|
@@ -231,34 +239,40 @@ export function registerCompositeTools(
|
|
|
231
239
|
for (const [publicKey, opField] of Object.entries(def.fieldRemap ?? {})) {
|
|
232
240
|
publicFieldName.set(opField, publicKey);
|
|
233
241
|
}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
);
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
242
|
+
// Loose for the same reason as registerOperationTools above: unknown args
|
|
243
|
+
// must reach the op layer's dead-field rejection instead of being stripped.
|
|
244
|
+
server.registerTool(
|
|
245
|
+
def.toolName,
|
|
246
|
+
{ description: def.description, inputSchema: z.looseObject(buildCompositeShape(def)) },
|
|
247
|
+
async (input: Record<string, unknown>) => {
|
|
248
|
+
try {
|
|
249
|
+
const host = await getHost();
|
|
250
|
+
const opName = resolveCompositeOp(def, input);
|
|
251
|
+
const op = getOperation(opName);
|
|
252
|
+
// Strip the composite discriminators (action + any extra, e.g. `kind`)
|
|
253
|
+
// and undo any field remap; the rest is the op's raw MCP args — the same
|
|
254
|
+
// value the standalone tool would receive.
|
|
255
|
+
const rest = stripCompositeDiscriminators(def, input);
|
|
256
|
+
const missing = requiredMcpFields(op).filter((field) => !requiredFieldSatisfied(opName, field, rest));
|
|
257
|
+
if (missing.length > 0) {
|
|
258
|
+
const named = missing.map((field) => `"${publicFieldName.get(field) ?? field}"`).join(', ');
|
|
259
|
+
throw new OperationError(
|
|
260
|
+
`${def.toolName} action "${String(input.action)}" requires ${named}. Unknown parameters are ignored — check the argument names.`,
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
const opInput = op.mcp?.buildInput ? op.mcp.buildInput(rest) : rest;
|
|
264
|
+
const result = await host.invoker().invoke(opName, opInput);
|
|
265
|
+
if (op.mcp?.formatResult) {
|
|
266
|
+
return await op.mcp.formatResult(result, rest, host);
|
|
267
|
+
}
|
|
268
|
+
return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] };
|
|
269
|
+
} catch (error) {
|
|
270
|
+
return {
|
|
271
|
+
content: [{ type: 'text' as const, text: error instanceof Error ? error.message : String(error) }],
|
|
272
|
+
isError: true,
|
|
273
|
+
};
|
|
254
274
|
}
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
return {
|
|
258
|
-
content: [{ type: 'text' as const, text: error instanceof Error ? error.message : String(error) }],
|
|
259
|
-
isError: true,
|
|
260
|
-
};
|
|
261
|
-
}
|
|
262
|
-
});
|
|
275
|
+
},
|
|
276
|
+
);
|
|
263
277
|
}
|
|
264
278
|
}
|
|
@@ -34,6 +34,9 @@ const presenceSetShape = {
|
|
|
34
34
|
focusNodeId: PRESENCE_SET_SHAPE.focusNodeId.describe('Node the agent is working on'),
|
|
35
35
|
cursor: PRESENCE_SET_SHAPE.cursor.describe('Agent cursor in world coordinates'),
|
|
36
36
|
attached: PRESENCE_SET_SHAPE.attached.describe('true attaches a session (Focus Session chrome); false detaches it'),
|
|
37
|
+
contextUsage: PRESENCE_SET_SHAPE.contextUsage.describe(
|
|
38
|
+
"The agent's real token window as {used,total} — shown on the board's context meter instead of the pinned-context estimate (null clears it)",
|
|
39
|
+
),
|
|
37
40
|
};
|
|
38
41
|
const presenceSetSchema = z.looseObject(presenceSetShape);
|
|
39
42
|
|
|
@@ -199,7 +199,7 @@ export function checkScopeOwnership(rawInput: unknown, writerKey?: string): stri
|
|
|
199
199
|
return 'the scope fence is set and cleared by the human in the session panel, not by the agent.';
|
|
200
200
|
const agentScopes = input.agentScopes;
|
|
201
201
|
if (writerKey && agentScopes && typeof agentScopes === 'object' && !Array.isArray(agentScopes)) {
|
|
202
|
-
if (Object.
|
|
202
|
+
if (Object.hasOwn(agentScopes, writerKey)) {
|
|
203
203
|
return `an agent cannot set or clear its OWN territory ("${writerKey}") — only the human or a different writer (the orchestrator) may.`;
|
|
204
204
|
}
|
|
205
205
|
}
|