pmx-canvas 0.2.4 → 0.2.6
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 +73 -0
- package/dist/canvas/index.js +51 -51
- package/dist/types/client/nodes/ExtAppFrame.d.ts +13 -0
- package/package.json +1 -1
- package/skills/pmx-canvas/SKILL.md +20 -0
- package/skills/pmx-canvas/references/full-reference.md +13 -3
- package/src/client/nodes/ExtAppFrame.tsx +83 -1
- package/src/server/intent-registry.ts +16 -0
- package/src/server/operations/ops/intent.ts +1 -0
|
@@ -3,11 +3,24 @@ 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
|
+
type ExtAppFrameStatus = 'loading' | 'ready' | 'done';
|
|
6
7
|
interface ExtAppHostDimensionsTarget {
|
|
7
8
|
clientWidth?: number;
|
|
8
9
|
clientHeight?: number;
|
|
9
10
|
getBoundingClientRect(): Pick<DOMRectReadOnly, 'width' | 'height'>;
|
|
10
11
|
}
|
|
12
|
+
/**
|
|
13
|
+
* Finding F (0.2.4): detect a WebKit-only host — Safari or a WKWebView (e.g. the
|
|
14
|
+
* GitHub Copilot app's embedded panel). Blink engines (Chrome / Chromium / Edge /
|
|
15
|
+
* the Codex browser, all of which carry a Chrome/Chromium/CriOS/Edg token) and
|
|
16
|
+
* Android WebView are excluded, as is Gecko (no `AppleWebKit`). Used to gate the
|
|
17
|
+
* one-time ext-app iframe repaint remount to the only engine that exhibits the
|
|
18
|
+
* present-at-load black-tile paint race, so the remount is a strict no-op
|
|
19
|
+
* everywhere we can test (Chrome / Codex / Playwright).
|
|
20
|
+
*/
|
|
21
|
+
export declare function isWebKitOnlyHost(userAgent: string): boolean;
|
|
22
|
+
export declare function nextWebkitRepaintSlot(): number;
|
|
23
|
+
export declare function shouldScheduleWebKitRepaint(status: ExtAppFrameStatus, hasReplayToolResult: boolean): boolean;
|
|
11
24
|
export declare function getExtAppBridgeInitKey(node: CanvasNodeState, retryKey: number): string;
|
|
12
25
|
export declare function resolveExtAppDisplayModeRequest(requestedMode: DisplayMode, isExpanded: boolean): {
|
|
13
26
|
nextMode: DisplayMode;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmx-canvas",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.6",
|
|
4
4
|
"description": "Spatial canvas workbench for coding agents — 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",
|
|
@@ -58,6 +58,15 @@ Both surfaces report `workspace`. It must match the intended workspace root.
|
|
|
58
58
|
- Target that port and re-check `/health`.
|
|
59
59
|
- `PMX_CANVAS_PORT` is the agent CLI target; the server's startup port is controlled by `--port`
|
|
60
60
|
or `PMX_WEB_CANVAS_PORT`.
|
|
61
|
+
- **MCP transport caveat (wrong-workspace split).** An MCP server (`pmx-canvas --mcp`) holds its own
|
|
62
|
+
in-memory canvas and, if its preferred port is already taken by a *different* workspace's daemon,
|
|
63
|
+
binds the next free port adopting **its own launch `cwd`** as the workspace — so its writes land on
|
|
64
|
+
a daemon the browser panel never renders (it self-reports this fallback on stderr). Before trusting
|
|
65
|
+
MCP-written state, confirm the MCP server's workspace matches the panel's (`/health` on both ports)
|
|
66
|
+
and that no stray higher-numbered listener exists. The durable fix is to launch the MCP server with
|
|
67
|
+
`cwd=<project>` or `PMX_CANVAS_PORT=<panel-port>`. The CLI's query/mutation commands are a thin HTTP
|
|
68
|
+
client and never start a server of their own (only `serve` / `--mcp` spawn a process), so prefer
|
|
69
|
+
those CLI commands for watcher/automation loops until the launch config is pinned.
|
|
61
70
|
|
|
62
71
|
## Choose the Smallest Useful Node Type
|
|
63
72
|
|
|
@@ -195,6 +204,17 @@ Prefer `canvas_query { action: "search" }` over parsing the full layout.
|
|
|
195
204
|
- Hosted MCP-app/ext-app nodes such as Excalidraw require the in-canvas host bridge and are not
|
|
196
205
|
standalone **Open as site** targets. URL-backed viewers and bundled web artifacts remain
|
|
197
206
|
openable.
|
|
207
|
+
- A hosted ext-app (Excalidraw) node that is already on the board when a **WebKit** host panel
|
|
208
|
+
loads (e.g. the GitHub Copilot app's embedded WKWebView) can render as a black tile — a host
|
|
209
|
+
compositor paint race on the nested iframe, **not** a broken node (the session is healthy and
|
|
210
|
+
`sessionStatus` is `ready`; it renders fine in Chrome, the Codex browser, and for nodes created
|
|
211
|
+
live after the panel hydrates). The canvas forces a one-time post-boot repaint remount under
|
|
212
|
+
WebKit, which reliably repaints a **single** present-at-load
|
|
213
|
+
ext-app — but a board with **several** ext-apps present at WebKit panel-load can still black out
|
|
214
|
+
(the simultaneous cold-hydration burst overwhelms the WebKit compositor). Recovery is
|
|
215
|
+
deterministic: **expand-then-close** any black tile (forces a fresh mount in the fullscreen
|
|
216
|
+
overlay, which always paints), or open the workbench in a normal browser (Chrome). Do not
|
|
217
|
+
diagnose a healthy app session as a broken node; the durable fix is upstream in the host panel.
|
|
198
218
|
- Graph and json-render standalone surfaces use `display=site` and fill the browser viewport, and
|
|
199
219
|
reflow on a live window resize in a normal browser. Some single-tab host browsers (e.g. the
|
|
200
220
|
Codex in-app browser) don't deliver live-resize events, so a resized standalone chart can look
|
|
@@ -386,7 +386,16 @@ Agents tend to pack boards too tightly. Give nodes room to breathe — readabili
|
|
|
386
386
|
|
|
387
387
|
### Colors (Semantic)
|
|
388
388
|
|
|
389
|
-
|
|
389
|
+
A `color` parameter is honored only for **group** nodes (frame accent) and **graph** nodes
|
|
390
|
+
(series/accent color). It is **not** a parameter for `markdown` / `status` / `context` nodes — a
|
|
391
|
+
top-level `color` on those is silently ignored over both HTTP and the CLI (report Finding H). Their
|
|
392
|
+
meaning comes from the node **type** and value instead: a `status` node colors its indicator from its
|
|
393
|
+
**phase** (`idle`/`running`/`planning`/`thinking`/`drafting`/`tooling`/`review`/`waiting-approval`/
|
|
394
|
+
`waiting` — e.g. `review` → green, `running` → accent; an unrecognized phase renders gray), and a
|
|
395
|
+
`trace` node from its `status` field (`success` → green, `failed` → red, `running` → accent). To
|
|
396
|
+
color an arbitrary region, group the nodes and set the group's `color`.
|
|
397
|
+
|
|
398
|
+
When you do set a color (group/graph), use this palette consistently to convey meaning:
|
|
390
399
|
- **Green** (`#22c55e`) — success, done, healthy
|
|
391
400
|
- **Yellow** (`#eab308`) — in progress, warning, attention needed
|
|
392
401
|
- **Red** (`#ef4444`) — error, blocked, failing
|
|
@@ -523,7 +532,7 @@ retrying the generic add.
|
|
|
523
532
|
- `content`: markdown text for most types. For `file`, pass the **file path** (e.g. `"src/auth/login.ts"`) —
|
|
524
533
|
the server auto-loads + watches it. For `image`, pass a file path, URL, or data URI.
|
|
525
534
|
- `path`: compatibility alias for image paths only; prefer `content` for new image calls
|
|
526
|
-
- `x`, `y`: position (prefer omitting for auto-layout); `width`, `height`: dimensions (sensible defaults); `
|
|
535
|
+
- `x`, `y`: position (prefer omitting for auto-layout); `width`, `height`: dimensions (sensible defaults); `metadata`: arbitrary JSON. (`color` applies to **group**/**graph** nodes only — it is ignored on basic node types; see Colors (Semantic).)
|
|
527
536
|
- Returns: `{ id: "<node-id>" }` — capture this ID for edges and groups
|
|
528
537
|
|
|
529
538
|
**`canvas_node { action: "update", id, … }`** — Update an existing node in place (preferred over
|
|
@@ -764,7 +773,8 @@ tools below operate on the live canvas state.
|
|
|
764
773
|
> "evaluate" | "resize" }` respectively. Field names are unchanged.
|
|
765
774
|
|
|
766
775
|
**`canvas_webview_status`** — Inspect the current automation session
|
|
767
|
-
- Returns `{ supported, active, backend,
|
|
776
|
+
- Returns `{ supported, active, headlessOnly, url, backend, width, height, dataStoreDir, startedAt, lastError }`
|
|
777
|
+
(the viewport size is `width` / `height`, not `viewportWidth` / `viewportHeight`)
|
|
768
778
|
- Call before `start` to check whether a session is already alive
|
|
769
779
|
|
|
770
780
|
**`canvas_webview_start`** — Start (or replace) the automation session
|
|
@@ -23,6 +23,7 @@ type McpUiTheme = 'light' | 'dark';
|
|
|
23
23
|
|
|
24
24
|
type ExtAppBridgeNotifications = Pick<AppBridge, 'sendToolInput' | 'sendToolResult'>;
|
|
25
25
|
type DisplayMode = 'inline' | 'fullscreen' | 'pip';
|
|
26
|
+
type ExtAppFrameStatus = 'loading' | 'ready' | 'done';
|
|
26
27
|
|
|
27
28
|
interface ExtAppHostDimensionsTarget {
|
|
28
29
|
clientWidth?: number;
|
|
@@ -45,6 +46,41 @@ async function postJson<T>(url: string, body: Record<string, unknown>): Promise<
|
|
|
45
46
|
return json.result as T;
|
|
46
47
|
}
|
|
47
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Finding F (0.2.4): detect a WebKit-only host — Safari or a WKWebView (e.g. the
|
|
51
|
+
* GitHub Copilot app's embedded panel). Blink engines (Chrome / Chromium / Edge /
|
|
52
|
+
* the Codex browser, all of which carry a Chrome/Chromium/CriOS/Edg token) and
|
|
53
|
+
* Android WebView are excluded, as is Gecko (no `AppleWebKit`). Used to gate the
|
|
54
|
+
* one-time ext-app iframe repaint remount to the only engine that exhibits the
|
|
55
|
+
* present-at-load black-tile paint race, so the remount is a strict no-op
|
|
56
|
+
* everywhere we can test (Chrome / Codex / Playwright).
|
|
57
|
+
*/
|
|
58
|
+
export function isWebKitOnlyHost(userAgent: string): boolean {
|
|
59
|
+
return /AppleWebKit/.test(userAgent) && !/Chrome|Chromium|CriOS|Edg|Android/.test(userAgent);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Finding F (0.2.5): SERIALIZED WebKit repaint slots. The black tile is a cold-
|
|
63
|
+
// hydration BURST problem — a single ext-app repaints fine into an idle panel (like
|
|
64
|
+
// a live-created node, or expand+close), but several compositing at once overwhelm
|
|
65
|
+
// WebKit and all stay black. We give each mounting ext-app an increasing slot so its
|
|
66
|
+
// one-time repaint remount fires into a progressively-quieter panel (one app at a
|
|
67
|
+
// time), turning the burst back into a sequence of single repaints. The shared
|
|
68
|
+
// counter resets after the burst so a later board load starts from slot 0.
|
|
69
|
+
let webkitRepaintSlot = 0;
|
|
70
|
+
let webkitRepaintSlotResetTimer: ReturnType<typeof setTimeout> | null = null;
|
|
71
|
+
export function nextWebkitRepaintSlot(): number {
|
|
72
|
+
const slot = webkitRepaintSlot++;
|
|
73
|
+
if (typeof window !== 'undefined') {
|
|
74
|
+
if (webkitRepaintSlotResetTimer) clearTimeout(webkitRepaintSlotResetTimer);
|
|
75
|
+
webkitRepaintSlotResetTimer = setTimeout(() => { webkitRepaintSlot = 0; }, 3000);
|
|
76
|
+
}
|
|
77
|
+
return slot;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function shouldScheduleWebKitRepaint(status: ExtAppFrameStatus, hasReplayToolResult: boolean): boolean {
|
|
81
|
+
return hasReplayToolResult ? status === 'done' : status === 'ready' || status === 'done';
|
|
82
|
+
}
|
|
83
|
+
|
|
48
84
|
export function getExtAppBridgeInitKey(node: CanvasNodeState, retryKey: number): string {
|
|
49
85
|
const html = typeof node.data.html === 'string' ? node.data.html : '';
|
|
50
86
|
const serverName = typeof node.data.serverName === 'string' ? node.data.serverName : '';
|
|
@@ -194,7 +230,9 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
194
230
|
const toolResultSendingRef = useRef<Promise<void> | null>(null);
|
|
195
231
|
const bridgeReadyRef = useRef(false);
|
|
196
232
|
const themeUnsubRef = useRef<(() => void) | null>(null);
|
|
197
|
-
const
|
|
233
|
+
const webkitRepaintDoneRef = useRef(false);
|
|
234
|
+
const webkitRepaintTimerRef = useRef<number | null>(null);
|
|
235
|
+
const [status, setStatus] = useState<ExtAppFrameStatus>('loading');
|
|
198
236
|
const [error, setError] = useState<string | null>(null);
|
|
199
237
|
const [retryKey, setRetryKey] = useState(0);
|
|
200
238
|
|
|
@@ -217,6 +255,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
217
255
|
const maxHeight = node.size.height;
|
|
218
256
|
const nodeId = node.id;
|
|
219
257
|
const frameKey = getExtAppBridgeInitKey(node, retryKey);
|
|
258
|
+
const hasReplayToolResult = toolResult != null;
|
|
220
259
|
const iframeSandbox = resolveExtAppSandbox(null);
|
|
221
260
|
// Phase 6 — opt-in ext-app AX bridge. When the node sets data.axCapabilities.enabled,
|
|
222
261
|
// inject window.PMX_AX into the app HTML and accept emits below (server re-validates).
|
|
@@ -262,6 +301,45 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
262
301
|
window.addEventListener('message', onAxMessage);
|
|
263
302
|
return () => window.removeEventListener('message', onAxMessage);
|
|
264
303
|
}, [axEnabled, axToken, nodeId]);
|
|
304
|
+
|
|
305
|
+
// Finding F (0.2.4/0.2.5): in a WebKit host panel (e.g. the GitHub Copilot app's
|
|
306
|
+
// WKWebView, and Bun's headless WebKit WebView) the doubly-nested ext-app iframe
|
|
307
|
+
// (workbench iframe → mcp-app.html iframe) can come up as a black tile for nodes
|
|
308
|
+
// present at panel-load. The mcp-app shell loads blank, then the app boots over the
|
|
309
|
+
// bridge and draws its content AFTER load; under a cold-hydration burst WebKit does
|
|
310
|
+
// not composite that late draw, so the layer stays black (clean in Blink, and clean
|
|
311
|
+
// for a node created live into an already-idle panel). A parent-side transform/src
|
|
312
|
+
// nudge does NOT repair a black layer — only a full remount (new iframe element +
|
|
313
|
+
// bridge re-init, what expand+close does) does, and only when it lands in an idle
|
|
314
|
+
// moment. So: once the app has booted — `ready` for empty apps, `done` for restored
|
|
315
|
+
// apps that must replay saved tool output — under WebKit only, force ONE remount,
|
|
316
|
+
// SERIALIZED per node (an increasing slot delay) so concurrent ext-apps
|
|
317
|
+
// don't re-form a single compositing burst (the 0.2.4 fixed-250ms-on-mount remount
|
|
318
|
+
// fired mid-burst before the app had even booted, and all siblings re-raced —
|
|
319
|
+
// insufficient). This reliably repaints a SINGLE present-at-load ext-app; a board
|
|
320
|
+
// with several ext-apps present at WebKit panel-load can still black out (a host
|
|
321
|
+
// compositor limit — expand+close or Chrome remains the fallback; see SKILL.md).
|
|
322
|
+
// Strict no-op in Blink/Gecko; the e2e engine is unaffected. Inline instance only.
|
|
323
|
+
useEffect(() => {
|
|
324
|
+
if (expanded || webkitRepaintDoneRef.current) return;
|
|
325
|
+
if (!shouldScheduleWebKitRepaint(status, hasReplayToolResult)) return;
|
|
326
|
+
if (typeof navigator === 'undefined' || typeof window === 'undefined') return;
|
|
327
|
+
if (!isWebKitOnlyHost(navigator.userAgent)) return;
|
|
328
|
+
webkitRepaintDoneRef.current = true;
|
|
329
|
+
// Serialize: each ext-app repaints into a progressively-quieter panel so the
|
|
330
|
+
// cold-hydration burst becomes a sequence of single (always-successful) repaints.
|
|
331
|
+
// The timer is held in a ref and cleared only on UNMOUNT (separate effect), NOT in
|
|
332
|
+
// this effect's cleanup — otherwise a later status change (ready→done) would cancel
|
|
333
|
+
// the one-shot remount before it fires. The gate above runs once per node.
|
|
334
|
+
const delayMs = 250 + nextWebkitRepaintSlot() * 450;
|
|
335
|
+
webkitRepaintTimerRef.current = window.setTimeout(() => setRetryKey((k) => k + 1), delayMs);
|
|
336
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
337
|
+
}, [status, hasReplayToolResult]);
|
|
338
|
+
|
|
339
|
+
useEffect(() => () => {
|
|
340
|
+
if (webkitRepaintTimerRef.current !== null) window.clearTimeout(webkitRepaintTimerRef.current);
|
|
341
|
+
}, []);
|
|
342
|
+
|
|
265
343
|
const toMcpTheme = (theme: string): McpUiTheme => (theme === 'light' ? 'light' : 'dark');
|
|
266
344
|
const isExpanded = expanded || expandedNodeId.value === nodeId;
|
|
267
345
|
|
|
@@ -792,6 +870,10 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
792
870
|
{...iframeDocument.attributes}
|
|
793
871
|
sandbox={iframeSandbox}
|
|
794
872
|
allow={buildAllowAttribute(resourceMeta?.permissions)}
|
|
873
|
+
// NB: do NOT add the `.mcp-app-frame` GPU-layer class (translateZ(0)) here —
|
|
874
|
+
// it creates a stacking context that breaks the AX emit→ack round-trip in the
|
|
875
|
+
// expanded ext-app overlay (#55 e2e); the post-boot WebKit repaint remount
|
|
876
|
+
// below recovers a single present-at-load ext-app without it (Finding F).
|
|
795
877
|
style={{
|
|
796
878
|
flex: 1,
|
|
797
879
|
width: '100%',
|
|
@@ -60,6 +60,7 @@ const intentUpdateSchema = z.looseObject({
|
|
|
60
60
|
confidence: z.number().min(0).max(1).optional(),
|
|
61
61
|
seq: z.number().int().min(0).max(9999).optional(),
|
|
62
62
|
ttlMs: z.number().positive().max(MAX_INTENT_TTL_MS).optional(),
|
|
63
|
+
vetoed: z.boolean().optional(),
|
|
63
64
|
});
|
|
64
65
|
|
|
65
66
|
function parseOrThrow<T>(schema: z.ZodType<T>, raw: unknown, label: string): T {
|
|
@@ -165,6 +166,21 @@ export class IntentRegistry {
|
|
|
165
166
|
if (!existing) throw new OperationError(`No live intent "${id}".`, 404);
|
|
166
167
|
const patch = parseOrThrow(intentUpdateSchema, raw, 'intent update');
|
|
167
168
|
const now = Date.now();
|
|
169
|
+
|
|
170
|
+
// A vetoed update is a veto, not a patch: poison the id and dissolve the ghost
|
|
171
|
+
// so a later linked settle (beginCommit) is rejected — for a live, non-committing
|
|
172
|
+
// intent the same authoritative outcome as clear({ vetoed:true }). (A committing
|
|
173
|
+
// intent is already gated above with a 409, so the commit wins the race.) Without
|
|
174
|
+
// this, vetoed-via-update only dissolved the ghost visually while the linked
|
|
175
|
+
// mutation still landed (#C, 0.2.4).
|
|
176
|
+
if (patch.vetoed === true) {
|
|
177
|
+
this.rememberVeto(id);
|
|
178
|
+
this.intents.delete(id);
|
|
179
|
+
this.emit('ax-intent-clear', { id, vetoed: true });
|
|
180
|
+
this.maybeStopSweeper();
|
|
181
|
+
return { ...existing, expiresAt: now };
|
|
182
|
+
}
|
|
183
|
+
|
|
168
184
|
const ttl = typeof patch.ttlMs === 'number' ? patch.ttlMs : DEFAULT_INTENT_TTL_MS;
|
|
169
185
|
|
|
170
186
|
const intent: PmxAxIntent = {
|
|
@@ -66,6 +66,7 @@ const intentUpdateShape = {
|
|
|
66
66
|
confidence: z.number().optional().describe('0..1 → ghost opacity/solidity.'),
|
|
67
67
|
seq: z.number().optional().describe('New ordering hint.'),
|
|
68
68
|
ttlMs: z.number().optional().describe('Reset the TTL to this many ms from now.'),
|
|
69
|
+
vetoed: z.boolean().optional().describe('Veto the intent: dissolves the ghost AND poisons the id so a later linked settle is rejected (same as clear { vetoed:true }).'),
|
|
69
70
|
};
|
|
70
71
|
|
|
71
72
|
const intentUpdateSchema = z.looseObject(intentUpdateShape);
|