pmx-canvas 0.1.13 → 0.1.15
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 +163 -0
- package/Readme.md +108 -1058
- package/dist/canvas/global.css +141 -0
- package/dist/canvas/index.js +137 -87
- package/dist/json-render/index.css +1 -1
- package/dist/types/client/nodes/ExtAppFrame.d.ts +2 -3
- package/dist/types/client/nodes/HtmlNode.d.ts +5 -0
- package/dist/types/client/nodes/McpAppNode.d.ts +2 -1
- package/dist/types/client/state/canvas-store.d.ts +5 -1
- package/dist/types/client/state/intent-bridge.d.ts +3 -1
- package/dist/types/client/types.d.ts +2 -2
- package/dist/types/json-render/catalog.d.ts +1 -1
- package/dist/types/mcp/canvas-access.d.ts +7 -1
- package/dist/types/server/agent-context.d.ts +1 -0
- package/dist/types/server/canvas-operations.d.ts +12 -2
- package/dist/types/server/canvas-provenance.d.ts +1 -1
- package/dist/types/server/canvas-serialization.d.ts +3 -0
- package/dist/types/server/canvas-state.d.ts +51 -4
- package/dist/types/server/demo.d.ts +5 -0
- package/dist/types/server/diagram-presets.d.ts +4 -0
- package/dist/types/server/index.d.ts +21 -3
- package/dist/types/server/mcp-app-runtime.d.ts +1 -0
- package/dist/types/server/web-artifacts.d.ts +18 -0
- package/dist/types/shared/canvas-node-kind.d.ts +5 -0
- package/package.json +1 -1
- package/skills/pmx-canvas/SKILL.md +43 -0
- package/skills/pmx-canvas-testing/SKILL.md +17 -0
- package/src/cli/agent.ts +66 -5
- package/src/cli/index.ts +2 -23
- package/src/client/canvas/AttentionHistory.tsx +14 -1
- package/src/client/canvas/CanvasNode.tsx +1 -1
- package/src/client/canvas/CanvasViewport.tsx +3 -0
- package/src/client/canvas/DockedNode.tsx +110 -12
- package/src/client/canvas/ExpandedNodeOverlay.tsx +8 -3
- package/src/client/canvas/Minimap.tsx +1 -0
- package/src/client/icons.tsx +1 -0
- package/src/client/nodes/ExtAppFrame.tsx +10 -35
- package/src/client/nodes/HtmlNode.tsx +151 -0
- package/src/client/nodes/McpAppNode.tsx +2 -2
- package/src/client/state/canvas-store.ts +24 -2
- package/src/client/state/intent-bridge.ts +4 -3
- package/src/client/state/sse-bridge.ts +2 -0
- package/src/client/theme/global.css +141 -0
- package/src/client/types.ts +3 -0
- package/src/mcp/canvas-access.ts +34 -7
- package/src/mcp/server.ts +199 -26
- package/src/server/agent-context.ts +50 -3
- package/src/server/canvas-operations.ts +55 -3
- package/src/server/canvas-provenance.ts +2 -1
- package/src/server/canvas-serialization.ts +38 -13
- package/src/server/canvas-state.ts +305 -34
- package/src/server/demo.ts +792 -0
- package/src/server/diagram-presets.ts +45 -25
- package/src/server/index.ts +64 -7
- package/src/server/mcp-app-runtime.ts +15 -5
- package/src/server/server.ts +169 -63
- package/src/server/web-artifacts.ts +116 -3
- package/src/shared/canvas-node-kind.ts +14 -0
package/src/cli/agent.ts
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
|
|
27
27
|
const DEFAULT_PORT = 4313;
|
|
28
28
|
const defaultConsoleLog = console.log;
|
|
29
|
+
const TRACE_NODE_FIELDS = ['toolName', 'category', 'status', 'duration', 'resultSummary', 'error'] as const;
|
|
29
30
|
|
|
30
31
|
interface CanvasSchemaField {
|
|
31
32
|
name: string;
|
|
@@ -153,7 +154,7 @@ function parseFlags(args: string[]): { positional: string[]; flags: Record<strin
|
|
|
153
154
|
const flags: Record<string, string | true> = {};
|
|
154
155
|
// Boolean-only flags (never take a value argument)
|
|
155
156
|
const BOOL_FLAGS = new Set([
|
|
156
|
-
'help', 'h', 'ids', 'stdin', 'yes', 'list', 'clear', 'set', 'animated', 'dry-run',
|
|
157
|
+
'help', 'h', 'ids', 'stdin', 'yes', 'list', 'clear', 'set', 'animated', 'dry-run', 'all',
|
|
157
158
|
'no-open-in-canvas', 'lock-arrange', 'unlock-arrange', 'json', 'compact', 'summary',
|
|
158
159
|
'verbose', 'include-logs', 'no-pan', 'schema', 'example', 'examples', 'strict-size', 'scroll-overflow',
|
|
159
160
|
]);
|
|
@@ -1078,6 +1079,12 @@ cmd('node add', 'Add a node to the canvas', [
|
|
|
1078
1079
|
height: 'Use a positive number, e.g. --height 280',
|
|
1079
1080
|
});
|
|
1080
1081
|
applyStrictSizeFlags(body, flags);
|
|
1082
|
+
if (type === 'trace') {
|
|
1083
|
+
for (const field of TRACE_NODE_FIELDS) {
|
|
1084
|
+
const value = getStringFlag(flags, field, field.replace(/[A-Z]/g, (char) => `-${char.toLowerCase()}`));
|
|
1085
|
+
if (value !== undefined) body[field] = value;
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1081
1088
|
|
|
1082
1089
|
// Support --stdin for piping content
|
|
1083
1090
|
if (flags.stdin) {
|
|
@@ -1308,6 +1315,11 @@ cmd('node update', 'Update a node by ID', [
|
|
|
1308
1315
|
|
|
1309
1316
|
applyStrictSizeFlags(body, flags);
|
|
1310
1317
|
|
|
1318
|
+
for (const field of TRACE_NODE_FIELDS) {
|
|
1319
|
+
const value = getStringFlag(flags, field, field.replace(/[A-Z]/g, (char) => `-${char.toLowerCase()}`));
|
|
1320
|
+
if (value !== undefined) body[field] = value;
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1311
1323
|
if (x !== undefined || y !== undefined || width !== undefined || frameHeight !== undefined || arrangeLocked !== undefined) {
|
|
1312
1324
|
const existing = await api('GET', `/api/canvas/node/${encodeURIComponent(id)}`) as {
|
|
1313
1325
|
position: { x: number; y: number };
|
|
@@ -1339,7 +1351,7 @@ cmd('node update', 'Update a node by ID', [
|
|
|
1339
1351
|
if (Object.keys(body).length === 0) {
|
|
1340
1352
|
die(
|
|
1341
1353
|
'No updates specified',
|
|
1342
|
-
'Use --title, --content, --x, --y, --width, --height, --strict-size, --pinned, --lock-arrange, --unlock-arrange, or --stdin',
|
|
1354
|
+
'Use --title, --content, --x, --y, --width, --height, --strict-size, --pinned, trace fields, --lock-arrange, --unlock-arrange, or --stdin',
|
|
1343
1355
|
);
|
|
1344
1356
|
}
|
|
1345
1357
|
|
|
@@ -1565,7 +1577,9 @@ cmd('external-app add', 'Create a hosted external app node', [
|
|
|
1565
1577
|
title: typeof flags.title === 'string' ? flags.title : 'Excalidraw Diagram',
|
|
1566
1578
|
elements: DEFAULT_EXCALIDRAW_ELEMENTS,
|
|
1567
1579
|
};
|
|
1568
|
-
const
|
|
1580
|
+
const nodeId = getStringFlag(flags, 'node-id', 'nodeId', 'id');
|
|
1581
|
+
if (nodeId) body.nodeId = nodeId;
|
|
1582
|
+
const elementsJson = getStringFlag(flags, 'elements-json', 'elements');
|
|
1569
1583
|
if (elementsJson !== undefined) body.elements = parseJsonValue(elementsJson, 'Excalidraw elements', 'Use --elements-json \'[{"type":"rectangle","id":"r1","x":0,"y":0,"width":120,"height":80}]\'');
|
|
1570
1584
|
const elementsFile = getStringFlag(flags, 'elements-file', 'initial-file');
|
|
1571
1585
|
if (elementsFile) body.elements = parseJsonValue(readFileSync(elementsFile, 'utf-8'), 'Excalidraw elements file', 'Use --elements-file ./scene.excalidraw');
|
|
@@ -1575,6 +1589,8 @@ cmd('external-app add', 'Create a hosted external app node', [
|
|
|
1575
1589
|
width: 'Use a positive number, e.g. --width 960',
|
|
1576
1590
|
height: 'Use a positive number, e.g. --height 720',
|
|
1577
1591
|
});
|
|
1592
|
+
const timeoutMs = optionalPositiveFiniteFlag(flags, 'timeout-ms', 'Use a positive number, e.g. --timeout-ms 120000');
|
|
1593
|
+
if (timeoutMs !== undefined) body.timeoutMs = timeoutMs;
|
|
1578
1594
|
|
|
1579
1595
|
const result = await api('POST', '/api/canvas/diagram', body);
|
|
1580
1596
|
output(result && typeof result === 'object' && !Array.isArray(result) && 'nodeId' in result && !('id' in result)
|
|
@@ -1673,13 +1689,41 @@ cmd('snapshot save', 'Save a named snapshot of the current canvas', [
|
|
|
1673
1689
|
});
|
|
1674
1690
|
|
|
1675
1691
|
// ── snapshot list ────────────────────────────────────────────
|
|
1676
|
-
cmd('snapshot list', 'List
|
|
1692
|
+
cmd('snapshot list', 'List saved snapshots', [
|
|
1677
1693
|
'pmx-canvas snapshot list',
|
|
1694
|
+
'pmx-canvas snapshot list --limit 50 --query baseline',
|
|
1695
|
+
'pmx-canvas snapshot list --all',
|
|
1678
1696
|
], async (args) => {
|
|
1679
1697
|
const { flags } = parseFlags(args);
|
|
1680
1698
|
if (flags.help || flags.h) return showCommandHelp('snapshot list');
|
|
1681
1699
|
|
|
1682
|
-
const
|
|
1700
|
+
const params = new URLSearchParams();
|
|
1701
|
+
const limit = optionalNumberFlag(flags, 'limit', 'Use a positive integer, e.g. --limit 50');
|
|
1702
|
+
const query = getStringFlag(flags, 'query', 'q');
|
|
1703
|
+
if (limit !== undefined) params.set('limit', String(limit));
|
|
1704
|
+
if (query) params.set('q', query);
|
|
1705
|
+
if (flags.all) params.set('all', 'true');
|
|
1706
|
+
const result = await api('GET', `/api/canvas/snapshots${params.size > 0 ? `?${params.toString()}` : ''}`);
|
|
1707
|
+
output(result);
|
|
1708
|
+
});
|
|
1709
|
+
|
|
1710
|
+
// ── snapshot gc ──────────────────────────────────────────────
|
|
1711
|
+
cmd('snapshot gc', 'Delete old snapshots, keeping the newest N', [
|
|
1712
|
+
'pmx-canvas snapshot gc --keep 20 --dry-run',
|
|
1713
|
+
'pmx-canvas snapshot gc --keep 50 --yes',
|
|
1714
|
+
], async (args) => {
|
|
1715
|
+
const { flags } = parseFlags(args);
|
|
1716
|
+
if (flags.help || flags.h) return showCommandHelp('snapshot gc');
|
|
1717
|
+
|
|
1718
|
+
const keep = optionalNumberFlag(flags, 'keep', 'Use a positive integer, e.g. --keep 20');
|
|
1719
|
+
const dryRun = flags['dry-run'] === true;
|
|
1720
|
+
if (!dryRun && !flags.yes) {
|
|
1721
|
+
die('Destructive operation requires --yes flag', 'Preview with: pmx-canvas snapshot gc --keep 20 --dry-run');
|
|
1722
|
+
}
|
|
1723
|
+
const result = await api('POST', '/api/canvas/snapshots/gc', {
|
|
1724
|
+
...(keep !== undefined ? { keep } : {}),
|
|
1725
|
+
dryRun,
|
|
1726
|
+
});
|
|
1683
1727
|
output(result);
|
|
1684
1728
|
});
|
|
1685
1729
|
|
|
@@ -2274,12 +2318,25 @@ function showCommandHelp(name: string): void {
|
|
|
2274
2318
|
console.log('\nOutput control:');
|
|
2275
2319
|
console.log(' --summary Return only validation summary metadata');
|
|
2276
2320
|
}
|
|
2321
|
+
if (name === 'snapshot list') {
|
|
2322
|
+
console.log('\nOptions:');
|
|
2323
|
+
console.log(' --limit <number> Maximum snapshots to return (default 20)');
|
|
2324
|
+
console.log(' --query <text> Case-insensitive ID/name filter');
|
|
2325
|
+
console.log(' --all Return all snapshots');
|
|
2326
|
+
}
|
|
2327
|
+
if (name === 'snapshot gc') {
|
|
2328
|
+
console.log('\nOptions:');
|
|
2329
|
+
console.log(' --keep <number> Number of newest snapshots to keep (default 20)');
|
|
2330
|
+
console.log(' --dry-run Preview deletions without removing files');
|
|
2331
|
+
console.log(' --yes Confirm deletion');
|
|
2332
|
+
}
|
|
2277
2333
|
if (name === 'web-artifact build') {
|
|
2278
2334
|
console.log('\nDependencies:');
|
|
2279
2335
|
console.log(' --deps <list> Add npm dependencies before bundling, e.g. --deps recharts,zod');
|
|
2280
2336
|
console.log('\nOutput control:');
|
|
2281
2337
|
console.log(' --include-logs Include raw build stdout/stderr in the response');
|
|
2282
2338
|
console.log(' --verbose Alias for --include-logs');
|
|
2339
|
+
console.log(' --timeout-ms <number> Optional init/install/build timeout in milliseconds');
|
|
2283
2340
|
}
|
|
2284
2341
|
if (name === 'focus') {
|
|
2285
2342
|
console.log('\nViewport:');
|
|
@@ -2303,9 +2360,12 @@ function showCommandHelp(name: string): void {
|
|
|
2303
2360
|
console.log('\nOptions:');
|
|
2304
2361
|
console.log(' --kind excalidraw External app kind to create');
|
|
2305
2362
|
console.log(' --title <title> Node title');
|
|
2363
|
+
console.log(' --node-id <id> Existing Excalidraw app node to update in place');
|
|
2364
|
+
console.log(' --elements <json> Optional Excalidraw elements array JSON');
|
|
2306
2365
|
console.log(' --elements-json <json> Optional Excalidraw elements array JSON');
|
|
2307
2366
|
console.log(' --elements-file <path> Optional file containing Excalidraw elements JSON');
|
|
2308
2367
|
console.log(' --initial-file <path> Alias for --elements-file');
|
|
2368
|
+
console.log(' --timeout-ms <number> Optional downstream MCP timeout for cold starts');
|
|
2309
2369
|
}
|
|
2310
2370
|
console.log('');
|
|
2311
2371
|
}
|
|
@@ -2375,6 +2435,7 @@ History:
|
|
|
2375
2435
|
Snapshots:
|
|
2376
2436
|
pmx-canvas snapshot save --name X Save a named snapshot
|
|
2377
2437
|
pmx-canvas snapshot list List snapshots
|
|
2438
|
+
pmx-canvas snapshot gc --keep 20 Delete old snapshots
|
|
2378
2439
|
pmx-canvas snapshot restore <id> Restore from snapshot
|
|
2379
2440
|
pmx-canvas snapshot diff <id> Compare current canvas to snapshot
|
|
2380
2441
|
pmx-canvas snapshot delete <id> Delete a snapshot
|
package/src/cli/index.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { dirname, join, resolve } from 'node:path';
|
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
6
|
import { runAgentCli } from './agent.js';
|
|
7
7
|
import { createCanvas } from '../server/index.js';
|
|
8
|
+
import { seedDemoCanvas } from '../server/demo.js';
|
|
8
9
|
|
|
9
10
|
const args = process.argv.slice(2);
|
|
10
11
|
|
|
@@ -594,29 +595,7 @@ Examples:
|
|
|
594
595
|
process.exit(1);
|
|
595
596
|
}
|
|
596
597
|
|
|
597
|
-
if (demo && canvas.getLayout().nodes.length === 0)
|
|
598
|
-
const n1 = canvas.addNode({
|
|
599
|
-
type: 'markdown',
|
|
600
|
-
title: 'Welcome to PMX Canvas',
|
|
601
|
-
content: '# PMX Canvas Workbench\n\nA spatial canvas for coding agents.\n\n## Features\n- Infinite 2D canvas with pan/zoom\n- Multiple node types\n- Edges between nodes\n- Real-time SSE updates\n- HTTP API for agent control',
|
|
602
|
-
});
|
|
603
|
-
|
|
604
|
-
const n2 = canvas.addNode({
|
|
605
|
-
type: 'markdown',
|
|
606
|
-
title: 'Getting Started',
|
|
607
|
-
content: `# Quick Start\n\n\`\`\`bash\n# Add a node via CLI\npmx-canvas node add --type markdown --title "Hello" --content "# World"\n\n# List nodes\npmx-canvas node list\n\n# Get canvas state\npmx-canvas layout\n\`\`\``,
|
|
608
|
-
});
|
|
609
|
-
|
|
610
|
-
const n3 = canvas.addNode({
|
|
611
|
-
type: 'status',
|
|
612
|
-
title: 'Agent Status',
|
|
613
|
-
content: 'Ready',
|
|
614
|
-
});
|
|
615
|
-
|
|
616
|
-
canvas.addEdge({ from: n1, to: n2, type: 'flow', label: 'next' });
|
|
617
|
-
canvas.addEdge({ from: n2, to: n3, type: 'flow' });
|
|
618
|
-
canvas.arrange('grid');
|
|
619
|
-
}
|
|
598
|
+
if (demo && canvas.getLayout().nodes.length === 0) seedDemoCanvas();
|
|
620
599
|
|
|
621
600
|
console.log(`\n PMX Canvas running at http://localhost:${canvas.port}`);
|
|
622
601
|
console.log(` Health: http://localhost:${canvas.port}/health\n`);
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
closeAttentionHistory,
|
|
6
6
|
openAttentionHistory,
|
|
7
7
|
} from '../state/attention-store';
|
|
8
|
+
import { collapseDockedContextNodes, hasOpenDockedContextPanel } from '../state/canvas-store';
|
|
8
9
|
|
|
9
10
|
function formatTimestamp(timestamp: number): string {
|
|
10
11
|
return new Date(timestamp).toLocaleTimeString([], {
|
|
@@ -13,19 +14,31 @@ function formatTimestamp(timestamp: number): string {
|
|
|
13
14
|
});
|
|
14
15
|
}
|
|
15
16
|
|
|
17
|
+
function handleOpenUpdates(): void {
|
|
18
|
+
// Mutual exclusion with the Context panel — only one side panel open at a
|
|
19
|
+
// time (they share the same right-edge anchor).
|
|
20
|
+
collapseDockedContextNodes();
|
|
21
|
+
openAttentionHistory();
|
|
22
|
+
}
|
|
23
|
+
|
|
16
24
|
export function AttentionHistory() {
|
|
17
25
|
const entries = attentionHistory.value;
|
|
18
26
|
if (entries.length === 0) return null;
|
|
19
27
|
|
|
20
28
|
const isOpen = attentionHistoryOpen.value;
|
|
21
29
|
const unread = attentionHistoryUnread.value;
|
|
30
|
+
// Hide the collapsed Updates pill while the Context side panel is open —
|
|
31
|
+
// the panel sits at the same right-edge and would visually cover the pill.
|
|
32
|
+
// Mutual exclusion guarantees both can't be expanded simultaneously, so the
|
|
33
|
+
// pill only needs to hide while context is expanded.
|
|
34
|
+
if (!isOpen && hasOpenDockedContextPanel.value) return null;
|
|
22
35
|
|
|
23
36
|
if (!isOpen) {
|
|
24
37
|
return (
|
|
25
38
|
<button
|
|
26
39
|
type="button"
|
|
27
40
|
class="attention-history-tab"
|
|
28
|
-
onClick={
|
|
41
|
+
onClick={handleOpenUpdates}
|
|
29
42
|
aria-label={unread > 0 ? `Recent updates — ${unread} new` : 'Recent updates'}
|
|
30
43
|
title={unread > 0 ? `${unread} new updates since last viewed` : 'Recent updates'}
|
|
31
44
|
>
|
|
@@ -164,7 +164,7 @@ export function CanvasNode({ node, children, onContextMenu }: CanvasNodeProps) {
|
|
|
164
164
|
window.clearTimeout(autoFitPersistTimer.current);
|
|
165
165
|
}
|
|
166
166
|
autoFitPersistTimer.current = window.setTimeout(() => {
|
|
167
|
-
persistLayout();
|
|
167
|
+
persistLayout({ recordHistory: false });
|
|
168
168
|
autoFitPersistTimer.current = null;
|
|
169
169
|
}, 0);
|
|
170
170
|
}
|
|
@@ -8,6 +8,7 @@ import { StatusNode } from '../nodes/StatusNode';
|
|
|
8
8
|
import { ImageNode } from '../nodes/ImageNode';
|
|
9
9
|
import { GroupNode } from '../nodes/GroupNode';
|
|
10
10
|
import { WebpageNode } from '../nodes/WebpageNode';
|
|
11
|
+
import { HtmlNode } from '../nodes/HtmlNode';
|
|
11
12
|
import { PromptNode } from '../nodes/PromptNode';
|
|
12
13
|
import { ResponseNode } from '../nodes/ResponseNode';
|
|
13
14
|
import { TraceNode } from '../nodes/TraceNode';
|
|
@@ -60,6 +61,8 @@ function renderNodeContent(node: CanvasNodeState) {
|
|
|
60
61
|
return <FileNode node={node} />;
|
|
61
62
|
case 'image':
|
|
62
63
|
return <ImageNode node={node} />;
|
|
64
|
+
case 'html':
|
|
65
|
+
return <HtmlNode node={node} />;
|
|
63
66
|
case 'group':
|
|
64
67
|
return <GroupNode node={node} />;
|
|
65
68
|
default:
|
|
@@ -2,6 +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 { attentionHistoryOpen, closeAttentionHistory } from '../state/attention-store';
|
|
5
6
|
import { toggleCollapsed, undockNode } from '../state/canvas-store';
|
|
6
7
|
import { TYPE_LABELS } from '../types';
|
|
7
8
|
import type { CanvasNodeState } from '../types';
|
|
@@ -19,20 +20,119 @@ function renderDockedContent(node: CanvasNodeState) {
|
|
|
19
20
|
}
|
|
20
21
|
}
|
|
21
22
|
|
|
23
|
+
function getContextItemCount(node: CanvasNodeState): number {
|
|
24
|
+
const cards = Array.isArray(node.data.cards) ? (node.data.cards as unknown[]) : [];
|
|
25
|
+
const auxTabs = Array.isArray(node.data.auxTabs) ? (node.data.auxTabs as unknown[]) : [];
|
|
26
|
+
return cards.length + auxTabs.length;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function ContextDockedNode({ node }: { node: CanvasNodeState }) {
|
|
30
|
+
const count = getContextItemCount(node);
|
|
31
|
+
const hasItems = count > 0;
|
|
32
|
+
const collapsed = node.collapsed === true;
|
|
33
|
+
|
|
34
|
+
const expand = () => {
|
|
35
|
+
// Mutual exclusion with the Updates panel — only one side panel open at a
|
|
36
|
+
// time. They share the same right-edge anchor, so opening both at once
|
|
37
|
+
// would visually collide.
|
|
38
|
+
closeAttentionHistory();
|
|
39
|
+
toggleCollapsed(node.id);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// Hide the collapsed Context pill while the Updates side panel is open.
|
|
43
|
+
// Mutual exclusion guarantees both panels can't be expanded simultaneously,
|
|
44
|
+
// but the pill itself would otherwise sit beneath/beside the Updates panel
|
|
45
|
+
// at the same right edge — better to hide until Updates is closed.
|
|
46
|
+
if (collapsed && attentionHistoryOpen.value) return null;
|
|
47
|
+
|
|
48
|
+
if (collapsed) {
|
|
49
|
+
return (
|
|
50
|
+
<button
|
|
51
|
+
type="button"
|
|
52
|
+
class="context-dock-tab"
|
|
53
|
+
data-docked-node="true"
|
|
54
|
+
onClick={expand}
|
|
55
|
+
aria-label={hasItems ? `Context — ${count} item${count === 1 ? '' : 's'}` : 'Context'}
|
|
56
|
+
title={hasItems ? `${count} item${count === 1 ? '' : 's'} in agent context` : 'Agent context'}
|
|
57
|
+
>
|
|
58
|
+
<svg
|
|
59
|
+
width="14"
|
|
60
|
+
height="14"
|
|
61
|
+
viewBox="0 0 16 16"
|
|
62
|
+
fill="none"
|
|
63
|
+
stroke="currentColor"
|
|
64
|
+
stroke-width="1.5"
|
|
65
|
+
stroke-linecap="round"
|
|
66
|
+
stroke-linejoin="round"
|
|
67
|
+
aria-hidden="true"
|
|
68
|
+
>
|
|
69
|
+
<rect x="1.5" y="2.5" width="13" height="11" rx="1.5" />
|
|
70
|
+
<line x1="1.5" y1="6" x2="14.5" y2="6" />
|
|
71
|
+
<circle cx="4" cy="4.25" r="0.6" fill="currentColor" stroke="none" />
|
|
72
|
+
</svg>
|
|
73
|
+
<span class="context-dock-tab-label">Context</span>
|
|
74
|
+
{hasItems && (
|
|
75
|
+
<span class="context-dock-tab-badge" aria-hidden="true">
|
|
76
|
+
{count > 99 ? '99+' : count}
|
|
77
|
+
</span>
|
|
78
|
+
)}
|
|
79
|
+
</button>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<aside class="context-dock-panel" data-docked-node="true" aria-label="Agent context">
|
|
85
|
+
<div class="context-dock-header">
|
|
86
|
+
<div class="context-dock-header-text">
|
|
87
|
+
<span class="context-dock-title">Context</span>
|
|
88
|
+
<span class="context-dock-subtitle">
|
|
89
|
+
{hasItems ? `${count} item${count === 1 ? '' : 's'} in agent context` : 'Active agent context'}
|
|
90
|
+
</span>
|
|
91
|
+
</div>
|
|
92
|
+
<div class="context-dock-controls">
|
|
93
|
+
<button
|
|
94
|
+
type="button"
|
|
95
|
+
class="context-dock-icon-button"
|
|
96
|
+
onClick={(e) => {
|
|
97
|
+
e.stopPropagation();
|
|
98
|
+
undockNode(node.id);
|
|
99
|
+
}}
|
|
100
|
+
aria-label="Undock to canvas"
|
|
101
|
+
title="Undock to canvas"
|
|
102
|
+
>
|
|
103
|
+
{'\u2299'}
|
|
104
|
+
</button>
|
|
105
|
+
<button
|
|
106
|
+
type="button"
|
|
107
|
+
class="context-dock-icon-button"
|
|
108
|
+
onClick={(e) => {
|
|
109
|
+
e.stopPropagation();
|
|
110
|
+
toggleCollapsed(node.id);
|
|
111
|
+
}}
|
|
112
|
+
aria-label="Collapse context panel"
|
|
113
|
+
title="Collapse"
|
|
114
|
+
>
|
|
115
|
+
×
|
|
116
|
+
</button>
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
<div class="context-dock-body">
|
|
120
|
+
<ContextNode node={node} />
|
|
121
|
+
</div>
|
|
122
|
+
</aside>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
22
126
|
export function DockedNode({ node }: { node: CanvasNodeState }) {
|
|
127
|
+
if (node.type === 'context') {
|
|
128
|
+
return <ContextDockedNode node={node} />;
|
|
129
|
+
}
|
|
130
|
+
|
|
23
131
|
return (
|
|
24
|
-
<div class="docked-node">
|
|
132
|
+
<div class="docked-node" data-docked-node="true">
|
|
25
133
|
<div class="docked-node-header">
|
|
26
134
|
<span class="node-type-badge">{TYPE_LABELS[node.type] ?? node.type}</span>
|
|
27
135
|
{node.type === 'status' && node.collapsed && <StatusSummary node={node} />}
|
|
28
|
-
{node.type === 'context' && node.collapsed && (
|
|
29
|
-
<span style={{ fontSize: '11px', color: 'var(--c-muted)' }}>
|
|
30
|
-
Active Agent Context
|
|
31
|
-
{typeof node.data.utilization === 'number' && (
|
|
32
|
-
<> · {Math.round(Number(node.data.utilization) * 100)}%</>
|
|
33
|
-
)}
|
|
34
|
-
</span>
|
|
35
|
-
)}
|
|
36
136
|
<div class="docked-node-controls">
|
|
37
137
|
<button
|
|
38
138
|
type="button"
|
|
@@ -57,9 +157,7 @@ export function DockedNode({ node }: { node: CanvasNodeState }) {
|
|
|
57
157
|
</div>
|
|
58
158
|
</div>
|
|
59
159
|
{!node.collapsed && (
|
|
60
|
-
<div class=
|
|
61
|
-
{renderDockedContent(node)}
|
|
62
|
-
</div>
|
|
160
|
+
<div class="docked-node-body">{renderDockedContent(node)}</div>
|
|
63
161
|
)}
|
|
64
162
|
</div>
|
|
65
163
|
);
|
|
@@ -7,6 +7,7 @@ import { McpAppNode } from '../nodes/McpAppNode';
|
|
|
7
7
|
import { StatusNode } from '../nodes/StatusNode';
|
|
8
8
|
import { ImageNode } from '../nodes/ImageNode';
|
|
9
9
|
import { WebpageNode } from '../nodes/WebpageNode';
|
|
10
|
+
import { HtmlNode } from '../nodes/HtmlNode';
|
|
10
11
|
import { PromptNode } from '../nodes/PromptNode';
|
|
11
12
|
import { ResponseNode } from '../nodes/ResponseNode';
|
|
12
13
|
import { TraceNode } from '../nodes/TraceNode';
|
|
@@ -26,13 +27,13 @@ function renderContent(node: CanvasNodeState, expanded: boolean) {
|
|
|
26
27
|
case 'markdown':
|
|
27
28
|
return <MarkdownNode node={node} expanded={expanded} />;
|
|
28
29
|
case 'mcp-app':
|
|
29
|
-
return <McpAppNode node={node} />;
|
|
30
|
+
return <McpAppNode node={node} expanded={expanded} />;
|
|
30
31
|
case 'webpage':
|
|
31
32
|
return <WebpageNode node={node} expanded={expanded} />;
|
|
32
33
|
case 'json-render':
|
|
33
|
-
return <McpAppNode node={node} />;
|
|
34
|
+
return <McpAppNode node={node} expanded={expanded} />;
|
|
34
35
|
case 'graph':
|
|
35
|
-
return <McpAppNode node={node} />;
|
|
36
|
+
return <McpAppNode node={node} expanded={expanded} />;
|
|
36
37
|
case 'prompt':
|
|
37
38
|
return <PromptNode node={node} />;
|
|
38
39
|
case 'response':
|
|
@@ -49,6 +50,8 @@ function renderContent(node: CanvasNodeState, expanded: boolean) {
|
|
|
49
50
|
return <FileNode node={node} expanded={expanded} />;
|
|
50
51
|
case 'image':
|
|
51
52
|
return <ImageNode node={node} expanded={expanded} />;
|
|
53
|
+
case 'html':
|
|
54
|
+
return <HtmlNode node={node} expanded={expanded} />;
|
|
52
55
|
default:
|
|
53
56
|
return <div>Unknown node type</div>;
|
|
54
57
|
}
|
|
@@ -63,6 +66,8 @@ function getNodeTextContent(node: CanvasNodeState): string {
|
|
|
63
66
|
return (node.data.fileContent as string) || '';
|
|
64
67
|
case 'webpage':
|
|
65
68
|
return (node.data.content as string) || '';
|
|
69
|
+
case 'html':
|
|
70
|
+
return (node.data.html as string) || (node.data.content as string) || '';
|
|
66
71
|
case 'json-render':
|
|
67
72
|
case 'graph':
|
|
68
73
|
return JSON.stringify(node.data.spec ?? node.data.graphConfig ?? {}, null, 2);
|
package/src/client/icons.tsx
CHANGED
|
@@ -419,6 +419,7 @@ export function getNodeIcon(type: string): (p: IconProps) => JSX.Element {
|
|
|
419
419
|
case 'ext-app': return IconNodeExtApp;
|
|
420
420
|
case 'json-render': return IconNodeJsonRender;
|
|
421
421
|
case 'graph': return IconNodeGraph;
|
|
422
|
+
case 'html': return IconNodeWebpage;
|
|
422
423
|
default: return IconNodeMarkdown;
|
|
423
424
|
}
|
|
424
425
|
}
|
|
@@ -13,11 +13,6 @@ import type { CanvasNodeState } from '../types';
|
|
|
13
13
|
|
|
14
14
|
type McpUiTheme = 'light' | 'dark';
|
|
15
15
|
|
|
16
|
-
type IframeLoadTarget = Pick<
|
|
17
|
-
HTMLIFrameElement,
|
|
18
|
-
'addEventListener' | 'removeEventListener' | 'contentDocument'
|
|
19
|
-
>;
|
|
20
|
-
|
|
21
16
|
type ExtAppBridgeNotifications = Pick<AppBridge, 'sendToolInput' | 'sendToolResult'>;
|
|
22
17
|
type DisplayMode = 'inline' | 'fullscreen' | 'pip';
|
|
23
18
|
const DEFAULT_EXT_APP_SANDBOX = 'allow-scripts allow-popups allow-popups-to-escape-sandbox';
|
|
@@ -43,21 +38,6 @@ async function postJson<T>(url: string, body: Record<string, unknown>): Promise<
|
|
|
43
38
|
return json.result as T;
|
|
44
39
|
}
|
|
45
40
|
|
|
46
|
-
export function waitForExtAppFrameLoad(target: IframeLoadTarget): Promise<void> {
|
|
47
|
-
const readyState = target.contentDocument?.readyState;
|
|
48
|
-
if (readyState === 'interactive' || readyState === 'complete') {
|
|
49
|
-
return Promise.resolve();
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
return new Promise<void>((resolve) => {
|
|
53
|
-
const onLoad = () => {
|
|
54
|
-
target.removeEventListener('load', onLoad);
|
|
55
|
-
resolve();
|
|
56
|
-
};
|
|
57
|
-
target.addEventListener('load', onLoad, { once: true });
|
|
58
|
-
});
|
|
59
|
-
}
|
|
60
|
-
|
|
61
41
|
export function getExtAppBridgeInitKey(node: CanvasNodeState, retryKey: number): string {
|
|
62
42
|
const html = typeof node.data.html === 'string' ? node.data.html : '';
|
|
63
43
|
const serverName = typeof node.data.serverName === 'string' ? node.data.serverName : '';
|
|
@@ -131,7 +111,7 @@ export function shouldApplyExtAppSizeChange(height: unknown, isExpanded: boolean
|
|
|
131
111
|
return typeof height === 'number' && Number.isFinite(height) && height > 0 && !isExpanded;
|
|
132
112
|
}
|
|
133
113
|
|
|
134
|
-
export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
|
|
114
|
+
export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState; expanded?: boolean }) {
|
|
135
115
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
|
136
116
|
const bridgeRef = useRef<AppBridge | null>(null);
|
|
137
117
|
const transportRef = useRef<PostMessageTransport | null>(null);
|
|
@@ -164,10 +144,9 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
|
|
|
164
144
|
const sessionError = node.data.sessionError as string | undefined;
|
|
165
145
|
const maxHeight = node.size.height;
|
|
166
146
|
const nodeId = node.id;
|
|
167
|
-
const frameKey =
|
|
168
|
-
const bridgeInitKey = getExtAppBridgeInitKey(node, retryKey);
|
|
147
|
+
const frameKey = getExtAppBridgeInitKey(node, retryKey);
|
|
169
148
|
const toMcpTheme = (theme: string): McpUiTheme => (theme === 'light' ? 'light' : 'dark');
|
|
170
|
-
const isExpanded = expandedNodeId.value === nodeId;
|
|
149
|
+
const isExpanded = expanded || expandedNodeId.value === nodeId;
|
|
171
150
|
|
|
172
151
|
latestToolInputRef.current = toolInput;
|
|
173
152
|
latestToolResultRef.current = toolResult;
|
|
@@ -221,9 +200,9 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
|
|
|
221
200
|
return sendPromise;
|
|
222
201
|
};
|
|
223
202
|
|
|
224
|
-
// Initialize
|
|
203
|
+
// Initialize as soon as HTML is mounted; some apps send initialize before iframe load fires.
|
|
225
204
|
useEffect(() => {
|
|
226
|
-
if (!html) return;
|
|
205
|
+
if (!html) return;
|
|
227
206
|
const iframe = iframeRef.current;
|
|
228
207
|
if (!iframe) return;
|
|
229
208
|
let disposed = false;
|
|
@@ -242,12 +221,8 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
|
|
|
242
221
|
};
|
|
243
222
|
|
|
244
223
|
const init = async () => {
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
await waitForExtAppFrameLoad(iframe);
|
|
248
|
-
if (disposed) return;
|
|
249
|
-
contentWindow = iframe.contentWindow;
|
|
250
|
-
}
|
|
224
|
+
if (!html) return;
|
|
225
|
+
const contentWindow = iframe.contentWindow;
|
|
251
226
|
if (!contentWindow) {
|
|
252
227
|
throw new Error('Ext-app iframe window is unavailable');
|
|
253
228
|
}
|
|
@@ -397,8 +372,8 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
|
|
|
397
372
|
bridgeReadyRef.current = true;
|
|
398
373
|
setStatus('ready');
|
|
399
374
|
setError(null);
|
|
400
|
-
|
|
401
|
-
|
|
375
|
+
void Promise.resolve(bridge.sendHostContextChange(buildHostContext(isExpanded ? 'fullscreen' : 'inline')))
|
|
376
|
+
.then(() => sendExtAppBootstrapState(bridge, latestToolInputRef.current, undefined))
|
|
402
377
|
.then(() => flushToolResult(bridge))
|
|
403
378
|
.catch((err) => {
|
|
404
379
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -481,7 +456,7 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
|
|
|
481
456
|
transportRef.current = null;
|
|
482
457
|
}
|
|
483
458
|
};
|
|
484
|
-
}, [
|
|
459
|
+
}, [frameKey]);
|
|
485
460
|
|
|
486
461
|
// Forward tool result when it arrives after bridge is ready
|
|
487
462
|
useEffect(() => {
|