pmx-canvas 0.4.6 → 0.4.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/Readme.md +56 -33
  3. package/dist/canvas/global.css +101 -3
  4. package/dist/canvas/index.js +93 -89
  5. package/dist/types/cli/shared.d.ts +13 -1
  6. package/dist/types/client/canvas/CanvasNode.d.ts +11 -0
  7. package/dist/types/client/canvas/EdgeLayer.d.ts +8 -0
  8. package/dist/types/client/nodes/AxStepControls.d.ts +4 -0
  9. package/dist/types/client/nodes/ExtAppFrame.d.ts +32 -0
  10. package/dist/types/client/nodes/FileNode.d.ts +14 -0
  11. package/dist/types/client/state/canvas-store.d.ts +10 -0
  12. package/dist/types/client/state/intent-bridge.d.ts +9 -0
  13. package/dist/types/server/ax-flow-loop.d.ts +5 -0
  14. package/dist/types/server/ax-interaction.d.ts +21 -2
  15. package/dist/types/server/canvas-db.d.ts +14 -0
  16. package/dist/types/server/canvas-operations.d.ts +5 -0
  17. package/dist/types/server/canvas-state.d.ts +8 -0
  18. package/dist/types/server/canvas-validation.d.ts +15 -21
  19. package/dist/types/server/file-content.d.ts +46 -0
  20. package/dist/types/server/html-primitives.d.ts +13 -1
  21. package/dist/types/server/operations/ops/ax-work.d.ts +1 -0
  22. package/dist/types/server/server.d.ts +1 -0
  23. package/dist/types/server/web-artifacts.d.ts +14 -0
  24. package/dist/types/shared/ax-flow.d.ts +51 -0
  25. package/dist/types/shared/node-sizes.d.ts +33 -0
  26. package/docs/RELEASE.md +48 -0
  27. package/docs/cli.md +19 -2
  28. package/docs/environment.md +1 -0
  29. package/docs/http-api.md +17 -0
  30. package/docs/mcp.md +12 -0
  31. package/docs/node-types.md +45 -1
  32. package/docs/screenshot.png +0 -0
  33. package/package.json +2 -2
  34. package/skills/pmx-canvas/SKILL.md +74 -9
  35. package/skills/pmx-canvas/evals/evals.json +54 -3
  36. package/skills/pmx-canvas/references/full-reference.md +11 -6
  37. package/skills/pmx-canvas-testing/SKILL.md +40 -0
  38. package/src/cli/commands/history.ts +1 -1
  39. package/src/cli/commands/nodes.ts +2 -2
  40. package/src/cli/commands/query.ts +1 -1
  41. package/src/cli/commands/view.ts +1 -1
  42. package/src/cli/index.ts +6 -0
  43. package/src/cli/shared.ts +34 -5
  44. package/src/client/App.tsx +28 -15
  45. package/src/client/canvas/CanvasNode.tsx +54 -5
  46. package/src/client/canvas/EdgeLayer.tsx +44 -24
  47. package/src/client/canvas/ExpandedNodeOverlay.tsx +11 -0
  48. package/src/client/canvas/auto-fit.ts +10 -1
  49. package/src/client/canvas/use-pan-zoom.ts +16 -1
  50. package/src/client/nodes/AxStepControls.tsx +226 -0
  51. package/src/client/nodes/ExtAppFrame.tsx +79 -2
  52. package/src/client/nodes/FileNode.tsx +249 -51
  53. package/src/client/state/canvas-store.ts +19 -0
  54. package/src/client/state/intent-bridge.ts +32 -0
  55. package/src/client/state/sse-bridge.ts +4 -1
  56. package/src/client/theme/global.css +101 -3
  57. package/src/server/ax-flow-loop.ts +114 -0
  58. package/src/server/ax-interaction.ts +299 -4
  59. package/src/server/canvas-db.ts +138 -32
  60. package/src/server/canvas-operations.ts +44 -17
  61. package/src/server/canvas-state.ts +20 -10
  62. package/src/server/canvas-validation.ts +50 -41
  63. package/src/server/demo-state.json +4605 -523
  64. package/src/server/demo.ts +24 -0
  65. package/src/server/file-content.ts +152 -0
  66. package/src/server/file-watcher.ts +7 -10
  67. package/src/server/html-primitives.ts +636 -0
  68. package/src/server/operations/ops/ax-work.ts +5 -0
  69. package/src/server/operations/ops/nodes.ts +15 -1
  70. package/src/server/operations/ops/viewport.ts +7 -1
  71. package/src/server/server.ts +106 -5
  72. package/src/server/web-artifacts.ts +109 -0
  73. package/src/shared/ax-flow.ts +102 -0
  74. package/src/shared/node-sizes.ts +52 -0
  75. package/dist/types/server/chart-template.d.ts +0 -29
  76. package/src/server/chart-template.ts +0 -448
@@ -65,6 +65,36 @@ export function isWebKitOnlyHost(userAgent: string): boolean {
65
65
  return /AppleWebKit/.test(userAgent) && !/Chrome|Chromium|CriOS|Edg|Android/.test(userAgent);
66
66
  }
67
67
 
68
+ /**
69
+ * Finding N (0.4.7 report): the GitHub Copilot panel's WKWebView reports
70
+ * `document.visibilityState === 'hidden'` CONTINUOUSLY — the workbench document
71
+ * never transitions to 'visible' even with the panel open and on-screen. Two
72
+ * things break as a result: the `visibilitychange` re-arm below can never fire
73
+ * (there is no transition to listen for), and `paint-ok` becomes a false green —
74
+ * the app document happily answers the double-rAF paint probe while the
75
+ * compositor still shows black. The instrumented trail from that session carries
76
+ * `visibility: "hidden"` on essentially every recovery event.
77
+ *
78
+ * So: when the document CLAIMS hidden but the frame is demonstrably on-screen,
79
+ * do not trust the claim — spend one more recovery round as if visible. A host
80
+ * that ever reports 'visible' has a working signal and is left to the
81
+ * visibilitychange path, which keeps a genuinely backgrounded panel quiet.
82
+ */
83
+ export function shouldAssumeVisibleRearm(options: {
84
+ userAgent: string;
85
+ visibilityState: string;
86
+ rect: { top: number; left: number; bottom: number; right: number; width: number; height: number };
87
+ viewport: { width: number; height: number };
88
+ alreadyRearmed: boolean;
89
+ }): boolean {
90
+ const { userAgent, visibilityState, rect, viewport, alreadyRearmed } = options;
91
+ if (alreadyRearmed) return false;
92
+ if (!isWebKitOnlyHost(userAgent)) return false;
93
+ if (visibilityState !== 'hidden') return false;
94
+ if (rect.width <= 0 || rect.height <= 0) return false;
95
+ return rect.bottom > 0 && rect.right > 0 && rect.top < viewport.height && rect.left < viewport.width;
96
+ }
97
+
68
98
  // Finding F (0.2.5, reworked 0.3.1): BOOT-AWARE serialized WebKit remount queue.
69
99
  // The black tile is a cold-hydration BURST problem — a single ext-app repaints fine
70
100
  // into an idle panel (like a live-created node, or expand+close), but several
@@ -352,6 +382,9 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
352
382
  // Recovery ran while the document was hidden (compositing suppressed) — re-arm
353
383
  // one fresh remount round when the panel becomes visible (Finding N).
354
384
  const bootedWhileHiddenRef = useRef(false);
385
+ // Finding N (0.4.7): at most one "assume visible" round per frame lifetime, so
386
+ // a host that is hidden forever cannot turn the re-arm into a remount loop.
387
+ const assumeVisibleRearmDoneRef = useRef(false);
355
388
  const unmountedRef = useRef(false);
356
389
  const [status, setStatus] = useState<ExtAppFrameStatus>('loading');
357
390
  const [error, setError] = useState<string | null>(null);
@@ -567,21 +600,64 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
567
600
  });
568
601
  });
569
602
 
603
+ // Finding N (0.4.7): the Copilot WKWebView never reports 'visible', so the
604
+ // visibilitychange re-arm below is dead code there and the ladder's own
605
+ // paint-ok cannot be trusted. When the document claims hidden but this frame
606
+ // is on-screen, spend one more full recovery round with a fresh budget —
607
+ // the same round the visibilitychange path would have run.
608
+ const ASSUME_VISIBLE_REARM_DELAY_MS = 600;
609
+ const maybeAssumeVisibleRearm = (): void => {
610
+ if (typeof document === 'undefined' || typeof navigator === 'undefined' || typeof window === 'undefined') return;
611
+ const frame = iframeRef.current;
612
+ if (!frame || unmountedRef.current) return;
613
+ if (
614
+ !shouldAssumeVisibleRearm({
615
+ userAgent: navigator.userAgent,
616
+ visibilityState: document.visibilityState,
617
+ rect: frame.getBoundingClientRect(),
618
+ viewport: { width: window.innerWidth, height: window.innerHeight },
619
+ alreadyRearmed: assumeVisibleRearmDoneRef.current,
620
+ })
621
+ )
622
+ return;
623
+ assumeVisibleRearmDoneRef.current = true;
624
+ // Re-check after a beat: a host whose visibility signal merely lagged the
625
+ // panel opening resolves itself here, and the visibilitychange path owns it.
626
+ window.setTimeout(() => {
627
+ if (unmountedRef.current || document.visibilityState === 'visible') return;
628
+ webkitRemountAttemptsRef.current = 0;
629
+ setRecoveryExhausted(false);
630
+ bootedWhileHiddenRef.current = false;
631
+ extAppRecoveryLog(nodeId, 'assume-visible-rearm');
632
+ scheduleWebkitRemount('assume-visible');
633
+ }, ASSUME_VISIBLE_REARM_DELAY_MS);
634
+ };
635
+
636
+ // A paint-ok collected while the document claims hidden proves only that the
637
+ // app's rAF pipeline is alive — NOT that anything composited (Finding N,
638
+ // 0.4.7). Say so in the trail instead of recording an unqualified success.
639
+ const paintOkLabel = (suffix = ''): string =>
640
+ typeof document !== 'undefined' && document.visibilityState === 'hidden'
641
+ ? `paint-ok${suffix} (unverified: host hidden)`
642
+ : `paint-ok${suffix}`;
643
+
570
644
  const runPaintLadder = async (): Promise<void> => {
571
645
  if (expanded) return;
572
646
  if (typeof navigator === 'undefined' || typeof window === 'undefined') return;
573
647
  if (!isWebKitOnlyHost(navigator.userAgent)) return;
574
648
  setPaintPending(true);
575
649
  if (await probePaint()) {
576
- extAppRecoveryLog(nodeId, 'paint-ok');
650
+ extAppRecoveryLog(nodeId, paintOkLabel());
577
651
  setPaintPending(false);
652
+ maybeAssumeVisibleRearm();
578
653
  return;
579
654
  }
580
655
  extAppRecoveryLog(nodeId, 'paint-fail');
581
656
  await runSoftExpandCycle();
582
657
  if (await probePaint()) {
583
- extAppRecoveryLog(nodeId, 'paint-ok (after soft-expand)');
658
+ extAppRecoveryLog(nodeId, paintOkLabel(' (after soft-expand)'));
584
659
  setPaintPending(false);
660
+ maybeAssumeVisibleRearm();
585
661
  return;
586
662
  }
587
663
  if (unmountedRef.current) return;
@@ -590,6 +666,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
590
666
  extAppRecoveryLog(nodeId, 'recovery-exhausted');
591
667
  setPaintPending(false);
592
668
  setRecoveryExhausted(true);
669
+ maybeAssumeVisibleRearm();
593
670
  }
594
671
  };
595
672
 
@@ -1,5 +1,6 @@
1
- import { useCallback, useEffect, useState } from 'preact/hooks';
1
+ import { useCallback, useEffect, useMemo, useState } from 'preact/hooks';
2
2
  import { updateNodeData } from '../state/canvas-store';
3
+ import { iframeMode } from '../state/iframe-mode';
3
4
  import { fetchFile, updateNodeFromClient } from '../state/intent-bridge';
4
5
  import type { CanvasNodeState } from '../types';
5
6
  import { runNodeAxInteraction } from './ax-node-actions';
@@ -39,20 +40,125 @@ function langFromPath(path: string): string {
39
40
  return map[ext] ?? (ext.toUpperCase() || 'Text');
40
41
  }
41
42
 
43
+ /** Rendered-row ceiling for delimited files — a 200k-row CSV must not lock the browser. */
44
+ const MAX_TABLE_ROWS = 500;
45
+
46
+ export interface DelimitedTable {
47
+ header: string[];
48
+ /** Rectangular: every row padded (short) or truncated (long) to the header width. */
49
+ rows: string[][];
50
+ }
51
+
52
+ /** Column delimiter for a delimited-data path, or null when the file is not one. */
53
+ function delimiterForPath(path: string): string | null {
54
+ const lower = path.toLowerCase();
55
+ if (lower.endsWith('.csv')) return ',';
56
+ if (lower.endsWith('.tsv')) return '\t';
57
+ return null;
58
+ }
59
+
60
+ /**
61
+ * RFC4180-ish delimited-text parser. Quoted fields may contain the delimiter,
62
+ * newlines, and `""` escapes; CRLF, LF and lone CR all end a record; a trailing
63
+ * newline produces no phantom empty row. Ragged records are squared off against
64
+ * the header width instead of throwing.
65
+ */
66
+ export function parseDelimitedText(text: string, delimiter: string): DelimitedTable {
67
+ const records: string[][] = [];
68
+ let row: string[] = [];
69
+ let field = '';
70
+ let quoted = false;
71
+ // Tracks whether the current record consumed anything — distinguishes a real
72
+ // empty-quoted record from the dangling state after a terminating newline.
73
+ let started = false;
74
+
75
+ for (let i = 0; i < text.length; i++) {
76
+ const char = text[i];
77
+ started = true;
78
+ if (quoted) {
79
+ if (char === '"') {
80
+ if (text[i + 1] === '"') {
81
+ field += '"';
82
+ i++;
83
+ } else {
84
+ quoted = false;
85
+ }
86
+ } else if (char === '\r' && text[i + 1] === '\n') {
87
+ field += '\n';
88
+ i++;
89
+ } else {
90
+ field += char;
91
+ }
92
+ continue;
93
+ }
94
+ if (char === '"' && field === '') {
95
+ quoted = true;
96
+ } else if (char === delimiter) {
97
+ row.push(field);
98
+ field = '';
99
+ } else if (char === '\n' || char === '\r') {
100
+ if (char === '\r' && text[i + 1] === '\n') i++;
101
+ row.push(field);
102
+ field = '';
103
+ records.push(row);
104
+ row = [];
105
+ started = false;
106
+ } else {
107
+ field += char;
108
+ }
109
+ }
110
+ if (started) {
111
+ row.push(field);
112
+ records.push(row);
113
+ }
114
+
115
+ const header = records.shift() ?? [];
116
+ return {
117
+ header,
118
+ rows: records.map((record) => Array.from({ length: header.length }, (_, i) => record[i] ?? '')),
119
+ };
120
+ }
121
+
122
+ /** Human-readable byte size, e.g. `4.2 MB`. */
123
+ export function formatBytes(bytes: number): string {
124
+ if (!Number.isFinite(bytes) || bytes < 0) return '';
125
+ if (bytes < 1024) return `${Math.round(bytes)} B`;
126
+ const units = ['KB', 'MB', 'GB', 'TB'];
127
+ let value = bytes / 1024;
128
+ let unit = 0;
129
+ while (value >= 1024 && unit < units.length - 1) {
130
+ value /= 1024;
131
+ unit++;
132
+ }
133
+ return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[unit]}`;
134
+ }
135
+
42
136
  export function FileNode({ node, expanded = false }: { node: CanvasNodeState; expanded?: boolean }) {
43
137
  const filePath = (node.data.path as string) || (node.data.content as string) || '';
44
138
  const title = (node.data.title as string) || filePath.split('/').pop() || 'File';
45
139
  const cachedContent = node.data.fileContent as string | undefined;
46
140
  const updatedAt = node.data.updatedAt as string | undefined;
47
141
  const lineCount = node.data.lineCount as number | undefined;
142
+ const byteSize = node.data.byteSize as number | undefined;
143
+ const isBinary = node.data.binary === true;
144
+ const truncated = node.data.truncated === true;
145
+ const mimeType = node.data.mimeType as string | undefined;
146
+ const isPdf = mimeType === 'application/pdf' || filePath.toLowerCase().endsWith('.pdf');
147
+ // Binary bytes and PDFs are served raw — never fetched or decoded as text.
148
+ const servesBytes = isBinary || isPdf;
48
149
 
49
150
  const [content, setContent] = useState<string>(cachedContent ?? '');
50
- const [loading, setLoading] = useState(!cachedContent && !!filePath);
151
+ const [loading, setLoading] = useState(!cachedContent && !!filePath && !servesBytes);
51
152
  const [error, setError] = useState<string | null>(null);
153
+ const [bytesVersion, setBytesVersion] = useState(0);
52
154
 
53
155
  // Load file content on mount or when path changes
54
156
  useEffect(() => {
55
157
  if (!filePath) return;
158
+ if (servesBytes) {
159
+ setLoading(false);
160
+ return;
161
+ }
56
162
  // If we already have cached content from SSE, use it
57
163
  if (cachedContent !== undefined) {
58
164
  setContent(cachedContent);
@@ -86,7 +192,7 @@ export function FileNode({ node, expanded = false }: { node: CanvasNodeState; ex
86
192
  return () => {
87
193
  cancelled = true;
88
194
  };
89
- }, [filePath, cachedContent]);
195
+ }, [filePath, cachedContent, servesBytes]);
90
196
 
91
197
  // Sync content when server pushes updates via SSE
92
198
  useEffect(() => {
@@ -97,6 +203,11 @@ export function FileNode({ node, expanded = false }: { node: CanvasNodeState; ex
97
203
 
98
204
  const handleReload = useCallback(() => {
99
205
  if (!filePath) return;
206
+ if (servesBytes) {
207
+ // Byte-served files have no cached text — bust the frame URL instead.
208
+ setBytesVersion((v) => v + 1);
209
+ return;
210
+ }
100
211
  setLoading(true);
101
212
  setError(null);
102
213
  // Clear cached content to force a fresh fetch
@@ -125,11 +236,31 @@ export function FileNode({ node, expanded = false }: { node: CanvasNodeState; ex
125
236
  setError('Failed to reload');
126
237
  setLoading(false);
127
238
  });
128
- }, [filePath, node.id]);
239
+ }, [filePath, node.id, servesBytes]);
129
240
 
130
241
  const lang = langFromPath(filePath);
131
242
  const lines = content.split('\n');
132
243
  const gutterWidth = `${String(lines.length).length + 1}ch`;
244
+ const delimiter = delimiterForPath(filePath);
245
+ // A .csv that is not actually delimited (single column) stays plain text.
246
+ const table = useMemo(() => {
247
+ if (!delimiter || servesBytes || !content) return null;
248
+ const parsed = parseDelimitedText(content, delimiter);
249
+ return parsed.header.length >= 2 ? parsed : null;
250
+ }, [content, delimiter, servesBytes]);
251
+ const bytesUrl = `/api/canvas/file-bytes?nodeId=${encodeURIComponent(node.id)}${
252
+ bytesVersion ? `&v=${bytesVersion}` : ''
253
+ }`;
254
+ // Amp orb portals block src-URL child iframes and force srcdoc, which cannot
255
+ // inline a PDF — offer the raw URL instead of a dead frame.
256
+ const pdfFrameBlocked = iframeMode.value === 'srcdoc';
257
+ const fillsBody = servesBytes || table !== null;
258
+
259
+ const truncatedNotice = truncated ? (
260
+ <div class="file-node-notice">
261
+ Showing the first {formatBytes(content.length)} of {formatBytes(byteSize ?? content.length)}
262
+ </div>
263
+ ) : null;
133
264
 
134
265
  if (!filePath) {
135
266
  return <div style={{ color: 'var(--c-dim)', fontStyle: 'italic', padding: '12px' }}>No file path set</div>;
@@ -236,57 +367,124 @@ export function FileNode({ node, expanded = false }: { node: CanvasNodeState; ex
236
367
  <div
237
368
  style={{
238
369
  flex: 1,
239
- overflow: 'auto',
240
- padding: '8px 0',
370
+ minHeight: 0,
371
+ overflow: fillsBody ? 'hidden' : 'auto',
372
+ padding: fillsBody ? 0 : '8px 0',
241
373
  background: expanded ? 'var(--c-panel-soft)' : undefined,
242
374
  borderRadius: expanded ? '0 0 8px 8px' : undefined,
243
375
  }}
244
376
  >
245
- {loading && <div style={{ color: 'var(--c-dim)', padding: '12px', fontStyle: 'italic' }}>Loading…</div>}
246
- {error && <div style={{ color: 'var(--c-danger)', padding: '12px' }}>{error}</div>}
247
- {!loading && !error && (
248
- <pre
249
- style={{
250
- margin: 0,
251
- lineHeight: '1.55',
252
- tabSize: 2,
253
- }}
254
- >
255
- {lines.map((line, i) => (
256
- <div
257
- key={i}
258
- style={{
259
- display: 'flex',
260
- minHeight: '1.55em',
261
- }}
262
- >
263
- <span
264
- style={{
265
- width: gutterWidth,
266
- minWidth: gutterWidth,
267
- textAlign: 'right',
268
- color: 'var(--c-dim)',
269
- paddingRight: '12px',
270
- paddingLeft: '10px',
271
- userSelect: 'none',
272
- flexShrink: 0,
273
- opacity: 0.6,
274
- }}
275
- >
276
- {i + 1}
277
- </span>
278
- <code
279
- style={{
280
- color: 'var(--c-text)',
281
- whiteSpace: 'pre',
282
- paddingRight: '10px',
283
- }}
284
- >
285
- {line || '\n'}
286
- </code>
287
- </div>
288
- ))}
289
- </pre>
377
+ {isPdf &&
378
+ (pdfFrameBlocked ? (
379
+ <div class="file-node-placeholder">
380
+ <div>PDF preview is unavailable in this embedded host.</div>
381
+ <a class="file-node-link" href={bytesUrl} target="_blank" rel="noreferrer">
382
+ Open PDF
383
+ </a>
384
+ </div>
385
+ ) : (
386
+ <iframe
387
+ src={bytesUrl}
388
+ title={title}
389
+ // SECURITY: same-origin URL, so it must be sandboxed like every
390
+ // other iframe in the client. Without allow-same-origin the frame
391
+ // gets an opaque origin and cannot reach the canvas API even if
392
+ // the bytes turn out to be markup. The built-in PDF viewer needs
393
+ // no script permissions from us.
394
+ sandbox=""
395
+ style={{ width: '100%', height: '100%', border: 'none', display: 'block' }}
396
+ />
397
+ ))}
398
+ {!isPdf && isBinary && (
399
+ <div class="file-node-placeholder">
400
+ <div class="file-node-placeholder-name">{title}</div>
401
+ <div>Binary file</div>
402
+ {byteSize !== undefined && <div>{formatBytes(byteSize)}</div>}
403
+ </div>
404
+ )}
405
+ {!servesBytes && (
406
+ <>
407
+ {loading && <div style={{ color: 'var(--c-dim)', padding: '12px', fontStyle: 'italic' }}>Loading…</div>}
408
+ {error && <div style={{ color: 'var(--c-danger)', padding: '12px' }}>{error}</div>}
409
+ {!loading &&
410
+ !error &&
411
+ (table ? (
412
+ <div class="file-table-shell">
413
+ {truncatedNotice}
414
+ {table.rows.length > MAX_TABLE_ROWS && (
415
+ <div class="file-node-notice">
416
+ Showing {MAX_TABLE_ROWS} of {table.rows.length} rows
417
+ </div>
418
+ )}
419
+ <div class="file-table-wrap">
420
+ <table class="file-table">
421
+ <thead>
422
+ <tr>
423
+ {table.header.map((cell, i) => (
424
+ <th key={i}>{cell}</th>
425
+ ))}
426
+ </tr>
427
+ </thead>
428
+ <tbody>
429
+ {table.rows.slice(0, MAX_TABLE_ROWS).map((row, rowIndex) => (
430
+ <tr key={rowIndex}>
431
+ {row.map((cell, cellIndex) => (
432
+ <td key={cellIndex}>{cell}</td>
433
+ ))}
434
+ </tr>
435
+ ))}
436
+ </tbody>
437
+ </table>
438
+ </div>
439
+ </div>
440
+ ) : (
441
+ <>
442
+ {truncatedNotice}
443
+ <pre
444
+ style={{
445
+ margin: 0,
446
+ lineHeight: '1.55',
447
+ tabSize: 2,
448
+ }}
449
+ >
450
+ {lines.map((line, i) => (
451
+ <div
452
+ key={i}
453
+ style={{
454
+ display: 'flex',
455
+ minHeight: '1.55em',
456
+ }}
457
+ >
458
+ <span
459
+ style={{
460
+ width: gutterWidth,
461
+ minWidth: gutterWidth,
462
+ textAlign: 'right',
463
+ color: 'var(--c-dim)',
464
+ paddingRight: '12px',
465
+ paddingLeft: '10px',
466
+ userSelect: 'none',
467
+ flexShrink: 0,
468
+ opacity: 0.6,
469
+ }}
470
+ >
471
+ {i + 1}
472
+ </span>
473
+ <code
474
+ style={{
475
+ color: 'var(--c-text)',
476
+ whiteSpace: 'pre',
477
+ paddingRight: '10px',
478
+ }}
479
+ >
480
+ {line || '\n'}
481
+ </code>
482
+ </div>
483
+ ))}
484
+ </pre>
485
+ </>
486
+ ))}
487
+ </>
290
488
  )}
291
489
  </div>
292
490
  </div>
@@ -466,6 +466,25 @@ function easeOutCubic(t: number): number {
466
466
  * Cancels any in-flight animation. Direct manipulation (pan/zoom gestures)
467
467
  * should use setViewport() instead for instant response.
468
468
  */
469
+ /**
470
+ * Zoom by a factor about the CENTRE of the viewport.
471
+ *
472
+ * The toolbar's +/- used to change `scale` alone and keep `x`/`y`, which anchors
473
+ * the zoom at the world origin — so zooming in visibly slid the board up-left and
474
+ * zooming out pushed it down-right, instead of magnifying what you were looking
475
+ * at. Same correction the pointer-anchored wheel zoom applies, with the viewport
476
+ * centre as the anchor.
477
+ */
478
+ export function zoomByFactor(factor: number, duration = 150): void {
479
+ const v = viewport.value;
480
+ const scale = Math.min(4, Math.max(0.1, v.scale * factor));
481
+ if (scale === v.scale) return;
482
+ const ratio = scale / v.scale;
483
+ const cx = window.innerWidth / 2;
484
+ const cy = window.innerHeight / 2;
485
+ animateViewport({ x: cx - ratio * (cx - v.x), y: cy - ratio * (cy - v.y), scale }, duration);
486
+ }
487
+
469
488
  export function animateViewport(
470
489
  target: ViewportState,
471
490
  duration = 300,
@@ -376,12 +376,44 @@ export async function updateViewportFromClient(
376
376
  headers: { 'Content-Type': 'application/json' },
377
377
  body: JSON.stringify({
378
378
  ...viewport,
379
+ ...clientWindowSize(),
379
380
  ...(options.recordHistory === false ? { recordHistory: false } : {}),
380
381
  }),
381
382
  },
382
383
  );
383
384
  }
384
385
 
386
+ /**
387
+ * This window's size, sent alongside every viewport write so a later agent
388
+ * `fit` computes against the human's REAL window instead of a hardcoded
389
+ * 1440x900 (0.4.6 orb feedback #2: fit clipped nodes on both sides).
390
+ */
391
+ function clientWindowSize(): { clientWidth: number; clientHeight: number } | Record<string, never> {
392
+ if (typeof window === 'undefined') return {};
393
+ const clientWidth = window.innerWidth;
394
+ const clientHeight = window.innerHeight;
395
+ if (!clientWidth || !clientHeight) return {};
396
+ return { clientWidth, clientHeight };
397
+ }
398
+
399
+ /**
400
+ * Report this window's size WITHOUT touching the stored viewport. The
401
+ * viewport op defaults every omitted coordinate to the server's CURRENT
402
+ * viewport, so a body carrying only the size records the size and leaves the
403
+ * viewport exactly as it was — never echoing a stale client viewport back over
404
+ * the server's. Called on connect and on resize so an agent `fit` has the real
405
+ * window size before the human ever pans or zooms.
406
+ */
407
+ export async function reportClientViewportSize(): Promise<void> {
408
+ const size = clientWindowSize();
409
+ if (!('clientWidth' in size)) return;
410
+ await requestBestEffort('reportClientViewportSize', '/api/canvas/viewport', {
411
+ method: 'POST',
412
+ headers: { 'Content-Type': 'application/json' },
413
+ body: JSON.stringify({ ...size, recordHistory: false }),
414
+ });
415
+ }
416
+
385
417
  // ── Group API ─────────────────────────────────────────────────
386
418
 
387
419
  /** Create a group containing the given child node IDs. */
@@ -25,7 +25,7 @@ import {
25
25
  updateNodeData,
26
26
  workbenchConnectionEpoch,
27
27
  } from './canvas-store';
28
- import { fetchAxSurfaceState } from './intent-bridge';
28
+ import { fetchAxSurfaceState, reportClientViewportSize } from './intent-bridge';
29
29
  import { initSessionThemeOverride, themeOverrideActive } from './theme-override';
30
30
  import { DEFAULT_POSITIONS, makeNodeState } from './node-factory';
31
31
  import { invalidateTokenCache } from '../theme/tokens';
@@ -435,6 +435,9 @@ function handleConnected(data: Record<string, unknown>): void {
435
435
  // Reconnect marker for holders of server-minted URLs (Finding S).
436
436
  workbenchConnectionEpoch.value += 1;
437
437
  reloadIfServerUpgraded(data.version);
438
+ // Tell the server how big this window actually is, so an agent `fit` sizes
439
+ // the board to the human's window (0.4.6 orb feedback #2).
440
+ void reportClientViewportSize();
438
441
  // A ?theme= session override (host-default theming) wins over the
439
442
  // server-global theme for THIS client only.
440
443
  if (typeof data.theme === 'string' && !themeOverrideActive()) {