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.
- package/CHANGELOG.md +22 -0
- package/Readme.md +2 -2
- package/dist/canvas/global.css +22 -3
- package/dist/canvas/index.js +53 -53
- package/dist/types/client/canvas/use-pan-zoom.d.ts +2 -1
- package/docs/node-types.md +1 -1
- package/package.json +1 -1
- package/skills/pmx-canvas/SKILL.md +10 -7
- 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-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/use-pan-zoom.ts +16 -3
- package/src/client/nodes/ExtAppFrame.tsx +7 -1
- package/src/client/state/sse-bridge.ts +3 -1
- package/src/client/theme/global.css +22 -3
- package/src/server/canvas-operations.ts +2 -2
- 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
|
@@ -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
|
-
* -
|
|
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>;
|
package/docs/node-types.md
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
|
159
|
-
|
|
160
|
-
|
|
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:
|
|
239
|
-
|
|
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
|
-
-
|
|
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
|
-
-
|
|
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/
|
|
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
|
|
@@ -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
|
];
|
|
@@ -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
|
}
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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 ?? {}),
|