pmx-canvas 0.4.5 → 0.4.7

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 (109) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/Readme.md +76 -36
  3. package/dist/canvas/global.css +169 -3
  4. package/dist/canvas/index.js +112 -87
  5. package/dist/canvas/mermaid-entry.js +3563 -0
  6. package/dist/json-render/index.css +1 -1
  7. package/dist/types/cli/agent.d.ts +1 -0
  8. package/dist/types/cli/commands/smoke.d.ts +1 -0
  9. package/dist/types/cli/shared.d.ts +13 -1
  10. package/dist/types/client/canvas/CanvasNode.d.ts +11 -0
  11. package/dist/types/client/canvas/EdgeLayer.d.ts +8 -0
  12. package/dist/types/client/mermaid-entry.d.ts +11 -0
  13. package/dist/types/client/nodes/AxStepControls.d.ts +4 -0
  14. package/dist/types/client/nodes/DiffNode.d.ts +20 -0
  15. package/dist/types/client/nodes/ExtAppFrame.d.ts +0 -2
  16. package/dist/types/client/nodes/FileNode.d.ts +14 -0
  17. package/dist/types/client/nodes/MermaidNode.d.ts +12 -0
  18. package/dist/types/client/state/canvas-store.d.ts +10 -0
  19. package/dist/types/client/state/intent-bridge.d.ts +9 -0
  20. package/dist/types/client/types.d.ts +2 -2
  21. package/dist/types/server/ax-flow-loop.d.ts +5 -0
  22. package/dist/types/server/ax-interaction.d.ts +21 -2
  23. package/dist/types/server/canvas-operations.d.ts +5 -0
  24. package/dist/types/server/canvas-provenance.d.ts +1 -1
  25. package/dist/types/server/canvas-state.d.ts +18 -1
  26. package/dist/types/server/canvas-validation.d.ts +28 -1
  27. package/dist/types/server/file-content.d.ts +46 -0
  28. package/dist/types/server/html-primitives.d.ts +13 -1
  29. package/dist/types/server/html-surface.d.ts +8 -0
  30. package/dist/types/server/index.d.ts +5 -0
  31. package/dist/types/server/operations/ops/ax-work.d.ts +1 -0
  32. package/dist/types/server/operations/ops/nodes.d.ts +1 -1
  33. package/dist/types/server/server.d.ts +1 -0
  34. package/dist/types/server/web-artifacts.d.ts +14 -0
  35. package/dist/types/server/workboard.d.ts +46 -0
  36. package/dist/types/shared/ax-flow.d.ts +51 -0
  37. package/dist/types/shared/ax-surface-protocol.d.ts +7 -0
  38. package/dist/types/shared/node-sizes.d.ts +33 -0
  39. package/docs/cli.md +31 -2
  40. package/docs/environment.md +1 -0
  41. package/docs/http-api.md +29 -0
  42. package/docs/mcp.md +13 -1
  43. package/docs/node-types.md +47 -1
  44. package/docs/screenshot.png +0 -0
  45. package/package.json +5 -4
  46. package/skills/pmx-canvas/SKILL.md +123 -25
  47. package/skills/pmx-canvas/evals/evals.json +54 -3
  48. package/skills/pmx-canvas/references/full-reference.md +11 -6
  49. package/skills/pmx-canvas-testing/SKILL.md +3 -0
  50. package/src/cli/agent.ts +2 -0
  51. package/src/cli/commands/ax.ts +28 -3
  52. package/src/cli/commands/history.ts +1 -1
  53. package/src/cli/commands/nodes.ts +2 -2
  54. package/src/cli/commands/query.ts +1 -1
  55. package/src/cli/commands/smoke.ts +212 -0
  56. package/src/cli/commands/view.ts +1 -1
  57. package/src/cli/index.ts +7 -0
  58. package/src/cli/shared.ts +35 -5
  59. package/src/client/App.tsx +28 -15
  60. package/src/client/canvas/CanvasNode.tsx +54 -5
  61. package/src/client/canvas/CanvasViewport.tsx +6 -0
  62. package/src/client/canvas/EdgeLayer.tsx +44 -24
  63. package/src/client/canvas/ExpandedNodeOverlay.tsx +21 -0
  64. package/src/client/canvas/Minimap.tsx +2 -0
  65. package/src/client/canvas/auto-fit.ts +12 -1
  66. package/src/client/canvas/use-pan-zoom.ts +16 -1
  67. package/src/client/icons.tsx +4 -0
  68. package/src/client/mermaid-entry.ts +76 -0
  69. package/src/client/nodes/AxStepControls.tsx +226 -0
  70. package/src/client/nodes/DiffNode.tsx +114 -0
  71. package/src/client/nodes/ExtAppFrame.tsx +259 -84
  72. package/src/client/nodes/FileNode.tsx +249 -51
  73. package/src/client/nodes/MermaidNode.tsx +81 -0
  74. package/src/client/state/canvas-store.ts +19 -0
  75. package/src/client/state/intent-bridge.ts +32 -0
  76. package/src/client/state/node-factory.ts +2 -0
  77. package/src/client/state/sse-bridge.ts +32 -1
  78. package/src/client/theme/global.css +169 -3
  79. package/src/client/types.ts +6 -0
  80. package/src/mcp/server.ts +19 -1
  81. package/src/server/ax-flow-loop.ts +114 -0
  82. package/src/server/ax-interaction.ts +305 -4
  83. package/src/server/canvas-operations.ts +83 -26
  84. package/src/server/canvas-provenance.ts +2 -0
  85. package/src/server/canvas-schema.ts +33 -0
  86. package/src/server/canvas-state.ts +46 -10
  87. package/src/server/canvas-validation.ts +88 -0
  88. package/src/server/demo-state.json +4605 -523
  89. package/src/server/demo.ts +24 -0
  90. package/src/server/file-content.ts +152 -0
  91. package/src/server/file-watcher.ts +7 -10
  92. package/src/server/html-primitives.ts +636 -0
  93. package/src/server/html-surface.ts +11 -0
  94. package/src/server/index.ts +25 -6
  95. package/src/server/operations/composites.ts +3 -2
  96. package/src/server/operations/ops/ax-work.ts +5 -0
  97. package/src/server/operations/ops/batch.ts +8 -0
  98. package/src/server/operations/ops/json-render.ts +44 -0
  99. package/src/server/operations/ops/nodes.ts +24 -2
  100. package/src/server/operations/ops/viewport.ts +15 -2
  101. package/src/server/server.ts +154 -6
  102. package/src/server/web-artifacts.ts +109 -0
  103. package/src/server/workboard.ts +159 -0
  104. package/src/shared/ax-flow.ts +102 -0
  105. package/src/shared/ax-surface-protocol.ts +8 -0
  106. package/src/shared/node-sizes.ts +52 -0
  107. package/src/shared/surface.ts +2 -0
  108. package/dist/types/server/chart-template.d.ts +0 -29
  109. package/src/server/chart-template.ts +0 -448
@@ -0,0 +1,212 @@
1
+ // One-command environment check: does a running canvas actually work here?
2
+ //
3
+ // Runs the checks every host test report hand-rolls — health + workspace,
4
+ // MCP stdio initialize, a temp-node create/search/remove round-trip, and
5
+ // structural board validation — and reports them as a single JSON result
6
+ // with a pass/fail exit code. Designed for fresh orbs/hosts: `pmx-canvas
7
+ // smoke` answers "does the canvas work in this environment?" in one call.
8
+ //
9
+ // The command tests a RUNNING server (like every other agent command); it
10
+ // never starts one. Board validation gates only on structural issues
11
+ // (missing edge endpoints) — node overlaps are a layout choice on real
12
+ // boards, so collisions are reported but never fail the smoke.
13
+
14
+ import { spawn } from 'node:child_process';
15
+ import { readFileSync } from 'node:fs';
16
+ import { fileURLToPath } from 'node:url';
17
+ import { HttpOperationInvoker } from '../../server/operations/index.js';
18
+ import { cmd, getBaseUrl, isRecord, output, parseFlags, showCommandHelp } from '../shared.js';
19
+
20
+ interface SmokeCheck {
21
+ name: string;
22
+ ok: boolean;
23
+ detail: string;
24
+ }
25
+
26
+ const MCP_INITIALIZE_TIMEOUT_MS = 10_000;
27
+
28
+ function cliPackageVersion(): string {
29
+ try {
30
+ const pkg = JSON.parse(readFileSync(new URL('../../../package.json', import.meta.url), 'utf-8')) as {
31
+ version?: string;
32
+ };
33
+ return pkg.version ?? 'unknown';
34
+ } catch {
35
+ return 'unknown';
36
+ }
37
+ }
38
+
39
+ async function checkHealth(baseUrl: string, cliVersion: string): Promise<SmokeCheck & { serverVersion?: string }> {
40
+ const name = 'health';
41
+ let body: unknown;
42
+ try {
43
+ const response = await fetch(`${baseUrl}/health`);
44
+ if (!response.ok) return { name, ok: false, detail: `GET /health returned HTTP ${response.status}` };
45
+ body = await response.json();
46
+ } catch (error) {
47
+ return {
48
+ name,
49
+ ok: false,
50
+ detail: `Cannot connect to pmx-canvas at ${baseUrl}: ${error instanceof Error ? error.message : String(error)}. Start the server first: pmx-canvas --no-open`,
51
+ };
52
+ }
53
+ if (!isRecord(body) || body.ok !== true) {
54
+ return { name, ok: false, detail: `GET /health returned an unhealthy body: ${JSON.stringify(body)}` };
55
+ }
56
+ const serverVersion = typeof body.version === 'string' ? body.version : 'unknown';
57
+ const persistence = isRecord(body.persistence) && body.persistence.ok === false ? 'DEGRADED' : 'ok';
58
+ const skew =
59
+ serverVersion !== 'unknown' && serverVersion !== cliVersion
60
+ ? ` — differs from CLI v${cliVersion} (version skew)`
61
+ : '';
62
+ return {
63
+ name,
64
+ ok: true,
65
+ serverVersion,
66
+ detail: `workspace ${String(body.workspace)}, server v${serverVersion}${skew}, pid ${String(body.pid)}, persistence ${persistence}`,
67
+ };
68
+ }
69
+
70
+ /**
71
+ * Spawn the bundled MCP server over stdio (same entry `pmx-canvas --mcp`
72
+ * runs) and verify a JSON-RPC initialize handshake completes. The transport
73
+ * is newline-delimited JSON; the child is killed as soon as the response
74
+ * with our request id arrives.
75
+ */
76
+ function checkMcpInitialize(cliVersion: string): Promise<SmokeCheck> {
77
+ const name = 'mcp-initialize';
78
+ const entry = fileURLToPath(new URL('../../mcp/server.ts', import.meta.url));
79
+ return new Promise((resolveCheck) => {
80
+ const child = spawn(process.execPath, ['run', entry], {
81
+ stdio: ['pipe', 'pipe', 'ignore'],
82
+ env: process.env,
83
+ });
84
+ let settled = false;
85
+ let stdoutBuffer = '';
86
+ const finish = (ok: boolean, detail: string) => {
87
+ if (settled) return;
88
+ settled = true;
89
+ clearTimeout(timer);
90
+ child.kill();
91
+ resolveCheck({ name, ok, detail });
92
+ };
93
+ const timer = setTimeout(
94
+ () => finish(false, `no initialize response within ${MCP_INITIALIZE_TIMEOUT_MS}ms`),
95
+ MCP_INITIALIZE_TIMEOUT_MS,
96
+ );
97
+ child.on('error', (error) => finish(false, `failed to spawn MCP server: ${error.message}`));
98
+ child.on('exit', (code) => finish(false, `MCP server exited before responding (code ${code ?? 'unknown'})`));
99
+ child.stdout.on('data', (chunk: Buffer) => {
100
+ stdoutBuffer += chunk.toString('utf-8');
101
+ const lines = stdoutBuffer.split('\n');
102
+ stdoutBuffer = lines.pop() ?? '';
103
+ for (const line of lines) {
104
+ if (!line.trim()) continue;
105
+ let message: unknown;
106
+ try {
107
+ message = JSON.parse(line);
108
+ } catch {
109
+ continue;
110
+ }
111
+ if (!isRecord(message) || message.id !== 1) continue;
112
+ const result = isRecord(message.result) ? message.result : null;
113
+ const serverInfo = result && isRecord(result.serverInfo) ? result.serverInfo : null;
114
+ if (serverInfo) {
115
+ finish(true, `initialize ok: ${String(serverInfo.name)} v${String(serverInfo.version)}`);
116
+ } else {
117
+ finish(false, `initialize returned an error: ${JSON.stringify(message.error ?? message)}`);
118
+ }
119
+ return;
120
+ }
121
+ });
122
+ child.stdin.write(
123
+ `${JSON.stringify({
124
+ jsonrpc: '2.0',
125
+ id: 1,
126
+ method: 'initialize',
127
+ params: {
128
+ protocolVersion: '2025-06-18',
129
+ capabilities: {},
130
+ clientInfo: { name: 'pmx-canvas-smoke', version: cliVersion },
131
+ },
132
+ })}\n`,
133
+ );
134
+ });
135
+ }
136
+
137
+ async function checkNodeLifecycle(invoker: HttpOperationInvoker): Promise<SmokeCheck> {
138
+ const name = 'node-lifecycle';
139
+ const marker = `pmx-smoke-${Math.random().toString(36).slice(2, 10)}`;
140
+ const searchHits = async (): Promise<Set<string>> => {
141
+ const result = await invoker.invoke('search', { q: marker });
142
+ const results = isRecord(result) && Array.isArray(result.results) ? result.results : [];
143
+ return new Set(results.filter(isRecord).map((entry) => String(entry.id)));
144
+ };
145
+ let nodeId: string | null = null;
146
+ try {
147
+ const added = await invoker.invoke('node.add', {
148
+ type: 'markdown',
149
+ title: marker,
150
+ content: `Temporary smoke-check node (${marker}). Safe to remove.`,
151
+ x: 100_000,
152
+ y: 100_000,
153
+ });
154
+ const node = isRecord(added) && isRecord(added.node) ? added.node : null;
155
+ nodeId = node && typeof node.id === 'string' ? node.id : null;
156
+ if (!nodeId) return { name, ok: false, detail: `node.add returned no node id: ${JSON.stringify(added)}` };
157
+ const found = (await searchHits()).has(nodeId);
158
+ // Remove BEFORE reporting either way: an early return here (search miss —
159
+ // exactly the version-skew case this command exists to detect) would skip
160
+ // the catch-block cleanup and park the temp node on the user's real board.
161
+ await invoker.invoke('node.remove', { id: nodeId });
162
+ nodeId = null;
163
+ if (!found) return { name, ok: false, detail: 'search did not find the created node' };
164
+ return { name, ok: true, detail: 'create → search → remove round-trip ok' };
165
+ } catch (error) {
166
+ // Best-effort cleanup so a failed smoke never leaves its temp node behind.
167
+ if (nodeId) await invoker.invoke('node.remove', { id: nodeId }).catch(() => {});
168
+ return { name, ok: false, detail: error instanceof Error ? error.message : String(error) };
169
+ }
170
+ }
171
+
172
+ async function checkValidation(invoker: HttpOperationInvoker): Promise<SmokeCheck> {
173
+ const name = 'validate';
174
+ try {
175
+ const result = await invoker.invoke('validate.get', {});
176
+ const summary = isRecord(result) && isRecord(result.summary) ? result.summary : {};
177
+ const missingEdgeEndpoints = Number(summary.missingEdgeEndpoints ?? 0);
178
+ const detail = `${String(summary.nodes ?? '?')} nodes, ${String(summary.edges ?? '?')} edges, ${String(
179
+ summary.collisions ?? '?',
180
+ )} collisions (informational), ${missingEdgeEndpoints} broken edges`;
181
+ return { name, ok: missingEdgeEndpoints === 0, detail };
182
+ } catch (error) {
183
+ return { name, ok: false, detail: error instanceof Error ? error.message : String(error) };
184
+ }
185
+ }
186
+
187
+ cmd(
188
+ 'smoke',
189
+ 'One-command check that a running canvas works in this environment',
190
+ ['pmx-canvas smoke', 'pmx-canvas smoke --skip-mcp', 'pmx-canvas smoke --port 4750'],
191
+ async (args) => {
192
+ const { flags } = parseFlags(args);
193
+ if (flags.help || flags.h) return showCommandHelp('smoke');
194
+
195
+ const baseUrl = getBaseUrl();
196
+ const cliVersion = cliPackageVersion();
197
+ const invoker = new HttpOperationInvoker(baseUrl);
198
+ const checks: SmokeCheck[] = [];
199
+
200
+ const health = await checkHealth(baseUrl, cliVersion);
201
+ checks.push({ name: health.name, ok: health.ok, detail: health.detail });
202
+ if (health.ok) {
203
+ if (!flags['skip-mcp']) checks.push(await checkMcpInitialize(cliVersion));
204
+ checks.push(await checkNodeLifecycle(invoker));
205
+ checks.push(await checkValidation(invoker));
206
+ }
207
+
208
+ const ok = checks.every((check) => check.ok);
209
+ output({ ok, target: baseUrl, cliVersion, serverVersion: health.serverVersion ?? null, checks });
210
+ if (!ok) process.exit(1);
211
+ },
212
+ );
@@ -44,7 +44,7 @@ cmd(
44
44
  'Get the full canvas layout (nodes, edges, viewport)',
45
45
  ['pmx-canvas layout', 'pmx-canvas layout --summary'],
46
46
  async (args) => {
47
- const { flags } = parseFlags(args);
47
+ const { flags } = parseFlags(args, { boolFlags: ['summary'] });
48
48
  if (flags.help || flags.h) return showCommandHelp('layout');
49
49
 
50
50
  if (flags.summary || flags.compact) {
package/src/cli/index.ts CHANGED
@@ -67,6 +67,7 @@ const AGENT_COMMANDS = new Set([
67
67
  'serve',
68
68
  'copilot',
69
69
  'skills',
70
+ 'smoke',
70
71
  ]);
71
72
 
72
73
  const firstArg = args[0] ?? '';
@@ -297,19 +298,25 @@ Agent CLI (works against running server):
297
298
  validate spec Validate json-render/graph payloads without creating nodes
298
299
  watch [--json] [--events ...] Watch low-token semantic canvas changes
299
300
  focus <node-id> Pan to node
301
+ fit [node-id ...] Fit the viewport to all nodes or a subset
300
302
  ax status|context|focus Inspect AX state and focus
301
303
  external-app add Add hosted external apps like Excalidraw
302
304
  diagram add Add an Excalidraw diagram node
305
+ html primitive add|schema Add or inspect HTML communication primitives
303
306
  pin <ids...> | --list | --clear Manage context pins
304
307
  undo / redo / history Time travel
305
308
  snapshot save|list|restore|diff|delete
306
309
  Manage snapshots
310
+ diff <snapshot-id> Compare the canvas against a snapshot
307
311
  group create|add|remove Manage groups
308
312
  web-artifact build Build bundled web artifacts
309
313
  clear --yes Clear canvas
310
314
  code-graph File dependencies
311
315
  spatial Spatial analysis
312
316
  watch Semantic watch stream
317
+ smoke Check that a running canvas works here
318
+ skills sync Sync installed copies of the bundled skills
319
+ copilot install-extension Install the GitHub Copilot app extension
313
320
 
314
321
  Run any command with --help for details and examples:
315
322
  pmx-canvas node add --help
package/src/cli/shared.ts CHANGED
@@ -47,6 +47,13 @@ export function extractGlobalTargetFlags(args: string[]): string[] {
47
47
  const arg = args[i];
48
48
  let name: 'port' | 'server-url' | null = null;
49
49
  let value: string | undefined;
50
+ if (arg === '--') {
51
+ // End of flags: everything after a bare `--` is an operand, so a literal
52
+ // `--port`-looking value is never mistaken for the global target flag.
53
+ // The separator itself is kept so parseFlags stops there too.
54
+ rest.push(...args.slice(i));
55
+ break;
56
+ }
50
57
  if (arg === '--port' || arg === '--server-url') {
51
58
  name = arg.slice(2) as 'port' | 'server-url';
52
59
  value = args[i + 1];
@@ -125,11 +132,25 @@ async function invokeOperation(name: string, input: Record<string, unknown>): Pr
125
132
 
126
133
  // ── Flag parsing ─────────────────────────────────────────────
127
134
 
128
- function parseFlags(args: string[]): { positional: string[]; flags: Record<string, string | true> } {
135
+ /**
136
+ * `options.boolFlags` marks flags that are boolean FOR THIS COMMAND. Value
137
+ * flags consume the next token verbatim (so a unified diff beginning with
138
+ * `--- a/file` survives), which makes a flag that is boolean here and
139
+ * value-taking elsewhere ambiguous — `--summary` is boolean in `history` /
140
+ * `layout` / `node get` / `validate spec` but carries text in `node add`.
141
+ * Without this per-command override, `history --summary --limit 5` would
142
+ * swallow `--limit` as the summary's value. The global set below holds flags
143
+ * that are boolean everywhere.
144
+ */
145
+ function parseFlags(
146
+ args: string[],
147
+ options: { boolFlags?: readonly string[] } = {},
148
+ ): { positional: string[]; flags: Record<string, string | true> } {
129
149
  const positional: string[] = [];
130
150
  const flags: Record<string, string | true> = {};
131
151
  // Boolean-only flags (never take a value argument)
132
- const BOOL_FLAGS = new Set([
152
+ const BOOL_FLAGS = new Set<string>([
153
+ ...(options.boolFlags ?? []),
133
154
  'help',
134
155
  'h',
135
156
  'ids',
@@ -164,17 +185,26 @@ function parseFlags(args: string[]): { positional: string[]; flags: Record<strin
164
185
  'files',
165
186
  'ui-prompts',
166
187
  'check',
188
+ 'skip-mcp',
167
189
  ]);
168
190
  for (let i = 0; i < args.length; i++) {
169
191
  const arg = args[i];
192
+ // Conventional end-of-flags separator: the rest is positional, never flags.
193
+ if (arg === '--') {
194
+ positional.push(...args.slice(i + 1));
195
+ break;
196
+ }
170
197
  if (arg.startsWith('--')) {
171
198
  const eq = arg.indexOf('=');
172
199
  if (eq !== -1) {
173
200
  flags[arg.slice(2, eq)] = arg.slice(eq + 1);
174
201
  } else {
175
202
  const key = arg.slice(2);
176
- // If not a boolean flag and next arg exists and isn't a flag, consume it as value
177
- if (!BOOL_FLAGS.has(key) && i + 1 < args.length && !args[i + 1].startsWith('-')) {
203
+ // A value flag consumes the very next token VERBATIM — including one
204
+ // that starts with `-`/`--`. Unified diffs legitimately begin with
205
+ // `--- a/file`, and the old "next token must not look like a flag"
206
+ // heuristic silently dropped that content.
207
+ if (!BOOL_FLAGS.has(key) && i + 1 < args.length) {
178
208
  flags[key] = args[++i];
179
209
  } else {
180
210
  flags[key] = true;
@@ -182,7 +212,7 @@ function parseFlags(args: string[]): { positional: string[]; flags: Record<strin
182
212
  }
183
213
  } else if (arg.startsWith('-') && arg.length === 2) {
184
214
  const key = arg.slice(1);
185
- if (!BOOL_FLAGS.has(key) && i + 1 < args.length && !args[i + 1].startsWith('-')) {
215
+ if (!BOOL_FLAGS.has(key) && i + 1 < args.length) {
186
216
  flags[key] = args[++i];
187
217
  } else {
188
218
  flags[key] = true;
@@ -36,10 +36,11 @@ import {
36
36
  traceEnabled,
37
37
  viewport,
38
38
  walkGraph,
39
+ zoomByFactor,
39
40
  } from './state/canvas-store';
40
41
  import { connectSSE } from './state/sse-bridge';
41
42
  import { intents } from './state/intent-store';
42
- import { saveCanvasTheme } from './state/intent-bridge';
43
+ import { reportClientViewportSize, saveCanvasTheme } from './state/intent-bridge';
43
44
  import {
44
45
  IconArrange,
45
46
  IconClearTrace,
@@ -258,20 +259,12 @@ function Toolbar({
258
259
  </button>
259
260
  </ToolbarHint>
260
261
  <ToolbarHint label="Zoom in" shortcut={`${MOD_KEY}++`}>
261
- <button
262
- type="button"
263
- onClick={() => animateViewport({ ...v, scale: Math.min(4, v.scale * 1.25) }, 150)}
264
- aria-label="Zoom in"
265
- >
262
+ <button type="button" onClick={() => zoomByFactor(1.25)} aria-label="Zoom in">
266
263
  <IconZoomIn />
267
264
  </button>
268
265
  </ToolbarHint>
269
266
  <ToolbarHint label="Zoom out" shortcut={`${MOD_KEY}+-`}>
270
- <button
271
- type="button"
272
- onClick={() => animateViewport({ ...v, scale: Math.max(0.1, v.scale / 1.25) }, 150)}
273
- aria-label="Zoom out"
274
- >
267
+ <button type="button" onClick={() => zoomByFactor(1 / 1.25)} aria-label="Zoom out">
275
268
  <IconZoomOut />
276
269
  </button>
277
270
  </ToolbarHint>
@@ -585,6 +578,25 @@ export function App() {
585
578
  return connectSSE();
586
579
  }, []);
587
580
 
581
+ // Keep the server's idea of this window's size current (0.4.6 orb feedback
582
+ // #2) so an agent `fit` computes a scale that actually fits the human's
583
+ // window. Connect-time reporting lives in the SSE bridge; this covers resizes.
584
+ useEffect(() => {
585
+ let timer: number | null = null;
586
+ const schedule = () => {
587
+ if (timer !== null) window.clearTimeout(timer);
588
+ timer = window.setTimeout(() => {
589
+ timer = null;
590
+ void reportClientViewportSize();
591
+ }, 400);
592
+ };
593
+ window.addEventListener('resize', schedule);
594
+ return () => {
595
+ if (timer !== null) window.clearTimeout(timer);
596
+ window.removeEventListener('resize', schedule);
597
+ };
598
+ }, []);
599
+
588
600
  useEffect(() => {
589
601
  const handleKeyDown = (e: KeyboardEvent) => {
590
602
  const mod = e.metaKey || e.ctrlKey;
@@ -640,12 +652,13 @@ export function App() {
640
652
  animateViewport({ x: 0, y: 0, scale: 1 }, 250);
641
653
  } else if (mod && (e.key === '=' || e.key === '+')) {
642
654
  e.preventDefault();
643
- const cur = viewport.value;
644
- animateViewport({ ...cur, scale: Math.min(4, cur.scale * 1.25) }, 150);
655
+ // Same centre-anchored zoom as the toolbar +/- buttons these shortcuts
656
+ // are advertised on (the tooltip names this key) — a raw scale change
657
+ // is origin-anchored and drifts the board away under the cursor.
658
+ zoomByFactor(1.25);
645
659
  } else if (mod && e.key === '-') {
646
660
  e.preventDefault();
647
- const cur = viewport.value;
648
- animateViewport({ ...cur, scale: Math.max(0.1, cur.scale / 1.25) }, 150);
661
+ zoomByFactor(1 / 1.25);
649
662
  } else if (e.key === 'Escape') {
650
663
  if (selectedNodeIds.value.size > 0) {
651
664
  clearSelection();
@@ -21,15 +21,54 @@ import {
21
21
  viewport,
22
22
  } from '../state/canvas-store';
23
23
  import { removeNodeFromClient, updateNodeFromClient } from '../state/intent-bridge';
24
+ import { AxStepControls } from '../nodes/AxStepControls';
24
25
  import { canOpenAsSite, openNodeAsSite } from '../nodes/surface-url';
25
26
  import { getNodeIcon } from '../icons';
26
27
  import { EXPANDABLE_TYPES, TYPE_LABELS } from '../types';
27
28
  import type { CanvasNodeState } from '../types';
28
- import { computeAutoFitHeight, shouldAutoFitNode } from './auto-fit';
29
+ import { AUTO_FIT_TITLEBAR_HEIGHT, computeAutoFitHeight, shouldAutoFitNode } from './auto-fit';
29
30
  import { activeGuides, buildSnapCache, clearSnapCache, snapToGuides } from './snap-guides';
30
31
  import { useNodeDrag } from './use-node-drag';
31
32
  import { useNodeResize } from './use-node-resize';
32
33
 
34
+ /** Fraction of a node's height the title bar may occupy before it starts
35
+ * crowding out the content it is labelling. */
36
+ const MAX_TITLEBAR_HEIGHT_RATIO = 0.4;
37
+ /** Fraction of a node's width the fixed title-bar chrome may occupy before it
38
+ * starts eating the node's NAME. */
39
+ const MAX_TITLEBAR_CHROME_WIDTH_RATIO = 0.55;
40
+ /** Unscaled width of everything in the bar that is not the title: type icon,
41
+ * status/type pill, the control cluster, and the gaps between them. */
42
+ const TITLEBAR_CHROME_BASE_WIDTH = 175;
43
+
44
+ /**
45
+ * How much to enlarge node chrome (title bar, badges, icons) when zoomed out so
46
+ * it stays legible. Full inverse compensation is capped at 2.2x — but the cap
47
+ * alone ignored the node it is drawn on, so on SHORT nodes the growing bar ate
48
+ * the body: a 116px-tall section label at 46% zoom gave the title bar 72% of the
49
+ * node and left 14px for text that needed 41, so the markdown was simply cut off.
50
+ * The scale is therefore also bounded by the node's own height, keeping the bar
51
+ * under MAX_TITLEBAR_HEIGHT_RATIO of it. Tall nodes are unaffected (their height
52
+ * never binds); pass height 0 for collapsed/auto-height nodes to skip the bound.
53
+ */
54
+ export function nodeChromeScale(viewportScale: number, nodeHeight: number, nodeWidth = 0): number {
55
+ if (!Number.isFinite(viewportScale) || viewportScale >= 1) return 1;
56
+ const zoomScale = Math.min(2.2, 1 / Math.max(viewportScale, 0.01));
57
+ let bounded = zoomScale;
58
+ if (Number.isFinite(nodeHeight) && nodeHeight > 0) {
59
+ bounded = Math.min(bounded, (nodeHeight * MAX_TITLEBAR_HEIGHT_RATIO) / AUTO_FIT_TITLEBAR_HEIGHT);
60
+ }
61
+ // Width matters as much as height: the icon, pill and control cluster are all
62
+ // flex-shrink: 0, so the NAME is the only thing that gives way. Unbounded, a
63
+ // 360px step node at 52% zoom spent 100 of 187 bar pixels on controls alone and
64
+ // the title collapsed to nothing. Bound the scale so the fixed chrome can never
65
+ // take more than MAX_TITLEBAR_CHROME_WIDTH_RATIO of the node.
66
+ if (Number.isFinite(nodeWidth) && nodeWidth > 0) {
67
+ bounded = Math.min(bounded, (nodeWidth * MAX_TITLEBAR_CHROME_WIDTH_RATIO) / TITLEBAR_CHROME_BASE_WIDTH);
68
+ }
69
+ return Math.max(1, bounded);
70
+ }
71
+
33
72
  interface CanvasNodeProps {
34
73
  node: CanvasNodeState;
35
74
  children: preact.ComponentChildren;
@@ -220,7 +259,7 @@ export function CanvasNode({ node, children, onContextMenu }: CanvasNodeProps) {
220
259
  const isGroup = node.type === 'group';
221
260
  const isStrictSize = node.data.strictSize === true;
222
261
  const viewportScale = Math.max(viewport.value.scale, 0.01);
223
- const chromeScale = viewportScale < 1 ? Math.min(2.2, 1 / viewportScale) : 1;
262
+ const chromeScale = nodeChromeScale(viewportScale, node.collapsed ? 0 : node.size.height, node.size.width);
224
263
 
225
264
  const groupColor = isGroup && node.data.color ? (node.data.color as string) : undefined;
226
265
  const nodeStyle: Record<string, string | number> = {
@@ -261,9 +300,13 @@ export function CanvasNode({ node, children, onContextMenu }: CanvasNodeProps) {
261
300
  return <NodeIcon size={Math.round(14 * chromeScale)} />;
262
301
  })()}
263
302
  </span>
264
- <span class="node-type-badge">{TYPE_LABELS[node.type]}</span>
265
- {typeof node.data.axWorkStatus === 'string' && (
303
+ {/* The icon already says what type this is, so the type badge yields to
304
+ the status chip rather than stacking two pills in front of the name —
305
+ on a narrow node that combination pushed the title to an ellipsis. */}
306
+ {typeof node.data.axWorkStatus === 'string' ? (
266
307
  <span class={`node-ax-status node-ax-status-${node.data.axWorkStatus}`}>{node.data.axWorkStatus}</span>
308
+ ) : (
309
+ <span class="node-type-badge">{TYPE_LABELS[node.type]}</span>
267
310
  )}
268
311
  {renaming ? (
269
312
  <input
@@ -278,7 +321,7 @@ export function CanvasNode({ node, children, onContextMenu }: CanvasNodeProps) {
278
321
  onClick={(e) => e.stopPropagation()}
279
322
  />
280
323
  ) : (
281
- <span class="node-title" onDblClick={handleTitleDblClick} title="Double-click to rename">
324
+ <span class="node-title" onDblClick={handleTitleDblClick} title={`${title} — double-click to rename`}>
282
325
  {title}
283
326
  </span>
284
327
  )}
@@ -356,6 +399,12 @@ export function CanvasNode({ node, children, onContextMenu }: CanvasNodeProps) {
356
399
  {!node.collapsed && (
357
400
  <div ref={bodyRef} class="node-body">
358
401
  {children}
402
+ {/* AX flow step controls — rendered here rather than in a node renderer
403
+ because a materialized step can be ANY node type, and because the
404
+ body is what auto-fit measures (a footer outside it would be clipped
405
+ on a content-fitted node). Renders nothing unless the node carries a
406
+ `data.axStep` stamp. */}
407
+ <AxStepControls node={node} />
359
408
  </div>
360
409
  )}
361
410
  {!node.collapsed && (
@@ -1,8 +1,10 @@
1
1
  import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
2
2
  import { ContextNode } from '../nodes/ContextNode';
3
+ import { DiffNode } from '../nodes/DiffNode';
3
4
  import { FileNode } from '../nodes/FileNode';
4
5
  import { LedgerNode } from '../nodes/LedgerNode';
5
6
  import { MarkdownNode } from '../nodes/MarkdownNode';
7
+ import { MermaidNode } from '../nodes/MermaidNode';
6
8
  import { McpAppNode } from '../nodes/McpAppNode';
7
9
  import { StatusNode } from '../nodes/StatusNode';
8
10
  import { ImageNode } from '../nodes/ImageNode';
@@ -64,6 +66,10 @@ function renderNodeContent(node: CanvasNodeState) {
64
66
  return <TraceNode node={node} />;
65
67
  case 'file':
66
68
  return <FileNode node={node} />;
69
+ case 'diff':
70
+ return <DiffNode node={node} />;
71
+ case 'mermaid':
72
+ return <MermaidNode node={node} />;
67
73
  case 'image':
68
74
  return <ImageNode node={node} />;
69
75
  case 'html':