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.
@@ -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
- {sessions.map((session) => {
42
- // Fleet roll-up: workers declaring this session as their parent count
43
- // into its chip instead of growing the bar one chip per worker.
44
- const workers = agentPresences.value.filter(
45
- (presence) =>
46
- presence.parentAgentId != null &&
47
- (presence.parentAgentId === session.sessionId || presence.parentAgentId === session.source),
48
- ).length;
49
- return (
50
- <BarHint
51
- key={session.sessionId}
52
- label={`Agent session — ${session.label}`}
53
- tapToOpen
54
- 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."
55
- >
56
- <span
57
- class={`agent-chip phase-${session.phase}`}
58
- data-phase={session.phase}
59
- style={{ '--identity-color': `hsl(${agentIdentityHue(session.sessionId)} 65% 62%)` }}
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 class="agent-chip-dot" aria-hidden="true" />
62
- <span class="agent-chip-label">{agentPhaseLabel(session)}</span>
63
- <span class="agent-chip-who hud-collapsible-text">{session.label}</span>
64
- {workers > 0 && (
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
- </button>
83
- </span>
84
- </BarHint>
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
- let extAppReportQueue: Array<{ t: number; nodeId: string; event: string; visibility?: string }> = [];
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.0 reopen): a double rAF fires even while
291
- // WKWebKit composites nothing — the tick alone was a false green. The tick
292
- // now carries a verdict: does this document actually CONTAIN rendered
293
- // content (a laid-out canvas/svg, or real text)? The parent only accepts
294
- // paint-ok when it does.
295
- function contentReady() {
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
- var els = document.querySelectorAll('canvas, svg');
298
- for (var i = 0; i < els.length; i++) {
299
- var r = els[i].getBoundingClientRect();
300
- if (r.width > 8 && r.height > 8) return true;
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
- return !!(body && body.innerText && body.innerText.trim().length > 0);
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
- window.parent.postMessage({ source: '${EXT_APP_BOOT_BEACON_SOURCE}', token: TOKEN, nodeId: NODE_ID, kind: 'paint-tick', content: contentReady() }, '*');
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 content = (data as { content?: unknown }).content === true;
475
- for (const waiter of paintTickWaitersRef.current.splice(0)) waiter(content);
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((content) => {
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
- const paintOkLabel = (suffix = ''): string =>
676
- typeof document !== 'undefined' && document.visibilityState === 'hidden'
677
- ? `paint-ok (content-verified)${suffix} (host hidden)`
678
- : `paint-ok (content-verified)${suffix}`;
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' ? 'paint-fail (tick without content)' : 'paint-fail (no tick)',
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(nodeId, verified ? 'settled (content-verified)' : 'settled (content-timeout)');
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: centre.x - cx * v.scale,
842
- y: centre.y - cy * v.scale,
843
- scale: v.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: transform 220ms cubic-bezier(0.22, 1, 0.36, 1);
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.tool(
30
+ server.registerTool(
31
31
  tool.toolName,
32
- tool.description,
33
- { ...op.inputShape, ...(tool.extraShape ?? {}) },
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
- server.tool(def.toolName, def.description, buildCompositeShape(def), async (input: Record<string, unknown>) => {
235
- try {
236
- const host = await getHost();
237
- const opName = resolveCompositeOp(def, input);
238
- const op = getOperation(opName);
239
- // Strip the composite discriminators (action + any extra, e.g. `kind`)
240
- // and undo any field remap; the rest is the op's raw MCP args — the same
241
- // value the standalone tool would receive.
242
- const rest = stripCompositeDiscriminators(def, input);
243
- const missing = requiredMcpFields(op).filter((field) => !requiredFieldSatisfied(opName, field, rest));
244
- if (missing.length > 0) {
245
- const named = missing.map((field) => `"${publicFieldName.get(field) ?? field}"`).join(', ');
246
- throw new OperationError(
247
- `${def.toolName} action "${String(input.action)}" requires ${named}. Unknown parameters are ignored — check the argument names.`,
248
- );
249
- }
250
- const opInput = op.mcp?.buildInput ? op.mcp.buildInput(rest) : rest;
251
- const result = await host.invoker().invoke(opName, opInput);
252
- if (op.mcp?.formatResult) {
253
- return await op.mcp.formatResult(result, rest, host);
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
- return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] };
256
- } catch (error) {
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.prototype.hasOwnProperty.call(agentScopes, writerKey)) {
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
  }