pmx-canvas 0.1.30 → 0.1.32
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 +117 -0
- package/dist/canvas/global.css +56 -59
- package/dist/canvas/index.js +59 -59
- package/dist/json-render/index.js +97 -97
- package/dist/types/client/nodes/surface-url.d.ts +7 -0
- package/dist/types/client/state/canvas-store.d.ts +1 -0
- package/dist/types/client/state/intent-bridge.d.ts +7 -0
- package/dist/types/json-render/renderer/index.d.ts +1 -0
- package/dist/types/json-render/server.d.ts +1 -0
- package/dist/types/server/ax-context.d.ts +24 -1
- package/dist/types/server/canvas-state.d.ts +7 -0
- package/dist/types/server/html-surface.d.ts +29 -0
- package/dist/types/server/index.d.ts +19 -3
- package/dist/types/server/server.d.ts +12 -0
- package/docs/sdk.md +3 -1
- package/package.json +1 -1
- package/skills/pmx-canvas/SKILL.md +96 -1
- package/src/cli/agent.ts +18 -1
- package/src/cli/index.ts +8 -1
- package/src/client/App.tsx +3 -3
- package/src/client/canvas/CanvasNode.tsx +16 -1
- package/src/client/canvas/DockedNode.tsx +38 -38
- package/src/client/canvas/ExpandedNodeOverlay.tsx +12 -1
- package/src/client/nodes/ContextNode.tsx +1 -1
- package/src/client/nodes/HtmlNode.tsx +26 -1
- package/src/client/nodes/McpAppNode.tsx +35 -8
- package/src/client/nodes/StatusNode.tsx +0 -20
- package/src/client/nodes/surface-url.ts +12 -0
- package/src/client/state/canvas-store.ts +4 -0
- package/src/client/state/intent-bridge.ts +19 -0
- package/src/client/state/sse-bridge.ts +17 -0
- package/src/client/theme/global.css +56 -59
- package/src/json-render/renderer/index.tsx +31 -2
- package/src/json-render/server.ts +3 -0
- package/src/mcp/canvas-access.ts +6 -1
- package/src/mcp/server.ts +23 -1
- package/src/server/ax-context.ts +49 -1
- package/src/server/ax-interaction.ts +3 -0
- package/src/server/ax-state.ts +3 -1
- package/src/server/canvas-state.ts +30 -11
- package/src/server/html-surface.ts +70 -13
- package/src/server/index.ts +32 -7
- package/src/server/server.ts +117 -4
|
@@ -20,3 +20,10 @@ export declare function nodeSurfaceUrl(nodeId: string, opts?: SurfaceUrlOptions)
|
|
|
20
20
|
export declare function canOpenAsSite(node: CanvasNodeState): boolean;
|
|
21
21
|
/** Open the node's surface in a new browser tab. */
|
|
22
22
|
export declare function openNodeAsSite(node: CanvasNodeState): void;
|
|
23
|
+
/**
|
|
24
|
+
* Open the node's surface in the user's real SYSTEM browser via the server's OS
|
|
25
|
+
* launcher — for hosts (e.g. Codex) whose embedded browser makes a normal
|
|
26
|
+
* `_blank` tab feel in-place. Falls back to a normal new-tab open when the server
|
|
27
|
+
* can't launch (headless / PMX_CANVAS_DISABLE_BROWSER_OPEN).
|
|
28
|
+
*/
|
|
29
|
+
export declare function openNodeInSystemBrowser(node: CanvasNodeState): Promise<void>;
|
|
@@ -9,6 +9,7 @@ export declare const sessionId: import("@preact/signals-core").Signal<string>;
|
|
|
9
9
|
export declare const traceEnabled: import("@preact/signals-core").Signal<boolean>;
|
|
10
10
|
export declare const canvasTheme: import("@preact/signals-core").Signal<string>;
|
|
11
11
|
export declare const hasInitialServerLayout: import("@preact/signals-core").Signal<boolean>;
|
|
12
|
+
export declare const axSurfaceState: import("@preact/signals-core").Signal<unknown>;
|
|
12
13
|
export declare const expandedNodeId: import("@preact/signals-core").Signal<string | null>;
|
|
13
14
|
export declare const pendingExpandedNodeCloseId: import("@preact/signals-core").Signal<string | null>;
|
|
14
15
|
export declare const pendingConnection: import("@preact/signals-core").Signal<{
|
|
@@ -126,6 +126,13 @@ export interface AxInteractionResponse {
|
|
|
126
126
|
code?: string;
|
|
127
127
|
error?: string;
|
|
128
128
|
}
|
|
129
|
+
/** Fetch the compact AX state snapshot pushed into AX-enabled surfaces. */
|
|
130
|
+
export declare function fetchAxSurfaceState(): Promise<unknown>;
|
|
131
|
+
/** Ask the server to open a node's surface in the system browser. */
|
|
132
|
+
export declare function openNodeInSystemBrowserRequest(nodeId: string): Promise<{
|
|
133
|
+
ok: boolean;
|
|
134
|
+
opened: boolean;
|
|
135
|
+
}>;
|
|
129
136
|
/** Submit a capability-gated AX interaction from a native node control. */
|
|
130
137
|
export declare function submitAxInteractionFromClient(input: AxInteractionRequest): Promise<AxInteractionResponse>;
|
|
131
138
|
/** Commit the current viewport to the authoritative server state. */
|
|
@@ -1,3 +1,26 @@
|
|
|
1
|
-
import { type PmxAxContext, type PmxAxPinnedContext } from './ax-state.js';
|
|
1
|
+
import { type PmxAxContext, type PmxAxPinnedContext, type PmxAxWorkItem, type PmxAxApprovalGate, type PmxAxReviewAnnotation, type PmxAxElicitation, type PmxAxModeRequest, type PmxAxPolicy } from './ax-state.js';
|
|
2
|
+
/**
|
|
3
|
+
* Compact, surface-safe view of the canvas-bound AX state, injected into (and
|
|
4
|
+
* pushed to) AX-enabled surfaces so authored boards can RENDER the work queue /
|
|
5
|
+
* focus, not just emit interactions. Deliberately excludes the timeline, pinned
|
|
6
|
+
* preamble, and serialized node bodies to keep the payload small.
|
|
7
|
+
*/
|
|
8
|
+
export interface PmxAxSurfaceSnapshot {
|
|
9
|
+
focus: string[];
|
|
10
|
+
workItems: PmxAxWorkItem[];
|
|
11
|
+
approvalGates: PmxAxApprovalGate[];
|
|
12
|
+
reviewAnnotations: Array<Omit<PmxAxReviewAnnotation, 'body' | 'author'>>;
|
|
13
|
+
elicitations: PmxAxElicitation[];
|
|
14
|
+
modeRequests: PmxAxModeRequest[];
|
|
15
|
+
policy: PmxAxPolicy;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* NOTE: this is whole-canvas AX state (every work item, etc.), exposed to ANY
|
|
19
|
+
* AX-enabled surface — reads are board-wide while emits are node-scoped. Acceptable
|
|
20
|
+
* under the single-workspace local-trust model, but author surfaces accordingly
|
|
21
|
+
* (don't embed untrusted third-party scripts in an AX-enabled surface). Sensitive
|
|
22
|
+
* human review text is redacted below.
|
|
23
|
+
*/
|
|
24
|
+
export declare function buildCanvasAxSurfaceSnapshot(): PmxAxSurfaceSnapshot;
|
|
2
25
|
export declare function buildCanvasAxPinnedContext(): PmxAxPinnedContext;
|
|
3
26
|
export declare function buildCanvasAxContext(): PmxAxContext;
|
|
@@ -199,6 +199,13 @@ declare class CanvasStateManager {
|
|
|
199
199
|
private emptyPersistedState;
|
|
200
200
|
/** Load canvas state from SQLite (or legacy JSON fallback). Call once on server startup. */
|
|
201
201
|
loadFromDisk(options?: LoadFromDiskOptions): boolean;
|
|
202
|
+
/**
|
|
203
|
+
* Whether this workspace's canvas DB already holds saved state. Used to gate
|
|
204
|
+
* brand-new-workspace seeding (e.g. the default docked status/context widgets)
|
|
205
|
+
* so we never add nodes to a canvas that already has content. Reflects the
|
|
206
|
+
* pre-run persisted flag until the next save.
|
|
207
|
+
*/
|
|
208
|
+
hasPersistedState(): boolean;
|
|
202
209
|
/** Debounced save — coalesces rapid mutations into a single write. */
|
|
203
210
|
private scheduleSave;
|
|
204
211
|
flushToDisk(): void;
|
|
@@ -20,8 +20,32 @@ export declare const SURFACE_THEME_STYLESHEET = "/canvas/surface-theme.css";
|
|
|
20
20
|
/** CSP sandbox tokens for an `html`/`html-primitive` surface — scripts only, opaque origin. */
|
|
21
21
|
export declare const HTML_SURFACE_SANDBOX = "allow-scripts";
|
|
22
22
|
export declare function normalizeSurfaceTheme(value: string | null | undefined): SurfaceTheme;
|
|
23
|
+
/**
|
|
24
|
+
* Bridge that exposes `window.PMX_AX.emit(type, payload)` to author HTML. Calls
|
|
25
|
+
* post a nonce-tagged message to the parent canvas, which validates the nonce +
|
|
26
|
+
* node id and submits the interaction through the capability-gated endpoint. Only
|
|
27
|
+
* injected when the node's AX capabilities are enabled (opt-in for `html`), and
|
|
28
|
+
* the server re-validates every interaction — so this is a convenience surface,
|
|
29
|
+
* not a trust boundary.
|
|
30
|
+
*/
|
|
31
|
+
export declare function buildAxBridge(axToken: string, nodeId: string): string;
|
|
32
|
+
/**
|
|
33
|
+
* Read-side bridge: seeds `window.PMX_AX.state` with a snapshot of the canvas AX
|
|
34
|
+
* state and keeps it live via nonce-validated `ax-update` messages from the parent
|
|
35
|
+
* canvas. Author HTML can read `window.PMX_AX.state` and subscribe to the
|
|
36
|
+
* `pmx-ax-update` CustomEvent to render a live work queue / focus. Injected only
|
|
37
|
+
* alongside the emit bridge (AX-enabled nodes). Read-only — no capability beyond
|
|
38
|
+
* the existing AX-enabled gate.
|
|
39
|
+
*/
|
|
40
|
+
export declare function buildAxStateBridge(axToken: string, snapshotJson: string): string;
|
|
23
41
|
export interface HtmlSurfaceOptions {
|
|
24
42
|
theme: SurfaceTheme;
|
|
43
|
+
/**
|
|
44
|
+
* Tab/document title. Injected as `<title>` only when the author HTML does not
|
|
45
|
+
* already declare one, so a standalone "Open as site" tab shows the node title
|
|
46
|
+
* instead of falling back to the raw URL.
|
|
47
|
+
*/
|
|
48
|
+
title?: string;
|
|
25
49
|
/** Client nonce that authorizes parent → iframe theme-update messages. */
|
|
26
50
|
themeToken?: string;
|
|
27
51
|
presentation?: boolean;
|
|
@@ -32,6 +56,11 @@ export interface HtmlSurfaceOptions {
|
|
|
32
56
|
axToken?: string;
|
|
33
57
|
/** Node id stamped on emitted interactions. */
|
|
34
58
|
nodeId?: string;
|
|
59
|
+
/**
|
|
60
|
+
* Initial AX state snapshot to seed `window.PMX_AX.state` (only used when
|
|
61
|
+
* axBridge is enabled). Kept live via parent → iframe `ax-update` messages.
|
|
62
|
+
*/
|
|
63
|
+
axState?: unknown;
|
|
35
64
|
}
|
|
36
65
|
/**
|
|
37
66
|
* Wrap author HTML into a complete, themed standalone document. Accepts either a
|
|
@@ -7,12 +7,22 @@ import type { AxTimelineQuery } from './canvas-db.js';
|
|
|
7
7
|
import { searchNodes } from './spatial-analysis.js';
|
|
8
8
|
import { diffLayouts } from './mutation-history.js';
|
|
9
9
|
import { fitCanvasView, gcCanvasSnapshots, listCanvasSnapshots } from './canvas-operations.js';
|
|
10
|
+
import { type SerializedCanvasNode } from './canvas-serialization.js';
|
|
10
11
|
import type { HtmlPrimitiveKind } from './html-primitives.js';
|
|
11
12
|
import { type WebArtifactBuildInput, type WebArtifactCanvasBuildResult } from './web-artifacts.js';
|
|
12
13
|
import { type ExternalMcpTransportConfig } from './mcp-app-runtime.js';
|
|
13
14
|
import { type DiagramPresetOpenInput } from './diagram-presets.js';
|
|
14
15
|
import { type GraphNodeInput, type JsonRenderNodeInput, type JsonRenderSpec } from '../json-render/server.js';
|
|
15
16
|
import type { CanvasAutomationWebViewOptions, CanvasAutomationWebViewStatus } from './server.js';
|
|
17
|
+
/**
|
|
18
|
+
* Node object returned by the SDK's create/get methods. It is the fully
|
|
19
|
+
* serialized node (adds `surfaceUrl`, `kind`, `title`, `content`, …) plus a
|
|
20
|
+
* `nodeId` alias for `id`, so the SDK return shape matches the HTTP/CLI
|
|
21
|
+
* `node`-create responses field-for-field.
|
|
22
|
+
*/
|
|
23
|
+
export type SdkCanvasNode = SerializedCanvasNode & {
|
|
24
|
+
nodeId: string;
|
|
25
|
+
};
|
|
16
26
|
export declare class PmxCanvas extends EventEmitter {
|
|
17
27
|
private _port;
|
|
18
28
|
private _server;
|
|
@@ -54,7 +64,7 @@ export declare class PmxCanvas extends EventEmitter {
|
|
|
54
64
|
width?: number;
|
|
55
65
|
height?: number;
|
|
56
66
|
strictSize?: boolean;
|
|
57
|
-
}):
|
|
67
|
+
}): SdkCanvasNode;
|
|
58
68
|
addWebpageNode(input: {
|
|
59
69
|
title?: string;
|
|
60
70
|
url: string;
|
|
@@ -262,7 +272,7 @@ export declare class PmxCanvas extends EventEmitter {
|
|
|
262
272
|
nodeIds?: string[];
|
|
263
273
|
}): ReturnType<typeof fitCanvasView>;
|
|
264
274
|
getLayout(): CanvasLayout;
|
|
265
|
-
getNode(id: string):
|
|
275
|
+
getNode(id: string): SdkCanvasNode | undefined;
|
|
266
276
|
search(query: string): ReturnType<typeof searchNodes>;
|
|
267
277
|
getSpatialContext(): import("./spatial-analysis.js").SpatialContext;
|
|
268
278
|
undo(): Promise<{
|
|
@@ -435,7 +445,13 @@ export declare class PmxCanvas extends EventEmitter {
|
|
|
435
445
|
width?: number;
|
|
436
446
|
height?: number;
|
|
437
447
|
strictSize?: boolean;
|
|
438
|
-
|
|
448
|
+
/** Opt this html node into AX interactions (window.PMX_AX.emit). Clamped to
|
|
449
|
+
* the html capability ceiling server-side; cannot escalate. */
|
|
450
|
+
axCapabilities?: {
|
|
451
|
+
enabled?: boolean;
|
|
452
|
+
allowed?: string[];
|
|
453
|
+
};
|
|
454
|
+
}): SdkCanvasNode;
|
|
439
455
|
addHtmlPrimitive(input: {
|
|
440
456
|
kind: HtmlPrimitiveKind;
|
|
441
457
|
title?: string;
|
|
@@ -83,6 +83,18 @@ export declare function hasWorkbenchSubscribers(): boolean;
|
|
|
83
83
|
export declare function setPrimaryWorkbenchCanvasPromptHandler(handler: PrimaryWorkbenchCanvasPromptHandler | null): void;
|
|
84
84
|
export declare function buildMacBrowserOpenScript(appName: string, url: string): string;
|
|
85
85
|
export declare function openUrlInExternalBrowser(url: string): boolean;
|
|
86
|
+
/**
|
|
87
|
+
* Seed the docked status (left) + context (right) widgets so a freshly opened
|
|
88
|
+
* canvas shows them by default — the same nodes the agent-event path creates on
|
|
89
|
+
* demand (`status-main`, `context-main`), just present from the start.
|
|
90
|
+
*
|
|
91
|
+
* First-run only: we bail if the workspace canvas already has persisted state,
|
|
92
|
+
* so we never add them to a board with content, and — because first-run state is
|
|
93
|
+
* persisted on save — deleting or undocking them later is respected (they are
|
|
94
|
+
* not re-seeded). Create-if-missing keeps it idempotent if the agent path
|
|
95
|
+
* already made one. Returns true if anything was seeded.
|
|
96
|
+
*/
|
|
97
|
+
export declare function ensureDefaultDockedNodes(): boolean;
|
|
86
98
|
export declare function emitPrimaryWorkbenchEvent(event: string, payload?: PrimaryWorkbenchEventPayload): void;
|
|
87
99
|
export declare function consumePrimaryWorkbenchIntents(limit?: number): PrimaryWorkbenchIntent[];
|
|
88
100
|
export declare function getPrimaryWorkbenchUrl(workspaceRoot?: string): string | null;
|
package/docs/sdk.md
CHANGED
|
@@ -15,7 +15,9 @@ import { createCanvas } from 'pmx-canvas';
|
|
|
15
15
|
const canvas = createCanvas({ port: 4313 });
|
|
16
16
|
await canvas.start({ open: true });
|
|
17
17
|
|
|
18
|
-
// Add nodes — addNode
|
|
18
|
+
// Add nodes — addNode/getNode/addHtmlNode return the created node: `.id`
|
|
19
|
+
// (plus a `.nodeId` alias), geometry, `.data`, and `.surfaceUrl` for
|
|
20
|
+
// surface-eligible types (html, json-render, graph, …).
|
|
19
21
|
const n1 = canvas.addNode({ type: 'markdown', title: 'Plan', content: '# Step 1\nDo the thing.' });
|
|
20
22
|
const n2 = canvas.addNode({ type: 'status', title: 'Build', content: 'passing' });
|
|
21
23
|
const n3 = canvas.addNode({ type: 'file', content: 'src/index.ts' });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmx-canvas",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.32",
|
|
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",
|
|
@@ -770,7 +770,11 @@ server's `ui://` resource as an iframe node on the canvas
|
|
|
770
770
|
|
|
771
771
|
Any renderable surface node can be opened full-page in its own browser tab — the same
|
|
772
772
|
document it shows in the canvas, just without the node chrome. In the workbench, use the
|
|
773
|
-
↗ **Open as site** button
|
|
773
|
+
↗ **Open as site** button (new tab) or the ⤤ **Open in system browser** button in the
|
|
774
|
+
node title bar (or the expanded overlay). "Open in system browser" launches the real OS
|
|
775
|
+
browser via `POST /api/canvas/open-external` `{ nodeId }` (it opens only this server's own
|
|
776
|
+
surface URL; falls back to a normal new tab when the server can't launch) — use it when
|
|
777
|
+
the host's embedded browser (e.g. Codex) opens `_blank` tabs in-place.
|
|
774
778
|
|
|
775
779
|
- Works for `html` / `html-primitive`, bundled `web-artifact`, `json-render` / `graph`,
|
|
776
780
|
`webpage`, and hosted ext-app `mcp-app` nodes.
|
|
@@ -879,6 +883,97 @@ elicitation, or mode request. One envelope, many transports:
|
|
|
879
883
|
Interactions request PMX-AX primitives only — never arbitrary shell, tool, MCP,
|
|
880
884
|
or host execution.
|
|
881
885
|
|
|
886
|
+
#### Where AX can be used — node capability matrix
|
|
887
|
+
|
|
888
|
+
AX interactions are gated per node type. The lists below are each type's **ceiling**
|
|
889
|
+
— `data.axCapabilities.allowed` can NARROW it, never escalate beyond it.
|
|
890
|
+
|
|
891
|
+
**Enabled by default** (no opt-in needed — an agent/native control can emit straight away):
|
|
892
|
+
|
|
893
|
+
| Node type | Allowed AX interaction types |
|
|
894
|
+
|-----------|------------------------------|
|
|
895
|
+
| `markdown` | `ax.steer`, `ax.work.create`, `ax.evidence.add`, `ax.command.invoke`, `ax.event.record` |
|
|
896
|
+
| `context` | `ax.focus.set`, `ax.steer`, `ax.evidence.add`, `ax.command.invoke`, `ax.event.record` |
|
|
897
|
+
| `status` | `ax.work.create`, `ax.work.update`, `ax.approval.request`, `ax.mode.request`, `ax.event.record` |
|
|
898
|
+
| `file` | `ax.evidence.add`, `ax.review.add`, `ax.focus.set`, `ax.event.record` |
|
|
899
|
+
| `json-render` | `ax.work.create`, `ax.work.update`, `ax.evidence.add`, `ax.elicitation.request`, `ax.event.record` |
|
|
900
|
+
| `graph` | `ax.evidence.add`, `ax.focus.set`, `ax.event.record` |
|
|
901
|
+
| `ledger` | `ax.evidence.add`, `ax.event.record` |
|
|
902
|
+
| `trace` | `ax.evidence.add`, `ax.event.record` |
|
|
903
|
+
| `image` | `ax.evidence.add`, `ax.review.add` |
|
|
904
|
+
| `webpage` | `ax.evidence.add`, `ax.review.add`, `ax.focus.set`, `ax.event.record` |
|
|
905
|
+
| `group` | `ax.focus.set`, `ax.work.create`, `ax.command.invoke`, `ax.event.record` |
|
|
906
|
+
|
|
907
|
+
**Opt-in** — set `data.axCapabilities.enabled = true` (MCP: pass `axCapabilities` to
|
|
908
|
+
`canvas_add_html_node` or `canvas_update_node`; HTTP: nest under `data`):
|
|
909
|
+
|
|
910
|
+
| Node type | Allowed AX interaction types |
|
|
911
|
+
|-----------|------------------------------|
|
|
912
|
+
| `html` / `html-primitive` | the full set: `ax.work.create`, `ax.work.update`, `ax.steer`, `ax.approval.request`, `ax.review.add`, `ax.evidence.add`, `ax.focus.set`, `ax.elicitation.request`, `ax.mode.request`, `ax.command.invoke`, `ax.event.record` |
|
|
913
|
+
| `mcp-app` (incl. **web-artifact**) | `ax.event.record`, `ax.evidence.add`, `ax.work.create`, `ax.work.update`, `ax.focus.set`, `ax.elicitation.request` |
|
|
914
|
+
|
|
915
|
+
**Never (anchor-only):** internal `prompt` / `response` thread nodes — `ax.event.record`
|
|
916
|
+
only, no human-facing emit.
|
|
917
|
+
|
|
918
|
+
The 11 interaction types and what they create: `ax.work.create` / `ax.work.update`
|
|
919
|
+
(work-queue items, status todo→in-progress→blocked→done→cancelled), `ax.evidence.add`
|
|
920
|
+
(timeline evidence), `ax.review.add` (review annotation), `ax.focus.set` (agent focus
|
|
921
|
+
pointer), `ax.steer` (a steering message delivered to the agent), `ax.approval.request`
|
|
922
|
+
(approval gate), `ax.elicitation.request` (structured human input), `ax.mode.request`
|
|
923
|
+
(plan/execute/autonomous transition), `ax.command.invoke` (registry command), and
|
|
924
|
+
`ax.event.record` (diagnostic agent-event).
|
|
925
|
+
|
|
926
|
+
#### Building an AX surface in the canvas (emit + reflect)
|
|
927
|
+
|
|
928
|
+
AX surfaces are **composable** — you can build a live work board, review board, or
|
|
929
|
+
inbox as a canvas node that BOTH emits AX interactions AND renders the current AX
|
|
930
|
+
state. The read side mirrors the write side:
|
|
931
|
+
|
|
932
|
+
- **Opt in** (html/mcp-app are off by default): create with
|
|
933
|
+
`canvas_add_html_node({ html, axCapabilities: { enabled: true, allowed: ["ax.work.create","ax.work.update"] } })`,
|
|
934
|
+
or flip an existing node on with
|
|
935
|
+
`canvas_update_node({ id, axCapabilities: { enabled: true, allowed: [...] } })`.
|
|
936
|
+
json-render / graph nodes are enabled by default.
|
|
937
|
+
- **Emit (write):** in `html`, call `window.PMX_AX.emit("ax.work.create", { title })`;
|
|
938
|
+
in `json-render`, bind a control action named after the AX type
|
|
939
|
+
(`on: { press: { action: "ax.work.create", params: { title } } }`).
|
|
940
|
+
- **Reflect (read):** the canvas seeds the surface with a compact AX snapshot at
|
|
941
|
+
load (the same shape as `GET /api/canvas/ax/surface-snapshot`) and live-updates it
|
|
942
|
+
as AX state changes. Works on all three authored surface types:
|
|
943
|
+
- `html` / `html-primitive`: read `window.PMX_AX.state` (`{ focus, workItems,
|
|
944
|
+
approvalGates, reviewAnnotations, elicitations, modeRequests, policy }`) and
|
|
945
|
+
subscribe to the `pmx-ax-update` event:
|
|
946
|
+
`window.addEventListener("pmx-ax-update", e => render(e.detail))`.
|
|
947
|
+
- `json-render` / `graph`: the snapshot is bound under `/ax`, so a spec reads
|
|
948
|
+
`{ "$state": "/ax/workItems" }` and it stays live as work items change.
|
|
949
|
+
- `web-artifact` (mcp-app): the same `window.PMX_AX.state` + `pmx-ax-update` bridge
|
|
950
|
+
is injected at the `/artifact` route once the node opts in — author the React app
|
|
951
|
+
against `window.PMX_AX`, not direct `fetch()` (the artifact iframe is sandboxed
|
|
952
|
+
opaque-origin, so it can't call the API directly).
|
|
953
|
+
|
|
954
|
+
Minimal html work board (drop-in via `canvas_add_html_node`, `axCapabilities.enabled: true`):
|
|
955
|
+
|
|
956
|
+
```html
|
|
957
|
+
<button onclick="window.PMX_AX.emit('ax.work.create',{title:'New task'})">+ Task</button>
|
|
958
|
+
<ul id="q"></ul>
|
|
959
|
+
<script>
|
|
960
|
+
function render(s){ document.getElementById('q').innerHTML =
|
|
961
|
+
((s&&s.workItems)||[]).map(w => '<li>['+w.status+'] '+w.title+'</li>').join(''); }
|
|
962
|
+
render(window.PMX_AX && window.PMX_AX.state);
|
|
963
|
+
window.addEventListener('pmx-ax-update', e => render(e.detail));
|
|
964
|
+
</script>
|
|
965
|
+
```
|
|
966
|
+
|
|
967
|
+
This is the right home for a deliberate, interactive AX experience — not the
|
|
968
|
+
native node buttons. Any agent (via MCP/SDK) can also create/update the same work
|
|
969
|
+
items, and the board reflects them live.
|
|
970
|
+
|
|
971
|
+
> Security note: an AX-enabled surface can READ the whole canvas AX board (all
|
|
972
|
+
> work items, focus, approval gates, etc. — human review comment text is redacted),
|
|
973
|
+
> while its EMITS are clamped to its own node. Under the single-workspace
|
|
974
|
+
> local-trust model this is fine, but don't embed untrusted third-party scripts in
|
|
975
|
+
> an AX-enabled surface.
|
|
976
|
+
|
|
882
977
|
### Reading Spatial Intent
|
|
883
978
|
|
|
884
979
|
The `canvas://spatial-context` resource reveals how the human has organized information:
|
package/src/cli/agent.ts
CHANGED
|
@@ -1448,6 +1448,8 @@ cmd('node update', 'Update a node by ID', [
|
|
|
1448
1448
|
'pmx-canvas node update <node-id> --spec-file ./dashboard.json',
|
|
1449
1449
|
'pmx-canvas node update <graph-id> --data-file ./metrics.json --chart-height 420',
|
|
1450
1450
|
'pmx-canvas node update <node-id> --pinned true',
|
|
1451
|
+
'pmx-canvas node update <node-id> --dock-position right',
|
|
1452
|
+
'pmx-canvas node update <node-id> --dock-position none # undock back to the canvas',
|
|
1451
1453
|
'pmx-canvas node update <node-id> --lock-arrange',
|
|
1452
1454
|
], async (args) => {
|
|
1453
1455
|
const { positional, flags } = parseFlags(args);
|
|
@@ -1521,10 +1523,25 @@ cmd('node update', 'Update a node by ID', [
|
|
|
1521
1523
|
|
|
1522
1524
|
if (pinned !== undefined) body.pinned = pinned;
|
|
1523
1525
|
|
|
1526
|
+
// --dock-position left|right|none : dock a node into the top HUD or undock it.
|
|
1527
|
+
// `none`/`null`/empty map to JS null (undock). Assigned with a !== undefined
|
|
1528
|
+
// guard so the null survives JSON.stringify to the server (which accepts a
|
|
1529
|
+
// top-level dockPosition: null). HTTP PATCH already supports this; this is the
|
|
1530
|
+
// CLI path the report (#40) found missing.
|
|
1531
|
+
const dockRaw = getStringFlag(flags, 'dock-position', 'dockPosition');
|
|
1532
|
+
let dockPosition: 'left' | 'right' | null | undefined;
|
|
1533
|
+
if (dockRaw !== undefined) {
|
|
1534
|
+
const v = dockRaw.trim().toLowerCase();
|
|
1535
|
+
if (v === 'left' || v === 'right') dockPosition = v;
|
|
1536
|
+
else if (v === 'none' || v === 'null' || v === '') dockPosition = null;
|
|
1537
|
+
else die(`Invalid --dock-position "${dockRaw}".`, 'Use left, right, or none (to undock).');
|
|
1538
|
+
}
|
|
1539
|
+
if (dockPosition !== undefined) body.dockPosition = dockPosition;
|
|
1540
|
+
|
|
1524
1541
|
if (Object.keys(body).length === 0) {
|
|
1525
1542
|
die(
|
|
1526
1543
|
'No updates specified',
|
|
1527
|
-
'Use --title, --content, --x, --y, --width, --height, --strict-size, --pinned, trace fields, --lock-arrange, --unlock-arrange, or --stdin',
|
|
1544
|
+
'Use --title, --content, --x, --y, --width, --height, --strict-size, --pinned, --dock-position, trace fields, --lock-arrange, --unlock-arrange, or --stdin',
|
|
1528
1545
|
);
|
|
1529
1546
|
}
|
|
1530
1547
|
|
package/src/cli/index.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
6
6
|
import { runAgentCli } from './agent.js';
|
|
7
7
|
import { createCanvas } from '../server/index.js';
|
|
8
8
|
import { seedDemoCanvas } from '../server/demo.js';
|
|
9
|
+
import { ensureDefaultDockedNodes } from '../server/server.js';
|
|
9
10
|
|
|
10
11
|
const args = process.argv.slice(2);
|
|
11
12
|
|
|
@@ -599,7 +600,13 @@ Examples:
|
|
|
599
600
|
process.exit(1);
|
|
600
601
|
}
|
|
601
602
|
|
|
602
|
-
if (demo && canvas.getLayout().nodes.length === 0)
|
|
603
|
+
if (demo && canvas.getLayout().nodes.length === 0) {
|
|
604
|
+
seedDemoCanvas();
|
|
605
|
+
} else if (!demo) {
|
|
606
|
+
// First-run only: dock a status (left) + context (right) widget by default so
|
|
607
|
+
// a fresh canvas isn't empty. No-op once the workspace has saved state.
|
|
608
|
+
ensureDefaultDockedNodes();
|
|
609
|
+
}
|
|
603
610
|
|
|
604
611
|
console.log(`\n PMX Canvas running at http://localhost:${canvas.port}`);
|
|
605
612
|
console.log(` Health: http://localhost:${canvas.port}/health\n`);
|
package/src/client/App.tsx
CHANGED
|
@@ -167,7 +167,7 @@ function Toolbar({
|
|
|
167
167
|
<ToolbarHint label="Canvas status" detail={hasSynced ? statusLabel : 'Syncing canvas from server'} align="start">
|
|
168
168
|
<span class={`connection-dot ${status}`} aria-label={`Canvas status: ${statusTitle}`} />
|
|
169
169
|
</ToolbarHint>
|
|
170
|
-
<span style={{ fontSize: '11px', color: 'var(--c-muted)' }}>
|
|
170
|
+
<span class="hud-collapsible-text" style={{ fontSize: '11px', color: 'var(--c-muted)' }}>
|
|
171
171
|
{sessionId.value ? sessionId.value.slice(0, 12) : '…'}
|
|
172
172
|
</span>
|
|
173
173
|
|
|
@@ -209,7 +209,7 @@ function Toolbar({
|
|
|
209
209
|
<IconZoomOut />
|
|
210
210
|
</button>
|
|
211
211
|
</ToolbarHint>
|
|
212
|
-
<span style={{ fontSize: '10px', color: 'var(--c-dim)', minWidth: '36px', textAlign: 'center' }}>
|
|
212
|
+
<span class="hud-collapsible-text" style={{ fontSize: '10px', color: 'var(--c-dim)', minWidth: '36px', textAlign: 'center' }}>
|
|
213
213
|
{Math.round(v.scale * 100)}%
|
|
214
214
|
</span>
|
|
215
215
|
|
|
@@ -357,7 +357,7 @@ function Toolbar({
|
|
|
357
357
|
</button>
|
|
358
358
|
</ToolbarHint>
|
|
359
359
|
|
|
360
|
-
<span style={{ fontSize: '10px', color: 'var(--c-dim)' }}>{countsLabel}</span>
|
|
360
|
+
<span class="hud-collapsible-text" style={{ fontSize: '10px', color: 'var(--c-dim)' }}>{countsLabel}</span>
|
|
361
361
|
</div>
|
|
362
362
|
</div>
|
|
363
363
|
);
|
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
viewport,
|
|
26
26
|
} from '../state/canvas-store';
|
|
27
27
|
import { removeNodeFromClient, updateNodeFromClient } from '../state/intent-bridge';
|
|
28
|
-
import { canOpenAsSite, openNodeAsSite } from '../nodes/surface-url';
|
|
28
|
+
import { canOpenAsSite, openNodeAsSite, openNodeInSystemBrowser } from '../nodes/surface-url';
|
|
29
29
|
import { getNodeIcon } from '../icons';
|
|
30
30
|
import { EXPANDABLE_TYPES, TYPE_LABELS } from '../types';
|
|
31
31
|
import type { CanvasNodeState } from '../types';
|
|
@@ -292,6 +292,21 @@ export function CanvasNode({ node, children, onContextMenu }: CanvasNodeProps) {
|
|
|
292
292
|
↗
|
|
293
293
|
</button>
|
|
294
294
|
)}
|
|
295
|
+
{/* Open in the real system browser (for hosts whose embedded browser
|
|
296
|
+
makes a normal new tab feel in-place, e.g. Codex). Falls back to a
|
|
297
|
+
new tab when the server can't launch the OS browser. */}
|
|
298
|
+
{canOpenAsSite(node) && (
|
|
299
|
+
<button
|
|
300
|
+
type="button"
|
|
301
|
+
onClick={(e) => {
|
|
302
|
+
e.stopPropagation();
|
|
303
|
+
void openNodeInSystemBrowser(node);
|
|
304
|
+
}}
|
|
305
|
+
title="Open in system browser"
|
|
306
|
+
>
|
|
307
|
+
⤤
|
|
308
|
+
</button>
|
|
309
|
+
)}
|
|
295
310
|
{/* Expand — opens node as full-viewport overlay for focused work */}
|
|
296
311
|
{EXPANDABLE_TYPES.has(node.type) && (
|
|
297
312
|
<button
|
|
@@ -2,7 +2,7 @@ import { ContextNode } from '../nodes/ContextNode';
|
|
|
2
2
|
import { LedgerNode } from '../nodes/LedgerNode';
|
|
3
3
|
import { StatusNode } from '../nodes/StatusNode';
|
|
4
4
|
import { StatusSummary } from '../nodes/StatusSummary';
|
|
5
|
-
import {
|
|
5
|
+
import { closeAttentionHistory } from '../state/attention-store';
|
|
6
6
|
import { getContextPinnedNodes, toggleCollapsed, undockNode } from '../state/canvas-store';
|
|
7
7
|
import { TYPE_LABELS } from '../types';
|
|
8
8
|
import type { CanvasNodeState } from '../types';
|
|
@@ -40,44 +40,44 @@ function ContextDockedNode({ node }: { node: CanvasNodeState }) {
|
|
|
40
40
|
toggleCollapsed(node.id);
|
|
41
41
|
};
|
|
42
42
|
|
|
43
|
-
// Hide the collapsed Context pill while the Updates side panel is open.
|
|
44
|
-
// Mutual exclusion guarantees both panels can't be expanded simultaneously,
|
|
45
|
-
// but the pill itself would otherwise sit beneath/beside the Updates panel
|
|
46
|
-
// at the same right edge — better to hide until Updates is closed.
|
|
47
|
-
if (collapsed && attentionHistoryOpen.value) return null;
|
|
48
|
-
|
|
49
43
|
if (collapsed) {
|
|
44
|
+
// Collapsed = a menu-height pill in the right of the top HUD row, mirroring
|
|
45
|
+
// the docked status widget on the left so the bar reads as one continuous menu.
|
|
50
46
|
return (
|
|
51
|
-
<
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
47
|
+
<div class="docked-node docked-node--collapsed" data-docked-node="true">
|
|
48
|
+
<div class="docked-node-header">
|
|
49
|
+
<span class="node-type-badge">Context</span>
|
|
50
|
+
{hasItems && (
|
|
51
|
+
<span class="docked-node-count" aria-hidden="true">
|
|
52
|
+
{count > 99 ? '99+' : count}
|
|
53
|
+
</span>
|
|
54
|
+
)}
|
|
55
|
+
<div class="docked-node-controls">
|
|
56
|
+
<button
|
|
57
|
+
type="button"
|
|
58
|
+
onClick={(e) => {
|
|
59
|
+
e.stopPropagation();
|
|
60
|
+
expand();
|
|
61
|
+
}}
|
|
62
|
+
title={hasItems ? `${count} item${count === 1 ? '' : 's'} in agent context — expand` : 'Expand agent context'}
|
|
63
|
+
aria-label={hasItems ? `Context — ${count} item${count === 1 ? '' : 's'}` : 'Expand agent context'}
|
|
64
|
+
>
|
|
65
|
+
{'▸'}
|
|
66
|
+
</button>
|
|
67
|
+
<button
|
|
68
|
+
type="button"
|
|
69
|
+
onClick={(e) => {
|
|
70
|
+
e.stopPropagation();
|
|
71
|
+
undockNode(node.id);
|
|
72
|
+
}}
|
|
73
|
+
title="Undock to canvas"
|
|
74
|
+
aria-label="Undock to canvas"
|
|
75
|
+
>
|
|
76
|
+
{'⊙'}
|
|
77
|
+
</button>
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
81
|
);
|
|
82
82
|
}
|
|
83
83
|
|
|
@@ -130,7 +130,7 @@ export function DockedNode({ node }: { node: CanvasNodeState }) {
|
|
|
130
130
|
}
|
|
131
131
|
|
|
132
132
|
return (
|
|
133
|
-
<div class=
|
|
133
|
+
<div class={`docked-node${node.collapsed ? ' docked-node--collapsed' : ''}`} data-docked-node="true">
|
|
134
134
|
<div class="docked-node-header">
|
|
135
135
|
<span class="node-type-badge">{TYPE_LABELS[node.type] ?? node.type}</span>
|
|
136
136
|
{node.type === 'status' && node.collapsed && <StatusSummary node={node} />}
|
|
@@ -8,7 +8,7 @@ import { StatusNode } from '../nodes/StatusNode';
|
|
|
8
8
|
import { ImageNode } from '../nodes/ImageNode';
|
|
9
9
|
import { WebpageNode } from '../nodes/WebpageNode';
|
|
10
10
|
import { HtmlNode, shouldShowPresentationControls } from '../nodes/HtmlNode';
|
|
11
|
-
import { canOpenAsSite, openNodeAsSite } from '../nodes/surface-url';
|
|
11
|
+
import { canOpenAsSite, openNodeAsSite, openNodeInSystemBrowser } from '../nodes/surface-url';
|
|
12
12
|
import { PromptNode } from '../nodes/PromptNode';
|
|
13
13
|
import { ResponseNode } from '../nodes/ResponseNode';
|
|
14
14
|
import { TraceNode } from '../nodes/TraceNode';
|
|
@@ -315,6 +315,17 @@ export function ExpandedNodeOverlay() {
|
|
|
315
315
|
</button>
|
|
316
316
|
)}
|
|
317
317
|
|
|
318
|
+
{canOpenAsSite(node) && (
|
|
319
|
+
<button
|
|
320
|
+
type="button"
|
|
321
|
+
class="expanded-action-btn"
|
|
322
|
+
onClick={() => void openNodeInSystemBrowser(node)}
|
|
323
|
+
title="Open in the system browser (e.g. Chrome) — useful when the host browser opens tabs in-place"
|
|
324
|
+
>
|
|
325
|
+
Open in system browser
|
|
326
|
+
</button>
|
|
327
|
+
)}
|
|
328
|
+
|
|
318
329
|
{canPresent && (
|
|
319
330
|
<button
|
|
320
331
|
type="button"
|
|
@@ -178,7 +178,7 @@ export function ContextNode({
|
|
|
178
178
|
<button
|
|
179
179
|
type="button"
|
|
180
180
|
class="ax-node-action"
|
|
181
|
-
title="
|
|
181
|
+
title="Point the agent at this node — sets it as the agent's current AX focus so the agent pulls it into context to work on next (a one-click alternative to manually pinning)."
|
|
182
182
|
style={axNodeActionButtonStyle}
|
|
183
183
|
onClick={(e) => {
|
|
184
184
|
e.stopPropagation();
|