pmx-canvas 0.6.1 → 0.6.2

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.
@@ -10,7 +10,8 @@ interface PanZoomOptions {
10
10
  * Hook that wires up pan/zoom interactions on a container element.
11
11
  * - Wheel + Ctrl/Cmd: zoom centered on pointer
12
12
  * - Wheel without modifier: pan
13
- * - Pointer drag on background: pan
13
+ * - Right or middle pointer drag: pan from anywhere
14
+ * - Pointer drag on background in pan mode: pan
14
15
  * - Pinch (touch): zoom
15
16
  */
16
17
  export declare function usePanZoom({ viewport, onViewportChange, onViewportCommit, disabled }: PanZoomOptions): import("preact/src").RefObject<HTMLDivElement>;
@@ -328,7 +328,7 @@ Groups are spatial containers that visually contain other nodes. They render
328
328
  as dashed-border frames with a title bar and optional accent color.
329
329
 
330
330
  - Select 2+ nodes and click "Group" in the selection bar
331
- - Ungroup from the frame's ⋯ menu, the right-click menu, or Shift+G — the frame dissolves and
331
+ - Ungroup from the frame's ⋯ menu, the double-click menu, or Shift+G — the frame dissolves and
332
332
  its children stay as independent nodes (one undo step); `canvas_group { action: "ungroup" }`
333
333
  does exactly the same
334
334
  - Collapsing a group hides children and shows a summary
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmx-canvas",
3
- "version": "0.6.1",
3
+ "version": "0.6.2",
4
4
  "description": "Spatial canvas workbench for coding agents \u2014 infinite 2D canvas with agent-native CLI, MCP integration, nodes, edges, file watching, and snapshots",
5
5
  "type": "module",
6
6
  "main": "./src/server/index.ts",
@@ -155,9 +155,9 @@ json-render, a graph, or an HTML primitive is sufficient.
155
155
 
156
156
  ## Current MCP Composites
157
157
 
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).
158
+ The live MCP surface is **22 tools**: the 16 composites below plus 6 standalones. This table is
159
+ kept in sync with the server's composite registry; `tools/list` on a fresh `pmx-canvas --mcp` is
160
+ always authoritative, independent of the installed release version.
161
161
 
162
162
  | Composite | Actions |
163
163
  |-----------|---------|
@@ -235,8 +235,10 @@ distant nodes, an auto-placed node lands off-camera.
235
235
  markdown 640×420, status 360×200, file 520×360, diff 640×420, mermaid 640×460, html 720×640,
236
236
  graph 760×520, mcp-app 960×600, web-artifact 960×720. A *hosted* app opened with
237
237
  `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.
238
+ the exception: request `width: 720, height: 500` for its standard landscape preset instead of
239
+ relying on the 960×600 default for a directly created `mcp-app` node. After the app mounts, read
240
+ the node back with `canvas_query { action: "layout" }` and use its persisted width and height for
241
+ subsequent placement or layout; the mounted app may have changed the requested dimensions.
240
242
  Since 0.4.6 the server clamps explicit creation sizes UP to per-type
241
243
  readability floors (e.g. markdown 360×180, graph/json-render/html 420×280, mcp-app 480×320) —
242
244
  a tiny probe size silently becomes the floor. `strictSize: true` is the only opt-out (a fixed
@@ -333,11 +335,12 @@ Use the visible workbench when the human is actively curating layout:
333
335
  - The selection bar (floating bottom-center) offers count, align left/top, distribute,
334
336
  auto-arrange, Group (G), Connect, Pin as context, delete and clear; selected nodes show an
335
337
  accent outline with corner handles.
336
- - Right-click a node for context pinning, position locking, focus, collapse, connect, refresh,
338
+ - Double-left-click a node for context pinning, position locking, focus, collapse, connect, refresh,
337
339
  open, close, and type-specific actions.
340
+ - Double-left-click empty canvas for the node-creation menu; hold and drag the right mouse button to pan.
338
341
  - Drop files or URLs to create matching nodes; an empty board shows starter actions (new note,
339
342
  pick files, paste a link, start an agent session).
340
- - Double-click markdown to edit inline.
343
+ - Use a markdown node's Edit button to edit its content.
341
344
  - **Groups** are frames with the name pill and an action cluster (auto-arrange children, collapse,
342
345
  ⋯ rename / ungroup / pin all) on the top edge. Membership changes only on release while the
343
346
  "release to add to <group>" pill shows (Esc keeps it out); dragging a child fully out offers
@@ -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
@@ -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
  ];
@@ -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
  }
@@ -1467,7 +1467,13 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
1467
1467
  minHeight: 0,
1468
1468
  border: 'none',
1469
1469
  background: 'var(--c-panel)',
1470
- pointerEvents: isExpanded && status !== 'loading' ? 'auto' : 'none',
1470
+ // Keep the expanded frame in Chromium's pointer hit-test tree from
1471
+ // its first paint. Toggling none → auto when boot completes can
1472
+ // leave the compositor routing the first click to the parent even
1473
+ // though DOM hit testing and Playwright both resolve the button.
1474
+ // The loading sibling is non-interactive; inline frames remain
1475
+ // guarded by the preview catcher below.
1476
+ pointerEvents: isExpanded ? 'auto' : 'none',
1471
1477
  }}
1472
1478
  title={`Ext App: ${toolName}`}
1473
1479
  />
@@ -922,7 +922,9 @@ function handleCanvasLayoutUpdate(data: Record<string, unknown>): void {
922
922
  }
923
923
  : undefined;
924
924
 
925
- cancelViewportAnimation();
925
+ // Routine node/layout broadcasts do not replace the camera, so must not
926
+ // interrupt a focus animation either (view.focus also emits a layout).
927
+ if (shouldApplyViewport) cancelViewportAnimation();
926
928
  applyServerCanvasLayout(
927
929
  {
928
930
  ...(nextViewport ? { viewport: nextViewport } : {}),
@@ -1377,7 +1377,7 @@ body,
1377
1377
  animation: session-panel-in 180ms ease;
1378
1378
  }
1379
1379
  .session-panel.is-collapsed {
1380
- width: 28px;
1380
+ width: 40px;
1381
1381
  align-items: center;
1382
1382
  padding-top: 10px;
1383
1383
  gap: 10px;
@@ -1390,6 +1390,17 @@ body,
1390
1390
  text-transform: uppercase;
1391
1391
  color: var(--c-dim);
1392
1392
  }
1393
+ .session-collapsed-live {
1394
+ display: inline-flex;
1395
+ flex-direction: column;
1396
+ align-items: center;
1397
+ gap: 4px;
1398
+ font: 9px var(--mono);
1399
+ color: var(--c-dim);
1400
+ }
1401
+ .session-collapsed-live .session-live-dot {
1402
+ background: var(--c-ok);
1403
+ }
1393
1404
 
1394
1405
  .session-panel-header {
1395
1406
  height: var(--hud-bar-height);
@@ -1717,7 +1728,7 @@ body,
1717
1728
  }
1718
1729
  .session-timeline-filters .activity-filter {
1719
1730
  flex-shrink: 0;
1720
- padding: 2px 4px;
1731
+ padding: 2px 3px;
1721
1732
  }
1722
1733
 
1723
1734
  .session-timeline-row {
@@ -3745,6 +3756,12 @@ body,
3745
3756
  }
3746
3757
 
3747
3758
  /* Node resize handle */
3759
+ .canvas-node:has(.node-resize-handle) {
3760
+ /* Keep the four-pixel corner grab inside the shell's inner clip, not on
3761
+ its rounded border. The other corners retain the standard card radius. */
3762
+ border-bottom-right-radius: 8px;
3763
+ }
3764
+
3748
3765
  .canvas-node .node-resize-handle {
3749
3766
  position: absolute;
3750
3767
  bottom: 0;
@@ -3789,7 +3806,9 @@ html.is-node-dragging .attention-field-layer {
3789
3806
  }
3790
3807
 
3791
3808
  html.is-node-dragging,
3792
- html.is-node-dragging * {
3809
+ html.is-node-dragging *,
3810
+ html.is-canvas-panning,
3811
+ html.is-canvas-panning * {
3793
3812
  cursor: grabbing !important;
3794
3813
  user-select: none !important;
3795
3814
  -webkit-user-select: none !important;
@@ -726,7 +726,7 @@ function shouldTreatFileContentAsPath(input: CanvasAddNodeInput): boolean {
726
726
  const content = input.content?.trim() ?? '';
727
727
  if (!content || content.includes('\n') || content.includes('\r')) return false;
728
728
  if (typeof input.data?.path === 'string' && input.data.path.length > 0) return true;
729
- if (existsSync(resolve(content))) return true;
729
+ if (existsSync(resolve(canvasState.getWorkspaceRoot(), content))) return true;
730
730
  if (!input.title) return true;
731
731
  return content.startsWith('/') || content.startsWith('./') || content.startsWith('../') || content.includes('/');
732
732
  }
@@ -748,7 +748,7 @@ function buildFileNodeData(input: CanvasAddNodeInput): Record<string, unknown> {
748
748
 
749
749
  const rawPath =
750
750
  typeof input.data?.path === 'string' && input.data.path.length > 0 ? input.data.path : (input.content ?? '');
751
- const resolved = resolve(rawPath);
751
+ const resolved = resolve(canvasState.getWorkspaceRoot(), rawPath);
752
752
  const fileName = basename(resolved) || rawPath;
753
753
  const data: Record<string, unknown> = {
754
754
  ...(input.data ?? {}),