pmx-canvas 0.2.4 → 0.2.5
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 +32 -0
- package/dist/canvas/index.js +56 -56
- package/dist/types/client/nodes/ExtAppFrame.d.ts +10 -0
- package/package.json +1 -1
- package/skills/pmx-canvas/SKILL.md +7 -0
- package/skills/pmx-canvas/references/full-reference.md +2 -1
- package/src/client/nodes/ExtAppFrame.tsx +37 -0
- package/src/server/intent-registry.ts +16 -0
- package/src/server/operations/ops/intent.ts +1 -0
|
@@ -8,6 +8,16 @@ interface ExtAppHostDimensionsTarget {
|
|
|
8
8
|
clientHeight?: number;
|
|
9
9
|
getBoundingClientRect(): Pick<DOMRectReadOnly, 'width' | 'height'>;
|
|
10
10
|
}
|
|
11
|
+
/**
|
|
12
|
+
* Finding F (0.2.4): detect a WebKit-only host — Safari or a WKWebView (e.g. the
|
|
13
|
+
* GitHub Copilot app's embedded panel). Blink engines (Chrome / Chromium / Edge /
|
|
14
|
+
* the Codex browser, all of which carry a Chrome/Chromium/CriOS/Edg token) and
|
|
15
|
+
* Android WebView are excluded, as is Gecko (no `AppleWebKit`). Used to gate the
|
|
16
|
+
* one-time ext-app iframe repaint remount to the only engine that exhibits the
|
|
17
|
+
* present-at-load black-tile paint race, so the remount is a strict no-op
|
|
18
|
+
* everywhere we can test (Chrome / Codex / Playwright).
|
|
19
|
+
*/
|
|
20
|
+
export declare function isWebKitOnlyHost(userAgent: string): boolean;
|
|
11
21
|
export declare function getExtAppBridgeInitKey(node: CanvasNodeState, retryKey: number): string;
|
|
12
22
|
export declare function resolveExtAppDisplayModeRequest(requestedMode: DisplayMode, isExpanded: boolean): {
|
|
13
23
|
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.5",
|
|
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",
|
|
@@ -195,6 +195,13 @@ Prefer `canvas_query { action: "search" }` over parsing the full layout.
|
|
|
195
195
|
- Hosted MCP-app/ext-app nodes such as Excalidraw require the in-canvas host bridge and are not
|
|
196
196
|
standalone **Open as site** targets. URL-backed viewers and bundled web artifacts remain
|
|
197
197
|
openable.
|
|
198
|
+
- A hosted ext-app (Excalidraw) node that is already on the board when a **WebKit** host panel
|
|
199
|
+
loads (e.g. the GitHub Copilot app's embedded WKWebView) can render as a black tile — a host
|
|
200
|
+
paint race on the nested iframe, not a broken node (the session is healthy and `sessionStatus`
|
|
201
|
+
is `ready`; it renders fine in Chrome, the Codex browser, and for nodes created live after the
|
|
202
|
+
panel hydrates). The canvas auto-remounts the iframe once on load under WebKit to force a
|
|
203
|
+
repaint; if a tile is still black, expand-then-close it (forces a remount) or open the workbench
|
|
204
|
+
in a normal browser. Do not diagnose a healthy app session as a broken node.
|
|
198
205
|
- Graph and json-render standalone surfaces use `display=site` and fill the browser viewport, and
|
|
199
206
|
reflow on a live window resize in a normal browser. Some single-tab host browsers (e.g. the
|
|
200
207
|
Codex in-app browser) don't deliver live-resize events, so a resized standalone chart can look
|
|
@@ -764,7 +764,8 @@ tools below operate on the live canvas state.
|
|
|
764
764
|
> "evaluate" | "resize" }` respectively. Field names are unchanged.
|
|
765
765
|
|
|
766
766
|
**`canvas_webview_status`** — Inspect the current automation session
|
|
767
|
-
- Returns `{ supported, active, backend,
|
|
767
|
+
- Returns `{ supported, active, headlessOnly, url, backend, width, height, dataStoreDir, startedAt, lastError }`
|
|
768
|
+
(the viewport size is `width` / `height`, not `viewportWidth` / `viewportHeight`)
|
|
768
769
|
- Call before `start` to check whether a session is already alive
|
|
769
770
|
|
|
770
771
|
**`canvas_webview_start`** — Start (or replace) the automation session
|
|
@@ -45,6 +45,19 @@ async function postJson<T>(url: string, body: Record<string, unknown>): Promise<
|
|
|
45
45
|
return json.result as T;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Finding F (0.2.4): detect a WebKit-only host — Safari or a WKWebView (e.g. the
|
|
50
|
+
* GitHub Copilot app's embedded panel). Blink engines (Chrome / Chromium / Edge /
|
|
51
|
+
* the Codex browser, all of which carry a Chrome/Chromium/CriOS/Edg token) and
|
|
52
|
+
* Android WebView are excluded, as is Gecko (no `AppleWebKit`). Used to gate the
|
|
53
|
+
* one-time ext-app iframe repaint remount to the only engine that exhibits the
|
|
54
|
+
* present-at-load black-tile paint race, so the remount is a strict no-op
|
|
55
|
+
* everywhere we can test (Chrome / Codex / Playwright).
|
|
56
|
+
*/
|
|
57
|
+
export function isWebKitOnlyHost(userAgent: string): boolean {
|
|
58
|
+
return /AppleWebKit/.test(userAgent) && !/Chrome|Chromium|CriOS|Edg|Android/.test(userAgent);
|
|
59
|
+
}
|
|
60
|
+
|
|
48
61
|
export function getExtAppBridgeInitKey(node: CanvasNodeState, retryKey: number): string {
|
|
49
62
|
const html = typeof node.data.html === 'string' ? node.data.html : '';
|
|
50
63
|
const serverName = typeof node.data.serverName === 'string' ? node.data.serverName : '';
|
|
@@ -194,6 +207,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
194
207
|
const toolResultSendingRef = useRef<Promise<void> | null>(null);
|
|
195
208
|
const bridgeReadyRef = useRef(false);
|
|
196
209
|
const themeUnsubRef = useRef<(() => void) | null>(null);
|
|
210
|
+
const webkitRepaintDoneRef = useRef(false);
|
|
197
211
|
const [status, setStatus] = useState<'loading' | 'ready' | 'done'>('loading');
|
|
198
212
|
const [error, setError] = useState<string | null>(null);
|
|
199
213
|
const [retryKey, setRetryKey] = useState(0);
|
|
@@ -262,6 +276,29 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
262
276
|
window.addEventListener('message', onAxMessage);
|
|
263
277
|
return () => window.removeEventListener('message', onAxMessage);
|
|
264
278
|
}, [axEnabled, axToken, nodeId]);
|
|
279
|
+
|
|
280
|
+
// Finding F (0.2.4): in a WebKit host panel (e.g. the GitHub Copilot app's
|
|
281
|
+
// WKWebView), the doubly-nested ext-app iframe (workbench iframe → mcp-app.html
|
|
282
|
+
// iframe) can lose the initial-paint race during board hydration and come up as a
|
|
283
|
+
// black tile for nodes already present at panel-load — clean in Blink (Chrome /
|
|
284
|
+
// Codex / our Playwright e2e) and clean for nodes created live after hydration.
|
|
285
|
+
// The deterministic recovery is an iframe remount (exactly what expand+close, and
|
|
286
|
+
// the error-retry button, already do). Replicate that recovery ONCE, on mount, in
|
|
287
|
+
// WebKit-only — gated on the engine so it is a strict no-op in Blink and cannot
|
|
288
|
+
// regress the engine we test against. Inline board instance only (the one that
|
|
289
|
+
// loads black); the expanded overlay already remounts on open.
|
|
290
|
+
useEffect(() => {
|
|
291
|
+
if (expanded || webkitRepaintDoneRef.current) return;
|
|
292
|
+
if (typeof navigator === 'undefined' || typeof window === 'undefined') return;
|
|
293
|
+
if (!isWebKitOnlyHost(navigator.userAgent)) return;
|
|
294
|
+
webkitRepaintDoneRef.current = true;
|
|
295
|
+
// Let the initial hydration burst settle, then force one remount so the nested
|
|
296
|
+
// iframe repaints (mirrors the post-hydration expand+close recovery).
|
|
297
|
+
const timer = window.setTimeout(() => setRetryKey((k) => k + 1), 250);
|
|
298
|
+
return () => window.clearTimeout(timer);
|
|
299
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
300
|
+
}, []);
|
|
301
|
+
|
|
265
302
|
const toMcpTheme = (theme: string): McpUiTheme => (theme === 'light' ? 'light' : 'dark');
|
|
266
303
|
const isExpanded = expanded || expandedNodeId.value === nodeId;
|
|
267
304
|
|
|
@@ -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);
|