pmx-canvas 0.1.28 → 0.1.30
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 +193 -0
- package/Readme.md +20 -10
- package/dist/canvas/global.css +13 -0
- package/dist/canvas/index.js +80 -163
- package/dist/canvas/surface-theme.css +142 -0
- package/dist/json-render/index.js +103 -103
- package/dist/types/client/nodes/HtmlNode.d.ts +0 -7
- package/dist/types/client/nodes/ax-node-actions.d.ts +18 -0
- package/dist/types/client/nodes/surface-url.d.ts +22 -0
- package/dist/types/client/state/attention-bridge.d.ts +3 -0
- package/dist/types/client/state/intent-bridge.d.ts +17 -0
- package/dist/types/json-render/renderer/index.d.ts +2 -0
- package/dist/types/json-render/schema.d.ts +2 -0
- package/dist/types/json-render/server.d.ts +2 -0
- package/dist/types/mcp/canvas-access.d.ts +47 -0
- package/dist/types/server/ax-interaction.d.ts +210 -0
- package/dist/types/server/ax-state.d.ts +67 -1
- package/dist/types/server/canvas-db.d.ts +4 -0
- package/dist/types/server/canvas-serialization.d.ts +2 -0
- package/dist/types/server/canvas-state.d.ts +47 -2
- package/dist/types/server/html-surface.d.ts +40 -0
- package/dist/types/server/index.d.ts +56 -2
- package/dist/types/server/mutation-history.d.ts +1 -1
- package/dist/types/server/placement.d.ts +1 -1
- package/dist/types/shared/surface.d.ts +19 -0
- package/docs/cli.md +30 -0
- package/docs/http-api.md +55 -0
- package/docs/mcp.md +40 -2
- package/docs/node-types.md +26 -0
- package/docs/plans/plan-004-pmx-ax-primitives.md +623 -394
- package/docs/sdk.md +20 -0
- package/package.json +2 -2
- package/skills/pmx-canvas/SKILL.md +107 -9
- package/src/cli/agent.ts +190 -0
- package/src/client/canvas/CanvasNode.tsx +8 -4
- package/src/client/canvas/ExpandedNodeOverlay.tsx +12 -0
- package/src/client/nodes/ContextNode.tsx +17 -0
- package/src/client/nodes/ExtAppFrame.tsx +40 -3
- package/src/client/nodes/FileNode.tsx +26 -0
- package/src/client/nodes/HtmlNode.tsx +60 -188
- package/src/client/nodes/LedgerNode.tsx +39 -5
- package/src/client/nodes/McpAppNode.tsx +47 -2
- package/src/client/nodes/StatusNode.tsx +20 -0
- package/src/client/nodes/ax-node-actions.ts +39 -0
- package/src/client/nodes/surface-url.ts +48 -0
- package/src/client/state/attention-bridge.ts +5 -0
- package/src/client/state/intent-bridge.ts +33 -0
- package/src/client/theme/global.css +13 -0
- package/src/client/theme/surface-theme.css +142 -0
- package/src/json-render/renderer/index.tsx +31 -0
- package/src/json-render/schema.ts +4 -0
- package/src/json-render/server.ts +31 -1
- package/src/mcp/canvas-access.ts +212 -1
- package/src/mcp/server.ts +238 -5
- package/src/server/ax-context.ts +3 -0
- package/src/server/ax-interaction.ts +549 -0
- package/src/server/ax-state.ts +188 -2
- package/src/server/canvas-db.ts +20 -0
- package/src/server/canvas-operations.ts +11 -0
- package/src/server/canvas-serialization.ts +9 -0
- package/src/server/canvas-state.ts +177 -16
- package/src/server/html-surface.ts +170 -0
- package/src/server/index.ts +105 -1
- package/src/server/mutation-history.ts +5 -0
- package/src/server/placement.ts +5 -1
- package/src/server/server.ts +305 -0
- package/src/shared/surface.ts +38 -0
package/docs/sdk.md
CHANGED
|
@@ -106,6 +106,26 @@ canvas.addReviewAnnotation({ body: 'off-by-one', kind: 'finding', severity: 'err
|
|
|
106
106
|
// Host/session capability (own table, survives clear)
|
|
107
107
|
canvas.reportHostCapability({ host: 'copilot', canvas: true, sessionMessaging: true }, { source: 'sdk' });
|
|
108
108
|
console.log(canvas.getHostCapability());
|
|
109
|
+
|
|
110
|
+
// Node interactions — one capability-gated envelope; the server re-validates and
|
|
111
|
+
// clamps sandboxed surfaces (html-node/mcp-app/json-render) to their own node.
|
|
112
|
+
canvas.submitAxInteraction({ type: 'ax.work.create', sourceNodeId: n1, payload: { title: 'Wire auth' } });
|
|
113
|
+
|
|
114
|
+
// Delivery — claim pending steering for a consumer (loop-safe), then acknowledge
|
|
115
|
+
const pending = canvas.getPendingSteering({ consumer: 'copilot', limit: 20 });
|
|
116
|
+
if (pending[0]) canvas.markSteeringDelivered(pending[0].id, { consumer: 'copilot' });
|
|
117
|
+
|
|
118
|
+
// Elicitation + mode requests (canvas-bound, snapshotted)
|
|
119
|
+
const elic = canvas.requestElicitation({ prompt: 'Who owns this?', fields: ['owner'] }, { source: 'sdk' });
|
|
120
|
+
canvas.respondElicitation(elic.id, { owner: 'alice' });
|
|
121
|
+
const mode = canvas.requestMode({ mode: 'execute', reason: 'plan approved' }, { source: 'sdk' });
|
|
122
|
+
canvas.resolveModeRequest(mode.id, 'approved');
|
|
123
|
+
|
|
124
|
+
// Command registry + tool/prompt policy
|
|
125
|
+
console.log(canvas.getCommandRegistry());
|
|
126
|
+
canvas.invokeCommand('pmx.plan', { note: 'draft a plan' }, { source: 'sdk' });
|
|
127
|
+
canvas.setPolicy({ tools: { excluded: ['shell'] }, prompt: { mode: 'concise' } }, { source: 'sdk' });
|
|
128
|
+
console.log(canvas.getPolicy());
|
|
109
129
|
```
|
|
110
130
|
|
|
111
131
|
## WebView automation
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmx-canvas",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.30",
|
|
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",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"dev:demo": "bun run src/cli/index.ts --demo",
|
|
33
33
|
"dev:portless": "portless run --name pmx --app-port 4313 bun run src/cli/index.ts --no-open --port=4313",
|
|
34
34
|
"dev:portless:demo": "portless run --name pmx --app-port 4313 bun run src/cli/index.ts --no-open --port=4313 --demo",
|
|
35
|
-
"build:client": "bun build src/client/index.tsx --outdir dist/canvas --minify && cp src/client/theme/global.css dist/canvas/global.css",
|
|
35
|
+
"build:client": "bun build src/client/index.tsx --outdir dist/canvas --minify && cp src/client/theme/global.css dist/canvas/global.css && cp src/client/theme/surface-theme.css dist/canvas/surface-theme.css",
|
|
36
36
|
"build:json-render": "bash scripts/build-json-render.sh",
|
|
37
37
|
"build:types": "tsc -p tsconfig.types.json",
|
|
38
38
|
"build": "bun run build:client && bun run build:json-render && bun run build:types",
|
|
@@ -75,14 +75,19 @@ reference before using adapter-native features:
|
|
|
75
75
|
- `references/codex-app-adapter.md` — Codex app native Browser + MCP adapter, AX context reading,
|
|
76
76
|
focus labeling, and live-test checklist.
|
|
77
77
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
-
|
|
84
|
-
|
|
85
|
-
|
|
78
|
+
Open the canvas first — always:
|
|
79
|
+
The canvas is the shared human↔agent surface. **Before you create or mutate any nodes, make the
|
|
80
|
+
workbench visible.** Do **not** assume the host opened it for you — some hosts (e.g. the Codex app)
|
|
81
|
+
do not open it on their own. Take the action to open it yourself, whatever the host:
|
|
82
|
+
- **Native adapter/panel available** (the GitHub Copilot app `pmx-canvas` canvas extension, or the
|
|
83
|
+
Codex in-app Browser): open/focus that panel to the server's `/workbench` route.
|
|
84
|
+
- **Any other browser** (Chrome, Safari, Arc, Edge, …) **or a generic/CLI agent** with no native
|
|
85
|
+
panel: open the server's `/workbench` URL in a browser.
|
|
86
|
+
|
|
87
|
+
Then reuse that **single** surface for the rest of the session — do **not** open a second panel to
|
|
88
|
+
the same workbench (it wastes space and confuses which surface is authoritative). If you genuinely
|
|
89
|
+
cannot open any browser (headless/CI), say so and proceed, but still print the `/workbench` URL so a
|
|
90
|
+
human can open it.
|
|
86
91
|
- External URLs in `mcp-app` nodes show the "Unverified domain" interstitial by design. Only
|
|
87
92
|
same-origin `/api/canvas/frame-documents/<id>` URLs are auto-trusted. For external tools, use a
|
|
88
93
|
bundled `web-artifact`, same-origin frame document, or set `data.trustedDomain: true` only when the
|
|
@@ -213,6 +218,9 @@ pmx-canvas spatial
|
|
|
213
218
|
`ax review add|list` — canvas-bound AX state (work items, approval gates,
|
|
214
219
|
review annotations) that rides snapshots and restore and is cleared by `clear`.
|
|
215
220
|
- `ax host report|status` — report/read the host/session capability (own partition).
|
|
221
|
+
- `ax command list|invoke`, `ax policy get|set` — list/invoke registry commands
|
|
222
|
+
(`pmx.plan`, `pmx.execute`, `pmx.promote-context`, `pmx.summarize`, `pmx.review`)
|
|
223
|
+
and read/patch the canvas-bound tool/prompt policy.
|
|
216
224
|
- `copilot install-extension [--dry-run] [--yes]` — install the bundled GitHub
|
|
217
225
|
Copilot adapter into a repo; the core stays host-agnostic.
|
|
218
226
|
- `fit [id ...]` — set the server viewport to fit the whole canvas or selected nodes before screenshots or whole-board review
|
|
@@ -290,6 +298,24 @@ points from `from` to `to`, indicating sequence or data flow direction.
|
|
|
290
298
|
pending dependencies, and `dotted` for weak/optional relationships. Use `animated: true` to
|
|
291
299
|
draw visual attention to critical paths.
|
|
292
300
|
|
|
301
|
+
### Layout: spacing and groups
|
|
302
|
+
|
|
303
|
+
Agents tend to pack boards too tightly. Give nodes room to breathe — readability beats density.
|
|
304
|
+
|
|
305
|
+
- **Default spacing:** leave a clear gap between neighbors — roughly half a node's width
|
|
306
|
+
horizontally and half its height vertically. A 280×180 node reads well with ~360px between
|
|
307
|
+
column origins and ~260px between row origins.
|
|
308
|
+
- **When nodes are connected by edges, space them further apart** so the edge line, its arrowhead,
|
|
309
|
+
and its label are clearly visible in the gap between nodes. Crowded nodes hide the flow — this is
|
|
310
|
+
the most common cause of an unreadable board.
|
|
311
|
+
- **Inside a group, keep the same breathing room** (groups no longer auto-pack children, so the
|
|
312
|
+
spacing you set is what the human sees). Edges between grouped children especially need the gap.
|
|
313
|
+
- **Size a group frame larger than its children's bounding box** so the group header — including the
|
|
314
|
+
node-count badge — stays visible and isn't hidden under the top-left child. For an explicit
|
|
315
|
+
(manual) group frame, add margin on every side (≈56px) plus room at the top for the header rather
|
|
316
|
+
than hugging the children. Auto-fit groups (created without an explicit width/height) already
|
|
317
|
+
reserve this margin.
|
|
318
|
+
|
|
293
319
|
### Colors (Semantic)
|
|
294
320
|
|
|
295
321
|
Use color consistently to convey meaning:
|
|
@@ -740,6 +766,28 @@ server's `ui://` resource as an iframe node on the canvas
|
|
|
740
766
|
- Read `htmlPrimitives` from `canvas_describe_schema` for the data shape and examples before constructing a payload
|
|
741
767
|
- For payload patterns, export loops, and the primitive catalog, read `references/html-primitives.md` before creating dense or editable artifacts
|
|
742
768
|
|
|
769
|
+
### Open as Site (standalone surfaces)
|
|
770
|
+
|
|
771
|
+
Any renderable surface node can be opened full-page in its own browser tab — the same
|
|
772
|
+
document it shows in the canvas, just without the node chrome. In the workbench, use the
|
|
773
|
+
↗ **Open as site** button in the node title bar (or the expanded overlay).
|
|
774
|
+
|
|
775
|
+
- Works for `html` / `html-primitive`, bundled `web-artifact`, `json-render` / `graph`,
|
|
776
|
+
`webpage`, and hosted ext-app `mcp-app` nodes.
|
|
777
|
+
- The tab loads the node's stable surface URL, `/api/canvas/surface/<nodeId>`. The
|
|
778
|
+
in-canvas iframe loads the **exact same URL**, so there is one render path and no
|
|
779
|
+
separate "preview" version — what you see in the canvas is what opens. The URL reflects
|
|
780
|
+
current node state and survives a refresh.
|
|
781
|
+
- Agents can read this URL from any node payload (`canvas_get_node` / `canvas_get_layout`)
|
|
782
|
+
as `surfaceUrl` — a reliable way to tell a human "open the artifact" without disturbing
|
|
783
|
+
the canvas.
|
|
784
|
+
- Served HTML stays sandboxed (opaque origin via a `Content-Security-Policy: sandbox`
|
|
785
|
+
response header), so opening author code top-level cannot reach the canvas origin.
|
|
786
|
+
- ext-app `mcp-app` nodes open their UI, but interactive tool-calls only work inside the
|
|
787
|
+
canvas (the host bridge has no peer in a bare tab). `webpage` and URL-backed `mcp-app`
|
|
788
|
+
nodes redirect to their external site.
|
|
789
|
+
- This is additive — opening a site never evicts or replaces canvas nodes.
|
|
790
|
+
|
|
743
791
|
### Choosing the Right Visual Tier
|
|
744
792
|
|
|
745
793
|
When the output is more than markdown, pick the lightest tier that fits:
|
|
@@ -761,6 +809,8 @@ Use native `json-render` and `graph` nodes when the output should stay fully ins
|
|
|
761
809
|
3. Use the repo-local `json-render-*` skills when you need help authoring or refining the spec itself
|
|
762
810
|
4. Use `canvas_build_web_artifact` instead when the result needs a full custom React app rather than a schema-driven UI
|
|
763
811
|
|
|
812
|
+
Spec elements support an `on` map (`on.press`, `on.change`, …) binding events to actions (`{ action, params }`) — built-in actions (`setState`, `pushState`, …) or, when named after an AX interaction type, a capability-gated AX emit. e.g. a Button with `on: { press: { action: 'ax.work.create', params: { title: '…' } } }` lets a human turn a panel control into a tracked work item; the viewer forwards it to the canvas, which validates and submits it server-side (clamped to the node's own id). See **Node AX Interactions** above.
|
|
813
|
+
|
|
764
814
|
## MCP Resources
|
|
765
815
|
|
|
766
816
|
These resources give you read access to canvas intelligence. Read them to understand
|
|
@@ -777,10 +827,58 @@ what the human has set up and what they're focusing on.
|
|
|
777
827
|
| `canvas://code-graph` | Auto-detected file import dependencies (JS/TS, Python, Go, Rust) |
|
|
778
828
|
| `canvas://ax` | Host-agnostic AX state: focus, work items, approval gates, review annotations, host capability |
|
|
779
829
|
| `canvas://ax-context` | Agent-ready AX context: pinned context + current focus |
|
|
780
|
-
| `canvas://ax-work` | Canvas-bound AX work: work items, approval gates, review annotations |
|
|
830
|
+
| `canvas://ax-work` | Canvas-bound AX work: work items, approval gates, review annotations, elicitations, mode requests, and tool/prompt policy |
|
|
781
831
|
| `canvas://ax-timeline` | Bounded AX timeline: recent agent events, evidence, and steering messages |
|
|
832
|
+
| `canvas://ax-pending-steering` | Undelivered steering an MCP client can claim, act on, and mark delivered (adapterless delivery) |
|
|
833
|
+
| `canvas://ax-delivery` | Steering delivery state (delivered flag) for diagnostics |
|
|
782
834
|
| `canvas://skills` | Index of bundled agent skills shipped with the install. Each skill is also addressable as `canvas://skills/<name>` (e.g. `canvas://skills/web-artifacts-builder`) and returns the full SKILL.md. Read this resource first to discover companion workflows the canvas is built to support. |
|
|
783
835
|
|
|
836
|
+
### Node AX Interactions (capability-gated)
|
|
837
|
+
|
|
838
|
+
Eligible nodes can emit one normalized, validated AX interaction that maps onto an
|
|
839
|
+
AX operation — work item, evidence, approval, review, focus, steering, event,
|
|
840
|
+
elicitation, or mode request. One envelope, many transports:
|
|
841
|
+
|
|
842
|
+
- **Endpoint:** `POST /api/canvas/ax/interaction` with
|
|
843
|
+
`{ type, sourceNodeId, payload }` (MCP: `canvas_ax_interaction`; CLI:
|
|
844
|
+
`pmx-canvas ax interaction`). Returns `{ ok, primitive }` or
|
|
845
|
+
`{ ok: false, code }` if the node type/metadata disallows the type.
|
|
846
|
+
- **Capabilities:** each node type has a default capability set (a ceiling). A
|
|
847
|
+
node may opt in or narrow via `data.axCapabilities` (`{ enabled, allowed }`),
|
|
848
|
+
clamped to the ceiling — a node can never escalate beyond its type's ceiling.
|
|
849
|
+
`html` / `html-primitive`, `mcp-app`, and internal `prompt` / `response` nodes
|
|
850
|
+
are **disabled by default** (opt-in).
|
|
851
|
+
- **Transports:** native node controls call the endpoint directly. Sandboxed
|
|
852
|
+
surfaces emit via a nonce-tagged `postMessage` the parent canvas validates
|
|
853
|
+
before submitting: `html` / `html-primitive` nodes (when opted in) call
|
|
854
|
+
`window.PMX_AX.emit(type, payload)`; **json-render / graph** viewers forward a
|
|
855
|
+
spec action named after an AX type (e.g. `on.press → { action:
|
|
856
|
+
"ax.work.create", params }`, `sourceSurface: 'json-render'`); opted-in ext-app
|
|
857
|
+
**`mcp-app`** nodes get the same `window.PMX_AX.emit` injected
|
|
858
|
+
(`sourceSurface: 'mcp-app'`). The server re-validates capabilities regardless
|
|
859
|
+
of transport — bridges are convenience, not a trust boundary.
|
|
860
|
+
- **Delivery:** steering can be claimed by adapterless MCP clients via
|
|
861
|
+
`canvas://ax-pending-steering` / `canvas_claim_ax_delivery` and acknowledged
|
|
862
|
+
with `canvas_mark_ax_delivery` (loop-safe — a consumer never receives steering
|
|
863
|
+
it originated).
|
|
864
|
+
- **Elicitation / mode:** request structured human input
|
|
865
|
+
(`canvas_request_elicitation` → `canvas_respond_elicitation`) or a workflow
|
|
866
|
+
mode transition (`canvas_request_mode` → `canvas_resolve_mode`); both are
|
|
867
|
+
canvas-bound and snapshotted.
|
|
868
|
+
- **Commands:** invoke a registry command — `pmx.plan`, `pmx.execute`,
|
|
869
|
+
`pmx.promote-context`, `pmx.summarize`, `pmx.review` — via
|
|
870
|
+
`canvas_invoke_command` (HTTP `POST /api/canvas/ax/command`; CLI
|
|
871
|
+
`pmx-canvas ax command invoke`; envelope `ax.command.invoke`). Unknown names
|
|
872
|
+
are rejected; an invocation records an `agent-event` of kind `command`.
|
|
873
|
+
- **Policy:** a canvas-bound, snapshotted tool/prompt policy
|
|
874
|
+
(`tools.allowed|excluded|approvalRequired`, `prompt.systemAppend|mode`) read
|
|
875
|
+
into `canvas://ax-context`. Patch it with `canvas_set_ax_policy` (HTTP
|
|
876
|
+
`GET|POST /api/canvas/ax/policy`; CLI `pmx-canvas ax policy get|set`); patches
|
|
877
|
+
merge and are normalized server-side.
|
|
878
|
+
|
|
879
|
+
Interactions request PMX-AX primitives only — never arbitrary shell, tool, MCP,
|
|
880
|
+
or host execution.
|
|
881
|
+
|
|
784
882
|
### Reading Spatial Intent
|
|
785
883
|
|
|
786
884
|
The `canvas://spatial-context` resource reveals how the human has organized information:
|
package/src/cli/agent.ts
CHANGED
|
@@ -1082,11 +1082,29 @@ const RESOURCE_COMMAND_ALIASES: Record<string, Record<string, string>> = {
|
|
|
1082
1082
|
delete: 'remove',
|
|
1083
1083
|
rm: 'remove',
|
|
1084
1084
|
},
|
|
1085
|
+
ax: {
|
|
1086
|
+
// Single-subcommand AX groups: the bare verb maps to its only action so
|
|
1087
|
+
// `ax event` / `ax evidence` suggest the full command instead of erroring.
|
|
1088
|
+
event: 'event add',
|
|
1089
|
+
evidence: 'evidence add',
|
|
1090
|
+
},
|
|
1085
1091
|
};
|
|
1086
1092
|
const RESOURCE_SUBCOMMAND_HINTS: Record<string, Record<string, string>> = {
|
|
1087
1093
|
node: {
|
|
1088
1094
|
pin: 'Use the top-level pin command instead: pmx-canvas pin <node-id>',
|
|
1089
1095
|
},
|
|
1096
|
+
ax: {
|
|
1097
|
+
// Multi-subcommand AX groups: point at the available actions.
|
|
1098
|
+
host: 'Pick an action: pmx-canvas ax host report | pmx-canvas ax host status',
|
|
1099
|
+
work: 'Pick an action: pmx-canvas ax work add | update | list',
|
|
1100
|
+
approval: 'Pick an action: pmx-canvas ax approval request | resolve | list',
|
|
1101
|
+
review: 'Pick an action: pmx-canvas ax review add | list',
|
|
1102
|
+
delivery: 'Pick an action: pmx-canvas ax delivery list | mark',
|
|
1103
|
+
elicitation: 'Pick an action: pmx-canvas ax elicitation request | respond | list',
|
|
1104
|
+
mode: 'Pick an action: pmx-canvas ax mode request | resolve | list',
|
|
1105
|
+
command: 'Pick an action: pmx-canvas ax command list | invoke',
|
|
1106
|
+
policy: 'Pick an action: pmx-canvas ax policy get | set',
|
|
1107
|
+
},
|
|
1090
1108
|
};
|
|
1091
1109
|
|
|
1092
1110
|
function cmd(
|
|
@@ -1866,6 +1884,178 @@ cmd('ax steer', 'Send a steering message to the active agent session', [
|
|
|
1866
1884
|
output(await api('POST', '/api/canvas/ax/steer', { message, source: 'cli' }));
|
|
1867
1885
|
});
|
|
1868
1886
|
|
|
1887
|
+
cmd('ax interaction', 'Submit a node-originated AX interaction (capability-gated)', [
|
|
1888
|
+
'pmx-canvas ax interaction --type ax.work.create --node node-1 --payload \'{"title":"Wire auth"}\'',
|
|
1889
|
+
'pmx-canvas ax interaction --type ax.focus.set --node node-2',
|
|
1890
|
+
], async (args) => {
|
|
1891
|
+
const { flags } = parseFlags(args);
|
|
1892
|
+
if (flags.help || flags.h) return showCommandHelp('ax interaction');
|
|
1893
|
+
|
|
1894
|
+
const type = getStringFlag(flags, 'type');
|
|
1895
|
+
if (!type) die('Missing --type', 'pmx-canvas ax interaction --type <ax.*> --node <id> [--payload <json>]');
|
|
1896
|
+
const sourceNodeId = getStringFlag(flags, 'node');
|
|
1897
|
+
if (!sourceNodeId) die('Missing --node', 'pmx-canvas ax interaction --type <ax.*> --node <id>');
|
|
1898
|
+
|
|
1899
|
+
let payload: unknown;
|
|
1900
|
+
const payloadRaw = getStringFlag(flags, 'payload');
|
|
1901
|
+
if (payloadRaw) {
|
|
1902
|
+
try {
|
|
1903
|
+
payload = JSON.parse(payloadRaw);
|
|
1904
|
+
} catch {
|
|
1905
|
+
die('Invalid --payload JSON', 'pmx-canvas ax interaction --payload \'{"title":"..."}\'');
|
|
1906
|
+
}
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1909
|
+
output(await api('POST', '/api/canvas/ax/interaction', {
|
|
1910
|
+
type,
|
|
1911
|
+
sourceNodeId,
|
|
1912
|
+
...(payload !== undefined ? { payload } : {}),
|
|
1913
|
+
source: 'cli',
|
|
1914
|
+
}));
|
|
1915
|
+
});
|
|
1916
|
+
|
|
1917
|
+
cmd('ax delivery list', 'List pending AX steering for a consumer (loop-safe)', [
|
|
1918
|
+
'pmx-canvas ax delivery list',
|
|
1919
|
+
'pmx-canvas ax delivery list --consumer copilot --limit 20',
|
|
1920
|
+
], async (args) => {
|
|
1921
|
+
const { flags } = parseFlags(args);
|
|
1922
|
+
if (flags.help || flags.h) return showCommandHelp('ax delivery list');
|
|
1923
|
+
const consumer = getStringFlag(flags, 'consumer');
|
|
1924
|
+
const limit = optionalNumberFlag(flags, 'limit', 'pmx-canvas ax delivery list --limit <n>');
|
|
1925
|
+
const params = new URLSearchParams();
|
|
1926
|
+
if (consumer) params.set('consumer', consumer);
|
|
1927
|
+
if (limit) params.set('limit', String(limit));
|
|
1928
|
+
const qs = params.toString();
|
|
1929
|
+
output(await api('GET', `/api/canvas/ax/delivery/pending${qs ? `?${qs}` : ''}`));
|
|
1930
|
+
});
|
|
1931
|
+
|
|
1932
|
+
cmd('ax delivery mark', 'Mark an AX steering message as delivered', [
|
|
1933
|
+
'pmx-canvas ax delivery mark <steering-id>',
|
|
1934
|
+
], async (args) => {
|
|
1935
|
+
const { positional, flags } = parseFlags(args);
|
|
1936
|
+
if (flags.help || flags.h) return showCommandHelp('ax delivery mark');
|
|
1937
|
+
const id = getStringFlag(flags, 'id') ?? positional[0];
|
|
1938
|
+
if (!id) die('Missing steering id', 'pmx-canvas ax delivery mark <steering-id>');
|
|
1939
|
+
output(await api('POST', `/api/canvas/ax/delivery/${encodeURIComponent(id)}/mark`, {}));
|
|
1940
|
+
});
|
|
1941
|
+
|
|
1942
|
+
cmd('ax elicitation request', 'Request structured human input', [
|
|
1943
|
+
'pmx-canvas ax elicitation request --prompt "Who owns this migration?"',
|
|
1944
|
+
'pmx-canvas ax elicitation request --prompt "Pick a region" --fields region,owner',
|
|
1945
|
+
], async (args) => {
|
|
1946
|
+
const { flags } = parseFlags(args);
|
|
1947
|
+
if (flags.help || flags.h) return showCommandHelp('ax elicitation request');
|
|
1948
|
+
const prompt = requireFlag(flags, 'prompt', 'pmx-canvas ax elicitation request --prompt <text>');
|
|
1949
|
+
const fields = getStringFlag(flags, 'fields');
|
|
1950
|
+
output(await api('POST', '/api/canvas/ax/elicitation', {
|
|
1951
|
+
prompt,
|
|
1952
|
+
...(fields ? { fields: fields.split(',').map((f) => f.trim()).filter(Boolean) } : {}),
|
|
1953
|
+
source: 'cli',
|
|
1954
|
+
}));
|
|
1955
|
+
});
|
|
1956
|
+
|
|
1957
|
+
cmd('ax elicitation respond', 'Answer a pending elicitation', [
|
|
1958
|
+
'pmx-canvas ax elicitation respond <id> --response \'{"owner":"alice"}\'',
|
|
1959
|
+
], async (args) => {
|
|
1960
|
+
const { positional, flags } = parseFlags(args);
|
|
1961
|
+
if (flags.help || flags.h) return showCommandHelp('ax elicitation respond');
|
|
1962
|
+
const id = getStringFlag(flags, 'id') ?? positional[0];
|
|
1963
|
+
if (!id) die('Missing elicitation id', 'pmx-canvas ax elicitation respond <id> --response <json>');
|
|
1964
|
+
let response: unknown = {};
|
|
1965
|
+
const raw = getStringFlag(flags, 'response');
|
|
1966
|
+
if (raw) {
|
|
1967
|
+
try { response = JSON.parse(raw); } catch { die('Invalid --response JSON', '--response \'{"k":"v"}\''); }
|
|
1968
|
+
}
|
|
1969
|
+
output(await api('POST', `/api/canvas/ax/elicitation/${encodeURIComponent(id)}/respond`, { response, source: 'cli' }));
|
|
1970
|
+
});
|
|
1971
|
+
|
|
1972
|
+
cmd('ax elicitation list', 'List elicitations', ['pmx-canvas ax elicitation list'], async (args) => {
|
|
1973
|
+
const { flags } = parseFlags(args);
|
|
1974
|
+
if (flags.help || flags.h) return showCommandHelp('ax elicitation list');
|
|
1975
|
+
output(await api('GET', '/api/canvas/ax/elicitation'));
|
|
1976
|
+
});
|
|
1977
|
+
|
|
1978
|
+
cmd('ax mode request', 'Request a workflow mode transition (plan/execute/autonomous)', [
|
|
1979
|
+
'pmx-canvas ax mode request --mode execute --reason "plan approved"',
|
|
1980
|
+
], async (args) => {
|
|
1981
|
+
const { flags } = parseFlags(args);
|
|
1982
|
+
if (flags.help || flags.h) return showCommandHelp('ax mode request');
|
|
1983
|
+
const mode = requireFlag(flags, 'mode', 'pmx-canvas ax mode request --mode plan|execute|autonomous');
|
|
1984
|
+
const reason = getStringFlag(flags, 'reason');
|
|
1985
|
+
output(await api('POST', '/api/canvas/ax/mode', { mode, ...(reason ? { reason } : {}), source: 'cli' }));
|
|
1986
|
+
});
|
|
1987
|
+
|
|
1988
|
+
cmd('ax mode resolve', 'Resolve a pending mode request', [
|
|
1989
|
+
'pmx-canvas ax mode resolve <id> --decision approved',
|
|
1990
|
+
], async (args) => {
|
|
1991
|
+
const { positional, flags } = parseFlags(args);
|
|
1992
|
+
if (flags.help || flags.h) return showCommandHelp('ax mode resolve');
|
|
1993
|
+
const id = getStringFlag(flags, 'id') ?? positional[0];
|
|
1994
|
+
if (!id) die('Missing mode request id', 'pmx-canvas ax mode resolve <id> --decision approved|rejected');
|
|
1995
|
+
const decision = getStringFlag(flags, 'decision');
|
|
1996
|
+
if (decision !== 'approved' && decision !== 'rejected') die('Invalid --decision', '--decision approved|rejected');
|
|
1997
|
+
const resolution = getStringFlag(flags, 'resolution');
|
|
1998
|
+
output(await api('POST', `/api/canvas/ax/mode/${encodeURIComponent(id)}/resolve`, {
|
|
1999
|
+
decision,
|
|
2000
|
+
...(resolution ? { resolution } : {}),
|
|
2001
|
+
source: 'cli',
|
|
2002
|
+
}));
|
|
2003
|
+
});
|
|
2004
|
+
|
|
2005
|
+
cmd('ax mode list', 'List mode requests', ['pmx-canvas ax mode list'], async (args) => {
|
|
2006
|
+
const { flags } = parseFlags(args);
|
|
2007
|
+
if (flags.help || flags.h) return showCommandHelp('ax mode list');
|
|
2008
|
+
output(await api('GET', '/api/canvas/ax/mode'));
|
|
2009
|
+
});
|
|
2010
|
+
|
|
2011
|
+
cmd('ax command list', 'List the PMX command registry', ['pmx-canvas ax command list'], async (args) => {
|
|
2012
|
+
const { flags } = parseFlags(args);
|
|
2013
|
+
if (flags.help || flags.h) return showCommandHelp('ax command list');
|
|
2014
|
+
output(await api('GET', '/api/canvas/ax/command'));
|
|
2015
|
+
});
|
|
2016
|
+
|
|
2017
|
+
cmd('ax command invoke', 'Invoke a registry-gated PMX command intent', [
|
|
2018
|
+
'pmx-canvas ax command invoke pmx.plan',
|
|
2019
|
+
'pmx-canvas ax command invoke pmx.promote-context --args \'{"nodeIds":["n1"]}\'',
|
|
2020
|
+
], async (args) => {
|
|
2021
|
+
const { positional, flags } = parseFlags(args);
|
|
2022
|
+
if (flags.help || flags.h) return showCommandHelp('ax command invoke');
|
|
2023
|
+
const name = getStringFlag(flags, 'name') ?? positional[0];
|
|
2024
|
+
if (!name) die('Missing command name', 'pmx-canvas ax command invoke <name>');
|
|
2025
|
+
let cmdArgs: unknown;
|
|
2026
|
+
const raw = getStringFlag(flags, 'args');
|
|
2027
|
+
if (raw) {
|
|
2028
|
+
try { cmdArgs = JSON.parse(raw); } catch { die('Invalid --args JSON', '--args \'{"k":"v"}\''); }
|
|
2029
|
+
}
|
|
2030
|
+
output(await api('POST', '/api/canvas/ax/command', { name, ...(cmdArgs !== undefined ? { args: cmdArgs } : {}), source: 'cli' }));
|
|
2031
|
+
});
|
|
2032
|
+
|
|
2033
|
+
cmd('ax policy get', 'Show the current declarative AX policy', ['pmx-canvas ax policy get'], async (args) => {
|
|
2034
|
+
const { flags } = parseFlags(args);
|
|
2035
|
+
if (flags.help || flags.h) return showCommandHelp('ax policy get');
|
|
2036
|
+
output(await api('GET', '/api/canvas/ax/policy'));
|
|
2037
|
+
});
|
|
2038
|
+
|
|
2039
|
+
cmd('ax policy set', 'Set the declarative AX policy (stored by PMX, enforced by adapters)', [
|
|
2040
|
+
'pmx-canvas ax policy set --excluded-tools shell,write --mode concise',
|
|
2041
|
+
], async (args) => {
|
|
2042
|
+
const { flags } = parseFlags(args);
|
|
2043
|
+
if (flags.help || flags.h) return showCommandHelp('ax policy set');
|
|
2044
|
+
const csv = (v?: string) => (v ? v.split(',').map((s) => s.trim()).filter(Boolean) : undefined);
|
|
2045
|
+
const allowed = csv(getStringFlag(flags, 'allowed-tools'));
|
|
2046
|
+
const excluded = csv(getStringFlag(flags, 'excluded-tools'));
|
|
2047
|
+
const approvalRequired = csv(getStringFlag(flags, 'approval-tools'));
|
|
2048
|
+
const mode = getStringFlag(flags, 'mode');
|
|
2049
|
+
const systemAppend = getStringFlag(flags, 'system-append');
|
|
2050
|
+
const tools = (allowed || excluded || approvalRequired)
|
|
2051
|
+
? { ...(allowed ? { allowed } : {}), ...(excluded ? { excluded } : {}), ...(approvalRequired ? { approvalRequired } : {}) }
|
|
2052
|
+
: undefined;
|
|
2053
|
+
const prompt = (mode || systemAppend)
|
|
2054
|
+
? { ...(mode ? { mode } : {}), ...(systemAppend ? { systemAppend } : {}) }
|
|
2055
|
+
: undefined;
|
|
2056
|
+
output(await api('POST', '/api/canvas/ax/policy', { ...(tools ? { tools } : {}), ...(prompt ? { prompt } : {}), source: 'cli' }));
|
|
2057
|
+
});
|
|
2058
|
+
|
|
1869
2059
|
cmd('ax timeline', 'Read the bounded AX timeline (events, evidence, steering)', [
|
|
1870
2060
|
'pmx-canvas ax timeline',
|
|
1871
2061
|
'pmx-canvas ax timeline --limit 100',
|
|
@@ -25,6 +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
29
|
import { getNodeIcon } from '../icons';
|
|
29
30
|
import { EXPANDABLE_TYPES, TYPE_LABELS } from '../types';
|
|
30
31
|
import type { CanvasNodeState } from '../types';
|
|
@@ -275,15 +276,18 @@ export function CanvasNode({ node, children, onContextMenu }: CanvasNodeProps) {
|
|
|
275
276
|
>
|
|
276
277
|
{'\u2726'}
|
|
277
278
|
</button>
|
|
278
|
-
{/* Open
|
|
279
|
-
|
|
279
|
+
{/* Open as site — full-page standalone view of this node's surface,
|
|
280
|
+
served from /api/canvas/surface/:id (same document as the canvas
|
|
281
|
+
iframe). Covers html, web-artifact/ext-app/url mcp-apps,
|
|
282
|
+
json-render/graph, and webpage nodes. */}
|
|
283
|
+
{canOpenAsSite(node) && (
|
|
280
284
|
<button
|
|
281
285
|
type="button"
|
|
282
286
|
onClick={(e) => {
|
|
283
287
|
e.stopPropagation();
|
|
284
|
-
|
|
288
|
+
openNodeAsSite(node);
|
|
285
289
|
}}
|
|
286
|
-
title="Open
|
|
290
|
+
title="Open as site (new tab)"
|
|
287
291
|
>
|
|
288
292
|
↗
|
|
289
293
|
</button>
|
|
@@ -8,6 +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
12
|
import { PromptNode } from '../nodes/PromptNode';
|
|
12
13
|
import { ResponseNode } from '../nodes/ResponseNode';
|
|
13
14
|
import { TraceNode } from '../nodes/TraceNode';
|
|
@@ -303,6 +304,17 @@ export function ExpandedNodeOverlay() {
|
|
|
303
304
|
</button>
|
|
304
305
|
)}
|
|
305
306
|
|
|
307
|
+
{canOpenAsSite(node) && (
|
|
308
|
+
<button
|
|
309
|
+
type="button"
|
|
310
|
+
class="expanded-action-btn"
|
|
311
|
+
onClick={() => openNodeAsSite(node)}
|
|
312
|
+
title="Open as a full-page site in a new tab"
|
|
313
|
+
>
|
|
314
|
+
Open as site
|
|
315
|
+
</button>
|
|
316
|
+
)}
|
|
317
|
+
|
|
306
318
|
{canPresent && (
|
|
307
319
|
<button
|
|
308
320
|
type="button"
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { openWorkbenchFile } from '../state/intent-bridge';
|
|
2
2
|
import { TYPE_LABELS, type CanvasNodeState } from '../types';
|
|
3
|
+
import { axNodeActionButtonStyle, runNodeAxInteraction } from './ax-node-actions';
|
|
3
4
|
|
|
4
5
|
interface ContextCard {
|
|
5
6
|
key?: string;
|
|
@@ -172,6 +173,22 @@ export function ContextNode({
|
|
|
172
173
|
padding: expanded ? '8px 0' : undefined,
|
|
173
174
|
}}
|
|
174
175
|
>
|
|
176
|
+
{/* AX: focus the agent on this context node */}
|
|
177
|
+
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
|
178
|
+
<button
|
|
179
|
+
type="button"
|
|
180
|
+
class="ax-node-action"
|
|
181
|
+
title="Set AX focus to this node"
|
|
182
|
+
style={axNodeActionButtonStyle}
|
|
183
|
+
onClick={(e) => {
|
|
184
|
+
e.stopPropagation();
|
|
185
|
+
void runNodeAxInteraction(node, 'ax.focus.set', undefined, 'Focus set');
|
|
186
|
+
}}
|
|
187
|
+
>
|
|
188
|
+
Set focus
|
|
189
|
+
</button>
|
|
190
|
+
</div>
|
|
191
|
+
|
|
175
192
|
{tokenLimit !== null && tokenLimit > 0 && (
|
|
176
193
|
<div style={{ marginBottom: '8px' }}>
|
|
177
194
|
<div
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import type { CallToolResult, ListToolsResult, RequestId, Tool } from '@modelcontextprotocol/sdk/types.js';
|
|
2
2
|
import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
3
3
|
import { AppBridge, PostMessageTransport, buildAllowAttribute } from '@modelcontextprotocol/ext-apps/app-bridge';
|
|
4
|
-
import { useEffect, useRef, useState } from 'preact/hooks';
|
|
4
|
+
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
|
5
5
|
import { extAppToolResultsMatch } from '../../shared/ext-app-tool-result.js';
|
|
6
|
+
import { DEFAULT_EXT_APP_SANDBOX } from '../../shared/surface.js';
|
|
7
|
+
import { submitAxInteractionFromClient } from '../state/intent-bridge';
|
|
8
|
+
import { showToast } from '../state/attention-bridge';
|
|
6
9
|
import {
|
|
7
10
|
canvasTheme,
|
|
8
11
|
collapseExpandedNode,
|
|
@@ -20,7 +23,6 @@ type McpUiTheme = 'light' | 'dark';
|
|
|
20
23
|
|
|
21
24
|
type ExtAppBridgeNotifications = Pick<AppBridge, 'sendToolInput' | 'sendToolResult'>;
|
|
22
25
|
type DisplayMode = 'inline' | 'fullscreen' | 'pip';
|
|
23
|
-
const DEFAULT_EXT_APP_SANDBOX = 'allow-scripts allow-popups allow-popups-to-escape-sandbox';
|
|
24
26
|
|
|
25
27
|
interface ExtAppHostDimensionsTarget {
|
|
26
28
|
clientWidth?: number;
|
|
@@ -156,7 +158,42 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
156
158
|
const nodeId = node.id;
|
|
157
159
|
const frameKey = getExtAppBridgeInitKey(node, retryKey);
|
|
158
160
|
const iframeSandbox = resolveExtAppSandbox(null);
|
|
159
|
-
|
|
161
|
+
// Phase 6 — opt-in ext-app AX bridge. When the node sets data.axCapabilities.enabled,
|
|
162
|
+
// inject window.PMX_AX into the app HTML and accept emits below (server re-validates).
|
|
163
|
+
const axCaps = node.data.axCapabilities as { enabled?: boolean } | undefined;
|
|
164
|
+
const axEnabled = axCaps?.enabled === true && typeof html === 'string' && html.length > 0;
|
|
165
|
+
const axToken = useMemo(() => `ax-${crypto.randomUUID()}`, []);
|
|
166
|
+
const axBridgeScript = axEnabled
|
|
167
|
+
? `<script data-pmx-canvas-ax-bridge>window.PMX_AX={emit:function(t,p){window.parent.postMessage({source:'pmx-canvas-ax',token:${JSON.stringify(axToken)},nodeId:${JSON.stringify(nodeId)},interaction:{type:String(t),payload:p&&typeof p==='object'?p:{}}},'*');}};</script>`
|
|
168
|
+
: '';
|
|
169
|
+
const iframeDocument = useIframeDocument((html ?? '') + axBridgeScript, iframeSandbox);
|
|
170
|
+
|
|
171
|
+
useEffect(() => {
|
|
172
|
+
if (!axEnabled) return;
|
|
173
|
+
function onAxMessage(event: MessageEvent) {
|
|
174
|
+
if (event.source !== iframeRef.current?.contentWindow) return;
|
|
175
|
+
const data = event.data as {
|
|
176
|
+
source?: string; token?: string; nodeId?: string;
|
|
177
|
+
interaction?: { type?: unknown; payload?: unknown };
|
|
178
|
+
} | null;
|
|
179
|
+
if (!data || data.source !== 'pmx-canvas-ax' || data.token !== axToken || data.nodeId !== nodeId) return;
|
|
180
|
+
const interaction = data.interaction;
|
|
181
|
+
if (!interaction || typeof interaction.type !== 'string') return;
|
|
182
|
+
void submitAxInteractionFromClient({
|
|
183
|
+
type: interaction.type,
|
|
184
|
+
sourceNodeId: nodeId,
|
|
185
|
+
sourceSurface: 'mcp-app',
|
|
186
|
+
...(interaction.payload && typeof interaction.payload === 'object'
|
|
187
|
+
? { payload: interaction.payload as Record<string, unknown> }
|
|
188
|
+
: {}),
|
|
189
|
+
}).then((res) => {
|
|
190
|
+
if (res.ok) showToast('context', 'AX interaction', interaction.type as string, [nodeId]);
|
|
191
|
+
else showToast('remove', 'AX interaction rejected', res.error ?? res.code ?? '', [nodeId]);
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
window.addEventListener('message', onAxMessage);
|
|
195
|
+
return () => window.removeEventListener('message', onAxMessage);
|
|
196
|
+
}, [axEnabled, axToken, nodeId]);
|
|
160
197
|
const toMcpTheme = (theme: string): McpUiTheme => (theme === 'light' ? 'light' : 'dark');
|
|
161
198
|
const isExpanded = expanded || expandedNodeId.value === nodeId;
|
|
162
199
|
|
|
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'preact/hooks';
|
|
|
2
2
|
import { updateNodeData } from '../state/canvas-store';
|
|
3
3
|
import { fetchFile, updateNodeFromClient } from '../state/intent-bridge';
|
|
4
4
|
import type { CanvasNodeState } from '../types';
|
|
5
|
+
import { runNodeAxInteraction } from './ax-node-actions';
|
|
5
6
|
|
|
6
7
|
/** Guess a language label from a file extension for display. */
|
|
7
8
|
function langFromPath(path: string): string {
|
|
@@ -170,6 +171,31 @@ export function FileNode({
|
|
|
170
171
|
{new Date(updatedAt).toLocaleTimeString()}
|
|
171
172
|
</span>
|
|
172
173
|
)}
|
|
174
|
+
<button
|
|
175
|
+
type="button"
|
|
176
|
+
class="ax-node-action"
|
|
177
|
+
title="Mark this file as AX evidence"
|
|
178
|
+
onClick={(e) => {
|
|
179
|
+
e.stopPropagation();
|
|
180
|
+
void runNodeAxInteraction(
|
|
181
|
+
node,
|
|
182
|
+
'ax.evidence.add',
|
|
183
|
+
{ kind: 'file', title: filePath.split('/').pop() || filePath, ref: filePath },
|
|
184
|
+
'Marked as evidence',
|
|
185
|
+
);
|
|
186
|
+
}}
|
|
187
|
+
style={{
|
|
188
|
+
background: 'none',
|
|
189
|
+
border: 'none',
|
|
190
|
+
color: 'var(--c-muted)',
|
|
191
|
+
cursor: 'pointer',
|
|
192
|
+
padding: '2px 4px',
|
|
193
|
+
fontSize: '12px',
|
|
194
|
+
flexShrink: 0,
|
|
195
|
+
}}
|
|
196
|
+
>
|
|
197
|
+
⊕
|
|
198
|
+
</button>
|
|
173
199
|
<button
|
|
174
200
|
type="button"
|
|
175
201
|
onClick={handleReload}
|