pmx-canvas 0.5.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.
Files changed (41) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/Readme.md +10 -7
  3. package/dist/canvas/global.css +101 -7
  4. package/dist/canvas/index.js +171 -128
  5. package/dist/canvas/mermaid-entry.js +1303 -1303
  6. package/dist/json-render/index.js +101 -102
  7. package/dist/types/client/canvas/use-pan-zoom.d.ts +2 -1
  8. package/docs/adr-001-bun-only-runtime.md +1 -1
  9. package/docs/bun-webview-integration.md +2 -2
  10. package/docs/node-types.md +1 -1
  11. package/docs/product-context-vision-2026-09.md +207 -0
  12. package/docs/product-review-2026-09.md +174 -0
  13. package/docs/product-vision-2026-09.md +200 -0
  14. package/docs/screenshot.png +0 -0
  15. package/package.json +2 -2
  16. package/skills/pmx-canvas/SKILL.md +62 -36
  17. package/skills/pmx-canvas/evals/evals.json +1 -1
  18. package/skills/pmx-canvas/references/excalidraw-diagram-authoring.md +5 -0
  19. package/skills/pmx-canvas/references/full-reference.md +5 -4
  20. package/skills/pmx-canvas/references/installing-pmx-canvas.md +126 -18
  21. package/skills/pmx-canvas-testing/SKILL.md +13 -4
  22. package/skills/web-artifacts-builder/scripts/bundle-artifact.sh +18 -8
  23. package/src/client/canvas/CanvasNode.tsx +9 -12
  24. package/src/client/canvas/CanvasViewport.tsx +20 -33
  25. package/src/client/canvas/ContextMenu.tsx +13 -0
  26. package/src/client/canvas/EdgeLayer.tsx +2 -2
  27. package/src/client/canvas/SessionPanel.tsx +23 -4
  28. package/src/client/canvas/ShortcutOverlay.tsx +2 -2
  29. package/src/client/canvas/TopBar.tsx +85 -45
  30. package/src/client/canvas/use-pan-zoom.ts +23 -3
  31. package/src/client/nodes/ExtAppFrame.tsx +97 -28
  32. package/src/client/state/canvas-store.ts +17 -5
  33. package/src/client/state/sse-bridge.ts +3 -1
  34. package/src/client/theme/global.css +101 -7
  35. package/src/server/canvas-operations.ts +2 -2
  36. package/src/server/operations/mcp.ts +45 -31
  37. package/src/server/operations/ops/ax-presence.ts +3 -0
  38. package/src/server/operations/ops/viewport.ts +6 -1
  39. package/src/server/scope-fence.ts +1 -1
  40. package/src/server/server.ts +9 -9
  41. package/src/server/web-artifacts/scripts/bundle-artifact.sh +29 -8
@@ -2,41 +2,101 @@
2
2
 
3
3
  Use this reference when the `pmx-canvas` skill is installed but the `pmx-canvas` command is not available yet.
4
4
 
5
- ## From npm
5
+ ## Prerequisites and version
6
+
7
+ The examples pin **PMX Canvas 0.6.1**, which requires **Bun >=1.4.2** even when
8
+ installed through npm: the CLI runs through Bun, not Node. Check `bun --version`
9
+ first. If missing or older, follow the [Bun installation instructions](https://bun.sh/docs/installation)
10
+ with the user's authorization. Ensure Bun is on the MCP host's PATH as well as the
11
+ interactive shell's; use an absolute `bun`/`bunx` executable path if needed.
12
+
13
+ Install/configure only when requested. After an intentional runtime upgrade, sync the
14
+ skill from that installed package and restart existing server/MCP processes as described
15
+ in `SKILL.md`. Do not pair newer checkout guidance with an older runtime unknowingly.
16
+
17
+ ## Install from npm
6
18
 
7
19
  ```bash
8
- npm install -g pmx-canvas
9
- pmx-canvas serve --daemon --no-open --wait-ms=20000
10
- pmx-canvas open
20
+ npm install -g pmx-canvas@0.6.1
21
+ pmx-canvas --version
11
22
  ```
12
23
 
13
- ## From a local checkout
24
+ Without a global install, use `bunx pmx-canvas@0.6.1` in place of `pmx-canvas` in
25
+ the commands below. Do not use unpinned `bunx pmx-canvas` for a version-specific check.
26
+
27
+ ## Choose the workspace and target
28
+
29
+ Select an existing absolute root and a dedicated free port. These shell examples use
30
+ POSIX syntax; supply equivalent environment variables on other platforms.
31
+
32
+ ```bash
33
+ export PMX_CANVAS_WORKSPACE_ROOT=/absolute/path/to/project
34
+ export PMX_CANVAS_PORT=14313 # Example only: choose a free port for this workspace.
35
+ unset PMX_CANVAS_URL # A stale URL overrides the environment's CLI port target.
36
+ ```
37
+
38
+ Use **both root and port**. Root pinning alone does not align the CLI, MCP, and browser
39
+ if a server falls back to another port. Without an explicit root, MCP may attach to a
40
+ different workspace on the preferred port. `PMX_CANVAS_ALLOW_WORKSPACE_SPLIT=1` opts
41
+ out of that heuristic but is not a substitute for checking the actual target.
42
+
43
+ For server startup, precedence is `--port`, then `PMX_WEB_CANVAS_PORT`, then
44
+ `PMX_CANVAS_PORT` (then `PORT` in Amp orbs), then the default 4313. For CLI queries and
45
+ mutations, explicit `--server-url`/`--port` override environment targeting; otherwise
46
+ `PMX_CANVAS_URL` wins over `PMX_CANVAS_PORT`. URL targeting does not set the bind port.
47
+
48
+ ## Start the server
49
+
50
+ On an ordinary local machine:
51
+
52
+ ```bash
53
+ pmx-canvas serve --daemon --no-open --port="$PMX_CANVAS_PORT" --wait-ms=20000
54
+ ```
55
+
56
+ In a managed environment, use the host's supervised-service mechanism with foreground
57
+ `pmx-canvas serve --no-open --port=<chosen-port>`, passing the selected environment.
58
+ Do not daemonize inside a supervisor or rely on `nohup`, background shell jobs, or tmux
59
+ to survive an orb restart. In an Amp orb, prefer declared services with
60
+ `amp orb services ensure`; otherwise use `amp orb service start` with the foreground
61
+ command. Use the host's authenticated portal/preview for remote viewing, not a raw
62
+ loopback URL. Verify health below before opening the workbench or making writes.
63
+
64
+ CLI query/mutation commands do not auto-start a server; `serve` or `--mcp` does.
65
+
66
+ ## From a local checkout (development)
14
67
 
15
68
  ```bash
16
69
  git clone https://github.com/pskoett/pmx-canvas.git
17
70
  cd pmx-canvas
18
71
  bun install
19
72
  bun run build
20
- bun run src/cli/index.ts serve --daemon --no-open --wait-ms=20000
73
+ bun run src/cli/index.ts --version
21
74
  ```
22
75
 
23
- For development, run commands through Bun from the checkout:
24
-
25
- ```bash
26
- bun run src/cli/index.ts status
27
- bun run src/cli/index.ts node add --type markdown --title "Hello" --content "# PMX"
28
- ```
76
+ This uses the checked-out source, not necessarily the published 0.6.1 package. Follow
77
+ that checkout's `package.json` Bun requirement and bundled skill. Set the same root/port
78
+ environment above; substitute `bun run /absolute/path/to/pmx-canvas/src/cli/index.ts`
79
+ for `pmx-canvas` in startup and verification commands. The target workspace can be
80
+ different from the source checkout. Build the client before browser verification.
29
81
 
30
82
  ## MCP Config
31
83
 
32
- For agents that support MCP, configure PMX Canvas as a stdio MCP server:
84
+ For agents that support MCP, configure the pinned package as a stdio server. Replace
85
+ the root and port with your selections; ensure the port is dedicated to this workspace.
86
+ The empty URL overrides a stale inherited value (empty is treated as unset). If the host
87
+ discards empty env values, remove `PMX_CANVAS_URL` from its launch environment instead.
33
88
 
34
89
  ```json
35
90
  {
36
91
  "mcpServers": {
37
92
  "canvas": {
38
93
  "command": "bunx",
39
- "args": ["pmx-canvas", "--mcp"]
94
+ "args": ["pmx-canvas@0.6.1", "--mcp"],
95
+ "env": {
96
+ "PMX_CANVAS_WORKSPACE_ROOT": "/absolute/path/to/project",
97
+ "PMX_CANVAS_PORT": "14313",
98
+ "PMX_CANVAS_URL": ""
99
+ }
40
100
  }
41
101
  }
42
102
  }
@@ -49,18 +109,66 @@ If you are using a local checkout instead of the published package, point the co
49
109
  "mcpServers": {
50
110
  "canvas": {
51
111
  "command": "bun",
52
- "args": ["run", "/path/to/pmx-canvas/src/cli/index.ts", "--mcp"]
112
+ "args": ["run", "/absolute/path/to/pmx-canvas/src/cli/index.ts", "--mcp"],
113
+ "env": {
114
+ "PMX_CANVAS_WORKSPACE_ROOT": "/absolute/path/to/project",
115
+ "PMX_CANVAS_PORT": "14313",
116
+ "PMX_CANVAS_URL": ""
117
+ }
53
118
  }
54
119
  }
55
120
  }
56
121
  ```
57
122
 
58
- ## Verify
123
+ Restart the host's MCP process after changing configuration. Inspect startup output for
124
+ fallback ports; do not assume the configured port is the one the MCP server bound.
125
+
126
+ ## Verify the actual target before writes
127
+
128
+ For the local target selected above:
59
129
 
60
130
  ```bash
61
131
  pmx-canvas --version
62
- pmx-canvas serve status
132
+ curl --fail --silent --show-error "http://localhost:${PMX_CANVAS_PORT}/health"
133
+ pmx-canvas serve status --port="$PMX_CANVAS_PORT"
134
+ ```
135
+
136
+ Require healthy `ok`, the expected running `version`, and a **`workspace`** matching the
137
+ intended canonical absolute root (resolve symlinks on both paths). Inspect persistence
138
+ health too. `serve status` checks a local port, not an arbitrary `PMX_CANVAS_URL`.
139
+ When deliberately targeting a URL or a fallback port, fetch `/health` there instead,
140
+ then align CLI, MCP, and browser to it. Never print a credential-bearing URL.
141
+ On mismatch, stop: leave the other server alone, choose a free port, and repeat.
142
+
143
+ Only after confirming the target:
144
+
145
+ ```bash
63
146
  pmx-canvas layout
147
+ pmx-canvas open
148
+ ```
149
+
150
+ Inside an orb, open the registered portal rather than asking the user to open localhost.
151
+
152
+ ## Disposable smoke verification
153
+
154
+ `smoke` creates/searches/removes a temporary node and can leave history/activity records.
155
+ It reports the workspace but does **not** compare it with your intended root. Do not run
156
+ it on a valuable board as an identity check. Use a new disposable workspace and its own
157
+ free port, start it with the appropriate startup method above, and verify `/health` first:
158
+
159
+ ```bash
160
+ export PMX_CANVAS_WORKSPACE_ROOT="$(mktemp -d)"
161
+ export PMX_CANVAS_PORT=14314 # Choose another free port.
162
+ unset PMX_CANVAS_URL
163
+ # Start this workspace's server, then verify its actual health workspace/version.
164
+ # Only after that verification succeeds:
165
+ pmx-canvas smoke
64
166
  ```
65
167
 
66
- The CLI defaults to `http://localhost:4313`. Override with `PMX_CANVAS_URL` or `PMX_CANVAS_PORT` if the server runs elsewhere.
168
+ Inspect the JSON report and exit status: health, MCP initialize, node lifecycle, and
169
+ validation should pass; failures exit 1. Version skew is reported in details and is not
170
+ necessarily a failing check. Smoke proves neither `tools/list` discovery nor browser
171
+ rendering: verify those separately when testing the complete onboarding experience.
172
+ Stop only the test server (service-manager stop, or `serve stop --port=<test-port>` for
173
+ your local daemon) before removing its disposable directory. Never clear a user's board
174
+ or stop an unrelated listener to make a smoke test pass.
@@ -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,7 +159,19 @@ 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
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=$?
164
+
165
+ # Self-heal a wedged cache: these build projects are REUSED across artifact
166
+ # builds, and a parcel killed mid-write (e.g. the canvas server torn down
167
+ # during a build) leaves a corrupted .parcel-cache that makes every later
168
+ # build die before emitting anything — permanently, until the cache goes.
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..."
172
+ rm -rf .parcel-cache dist
173
+ run_with_filtered_stderr run_local_binary parcel build index.html --dist-dir dist --no-source-maps --log-level error
174
+ fi
165
175
 
166
176
  if [ ! -s "dist/index.html" ]; then
167
177
  echo "❌ Error: Parcel did not produce dist/index.html" >&2
@@ -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,50 +659,36 @@ 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
- });
672
+ onCanvasContextMenu(e, wx, wy);
683
673
  },
684
- [annotationMode, containerRef],
674
+ [annotationMode, containerRef, onCanvasContextMenu],
685
675
  );
686
676
 
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;
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
+ }, []);
697
682
 
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);
703
- },
704
- [annotationMode, containerRef, onCanvasContextMenu],
705
- );
683
+ // Internal native drags (selected node text, links inside rendered
684
+ // markdown) re-enter this same container as drops and mint webpage nodes
685
+ // from card text (0.5.1 Amp finding D). Kill them at the source — external
686
+ // drags never fire a local dragstart, so file/URL import is untouched.
687
+ // Editable elements keep their native drag (text drag inside an editor).
688
+ const handleDragStart = useCallback((e: DragEvent) => {
689
+ if (isEditableElement(e.target instanceof Element ? e.target : null)) return;
690
+ e.preventDefault();
691
+ }, []);
706
692
 
707
693
  // ── Drag-and-drop files from filesystem ──
708
694
  const handleDragEnter = useCallback((e: DragEvent) => {
@@ -824,6 +810,7 @@ export function CanvasViewport({
824
810
  onPointerUp={handlePointerUp}
825
811
  onContextMenu={handleContextMenu}
826
812
  onDblClick={handleDblClick}
813
+ onDragStart={handleDragStart}
827
814
  onDragEnter={handleDragEnter}
828
815
  onDragOver={handleDragOver}
829
816
  onDragLeave={handleDragLeave}
@@ -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
  ];