pmx-canvas 0.6.1 → 0.6.3

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 (52) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/Readme.md +14 -2
  3. package/dist/canvas/global.css +22 -3
  4. package/dist/canvas/index.js +64 -64
  5. package/dist/json-render/index.js +57 -57
  6. package/dist/types/client/canvas/auto-fit.d.ts +3 -0
  7. package/dist/types/client/canvas/use-pan-zoom.d.ts +2 -1
  8. package/dist/types/client/nodes/use-iframe-content-height.d.ts +3 -3
  9. package/dist/types/client/state/human-store.d.ts +1 -0
  10. package/dist/types/json-render/catalog.d.ts +20 -20
  11. package/dist/types/json-render/schema.d.ts +40 -0
  12. package/dist/types/mcp/skills.d.ts +13 -0
  13. package/dist/types/server/bundled-skills.d.ts +32 -4
  14. package/docs/http-api.md +5 -1
  15. package/docs/mcp.md +35 -1
  16. package/docs/node-types.md +1 -1
  17. package/docs/product-context-vision-2026-09.md +16 -5
  18. package/docs/product-vision-2026-09.md +4 -2
  19. package/package.json +11 -11
  20. package/skills/json-render-mcp/SKILL.md +118 -2
  21. package/skills/pmx-canvas/SKILL.md +42 -16
  22. package/skills/pmx-canvas/evals/evals.json +1 -1
  23. package/skills/pmx-canvas/references/excalidraw-diagram-authoring.md +5 -0
  24. package/skills/pmx-canvas/references/full-reference.md +5 -4
  25. package/skills/pmx-canvas/references/installing-pmx-canvas.md +5 -5
  26. package/skills/pmx-canvas-testing/SKILL.md +13 -4
  27. package/skills/web-artifacts-builder/scripts/bundle-artifact.sh +11 -12
  28. package/src/client/canvas/CanvasNode.tsx +9 -12
  29. package/src/client/canvas/CanvasViewport.tsx +10 -34
  30. package/src/client/canvas/ContextMenu.tsx +13 -0
  31. package/src/client/canvas/EdgeLayer.tsx +2 -2
  32. package/src/client/canvas/SessionPanel.tsx +23 -4
  33. package/src/client/canvas/ShortcutOverlay.tsx +2 -2
  34. package/src/client/canvas/auto-fit.ts +20 -1
  35. package/src/client/canvas/use-pan-zoom.ts +16 -3
  36. package/src/client/nodes/ExtAppFrame.tsx +7 -1
  37. package/src/client/nodes/use-iframe-content-height.ts +44 -24
  38. package/src/client/state/human-store.ts +6 -6
  39. package/src/client/state/sse-bridge.ts +3 -1
  40. package/src/client/theme/global.css +22 -3
  41. package/src/json-render/catalog.ts +6 -0
  42. package/src/json-render/renderer/index.tsx +17 -0
  43. package/src/json-render/schema.ts +4 -0
  44. package/src/mcp/server.ts +9 -4
  45. package/src/mcp/skills.ts +47 -0
  46. package/src/server/bundled-skills.ts +92 -53
  47. package/src/server/canvas-operations.ts +2 -2
  48. package/src/server/operations/ops/nodes.ts +43 -11
  49. package/src/server/operations/ops/viewport.ts +6 -1
  50. package/src/server/server.ts +9 -9
  51. package/src/server/web-artifacts/scripts/bundle-artifact.sh +29 -8
  52. package/src/shared/content-height-reporter.ts +4 -2
@@ -18,12 +18,24 @@ Humans curate agent context by pinning nodes; agents read that curation through
18
18
 
19
19
  ## Runtime prerequisites
20
20
 
21
- PMX Canvas 0.6.1 requires **Bun >=1.4.2**, including when installed through npm.
21
+ PMX Canvas 0.6.3 requires **Bun >=1.4.2**, including when installed through npm.
22
22
  Check `bun --version` and ensure Bun is on the MCP host's PATH, not just your shell's;
23
23
  use an absolute executable path if needed. Install/configure only when requested.
24
24
  For pinned installation, MCP configuration, managed services, and disposable verification,
25
25
  read [Installing PMX Canvas](references/installing-pmx-canvas.md).
26
26
 
27
+ ## Reading bundled skills over MCP
28
+
29
+ When the server advertises `io.modelcontextprotocol/skills`, supporting hosts can discover
30
+ bundled skills with `skills/list` and retrieve a manifest with `skills/get`. These are MCP
31
+ protocol methods, not canvas tools. Read `skill://pmx-canvas/SKILL.md` and relative supporting
32
+ files (for example `skill://pmx-canvas/references/installing-pmx-canvas.md`) through
33
+ `resources/read`, fetching only what is needed. Hosts without extension support can still read
34
+ ordinary resources or use installed filesystem skills; `canvas://skills` remains available.
35
+ Resource reads do not activate skills or authorize executing their scripts. The host must
36
+ preserve MCP origin and apply integrity/approval checks. Restart MCP after upgrading the package;
37
+ the served catalog is a startup snapshot. Local mirrors still use `skills sync` below.
38
+
27
39
  ## Required Operating Sequence
28
40
 
29
41
  0. **After a `pmx-canvas` upgrade, refresh this skill first.** Run
@@ -155,9 +167,9 @@ json-render, a graph, or an HTML primitive is sufficient.
155
167
 
156
168
  ## Current MCP Composites
157
169
 
158
- The live MCP surface is **22 tools**: the 16 composites below plus 6 standalones (v0.4.x —
159
- this table is kept in sync with the server's composite registry; `tools/list` on a fresh
160
- `pmx-canvas --mcp` is always authoritative).
170
+ The live MCP surface is **22 tools**: the 16 composites below plus 6 standalones. This table is
171
+ kept in sync with the server's composite registry; `tools/list` on a fresh `pmx-canvas --mcp` is
172
+ always authoritative, independent of the installed release version.
161
173
 
162
174
  | Composite | Actions |
163
175
  |-----------|---------|
@@ -235,8 +247,20 @@ distant nodes, an auto-placed node lands off-camera.
235
247
  markdown 640×420, status 360×200, file 520×360, diff 640×420, mermaid 640×460, html 720×640,
236
248
  graph 760×520, mcp-app 960×600, web-artifact 960×720. A *hosted* app opened with
237
249
  `canvas_app { action: "open-mcp-app" | "diagram" }` — including the Excalidraw diagram preset — is
238
- the exception: it opens at 720×500, not 960×600 (960×600 is the default for an `mcp-app` node you
239
- create directly). Pass `width`/`height` to that action when you want a bigger diagram tile.
250
+ the exception: request `width: 720, height: 500` for its standard landscape preset instead of
251
+ relying on the 960×600 default for a directly created `mcp-app` node. After the app mounts, read
252
+ only its geometry, e.g. `pmx-canvas node get <id> --fields id,type,position,size`, and use the
253
+ persisted size for subsequent placement. With MCP tool execution that supports output projection,
254
+ project the layout to node id/type/position/size before returning it to context; otherwise prefer
255
+ a targeted `canvas_node { action: "get", id }`. Read the full layout only when its other state
256
+ is needed. Mounted apps may change the requested dimensions.
257
+ Authored iframe surfaces grow in height as content changes, up to 1400px. A growing ungrouped,
258
+ unpinned card moves down when needed to leave a 24px gap without moving its neighbors; grouped
259
+ cards retain their authored placement. Width stays explicit, and `strictSize` or manually resized
260
+ cards do not auto-grow. Fitting waits while a human holds the card, including in another tab;
261
+ automatic relocations are undoable. This is not a shrink-to-fit action or `view.fit` (which only changes zoom).
262
+ After content settles, inspect the rendered result and arrange the intended node IDs if more
263
+ spacing is needed; do not compensate with arbitrarily huge fixed frames.
240
264
  Since 0.4.6 the server clamps explicit creation sizes UP to per-type
241
265
  readability floors (e.g. markdown 360×180, graph/json-render/html 420×280, mcp-app 480×320) —
242
266
  a tiny probe size silently becomes the floor. `strictSize: true` is the only opt-out (a fixed
@@ -333,11 +357,12 @@ Use the visible workbench when the human is actively curating layout:
333
357
  - The selection bar (floating bottom-center) offers count, align left/top, distribute,
334
358
  auto-arrange, Group (G), Connect, Pin as context, delete and clear; selected nodes show an
335
359
  accent outline with corner handles.
336
- - Right-click a node for context pinning, position locking, focus, collapse, connect, refresh,
360
+ - Double-left-click a node for context pinning, position locking, focus, collapse, connect, refresh,
337
361
  open, close, and type-specific actions.
362
+ - Double-left-click empty canvas for the node-creation menu; hold and drag the right mouse button to pan.
338
363
  - Drop files or URLs to create matching nodes; an empty board shows starter actions (new note,
339
364
  pick files, paste a link, start an agent session).
340
- - Double-click markdown to edit inline.
365
+ - Use a markdown node's Edit button to edit its content.
341
366
  - **Groups** are frames with the name pill and an action cluster (auto-arrange children, collapse,
342
367
  ⋯ rename / ungroup / pin all) on the top edge. Membership changes only on release while the
343
368
  "release to add to <group>" pill shows (Esc keeps it out); dragging a child fully out offers
@@ -464,20 +489,21 @@ Prefer `canvas_query { action: "search" }` over parsing the full layout.
464
489
  iframe's per-mount nonce, so AX buttons only work inside the in-canvas node (0.4.4 Codex note).
465
490
  Do not tell a user a standalone tab's controls will steer the agent.
466
491
  - A hosted ext-app (Excalidraw) node in a **WebKit** host panel (e.g. the GitHub Copilot app's
467
- embedded WKWebView) historically could render as a black tile — a host compositor paint race
468
- on the nested iframe, **not** a broken node (the session is healthy, `sessionStatus` is
469
- `ready`, and it renders fine in Chrome/Codex). Since 0.4.6 the canvas runs a **paint oracle +
492
+ embedded WKWebView) can remain blank even when its API session is healthy and `sessionStatus`
493
+ is `ready`. This host-specific rendering issue remains open; successful Chrome/Codex rendering
494
+ does not establish native-host health. Since 0.4.6 the canvas runs a **paint oracle +
470
495
  recovery ladder** under WebKit: present-at-load ext-apps mount strictly one at a time (the
471
496
  cold burst was the trigger), each frame answers a double-rAF **paint probe** after settle, and
472
497
  on silence the ladder escalates — soft-expand cycle (the automatic analogue of the proven
473
498
  enlarge+close) → serialized remount → an explicit "App surface failed to paint / Retry"
474
- affordance. The connecting overlay stays up until paint is confirmed, so a black layer is
475
- never presented as ready. The recovery trail
499
+ affordance. Native Copilot/WebKit surfaces can still remain blank despite a successful
500
+ probe, including when visibility is reported as visible. The recovery trail
476
501
  (`GET /api/canvas/debug/ext-app-recovery` / `window.__PMX_EXTAPP_LOG`) now records
477
502
  `mount-slot`, `paint-ok`, `paint-fail`, `soft-expand-cycle`, `recovery-exhausted`, and
478
- `assume-visible-rearm` — when diagnosing, trust `paint-ok`/`paint-fail`, and never assert
479
- health from `settled` alone. Exception: in a host that reports the document hidden (the
480
- GitHub Copilot panel does so continuously), `paint-ok` is recorded as
503
+ `assume-visible-rearm`. Treat `paint-ok`/`paint-fail` as diagnostic evidence, not compositor
504
+ proof. Require a rendered screenshot in the affected host before declaring its surface
505
+ healthy; a Chromium pass does not close a native Copilot/WebKit failure. In a host that
506
+ reports the document hidden, `paint-ok` is recorded as
481
507
  `paint-ok (unverified: host hidden)` — the app answered the paint probe, but nothing is
482
508
  proven composited, so do not treat it as a verified paint. If a
483
509
  tile still shows the Retry affordance, click Retry (fresh recovery budget) or expand-then-close;
@@ -274,7 +274,7 @@
274
274
  "id": 11,
275
275
  "name": "context-pin-workflow",
276
276
  "prompt": "Pin the auth design node so I (the agent) keep it in context, then confirm it's pinned.",
277
- "expected_output": "Pins via a verified path (canvas_pin_nodes, the CLI pin, or browser/right-click 'Pin as context'), then reads canvas://pinned-context to confirm; does not claim a broken UI action succeeded.",
277
+ "expected_output": "Pins via a verified path (canvas_pin_nodes, the CLI pin, or browser/double-left-click menu 'Pin as context'), then reads canvas://pinned-context to confirm; does not claim a broken UI action succeeded.",
278
278
  "assertions": [
279
279
  {
280
280
  "name": "pins-via-verified-path",
@@ -3,6 +3,11 @@
3
3
  Use this guide when creating diagrams through PMX Canvas with `canvas_app { action: "diagram" }` or
4
4
  `pmx-canvas external-app add --kind excalidraw`.
5
5
 
6
+ Request `width: 720, height: 500` when opening the standard landscape diagram tile. Treat those as
7
+ requested preset dimensions, not authoritative geometry: after the app mounts, query
8
+ `canvas_query { action: "layout" }` and use the node's persisted width and height for placement,
9
+ overlap checks, and any later layout. A hosted app can resize during mount.
10
+
6
11
  ## Why Text Can Still Drift
7
12
 
8
13
  PMX normalizes canonical Excalidraw bound text (`containerId` / `boundElements`) into the hosted
@@ -82,7 +82,7 @@ section below.
82
82
  6. **Arrange + validate** — `canvas_view { action: "arrange" }` after batch adds, then
83
83
  `canvas_query { action: "validate" }` to catch collisions / dangling edges before finishing.
84
84
  7. **Pin through a verified path** — `canvas_pin_nodes` (or the browser "Pin as context" /
85
- right-click "Pin as context"), then read `canvas://pinned-context` to confirm.
85
+ double-click menu's "Pin as context"), then read `canvas://pinned-context` to confirm.
86
86
  8. **Clean up** — remove retry/test fixtures (`canvas_node { action: "remove" }` — works for
87
87
  every type, including `status`), and restore the baseline snapshot.
88
88
 
@@ -192,16 +192,17 @@ running". `serve stop` reads the pid file, verifies the pid still looks like a c
192
192
  The browser is not just a passive view. Human interactions on the canvas persist back to the
193
193
  server and become part of the authoritative canvas state.
194
194
 
195
- - Double-click empty canvas — create a markdown note at that position
195
+ - Double-left-click empty canvas — open the node-creation menu at that position
196
+ - Right-button hold and drag — pan the board, including over node content
196
197
  - Shift+drag on empty canvas — lasso-select multiple nodes
197
198
  - Selection bar actions — when nodes are selected, the browser exposes `Pin as context`,
198
199
  `Group`, `Connect`, and `Clear`
199
- - Right-click a node — open the node context menu. **`Pin as context`** adds the node to the
200
+ - Double-left-click a node — open the node context menu. **`Pin as context`** adds the node to the
200
201
  human-curated agent context set (updates the context count + the node's context-pin indicator);
201
202
  `Lock position` is the separate arrange-lock. Plus focus, collapse, connect, refresh/open, close,
202
203
  and type-specific actions. (Every node type, **including `status`, has a remove `×`** in its title
203
204
  bar and a `Close` menu item.)
204
- - Right-click a group node — recolor the group using preset swatches or a custom color picker,
205
+ - Double-left-click a group node — recolor the group using preset swatches or a custom color picker,
205
206
  or ungroup it (dissolves the frame; children stay)
206
207
  - Drag-and-drop files or URLs — add file, image, markdown, or webpage nodes directly
207
208
  - Paste URLs — create webpage nodes from the clipboard
@@ -4,7 +4,7 @@ Use this reference when the `pmx-canvas` skill is installed but the `pmx-canvas`
4
4
 
5
5
  ## Prerequisites and version
6
6
 
7
- The examples pin **PMX Canvas 0.6.1**, which requires **Bun >=1.4.2** even when
7
+ The examples pin **PMX Canvas 0.6.3**, which requires **Bun >=1.4.2** even when
8
8
  installed through npm: the CLI runs through Bun, not Node. Check `bun --version`
9
9
  first. If missing or older, follow the [Bun installation instructions](https://bun.sh/docs/installation)
10
10
  with the user's authorization. Ensure Bun is on the MCP host's PATH as well as the
@@ -17,11 +17,11 @@ in `SKILL.md`. Do not pair newer checkout guidance with an older runtime unknowi
17
17
  ## Install from npm
18
18
 
19
19
  ```bash
20
- npm install -g pmx-canvas@0.6.1
20
+ npm install -g pmx-canvas@0.6.3
21
21
  pmx-canvas --version
22
22
  ```
23
23
 
24
- Without a global install, use `bunx pmx-canvas@0.6.1` in place of `pmx-canvas` in
24
+ Without a global install, use `bunx pmx-canvas@0.6.3` in place of `pmx-canvas` in
25
25
  the commands below. Do not use unpinned `bunx pmx-canvas` for a version-specific check.
26
26
 
27
27
  ## Choose the workspace and target
@@ -73,7 +73,7 @@ bun run build
73
73
  bun run src/cli/index.ts --version
74
74
  ```
75
75
 
76
- This uses the checked-out source, not necessarily the published 0.6.1 package. Follow
76
+ This uses the checked-out source, not necessarily the published package. Follow
77
77
  that checkout's `package.json` Bun requirement and bundled skill. Set the same root/port
78
78
  environment above; substitute `bun run /absolute/path/to/pmx-canvas/src/cli/index.ts`
79
79
  for `pmx-canvas` in startup and verification commands. The target workspace can be
@@ -91,7 +91,7 @@ discards empty env values, remove `PMX_CANVAS_URL` from its launch environment i
91
91
  "mcpServers": {
92
92
  "canvas": {
93
93
  "command": "bunx",
94
- "args": ["pmx-canvas@0.6.1", "--mcp"],
94
+ "args": ["pmx-canvas@0.6.3", "--mcp"],
95
95
  "env": {
96
96
  "PMX_CANVAS_WORKSPACE_ROOT": "/absolute/path/to/project",
97
97
  "PMX_CANVAS_PORT": "14313",
@@ -9,7 +9,14 @@ description: >
9
9
 
10
10
  # PMX Canvas Testing
11
11
 
12
- Use this skill whenever you touch code in this repo and need a consistent verification path.
12
+ Use this skill when changing the PMX Canvas product checkout and you need a consistent verification
13
+ path. The commands and paths below refer to that source checkout, not to a workspace that merely
14
+ has the published package and skills installed.
15
+
16
+ For an isolated consumer workspace, do not expect the package to contain this checkout's
17
+ `tests/`, source-only scripts, or Playwright configuration. Validate package transports and host
18
+ integration through their public CLI, MCP, HTTP, and SDK surfaces; use the
19
+ `published-consumer-e2e` skill when a packed-install outside-in check is required.
13
20
 
14
21
  ## When To Use
15
22
 
@@ -59,9 +66,11 @@ and check `$?`, then read the file.
59
66
 
60
67
  ## Current Project Test Surface
61
68
 
62
- - Bun tests live under `tests/unit/`
63
- - Playwright browser smoke lives under `tests/e2e/`
64
- - CI runs Bun coverage plus the browser smoke flow
69
+ - In the product source checkout, Bun tests live under `tests/unit/`.
70
+ - In the product source checkout, Playwright browser smoke lives under `tests/e2e/`.
71
+ - Product CI runs those checkout suites: Bun coverage plus the browser smoke flow.
72
+ - An isolated installed consumer instead tests the package's public transports and host behavior;
73
+ it does not run or depend on the product checkout's source suites.
65
74
 
66
75
  ## WebView Automation Caveat
67
76
 
@@ -56,14 +56,12 @@ function replay_filtered_stderr() {
56
56
 
57
57
  function run_with_filtered_stderr() {
58
58
  local stderr_file
59
+ local status
59
60
  stderr_file="$(mktemp)"
60
- if "$@" 2>"$stderr_file"; then
61
- replay_filtered_stderr "$stderr_file"
62
- rm -f "$stderr_file"
63
- return 0
64
- fi
65
-
66
- local status=$?
61
+ set +e
62
+ "$@" 2>"$stderr_file"
63
+ status=$?
64
+ set -e
67
65
  replay_filtered_stderr "$stderr_file"
68
66
  rm -f "$stderr_file"
69
67
  return "$status"
@@ -161,17 +159,18 @@ rm -rf dist bundle.html
161
159
 
162
160
  # Build with Parcel
163
161
  echo "🔨 Building with Parcel..."
164
- run_with_filtered_stderr run_local_binary parcel build index.html --dist-dir dist --no-source-maps --log-level error || true
162
+ parcel_status=0
163
+ run_with_filtered_stderr run_local_binary parcel build index.html --dist-dir dist --no-source-maps --log-level error || parcel_status=$?
165
164
 
166
165
  # Self-heal a wedged cache: these build projects are REUSED across artifact
167
166
  # builds, and a parcel killed mid-write (e.g. the canvas server torn down
168
167
  # during a build) leaves a corrupted .parcel-cache that makes every later
169
168
  # build die before emitting anything — permanently, until the cache goes.
170
- # One cold-cache retry converts that wedge into a slow build.
171
- if [ ! -s "dist/index.html" ]; then
172
- echo "🧹 Parcel produced no output — clearing .parcel-cache and retrying cold..."
169
+ # Retry exactly once from a cold cache, then preserve a genuine build failure.
170
+ if [ "$parcel_status" -ne 0 ] || [ ! -s "dist/index.html" ]; then
171
+ echo "🧹 Parcel build failed or produced no output — clearing .parcel-cache and retrying cold..."
173
172
  rm -rf .parcel-cache dist
174
- run_with_filtered_stderr run_local_binary parcel build index.html --dist-dir dist --no-source-maps --log-level error || true
173
+ run_with_filtered_stderr run_local_binary parcel build index.html --dist-dir dist --no-source-maps --log-level error
175
174
  fi
176
175
 
177
176
  if [ ! -s "dist/index.html" ]; then
@@ -234,6 +234,7 @@ export function CanvasNode({ node, children, onContextMenu }: CanvasNodeProps) {
234
234
 
235
235
  const handleTitlePointerDown = useCallback(
236
236
  (e: PointerEvent) => {
237
+ if (e.button !== 0) return;
237
238
  if (renaming) return;
238
239
  if (canvasTool.value === 'connect') {
239
240
  startEdgeDrag(e);
@@ -267,6 +268,7 @@ export function CanvasNode({ node, children, onContextMenu }: CanvasNodeProps) {
267
268
 
268
269
  const handlePointerDown = useCallback(
269
270
  (e: PointerEvent) => {
271
+ if (e.button !== 0) return;
270
272
  e.stopPropagation();
271
273
  if (canvasTool.value === 'connect') {
272
274
  startEdgeDrag(e);
@@ -281,8 +283,10 @@ export function CanvasNode({ node, children, onContextMenu }: CanvasNodeProps) {
281
283
  [node.id, startEdgeDrag],
282
284
  );
283
285
 
284
- const handleContextMenuEvent = useCallback(
286
+ const handleDblClick = useCallback(
285
287
  (e: MouseEvent) => {
288
+ const target = e.target instanceof Element ? e.target : null;
289
+ if (target?.closest('button, input, textarea, select, a, [contenteditable="true"]')) return;
286
290
  if (onContextMenu) onContextMenu(e, node.id);
287
291
  },
288
292
  [onContextMenu, node.id],
@@ -312,13 +316,6 @@ export function CanvasNode({ node, children, onContextMenu }: CanvasNodeProps) {
312
316
  [node],
313
317
  );
314
318
 
315
- // ── Double-click rename ───────────────────────────────
316
- const handleTitleDblClick = useCallback((e: MouseEvent) => {
317
- e.stopPropagation();
318
- setRenaming(true);
319
- requestAnimationFrame(() => renameRef.current?.focus());
320
- }, []);
321
-
322
319
  const title =
323
320
  (node.data.title as string) ||
324
321
  // Untitled file nodes (created before titles were synthesized server-side)
@@ -453,7 +450,7 @@ export function CanvasNode({ node, children, onContextMenu }: CanvasNodeProps) {
453
450
  data-node-type={node.type}
454
451
  style={{ ...nodeStyle, width: 'auto', height: 'auto' }}
455
452
  onPointerDown={handlePointerDown}
456
- onContextMenu={handleContextMenuEvent}
453
+ onDblClick={handleDblClick}
457
454
  title="Expand group"
458
455
  data-testid="group-chip"
459
456
  >
@@ -499,7 +496,7 @@ export function CanvasNode({ node, children, onContextMenu }: CanvasNodeProps) {
499
496
  onMouseDown={(e) => {
500
497
  if (e.shiftKey) e.preventDefault();
501
498
  }}
502
- onContextMenu={handleContextMenuEvent}
499
+ onDblClick={handleDblClick}
503
500
  >
504
501
  {isGroup && (
505
502
  <div class="node-titlebar group-edge-row" onPointerDown={handleTitlePointerDown}>
@@ -517,7 +514,7 @@ export function CanvasNode({ node, children, onContextMenu }: CanvasNodeProps) {
517
514
  onClick={(e) => e.stopPropagation()}
518
515
  />
519
516
  ) : (
520
- <span class="group-name" onDblClick={handleTitleDblClick} title={`${title} — double-click to rename`}>
517
+ <span class="group-name" title={title}>
521
518
  {title}
522
519
  </span>
523
520
  )}
@@ -659,7 +656,7 @@ export function CanvasNode({ node, children, onContextMenu }: CanvasNodeProps) {
659
656
  onClick={(e) => e.stopPropagation()}
660
657
  />
661
658
  ) : (
662
- <span class="node-title" onDblClick={handleTitleDblClick} title={`${title} — double-click to rename`}>
659
+ <span class="node-title" title={title}>
663
660
  {title}
664
661
  </span>
665
662
  )}
@@ -409,7 +409,7 @@ export function CanvasViewport({
409
409
  if (e.target !== container) return;
410
410
 
411
411
  // Pan tool / held Space: usePanZoom owns this drag.
412
- if (isPanModeActive() || e.button === 1) {
412
+ if (isPanModeActive() || e.button === 1 || e.button === 2) {
413
413
  if (!e.shiftKey && !lassoRef.current) {
414
414
  activeNodeId.value = null;
415
415
  clearSelection();
@@ -659,51 +659,27 @@ export function CanvasViewport({
659
659
  };
660
660
  }, [containerRef]);
661
661
 
662
- // ── Double-click on background → create new markdown node ──
662
+ // ── Double-click on background → open node creation menu ──
663
663
  const handleDblClick = useCallback(
664
664
  (e: MouseEvent) => {
665
665
  if (annotationMode) return;
666
666
  const container = containerRef.current;
667
- if (!container || e.target !== container) return;
667
+ if (!container || e.target !== container || !onCanvasContextMenu) return;
668
668
  const rect = container.getBoundingClientRect();
669
669
  const v = viewport.value;
670
670
  const wx = (e.clientX - rect.left - v.x) / v.scale;
671
671
  const wy = (e.clientY - rect.top - v.y) / v.scale;
672
- // Offset so node centers on click point
673
- const nodeW = 520;
674
- const nodeH = 360;
675
- createNodeFromClient({
676
- type: 'markdown',
677
- title: 'New note',
678
- x: wx - nodeW / 2,
679
- y: wy - nodeH / 2,
680
- width: nodeW,
681
- height: nodeH,
682
- });
683
- },
684
- [annotationMode, containerRef],
685
- );
686
-
687
- const handleContextMenu = useCallback(
688
- (e: MouseEvent) => {
689
- if (annotationMode) return;
690
- if (!onCanvasContextMenu) return;
691
-
692
- const container = containerRef.current;
693
- if (!container) return;
694
-
695
- const target = e.target instanceof Element ? e.target : null;
696
- if (target?.closest('.canvas-node')) return;
697
-
698
- const rect = container.getBoundingClientRect();
699
- const v = viewport.value;
700
- const canvasX = (e.clientX - rect.left - v.x) / v.scale;
701
- const canvasY = (e.clientY - rect.top - v.y) / v.scale;
702
- onCanvasContextMenu(e, canvasX, canvasY);
672
+ onCanvasContextMenu(e, wx, wy);
703
673
  },
704
674
  [annotationMode, containerRef, onCanvasContextMenu],
705
675
  );
706
676
 
677
+ const handleContextMenu = useCallback((e: MouseEvent) => {
678
+ // Right-click is a pan gesture, never a menu trigger. Suppress the
679
+ // browser menu whether it lands on the board or bubbles from a node.
680
+ e.preventDefault();
681
+ }, []);
682
+
707
683
  // Internal native drags (selected node text, links inside rendered
708
684
  // markdown) re-enter this same container as drops and mint webpage nodes
709
685
  // from card text (0.5.1 Amp finding D). Kill them at the source — external
@@ -466,6 +466,19 @@ function buildNodeMenuItems(node: CanvasNodeState): MenuItem[] {
466
466
  action: () => focusNode(node.id),
467
467
  });
468
468
 
469
+ items.push({
470
+ label: 'Rename…',
471
+ action: () => {
472
+ void askText('Rename node', 'Node title', {
473
+ initial: typeof node.data.title === 'string' ? node.data.title : '',
474
+ confirm: 'Save',
475
+ }).then((title) => {
476
+ if (!title?.trim()) return;
477
+ void updateNodeFromClient(node.id, { title: title.trim() });
478
+ });
479
+ },
480
+ });
481
+
469
482
  // Expand into full-viewport overlay for focused work
470
483
  if (EXPANDABLE_TYPES.has(node.type)) {
471
484
  items.push({
@@ -166,7 +166,7 @@ function EdgePath({ edge, fromNode, toNode, focused, dimmed, selected, scale, on
166
166
  <g>
167
167
  {/* Invisible wide hitbox — the ONE interactive part of the edge layer
168
168
  (the svg itself is pointer-events:none): click selects the edge
169
- (Delete then removes it), right-click opens the edge menu. */}
169
+ (Delete then removes it), double-click opens the edge menu. */}
170
170
  <path
171
171
  d={d}
172
172
  fill="none"
@@ -179,7 +179,7 @@ function EdgePath({ edge, fromNode, toNode, focused, dimmed, selected, scale, on
179
179
  e.stopPropagation();
180
180
  selectedEdgeId.value = edge.id;
181
181
  }}
182
- onContextMenu={onContextMenu ? (e) => onContextMenu(e as unknown as MouseEvent, edge.id) : undefined}
182
+ onDblClick={onContextMenu ? (e) => onContextMenu(e as unknown as MouseEvent, edge.id) : undefined}
183
183
  />
184
184
 
185
185
  {/* Glow layer for focused or selected edges */}
@@ -288,7 +288,10 @@ function TimelineRow({ entry }: { entry: TimelineEntry }) {
288
288
  }
289
289
 
290
290
  export function SessionPanel() {
291
- const [collapsed, setCollapsed] = useState(false);
291
+ // A gate may temporarily expand the panel without turning that expansion
292
+ // into a user preference. Once the last gate settles, only an explicitly
293
+ // opened panel remains open.
294
+ const [manuallyOpened, setManuallyOpened] = useState(false);
292
295
  const session = activeSession.value;
293
296
  const items = sessionWorkItems.value;
294
297
  const gates = pendingGates.value;
@@ -307,12 +310,28 @@ export function SessionPanel() {
307
310
  void refreshTimeline();
308
311
  }, []);
309
312
 
310
- if (collapsed) {
313
+ const expanded = manuallyOpened || gates.length > 0;
314
+ if (!expanded) {
311
315
  return (
312
316
  <aside class="session-panel is-collapsed" aria-label="Session (collapsed)">
313
- <button type="button" class="session-collapse" onClick={() => setCollapsed(false)} title="Expand session panel">
317
+ <button
318
+ type="button"
319
+ class="session-collapse"
320
+ onClick={() => setManuallyOpened(true)}
321
+ title="Expand session panel"
322
+ >
314
323
  ‹
315
324
  </button>
325
+ {session && (
326
+ <span
327
+ class={`session-collapsed-live phase-${session.phase}`}
328
+ title={agentPhaseLabel(session)}
329
+ aria-label={`Session live: ${agentPhaseLabel(session)}`}
330
+ >
331
+ <span class="session-live-dot" aria-hidden="true" />
332
+ live
333
+ </span>
334
+ )}
316
335
  <span class="session-collapsed-label">Session</span>
317
336
  </aside>
318
337
  );
@@ -342,7 +361,7 @@ export function SessionPanel() {
342
361
  <button
343
362
  type="button"
344
363
  class="session-collapse"
345
- onClick={() => setCollapsed(true)}
364
+ onClick={() => setManuallyOpened(false)}
346
365
  title="Collapse session panel"
347
366
  >
348
367
  ›
@@ -32,7 +32,7 @@ const GROUPS: ShortcutGroup[] = [
32
32
  { keys: 'I / W / H', desc: 'New image / webpage / HTML surface' },
33
33
  { keys: 'Shift+F', desc: 'New file node (workspace path)' },
34
34
  { keys: 'G / Shift+G', desc: 'Group the selection (new frame when empty) / ungroup' },
35
- { keys: 'Double-click', desc: 'Create new markdown note on canvas' },
35
+ { keys: 'Double-click', desc: 'Open actions on a node or create menu on canvas' },
36
36
  { keys: 'Drag port \u2192 node', desc: 'Connect two nodes (hover to reveal ports)' },
37
37
  ],
38
38
  },
@@ -52,7 +52,7 @@ const GROUPS: ShortcutGroup[] = [
52
52
  shortcuts: [
53
53
  { keys: '?', desc: 'Toggle this shortcut overlay' },
54
54
  { keys: 'Minimap', desc: 'Click/drag to navigate (toggle in the rail)' },
55
- { keys: 'Right-click', desc: 'Context menu — dock, focus, connect' },
55
+ { keys: 'Right-drag', desc: 'Pan the board from anywhere' },
56
56
  ],
57
57
  },
58
58
  ];
@@ -1,5 +1,6 @@
1
1
  import type { CanvasNodeState } from '../types';
2
2
  import { nodeMinSize } from '../../shared/node-sizes.js';
3
+ import { findBlocker } from '../../shared/placement.js';
3
4
 
4
5
  export const AUTO_FIT_TITLEBAR_HEIGHT = 37;
5
6
  export const AUTO_FIT_MAX_HEIGHT = 600;
@@ -81,7 +82,25 @@ export function shouldContentFitIframeNode(node: CanvasNodeState): boolean {
81
82
  * capped at the iframe ceiling.
82
83
  */
83
84
  export function computeContentGrowHeight(node: CanvasNodeState, contentHeight: number): number | null {
84
- if (!shouldContentFitIframeNode(node) || contentHeight <= 0) return null;
85
+ if (!shouldContentFitIframeNode(node) || !Number.isFinite(contentHeight) || contentHeight <= 0) return null;
85
86
  const want = Math.min(contentHeight + AUTO_FIT_TITLEBAR_HEIGHT + AUTO_FIT_BODY_PADDING, AUTO_FIT_MAX_HEIGHT_IFRAME);
86
87
  return want > node.size.height + 8 ? want : null;
87
88
  }
89
+
90
+ /** Keep an automatically growing card clear without moving its neighbors or
91
+ * changing its column. Groups and screen-pinned cards retain authored placement. */
92
+ export function contentFitPosition(
93
+ node: CanvasNodeState,
94
+ height: number,
95
+ allNodes: CanvasNodeState[],
96
+ ): CanvasNodeState['position'] {
97
+ if (height <= node.size.height || node.pinned || node.data.parentGroup) return node.position;
98
+ const obstacles = allNodes.filter((other) => other.id !== node.id && !other.pinned && other.type !== 'group');
99
+ const position = { ...node.position };
100
+ let blocker = findBlocker(position, node.size.width, height, obstacles, 24);
101
+ while (blocker) {
102
+ position.y = blocker.position.y + blocker.size.height + 24;
103
+ blocker = findBlocker(position, node.size.width, height, obstacles, 24);
104
+ }
105
+ return position;
106
+ }
@@ -22,7 +22,8 @@ interface PanZoomOptions {
22
22
  * Hook that wires up pan/zoom interactions on a container element.
23
23
  * - Wheel + Ctrl/Cmd: zoom centered on pointer
24
24
  * - Wheel without modifier: pan
25
- * - Pointer drag on background: pan
25
+ * - Right or middle pointer drag: pan from anywhere
26
+ * - Pointer drag on background in pan mode: pan
26
27
  * - Pinch (touch): zoom
27
28
  */
28
29
  export function usePanZoom({ viewport, onViewportChange, onViewportCommit, disabled = false }: PanZoomOptions) {
@@ -103,8 +104,9 @@ export function usePanZoom({ viewport, onViewportChange, onViewportCommit, disab
103
104
  if (disabled) return;
104
105
  const container = containerRef.current;
105
106
  if (!container) return;
106
- // Middle-drag pans from anywhere, any tool — standard canvas UX.
107
- if (e.button === 1) {
107
+ // Right- and middle-drag pan from anywhere, any tool. Node handlers
108
+ // deliberately leave non-left buttons alone so this bubbles here.
109
+ if (e.button === 1 || e.button === 2) {
108
110
  e.preventDefault();
109
111
  } else if (isPanModeActive()) {
110
112
  // Pan tool / held Space: the world layer is pointer-inert (CSS), so
@@ -122,6 +124,7 @@ export function usePanZoom({ viewport, onViewportChange, onViewportCommit, disab
122
124
  return;
123
125
  }
124
126
  isPanning.current = true;
127
+ document.documentElement.classList.add('is-canvas-panning');
125
128
  lastPointer.current = { x: e.clientX, y: e.clientY };
126
129
  container.setPointerCapture(e.pointerId);
127
130
  },
@@ -147,6 +150,7 @@ export function usePanZoom({ viewport, onViewportChange, onViewportCommit, disab
147
150
  onViewportCommit(viewport.value);
148
151
  }
149
152
  isPanning.current = false;
153
+ document.documentElement.classList.remove('is-canvas-panning');
150
154
  }, [onViewportCommit, viewport]);
151
155
 
152
156
  // Touch pinch
@@ -207,6 +211,7 @@ export function usePanZoom({ viewport, onViewportChange, onViewportCommit, disab
207
211
  el.addEventListener('pointermove', handlePointerMove);
208
212
  el.addEventListener('pointerup', handlePointerUp);
209
213
  el.addEventListener('pointercancel', handlePointerUp);
214
+ el.addEventListener('lostpointercapture', handlePointerUp);
210
215
  el.addEventListener('touchmove', handleTouchMove, { passive: false });
211
216
  el.addEventListener('touchend', handleTouchEnd);
212
217
 
@@ -219,10 +224,18 @@ export function usePanZoom({ viewport, onViewportChange, onViewportCommit, disab
219
224
  el.removeEventListener('pointermove', handlePointerMove);
220
225
  el.removeEventListener('pointerup', handlePointerUp);
221
226
  el.removeEventListener('pointercancel', handlePointerUp);
227
+ el.removeEventListener('lostpointercapture', handlePointerUp);
222
228
  el.removeEventListener('touchmove', handleTouchMove);
223
229
  el.removeEventListener('touchend', handleTouchEnd);
224
230
  };
225
231
  }, [handleWheel, handlePointerDown, handlePointerMove, handlePointerUp, handleTouchMove, handleTouchEnd]);
226
232
 
233
+ useEffect(
234
+ () => () => {
235
+ document.documentElement.classList.remove('is-canvas-panning');
236
+ },
237
+ [],
238
+ );
239
+
227
240
  return containerRef;
228
241
  }