pmx-canvas 0.5.0 → 0.5.1
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 +17 -0
- package/Readme.md +1 -1
- package/dist/canvas/global.css +4 -2
- package/dist/canvas/index.js +72 -54
- package/dist/types/client/canvas/canvas-area.d.ts +14 -0
- package/dist/types/client/nodes/ExtAppFrame.d.ts +2 -0
- package/dist/types/server/canvas-operations.d.ts +10 -0
- package/docs/RELEASE.md +14 -0
- package/docs/screenshot.png +0 -0
- package/package.json +4 -4
- package/skills/pmx-canvas/SKILL.md +9 -1
- package/src/cli/commands/pump.ts +13 -2
- package/src/cli/index.ts +1 -0
- package/src/client/canvas/canvas-area.ts +33 -0
- package/src/client/nodes/ExtAppFrame.tsx +86 -20
- package/src/client/state/canvas-store.ts +13 -7
- package/src/client/theme/global.css +4 -2
- package/src/server/canvas-operations.ts +41 -6
- package/src/server/index.ts +4 -2
- package/src/server/operations/ops/nodes.ts +12 -0
- package/src/server/operations/ops/snapshots.ts +6 -19
|
@@ -23,3 +23,17 @@ export declare function canvasAreaCenter(): {
|
|
|
23
23
|
x: number;
|
|
24
24
|
y: number;
|
|
25
25
|
};
|
|
26
|
+
export interface CanvasFitInsets {
|
|
27
|
+
top: number;
|
|
28
|
+
right: number;
|
|
29
|
+
bottom: number;
|
|
30
|
+
left: number;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Screen-space insets Fit must reserve for the floating chrome that overlays
|
|
34
|
+
* the canvas region: the bottom command bar (whose pinned-chip row grows it)
|
|
35
|
+
* and the bottom-right minimap. Measured from the live elements — fit's old
|
|
36
|
+
* world-space padding shrank with the fit zoom and parked nodes underneath
|
|
37
|
+
* both (0.5.0 Amp report, finding A; architecture rule 9's trap).
|
|
38
|
+
*/
|
|
39
|
+
export declare function canvasFitInsets(): CanvasFitInsets;
|
|
@@ -3,6 +3,8 @@ import { AppBridge } from '@modelcontextprotocol/ext-apps/app-bridge';
|
|
|
3
3
|
import type { CanvasNodeState } from '../types';
|
|
4
4
|
type ExtAppBridgeNotifications = Pick<AppBridge, 'sendToolInput' | 'sendToolResult'>;
|
|
5
5
|
type DisplayMode = 'inline' | 'fullscreen' | 'pip';
|
|
6
|
+
/** Paint probe outcome: only a content-carrying tick counts as painted. */
|
|
7
|
+
export type PaintProbeVerdict = 'content-verified' | 'tick-no-content' | 'timeout';
|
|
6
8
|
interface ExtAppHostDimensionsTarget {
|
|
7
9
|
clientWidth?: number;
|
|
8
10
|
clientHeight?: number;
|
|
@@ -178,6 +178,16 @@ export declare function setCanvasContextPins(nodeIds: string[], mode?: CanvasPin
|
|
|
178
178
|
nodeIds: string[];
|
|
179
179
|
};
|
|
180
180
|
export declare function listCanvasSnapshots(options?: Parameters<typeof canvasState.listSnapshots>[0]): CanvasSnapshot[];
|
|
181
|
+
/**
|
|
182
|
+
* Snapshot save with unchanged-board reuse — the ONE path every surface must
|
|
183
|
+
* take (op registry, SDK). The SDK once called saveCanvasSnapshot directly and
|
|
184
|
+
* stacked identical snapshots the HTTP/MCP path deduplicated (0.5.0 Codex
|
|
185
|
+
* finding V).
|
|
186
|
+
*/
|
|
187
|
+
export declare function saveCanvasSnapshotWithReuse(name: string): {
|
|
188
|
+
snapshot: CanvasSnapshot | null;
|
|
189
|
+
reused: boolean;
|
|
190
|
+
};
|
|
181
191
|
export declare function saveCanvasSnapshot(name: string): CanvasSnapshot | null;
|
|
182
192
|
export declare function restoreCanvasSnapshot(idOrName: string): Promise<{
|
|
183
193
|
ok: boolean;
|
package/docs/RELEASE.md
CHANGED
|
@@ -250,3 +250,17 @@ with `ok: true`, the published tarball is intact end-to-end.
|
|
|
250
250
|
- **Node 24 deprecation timeline**: GitHub Actions is migrating
|
|
251
251
|
`actions/checkout`, `setup-node`, `upload-artifact` to Node 24 by
|
|
252
252
|
June 2, 2026. The publish workflow already pins `@v5` of all three.
|
|
253
|
+
- **npm token failures wear two disguises** (both hit the 0.5.0 publish,
|
|
254
|
+
after build/tests/provenance all succeeded):
|
|
255
|
+
- `404 Not Found - PUT …/pmx-canvas` on a package that exists = the
|
|
256
|
+
token was rejected (npm masks permission refusals as 404). Rotate the
|
|
257
|
+
`NPM_TOKEN` repo secret.
|
|
258
|
+
- `EOTP: This operation requires a one-time password` = the account
|
|
259
|
+
enforces 2FA on writes and the token doesn't have **Bypass 2FA**
|
|
260
|
+
ticked — CI cannot answer an OTP. A granular token needs that box
|
|
261
|
+
checked to publish from Actions.
|
|
262
|
+
- Both are fixed without re-tagging: update the secret, then
|
|
263
|
+
`gh run rerun <publish-run-id> --failed` re-publishes the same tag.
|
|
264
|
+
- npm restricts bypass-2FA tokens for direct publishing by **Jan 2027**
|
|
265
|
+
— migrate to Trusted Publishing (OIDC; `publish.yml` already has
|
|
266
|
+
`id-token: write`) and delete the token before then.
|
package/docs/screenshot.png
CHANGED
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmx-canvas",
|
|
3
|
-
"version": "0.5.
|
|
4
|
-
"description": "Spatial canvas workbench for coding agents
|
|
3
|
+
"version": "0.5.1",
|
|
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",
|
|
7
7
|
"module": "./src/server/index.ts",
|
|
@@ -43,9 +43,9 @@
|
|
|
43
43
|
"typecheck": "tsc --noEmit && tsc -p tsconfig.tests.json",
|
|
44
44
|
"lint": "biome check .",
|
|
45
45
|
"lint:fix": "biome check --write .",
|
|
46
|
-
"test": "if [ -d tests/unit ]; then PMX_MCP_APP_HOST_STATE_FILE=/tmp/pmx-canvas-test-app-host-state.json PMX_CANVAS_DISABLE_BROWSER_OPEN=1 bun test tests/unit; else echo 'tests/ is not shipped in the npm package
|
|
46
|
+
"test": "if [ -d tests/unit ]; then PMX_MCP_APP_HOST_STATE_FILE=/tmp/pmx-canvas-test-app-host-state.json PMX_CANVAS_DISABLE_BROWSER_OPEN=1 bun test tests/unit; else echo 'tests/ is not shipped in the npm package \u2014 clone the repo to run the suite'; fi",
|
|
47
47
|
"test:unit": "PMX_MCP_APP_HOST_STATE_FILE=/tmp/pmx-canvas-test-app-host-state.json PMX_CANVAS_DISABLE_BROWSER_OPEN=1 bun test tests/unit",
|
|
48
|
-
"test:client": "if [ -d tests/client ]; then bun test --preload ./tests/client/setup.ts tests/client; else echo 'tests/ is not shipped in the npm package
|
|
48
|
+
"test:client": "if [ -d tests/client ]; then bun test --preload ./tests/client/setup.ts tests/client; else echo 'tests/ is not shipped in the npm package \u2014 clone the repo to run the suite'; fi",
|
|
49
49
|
"test:coverage": "PMX_MCP_APP_HOST_STATE_FILE=/tmp/pmx-canvas-test-app-host-state.json PMX_CANVAS_DISABLE_BROWSER_OPEN=1 bun test tests/unit --coverage --coverage-reporter=text --coverage-reporter=lcov --coverage-dir coverage && bun scripts/check-coverage.ts",
|
|
50
50
|
"test:web-canvas": "PMX_CANVAS_DISABLE_BROWSER_OPEN=1 bun run build && PMX_CANVAS_DISABLE_BROWSER_OPEN=1 bash scripts/run-playwright.sh",
|
|
51
51
|
"test:e2e": "bun run test:web-canvas",
|
|
@@ -147,7 +147,7 @@ this table is kept in sync with the server's composite registry; `tools/list` on
|
|
|
147
147
|
| `canvas_query` | `search`, `layout`, `validate` |
|
|
148
148
|
| `canvas_webview` | `status`, `start`, `stop`, `resize`, `evaluate` |
|
|
149
149
|
| `canvas_app` | `open-mcp-app`, `diagram`, `build-artifact` |
|
|
150
|
-
| `canvas_ax_state` | `get`, `set-focus`, `set-policy`, `report-capability` |
|
|
150
|
+
| `canvas_ax_state` | `get`, `set-focus`, `set-policy`, `report-capability`, `presence`, `set-presence` |
|
|
151
151
|
| `canvas_ax_work` | `add`, `update`, `annotate` |
|
|
152
152
|
| `canvas_ax_gate` | `request`, `resolve`, `await` with `approval`, `elicitation`, or `mode` |
|
|
153
153
|
| `canvas_ax_timeline` | `read`, `record-event`, `add-evidence`, `send-steering` |
|
|
@@ -262,6 +262,14 @@ The board has three modes, all gated on one fact — whether a session is attach
|
|
|
262
262
|
extension) attach for you; the human can also start a session from the board's *Start agent
|
|
263
263
|
session* button — your writes (transport or `PMX_CANVAS_AGENT_SOURCE` label alike) are
|
|
264
264
|
attributed to it and it takes your name.
|
|
265
|
+
- **No adapter? Use the pump.** Any CLI agent becomes steer-reactive with one command in a
|
|
266
|
+
terminal: `pmx-canvas pump --consumer <your-key> --exec '<command>'`. It long-polls your
|
|
267
|
+
delivery queue, runs the command once per steer (message on stdin plus the
|
|
268
|
+
`PMX_STEER_MESSAGE`/`ID`/`SOURCE`/`TARGET`/`CREATED_AT` env envelope), and marks
|
|
269
|
+
per-consumer only after the command exits 0 — a failed hand-off stays pending and the pump
|
|
270
|
+
exits non-zero. `{message}` in the template expands to a quoted env reference (never spliced;
|
|
271
|
+
refused on Windows — read stdin there). `--parent <key>` rolls you up under an orchestrator's
|
|
272
|
+
chip; `--once` for scripts. See `pmx-canvas pump --help`.
|
|
265
273
|
|
|
266
274
|
What the session asks of you:
|
|
267
275
|
|
package/src/cli/commands/pump.ts
CHANGED
|
@@ -16,7 +16,15 @@
|
|
|
16
16
|
* takes the message from stdin instead. See `renderExecTemplate`.
|
|
17
17
|
*/
|
|
18
18
|
import { spawn } from 'node:child_process';
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
cmd,
|
|
21
|
+
getBaseUrl,
|
|
22
|
+
getStringFlag,
|
|
23
|
+
invokeOperation,
|
|
24
|
+
parseFlags,
|
|
25
|
+
requireFlag,
|
|
26
|
+
showCommandHelp,
|
|
27
|
+
} from '../shared.js';
|
|
20
28
|
|
|
21
29
|
interface PendingSteer {
|
|
22
30
|
id: string;
|
|
@@ -137,7 +145,10 @@ cmd(
|
|
|
137
145
|
`pmx-canvas pump --consumer testbot --exec 'cat' --once`,
|
|
138
146
|
],
|
|
139
147
|
async (args) => {
|
|
140
|
-
const { flags } = parseFlags(args, { boolFlags: ['once'] });
|
|
148
|
+
const { flags } = parseFlags(args, { boolFlags: ['once', 'help', 'h'] });
|
|
149
|
+
// Help before required-flag validation — `pump --help` died on
|
|
150
|
+
// "Missing required flag: --consumer" (0.5.0 Codex finding W).
|
|
151
|
+
if (flags.help || flags.h) return showCommandHelp('pump');
|
|
141
152
|
const consumer = requireFlag(
|
|
142
153
|
flags,
|
|
143
154
|
'consumer',
|
package/src/cli/index.ts
CHANGED
|
@@ -314,6 +314,7 @@ Agent CLI (works against running server):
|
|
|
314
314
|
code-graph File dependencies
|
|
315
315
|
spatial Spatial analysis
|
|
316
316
|
watch Semantic watch stream
|
|
317
|
+
pump Reactive steering loop for CLI agents (Amp, Codex CLI)
|
|
317
318
|
smoke Check that a running canvas works here
|
|
318
319
|
skills sync Sync installed copies of the bundled skills
|
|
319
320
|
copilot install-extension Install the GitHub Copilot app extension
|
|
@@ -39,3 +39,36 @@ export function canvasAreaCenter(): { x: number; y: number } {
|
|
|
39
39
|
const rect = canvasArea();
|
|
40
40
|
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
|
|
41
41
|
}
|
|
42
|
+
|
|
43
|
+
export interface CanvasFitInsets {
|
|
44
|
+
top: number;
|
|
45
|
+
right: number;
|
|
46
|
+
bottom: number;
|
|
47
|
+
left: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Screen-space insets Fit must reserve for the floating chrome that overlays
|
|
52
|
+
* the canvas region: the bottom command bar (whose pinned-chip row grows it)
|
|
53
|
+
* and the bottom-right minimap. Measured from the live elements — fit's old
|
|
54
|
+
* world-space padding shrank with the fit zoom and parked nodes underneath
|
|
55
|
+
* both (0.5.0 Amp report, finding A; architecture rule 9's trap).
|
|
56
|
+
*/
|
|
57
|
+
export function canvasFitInsets(): CanvasFitInsets {
|
|
58
|
+
let bottom = 16;
|
|
59
|
+
try {
|
|
60
|
+
const area = canvasArea();
|
|
61
|
+
const areaBottom = area.top + area.height;
|
|
62
|
+
for (const selector of ['.command-bar', '.minimap']) {
|
|
63
|
+
const el = document.querySelector(selector);
|
|
64
|
+
if (!(el instanceof HTMLElement) || !el.isConnected) continue;
|
|
65
|
+
const rect = el.getBoundingClientRect();
|
|
66
|
+
if (rect.height <= 0) continue;
|
|
67
|
+
const overlap = areaBottom - rect.top;
|
|
68
|
+
if (overlap > 0) bottom = Math.max(bottom, overlap + 12);
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
71
|
+
// Chromeless embeds and unit DOMs: the defaults below are the contract.
|
|
72
|
+
}
|
|
73
|
+
return { top: 16, right: 16, bottom, left: 16 };
|
|
74
|
+
}
|
|
@@ -30,6 +30,8 @@ type McpUiTheme = 'light' | 'dark';
|
|
|
30
30
|
type ExtAppBridgeNotifications = Pick<AppBridge, 'sendToolInput' | 'sendToolResult'>;
|
|
31
31
|
type DisplayMode = 'inline' | 'fullscreen' | 'pip';
|
|
32
32
|
type ExtAppFrameStatus = 'loading' | 'ready' | 'done';
|
|
33
|
+
/** Paint probe outcome: only a content-carrying tick counts as painted. */
|
|
34
|
+
export type PaintProbeVerdict = 'content-verified' | 'tick-no-content' | 'timeout';
|
|
33
35
|
|
|
34
36
|
interface ExtAppHostDimensionsTarget {
|
|
35
37
|
clientWidth?: number;
|
|
@@ -285,10 +287,28 @@ export function buildExtAppBootBeaconScript(frameToken: string, nodeId: string):
|
|
|
285
287
|
// rendering pipeline is actually running — a frame whose layer WebKit never
|
|
286
288
|
// composites stays silent, and that silence drives the parent's recovery
|
|
287
289
|
// ladder. One unsolicited tick at boot, then on-demand answers to probes.
|
|
290
|
+
// Content oracle (Finding N, 0.5.0 reopen): a double rAF fires even while
|
|
291
|
+
// WKWebKit composites nothing — the tick alone was a false green. The tick
|
|
292
|
+
// now carries a verdict: does this document actually CONTAIN rendered
|
|
293
|
+
// content (a laid-out canvas/svg, or real text)? The parent only accepts
|
|
294
|
+
// paint-ok when it does.
|
|
295
|
+
function contentReady() {
|
|
296
|
+
try {
|
|
297
|
+
var els = document.querySelectorAll('canvas, svg');
|
|
298
|
+
for (var i = 0; i < els.length; i++) {
|
|
299
|
+
var r = els[i].getBoundingClientRect();
|
|
300
|
+
if (r.width > 8 && r.height > 8) return true;
|
|
301
|
+
}
|
|
302
|
+
var body = document.body;
|
|
303
|
+
return !!(body && body.innerText && body.innerText.trim().length > 0);
|
|
304
|
+
} catch (e) {
|
|
305
|
+
return false;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
288
308
|
function sendPaintTick() {
|
|
289
309
|
requestAnimationFrame(function () {
|
|
290
310
|
requestAnimationFrame(function () {
|
|
291
|
-
window.parent.postMessage({ source: '${EXT_APP_BOOT_BEACON_SOURCE}', token: TOKEN, nodeId: NODE_ID, kind: 'paint-tick' }, '*');
|
|
311
|
+
window.parent.postMessage({ source: '${EXT_APP_BOOT_BEACON_SOURCE}', token: TOKEN, nodeId: NODE_ID, kind: 'paint-tick', content: contentReady() }, '*');
|
|
292
312
|
});
|
|
293
313
|
});
|
|
294
314
|
}
|
|
@@ -367,8 +387,9 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
367
387
|
const bridgeReadyRef = useRef(false);
|
|
368
388
|
const themeUnsubRef = useRef<(() => void) | null>(null);
|
|
369
389
|
const webkitRemountAttemptsRef = useRef(0);
|
|
370
|
-
// Paint oracle (Finding N): resolvers waiting for the frame's next paint-tick
|
|
371
|
-
|
|
390
|
+
// Paint oracle (Finding N): resolvers waiting for the frame's next paint-tick,
|
|
391
|
+
// each handed the tick's content verdict.
|
|
392
|
+
const paintTickWaitersRef = useRef<Array<(content: boolean) => void>>([]);
|
|
372
393
|
// Genuine boot signal: set ONLY when the app completes the ui/initialize
|
|
373
394
|
// handshake (bridge.oninitialized) — NOT by the 1200ms bootstrap fallback,
|
|
374
395
|
// which flips status via notifications that resolve even into a dead iframe.
|
|
@@ -450,7 +471,8 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
450
471
|
return;
|
|
451
472
|
appScriptsRanRef.current = true;
|
|
452
473
|
if (data.kind === 'paint-tick') {
|
|
453
|
-
|
|
474
|
+
const content = (data as { content?: unknown }).content === true;
|
|
475
|
+
for (const waiter of paintTickWaitersRef.current.splice(0)) waiter(content);
|
|
454
476
|
}
|
|
455
477
|
}
|
|
456
478
|
window.addEventListener('message', onBootBeacon);
|
|
@@ -503,7 +525,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
503
525
|
enqueueWebkitRemount({
|
|
504
526
|
remount: () => {
|
|
505
527
|
remountQueuedRef.current = false;
|
|
506
|
-
if (unmountedRef.current || expandedNodeId.value === nodeId) {
|
|
528
|
+
if (unmountedRef.current || (!expanded && expandedNodeId.value === nodeId)) {
|
|
507
529
|
extAppRecoveryLog(nodeId, 'remount-skipped');
|
|
508
530
|
// The ladder left paintPending set on the promise that this remount
|
|
509
531
|
// would re-enter it via the new frame's settle path. A skip means
|
|
@@ -555,28 +577,42 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
555
577
|
// again → serialized remount (bounded by the shared attempt cap) → explicit
|
|
556
578
|
// Retry affordance. Never silent eternal black. WebKit inline frames only.
|
|
557
579
|
const PAINT_TICK_TIMEOUT_MS = 1500;
|
|
558
|
-
const probePaint = (): Promise<
|
|
580
|
+
const probePaint = (): Promise<PaintProbeVerdict> =>
|
|
559
581
|
new Promise((resolve) => {
|
|
560
582
|
const contentWindow = iframeRef.current?.contentWindow;
|
|
561
583
|
if (!contentWindow || unmountedRef.current) {
|
|
562
|
-
resolve(
|
|
584
|
+
resolve('timeout');
|
|
563
585
|
return;
|
|
564
586
|
}
|
|
565
587
|
let settled = false;
|
|
566
588
|
const timer = window.setTimeout(() => {
|
|
567
589
|
if (settled) return;
|
|
568
590
|
settled = true;
|
|
569
|
-
resolve(
|
|
591
|
+
resolve('timeout');
|
|
570
592
|
}, PAINT_TICK_TIMEOUT_MS);
|
|
571
|
-
paintTickWaitersRef.current.push(() => {
|
|
593
|
+
paintTickWaitersRef.current.push((content) => {
|
|
572
594
|
if (settled) return;
|
|
573
595
|
settled = true;
|
|
574
596
|
window.clearTimeout(timer);
|
|
575
|
-
|
|
597
|
+
// A tick without content is NOT paint-ok: the rAF pipeline runs while
|
|
598
|
+
// the document is empty cream / never composited (Finding N reopen).
|
|
599
|
+
resolve(content ? 'content-verified' : 'tick-no-content');
|
|
576
600
|
});
|
|
577
601
|
contentWindow.postMessage({ source: EXT_APP_PAINT_PROBE_SOURCE, token: axToken }, '*');
|
|
578
602
|
});
|
|
579
603
|
|
|
604
|
+
// Bounded wait for the frame's content oracle to turn green — the mount
|
|
605
|
+
// queue's release gate (guidance: never green-light the NEXT app while the
|
|
606
|
+
// previous document is still contentless; D-then-E was the repro).
|
|
607
|
+
const awaitContentVerified = async (maxMs: number): Promise<boolean> => {
|
|
608
|
+
const deadline = Date.now() + maxMs;
|
|
609
|
+
while (Date.now() < deadline && !unmountedRef.current) {
|
|
610
|
+
if ((await probePaint()) === 'content-verified') return true;
|
|
611
|
+
await new Promise((resolve) => window.setTimeout(resolve, 400));
|
|
612
|
+
}
|
|
613
|
+
return false;
|
|
614
|
+
};
|
|
615
|
+
|
|
580
616
|
// Force a real layer reallocation: grow the iframe's box meaningfully for two
|
|
581
617
|
// frames, then restore. Invisible under the paint-pending overlay.
|
|
582
618
|
const runSoftExpandCycle = (): Promise<void> =>
|
|
@@ -638,29 +674,44 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
638
674
|
// 0.4.7). Say so in the trail instead of recording an unqualified success.
|
|
639
675
|
const paintOkLabel = (suffix = ''): string =>
|
|
640
676
|
typeof document !== 'undefined' && document.visibilityState === 'hidden'
|
|
641
|
-
? `paint-ok${suffix} (
|
|
642
|
-
: `paint-ok${suffix}`;
|
|
677
|
+
? `paint-ok (content-verified)${suffix} (host hidden)`
|
|
678
|
+
: `paint-ok (content-verified)${suffix}`;
|
|
643
679
|
|
|
644
680
|
const runPaintLadder = async (): Promise<void> => {
|
|
645
|
-
|
|
681
|
+
// Runs for the EXPANDED instance too (Finding N reopen: expanded C was a
|
|
682
|
+
// black frame with no recovery — the ladder used to early-return here).
|
|
646
683
|
if (typeof navigator === 'undefined' || typeof window === 'undefined') return;
|
|
647
684
|
if (!isWebKitOnlyHost(navigator.userAgent)) return;
|
|
648
685
|
setPaintPending(true);
|
|
649
|
-
|
|
686
|
+
let verdict = await probePaint();
|
|
687
|
+
if (verdict === 'content-verified') {
|
|
650
688
|
extAppRecoveryLog(nodeId, paintOkLabel());
|
|
651
689
|
setPaintPending(false);
|
|
652
690
|
maybeAssumeVisibleRearm();
|
|
653
691
|
return;
|
|
654
692
|
}
|
|
655
|
-
extAppRecoveryLog(
|
|
693
|
+
extAppRecoveryLog(
|
|
694
|
+
nodeId,
|
|
695
|
+
verdict === 'tick-no-content' ? 'paint-fail (tick without content)' : 'paint-fail (no tick)',
|
|
696
|
+
);
|
|
656
697
|
await runSoftExpandCycle();
|
|
657
|
-
|
|
698
|
+
verdict = await probePaint();
|
|
699
|
+
if (verdict === 'content-verified') {
|
|
658
700
|
extAppRecoveryLog(nodeId, paintOkLabel(' (after soft-expand)'));
|
|
659
701
|
setPaintPending(false);
|
|
660
702
|
maybeAssumeVisibleRearm();
|
|
661
703
|
return;
|
|
662
704
|
}
|
|
663
705
|
if (unmountedRef.current) return;
|
|
706
|
+
// A contentless-but-alive app may still be DRAWING its scene: give the
|
|
707
|
+
// oracle a bounded settle before burning a remount attempt on it.
|
|
708
|
+
if (verdict === 'tick-no-content' && (await awaitContentVerified(3_000))) {
|
|
709
|
+
extAppRecoveryLog(nodeId, paintOkLabel(' (after content wait)'));
|
|
710
|
+
setPaintPending(false);
|
|
711
|
+
maybeAssumeVisibleRearm();
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
if (unmountedRef.current) return;
|
|
664
715
|
// The remounted frame re-enters this ladder from its own settle path.
|
|
665
716
|
if (!scheduleWebkitRemount('paint-fail')) {
|
|
666
717
|
extAppRecoveryLog(nodeId, 'recovery-exhausted');
|
|
@@ -709,7 +760,8 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
709
760
|
// serialized queue (bounded by the shared attempt cap).
|
|
710
761
|
const WEBKIT_BOOT_WATCHDOG_MS = 6000;
|
|
711
762
|
useEffect(() => {
|
|
712
|
-
|
|
763
|
+
// Covers the expanded instance too — its fresh iframe was black with no
|
|
764
|
+
// recovery at all (Finding N reopen, expanded C).
|
|
713
765
|
if (typeof navigator === 'undefined' || typeof window === 'undefined') return;
|
|
714
766
|
if (!isWebKitOnlyHost(navigator.userAgent)) return;
|
|
715
767
|
if (!iframeDocument.ready || !mountGranted) return;
|
|
@@ -725,7 +777,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
725
777
|
() => () => {
|
|
726
778
|
unmountedRef.current = true;
|
|
727
779
|
for (const waiter of bootWaitersRef.current.splice(0)) waiter();
|
|
728
|
-
for (const waiter of paintTickWaitersRef.current.splice(0)) waiter();
|
|
780
|
+
for (const waiter of paintTickWaitersRef.current.splice(0)) waiter(false);
|
|
729
781
|
},
|
|
730
782
|
[],
|
|
731
783
|
);
|
|
@@ -1035,8 +1087,22 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
1035
1087
|
// its own settle pause covering the draw) only from this genuine path;
|
|
1036
1088
|
// the bootstrap fallback never releases it (a dead iframe must run the
|
|
1037
1089
|
// queue's bounded timeout instead of green-lighting the next remount).
|
|
1038
|
-
|
|
1039
|
-
|
|
1090
|
+
// Release the mount queue only once this document's CONTENT
|
|
1091
|
+
// oracle is green (bounded): a settled-but-empty app must not
|
|
1092
|
+
// green-light the next mount slot (D-then-E repro, Finding N
|
|
1093
|
+
// reopen). WebKit only — elsewhere the oracle race doesn't exist.
|
|
1094
|
+
const releaseQueue = () => {
|
|
1095
|
+
for (const waiter of bootWaitersRef.current.splice(0)) waiter();
|
|
1096
|
+
};
|
|
1097
|
+
if (typeof navigator !== 'undefined' && isWebKitOnlyHost(navigator.userAgent)) {
|
|
1098
|
+
void awaitContentVerified(4_000).then((verified) => {
|
|
1099
|
+
extAppRecoveryLog(nodeId, verified ? 'settled (content-verified)' : 'settled (content-timeout)');
|
|
1100
|
+
releaseQueue();
|
|
1101
|
+
});
|
|
1102
|
+
} else {
|
|
1103
|
+
extAppRecoveryLog(nodeId, 'settled');
|
|
1104
|
+
releaseQueue();
|
|
1105
|
+
}
|
|
1040
1106
|
nudgeHostContextAfterLayout();
|
|
1041
1107
|
schedulePaintLadder();
|
|
1042
1108
|
})
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
type ViewportState,
|
|
10
10
|
} from '../types';
|
|
11
11
|
import { computeAutoArrange } from '../../shared/auto-arrange';
|
|
12
|
-
import { canvasAreaCenter } from '../canvas/canvas-area';
|
|
12
|
+
import { canvasAreaCenter, canvasFitInsets } from '../canvas/canvas-area';
|
|
13
13
|
import { pushCanvasUpdate, requestBestEffort, requestOk, updateViewportFromClient } from './intent-bridge';
|
|
14
14
|
|
|
15
15
|
function logCanvasStoreError(action: string, error: unknown): void {
|
|
@@ -808,16 +808,22 @@ export function fitAll(containerW: number, containerH: number): void {
|
|
|
808
808
|
maxY = Math.max(maxY, n.position.y + n.size.height);
|
|
809
809
|
}
|
|
810
810
|
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
811
|
+
// Screen-space fit (architecture rule 9): the margins that must clear the
|
|
812
|
+
// floating chrome are SCREEN constants. The old world-space PAD shrank with
|
|
813
|
+
// the fit zoom, parking fitted nodes under the command bar and minimap
|
|
814
|
+
// (0.5.0 Amp report, finding A).
|
|
815
|
+
const insets = canvasFitInsets();
|
|
816
|
+
const usableW = Math.max(80, containerW - insets.left - insets.right);
|
|
817
|
+
const usableH = Math.max(80, containerH - insets.top - insets.bottom);
|
|
818
|
+
const worldW = Math.max(1, maxX - minX);
|
|
819
|
+
const worldH = Math.max(1, maxY - minY);
|
|
820
|
+
const scale = Math.min(1, Math.min(usableW / worldW, usableH / worldH));
|
|
815
821
|
const cx = (minX + maxX) / 2;
|
|
816
822
|
const cy = (minY + maxY) / 2;
|
|
817
823
|
|
|
818
824
|
animateViewport({
|
|
819
|
-
x:
|
|
820
|
-
y:
|
|
825
|
+
x: insets.left + usableW / 2 - cx * scale,
|
|
826
|
+
y: insets.top + usableH / 2 - cy * scale,
|
|
821
827
|
scale,
|
|
822
828
|
});
|
|
823
829
|
}
|
|
@@ -1632,7 +1632,9 @@ body,
|
|
|
1632
1632
|
flex-wrap: nowrap;
|
|
1633
1633
|
gap: 1px;
|
|
1634
1634
|
margin: 2px -12px 8px;
|
|
1635
|
-
|
|
1635
|
+
/* Compact panels (Amp portal, 0.5.0 report B): six chips overflowed the
|
|
1636
|
+
row by 11px — tighter inset + chip padding buys ~16px back. */
|
|
1637
|
+
padding: 0 8px;
|
|
1636
1638
|
overflow-x: auto;
|
|
1637
1639
|
scrollbar-width: none;
|
|
1638
1640
|
}
|
|
@@ -1651,7 +1653,7 @@ body,
|
|
|
1651
1653
|
}
|
|
1652
1654
|
.session-timeline-filters .activity-filter {
|
|
1653
1655
|
flex-shrink: 0;
|
|
1654
|
-
padding: 2px
|
|
1656
|
+
padding: 2px 4px;
|
|
1655
1657
|
}
|
|
1656
1658
|
|
|
1657
1659
|
.session-timeline-row {
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
2
2
|
import { basename, resolve } from 'node:path';
|
|
3
|
+
import { agentPresence } from './agent-presence.js';
|
|
3
4
|
import { recomputeCodeGraph } from './code-graph.js';
|
|
5
|
+
import { diffLayouts } from './mutation-history.js';
|
|
4
6
|
import {
|
|
5
7
|
canvasState,
|
|
6
8
|
type CanvasEdge,
|
|
@@ -1396,6 +1398,30 @@ export function listCanvasSnapshots(options?: Parameters<typeof canvasState.list
|
|
|
1396
1398
|
return canvasState.listSnapshots(options);
|
|
1397
1399
|
}
|
|
1398
1400
|
|
|
1401
|
+
/**
|
|
1402
|
+
* Snapshot save with unchanged-board reuse — the ONE path every surface must
|
|
1403
|
+
* take (op registry, SDK). The SDK once called saveCanvasSnapshot directly and
|
|
1404
|
+
* stacked identical snapshots the HTTP/MCP path deduplicated (0.5.0 Codex
|
|
1405
|
+
* finding V).
|
|
1406
|
+
*/
|
|
1407
|
+
export function saveCanvasSnapshotWithReuse(name: string): { snapshot: CanvasSnapshot | null; reused: boolean } {
|
|
1408
|
+
const newest = canvasState.listSnapshots({ limit: 1 })[0];
|
|
1409
|
+
if (newest) {
|
|
1410
|
+
const newestData = canvasState.getSnapshotData(newest.id);
|
|
1411
|
+
if (newestData) {
|
|
1412
|
+
const diff = diffLayouts(newestData.name, newestData, canvasState.getLayout());
|
|
1413
|
+
const identical =
|
|
1414
|
+
diff.addedNodes.length === 0 &&
|
|
1415
|
+
diff.removedNodes.length === 0 &&
|
|
1416
|
+
diff.modifiedNodes.length === 0 &&
|
|
1417
|
+
diff.addedEdges.length === 0 &&
|
|
1418
|
+
diff.removedEdges.length === 0;
|
|
1419
|
+
if (identical) return { snapshot: newest, reused: true };
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
return { snapshot: saveCanvasSnapshot(name), reused: false };
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1399
1425
|
export function saveCanvasSnapshot(name: string): CanvasSnapshot | null {
|
|
1400
1426
|
return canvasState.saveSnapshot(name);
|
|
1401
1427
|
}
|
|
@@ -1748,7 +1774,7 @@ export function getClientViewportSize(): { width: number; height: number } | nul
|
|
|
1748
1774
|
export function fitCanvasView(options: CanvasFitViewOptions = {}): CanvasFitViewResult {
|
|
1749
1775
|
const width = positiveNumber(options.width, lastClientViewportSize?.width ?? 1440);
|
|
1750
1776
|
const height = positiveNumber(options.height, lastClientViewportSize?.height ?? 900);
|
|
1751
|
-
const padding = positiveNumber(options.padding,
|
|
1777
|
+
const padding = positiveNumber(options.padding, 24); // screen px now, not world units
|
|
1752
1778
|
const maxScale = positiveNumber(options.maxScale, 1);
|
|
1753
1779
|
const nodeIdFilter = options.nodeIds && options.nodeIds.length > 0 ? new Set(options.nodeIds) : null;
|
|
1754
1780
|
const targetNodes = canvasState.getLayout().nodes.filter((node) => !nodeIdFilter || nodeIdFilter.has(node.id));
|
|
@@ -1770,14 +1796,23 @@ export function fitCanvasView(options: CanvasFitViewOptions = {}): CanvasFitView
|
|
|
1770
1796
|
}
|
|
1771
1797
|
|
|
1772
1798
|
const bounds = { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1799
|
+
// Screen-space fit (architecture rule 9): `padding` and the chrome reserve
|
|
1800
|
+
// are SCREEN pixels — world-space padding shrank with the fit zoom and
|
|
1801
|
+
// parked nodes under the command bar and minimap (0.5.0 Amp report,
|
|
1802
|
+
// finding A). The server cannot measure the client's chrome, so it reserves
|
|
1803
|
+
// nominal bottom space: the minimap is always there; the command bar only
|
|
1804
|
+
// while a session is attached.
|
|
1805
|
+
const bottomReserve = agentPresence.snapshot().sessionActive ? 128 : 96;
|
|
1806
|
+
const usableWidth = Math.max(80, width - padding * 2);
|
|
1807
|
+
const usableHeight = Math.max(80, height - padding - bottomReserve);
|
|
1808
|
+
const worldWidth = Math.max(1, bounds.width);
|
|
1809
|
+
const worldHeight = Math.max(1, bounds.height);
|
|
1810
|
+
const scale = Math.min(maxScale, usableWidth / worldWidth, usableHeight / worldHeight);
|
|
1776
1811
|
const centerX = minX + bounds.width / 2;
|
|
1777
1812
|
const centerY = minY + bounds.height / 2;
|
|
1778
1813
|
const viewport = {
|
|
1779
|
-
x:
|
|
1780
|
-
y:
|
|
1814
|
+
x: padding + usableWidth / 2 - centerX * scale,
|
|
1815
|
+
y: padding + usableHeight / 2 - centerY * scale,
|
|
1781
1816
|
scale,
|
|
1782
1817
|
};
|
|
1783
1818
|
|
package/src/server/index.ts
CHANGED
|
@@ -59,7 +59,7 @@ import {
|
|
|
59
59
|
removeCanvasEdge,
|
|
60
60
|
resolveHtmlContent,
|
|
61
61
|
restoreCanvasSnapshot,
|
|
62
|
-
|
|
62
|
+
saveCanvasSnapshotWithReuse,
|
|
63
63
|
scheduleCodeGraphRecompute,
|
|
64
64
|
syncCanvasRuntimeBackends,
|
|
65
65
|
setCanvasContextPins,
|
|
@@ -1072,7 +1072,9 @@ export class PmxCanvas extends EventEmitter {
|
|
|
1072
1072
|
}
|
|
1073
1073
|
|
|
1074
1074
|
saveSnapshot(name: string) {
|
|
1075
|
-
|
|
1075
|
+
// Same reuse path as HTTP/MCP: an unchanged board returns the existing
|
|
1076
|
+
// newest snapshot instead of stacking an identical copy (Codex finding V).
|
|
1077
|
+
return saveCanvasSnapshotWithReuse(name).snapshot;
|
|
1076
1078
|
}
|
|
1077
1079
|
|
|
1078
1080
|
async restoreSnapshot(id: string): Promise<{ ok: boolean }> {
|
|
@@ -422,6 +422,13 @@ export function createBasicCanvasNode(
|
|
|
422
422
|
: '"path" is only an image-node compatibility alias — use `content`.',
|
|
423
423
|
);
|
|
424
424
|
}
|
|
425
|
+
// Docking was removed in 0.5.0 — a create that still sends it silently
|
|
426
|
+
// succeeded and read as "docking works" (0.5.0 Copilot report, PASS-ish).
|
|
427
|
+
if (body.dockPosition !== undefined) {
|
|
428
|
+
throw new OperationError(
|
|
429
|
+
'Unknown field "dockPosition" — docking was removed in 0.5.0; use `position` and `pinned`.',
|
|
430
|
+
);
|
|
431
|
+
}
|
|
425
432
|
const content =
|
|
426
433
|
type === 'image' && typeof body.path === 'string' && typeof body.content !== 'string' ? body.path : body.content;
|
|
427
434
|
// For html nodes, accept top-level `html` AND `axCapabilities` and merge into data
|
|
@@ -496,6 +503,11 @@ export function buildNodePatch(
|
|
|
496
503
|
if (body.src !== undefined) {
|
|
497
504
|
throw new OperationError('Unknown field "src" — update the image source via `content`.');
|
|
498
505
|
}
|
|
506
|
+
if (body.dockPosition !== undefined) {
|
|
507
|
+
throw new OperationError(
|
|
508
|
+
'Unknown field "dockPosition" — docking was removed in 0.5.0; use `position` and `pinned`.',
|
|
509
|
+
);
|
|
510
|
+
}
|
|
499
511
|
if (body.path !== undefined && existing.type !== 'image') {
|
|
500
512
|
if (existing.type === 'file') {
|
|
501
513
|
// Repoint: `path` on a file-node patch moves the node to another file
|
|
@@ -29,9 +29,9 @@ import {
|
|
|
29
29
|
gcCanvasSnapshots,
|
|
30
30
|
listCanvasSnapshots,
|
|
31
31
|
restoreCanvasSnapshot,
|
|
32
|
-
|
|
32
|
+
saveCanvasSnapshotWithReuse,
|
|
33
33
|
} from '../../canvas-operations.js';
|
|
34
|
-
import {
|
|
34
|
+
import { formatDiff, diffLayouts } from '../../mutation-history.js';
|
|
35
35
|
import { defineOperation, OperationError, type Operation } from '../types.js';
|
|
36
36
|
import { isRecord } from './nodes.js';
|
|
37
37
|
|
|
@@ -129,24 +129,11 @@ const snapshotSaveOperation = defineOperation<z.infer<typeof snapshotSaveSchema>
|
|
|
129
129
|
const name = typeof input.name === 'string' ? input.name.trim() : '';
|
|
130
130
|
if (!name) throw new OperationError('Missing snapshot name');
|
|
131
131
|
// A save of an unchanged board reuses the newest snapshot instead of
|
|
132
|
-
// stacking an identical copy
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
const newestData = canvasState.getSnapshotData(newest.id);
|
|
136
|
-
if (newestData) {
|
|
137
|
-
const diff = diffLayouts(newestData.name, newestData, canvasState.getLayout());
|
|
138
|
-
const identical =
|
|
139
|
-
diff.addedNodes.length === 0 &&
|
|
140
|
-
diff.removedNodes.length === 0 &&
|
|
141
|
-
diff.modifiedNodes.length === 0 &&
|
|
142
|
-
diff.addedEdges.length === 0 &&
|
|
143
|
-
diff.removedEdges.length === 0;
|
|
144
|
-
if (identical) return { ok: true, id: newest.id, snapshot: newest, reused: true };
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
const snapshot = saveCanvasSnapshot(name);
|
|
132
|
+
// stacking an identical copy — via the ONE shared path the SDK uses too
|
|
133
|
+
// (0.5.0 Codex finding V).
|
|
134
|
+
const { snapshot, reused } = saveCanvasSnapshotWithReuse(name);
|
|
148
135
|
if (!snapshot) throw new OperationError('Failed to save snapshot');
|
|
149
|
-
return { ok: true, id: snapshot.id, snapshot };
|
|
136
|
+
return { ok: true, id: snapshot.id, snapshot, ...(reused ? { reused: true } : {}) };
|
|
150
137
|
},
|
|
151
138
|
});
|
|
152
139
|
|