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.
- package/CHANGELOG.md +41 -0
- package/Readme.md +14 -2
- package/dist/canvas/global.css +22 -3
- package/dist/canvas/index.js +64 -64
- package/dist/json-render/index.js +57 -57
- package/dist/types/client/canvas/auto-fit.d.ts +3 -0
- package/dist/types/client/canvas/use-pan-zoom.d.ts +2 -1
- package/dist/types/client/nodes/use-iframe-content-height.d.ts +3 -3
- package/dist/types/client/state/human-store.d.ts +1 -0
- package/dist/types/json-render/catalog.d.ts +20 -20
- package/dist/types/json-render/schema.d.ts +40 -0
- package/dist/types/mcp/skills.d.ts +13 -0
- package/dist/types/server/bundled-skills.d.ts +32 -4
- package/docs/http-api.md +5 -1
- package/docs/mcp.md +35 -1
- package/docs/node-types.md +1 -1
- package/docs/product-context-vision-2026-09.md +16 -5
- package/docs/product-vision-2026-09.md +4 -2
- package/package.json +11 -11
- package/skills/json-render-mcp/SKILL.md +118 -2
- package/skills/pmx-canvas/SKILL.md +42 -16
- package/skills/pmx-canvas/evals/evals.json +1 -1
- package/skills/pmx-canvas/references/excalidraw-diagram-authoring.md +5 -0
- package/skills/pmx-canvas/references/full-reference.md +5 -4
- package/skills/pmx-canvas/references/installing-pmx-canvas.md +5 -5
- package/skills/pmx-canvas-testing/SKILL.md +13 -4
- package/skills/web-artifacts-builder/scripts/bundle-artifact.sh +11 -12
- package/src/client/canvas/CanvasNode.tsx +9 -12
- package/src/client/canvas/CanvasViewport.tsx +10 -34
- package/src/client/canvas/ContextMenu.tsx +13 -0
- package/src/client/canvas/EdgeLayer.tsx +2 -2
- package/src/client/canvas/SessionPanel.tsx +23 -4
- package/src/client/canvas/ShortcutOverlay.tsx +2 -2
- package/src/client/canvas/auto-fit.ts +20 -1
- package/src/client/canvas/use-pan-zoom.ts +16 -3
- package/src/client/nodes/ExtAppFrame.tsx +7 -1
- package/src/client/nodes/use-iframe-content-height.ts +44 -24
- package/src/client/state/human-store.ts +6 -6
- package/src/client/state/sse-bridge.ts +3 -1
- package/src/client/theme/global.css +22 -3
- package/src/json-render/catalog.ts +6 -0
- package/src/json-render/renderer/index.tsx +17 -0
- package/src/json-render/schema.ts +4 -0
- package/src/mcp/server.ts +9 -4
- package/src/mcp/skills.ts +47 -0
- package/src/server/bundled-skills.ts +92 -53
- package/src/server/canvas-operations.ts +2 -2
- package/src/server/operations/ops/nodes.ts +43 -11
- package/src/server/operations/ops/viewport.ts +6 -1
- package/src/server/server.ts +9 -9
- package/src/server/web-artifacts/scripts/bundle-artifact.sh +29 -8
- 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.
|
|
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
|
|
159
|
-
|
|
160
|
-
|
|
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:
|
|
239
|
-
|
|
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
|
-
-
|
|
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
|
-
-
|
|
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)
|
|
468
|
-
|
|
469
|
-
|
|
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.
|
|
475
|
-
|
|
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
|
|
479
|
-
|
|
480
|
-
|
|
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/
|
|
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
|
-
|
|
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 —
|
|
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
|
-
-
|
|
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
|
-
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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"
|
|
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"
|
|
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 →
|
|
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
|
-
|
|
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),
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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={() =>
|
|
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: '
|
|
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-
|
|
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
|
-
* -
|
|
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
|
-
//
|
|
107
|
-
|
|
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
|
}
|