pmx-canvas 0.1.14 → 0.1.16
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 +153 -0
- package/Readme.md +108 -1058
- package/dist/canvas/global.css +141 -0
- package/dist/canvas/index.js +124 -74
- package/dist/json-render/index.css +1 -1
- package/dist/types/client/nodes/ContextNode.d.ts +11 -2
- package/dist/types/client/nodes/HtmlNode.d.ts +5 -0
- package/dist/types/client/nodes/StatusNode.d.ts +1 -0
- package/dist/types/client/state/canvas-store.d.ts +11 -3
- package/dist/types/client/state/intent-bridge.d.ts +5 -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 +4 -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/index.d.ts +13 -3
- 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 +52 -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/ContextPinBar.tsx +2 -1
- package/src/client/canvas/DockedNode.tsx +112 -13
- package/src/client/canvas/ExpandedNodeOverlay.tsx +5 -0
- package/src/client/canvas/Minimap.tsx +1 -0
- package/src/client/icons.tsx +1 -0
- package/src/client/nodes/ContextNode.tsx +128 -6
- package/src/client/nodes/HtmlNode.tsx +151 -0
- package/src/client/nodes/StatusNode.tsx +16 -1
- package/src/client/nodes/StatusSummary.tsx +2 -1
- package/src/client/state/canvas-store.ts +37 -7
- package/src/client/state/intent-bridge.ts +9 -4
- package/src/client/state/sse-bridge.ts +2 -1
- 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 +178 -25
- package/src/server/agent-context.ts +50 -3
- package/src/server/canvas-operations.ts +20 -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/index.ts +33 -3
- package/src/server/server.ts +98 -14
- package/src/server/web-artifacts.ts +116 -3
- package/src/shared/canvas-node-kind.ts +14 -0
|
@@ -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,10 +2,11 @@ import {
|
|
|
2
2
|
clearContextPins,
|
|
3
3
|
contextPinnedNodeIds,
|
|
4
4
|
} from '../state/canvas-store';
|
|
5
|
+
import { attentionHistoryOpen } from '../state/attention-store';
|
|
5
6
|
|
|
6
7
|
export function ContextPinBar() {
|
|
7
8
|
const count = contextPinnedNodeIds.value.size;
|
|
8
|
-
if (count === 0) return null;
|
|
9
|
+
if (count === 0 || attentionHistoryOpen.value) return null;
|
|
9
10
|
|
|
10
11
|
return (
|
|
11
12
|
<div class="context-pin-bar">
|
|
@@ -2,7 +2,8 @@ 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 { attentionHistoryOpen, closeAttentionHistory } from '../state/attention-store';
|
|
6
|
+
import { getContextPinnedNodes, toggleCollapsed, undockNode } from '../state/canvas-store';
|
|
6
7
|
import { TYPE_LABELS } from '../types';
|
|
7
8
|
import type { CanvasNodeState } from '../types';
|
|
8
9
|
|
|
@@ -19,20 +20,120 @@ 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 pinnedNodes = getContextPinnedNodes();
|
|
31
|
+
const count = pinnedNodes.length > 0 ? pinnedNodes.length : getContextItemCount(node);
|
|
32
|
+
const hasItems = count > 0;
|
|
33
|
+
const collapsed = node.collapsed === true;
|
|
34
|
+
|
|
35
|
+
const expand = () => {
|
|
36
|
+
// Mutual exclusion with the Updates panel — only one side panel open at a
|
|
37
|
+
// time. They share the same right-edge anchor, so opening both at once
|
|
38
|
+
// would visually collide.
|
|
39
|
+
closeAttentionHistory();
|
|
40
|
+
toggleCollapsed(node.id);
|
|
41
|
+
};
|
|
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
|
+
if (collapsed) {
|
|
50
|
+
return (
|
|
51
|
+
<button
|
|
52
|
+
type="button"
|
|
53
|
+
class="context-dock-tab"
|
|
54
|
+
data-docked-node="true"
|
|
55
|
+
onClick={expand}
|
|
56
|
+
aria-label={hasItems ? `Context — ${count} item${count === 1 ? '' : 's'}` : 'Context'}
|
|
57
|
+
title={hasItems ? `${count} item${count === 1 ? '' : 's'} in agent context` : 'Agent context'}
|
|
58
|
+
>
|
|
59
|
+
<svg
|
|
60
|
+
width="14"
|
|
61
|
+
height="14"
|
|
62
|
+
viewBox="0 0 16 16"
|
|
63
|
+
fill="none"
|
|
64
|
+
stroke="currentColor"
|
|
65
|
+
stroke-width="1.5"
|
|
66
|
+
stroke-linecap="round"
|
|
67
|
+
stroke-linejoin="round"
|
|
68
|
+
aria-hidden="true"
|
|
69
|
+
>
|
|
70
|
+
<rect x="1.5" y="2.5" width="13" height="11" rx="1.5" />
|
|
71
|
+
<line x1="1.5" y1="6" x2="14.5" y2="6" />
|
|
72
|
+
<circle cx="4" cy="4.25" r="0.6" fill="currentColor" stroke="none" />
|
|
73
|
+
</svg>
|
|
74
|
+
<span class="context-dock-tab-label">Context</span>
|
|
75
|
+
{hasItems && (
|
|
76
|
+
<span class="context-dock-tab-badge" aria-hidden="true">
|
|
77
|
+
{count > 99 ? '99+' : count}
|
|
78
|
+
</span>
|
|
79
|
+
)}
|
|
80
|
+
</button>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<aside class="context-dock-panel" data-docked-node="true" aria-label="Agent context">
|
|
86
|
+
<div class="context-dock-header">
|
|
87
|
+
<div class="context-dock-header-text">
|
|
88
|
+
<span class="context-dock-title">Context</span>
|
|
89
|
+
<span class="context-dock-subtitle">
|
|
90
|
+
{hasItems ? `${count} item${count === 1 ? '' : 's'} in agent context` : 'Active agent context'}
|
|
91
|
+
</span>
|
|
92
|
+
</div>
|
|
93
|
+
<div class="context-dock-controls">
|
|
94
|
+
<button
|
|
95
|
+
type="button"
|
|
96
|
+
class="context-dock-icon-button"
|
|
97
|
+
onClick={(e) => {
|
|
98
|
+
e.stopPropagation();
|
|
99
|
+
undockNode(node.id);
|
|
100
|
+
}}
|
|
101
|
+
aria-label="Undock to canvas"
|
|
102
|
+
title="Undock to canvas"
|
|
103
|
+
>
|
|
104
|
+
{'\u2299'}
|
|
105
|
+
</button>
|
|
106
|
+
<button
|
|
107
|
+
type="button"
|
|
108
|
+
class="context-dock-icon-button"
|
|
109
|
+
onClick={(e) => {
|
|
110
|
+
e.stopPropagation();
|
|
111
|
+
toggleCollapsed(node.id);
|
|
112
|
+
}}
|
|
113
|
+
aria-label="Collapse context panel"
|
|
114
|
+
title="Collapse"
|
|
115
|
+
>
|
|
116
|
+
×
|
|
117
|
+
</button>
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
120
|
+
<div class="context-dock-body">
|
|
121
|
+
<ContextNode node={node} pinnedNodes={pinnedNodes} />
|
|
122
|
+
</div>
|
|
123
|
+
</aside>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
22
127
|
export function DockedNode({ node }: { node: CanvasNodeState }) {
|
|
128
|
+
if (node.type === 'context') {
|
|
129
|
+
return <ContextDockedNode node={node} />;
|
|
130
|
+
}
|
|
131
|
+
|
|
23
132
|
return (
|
|
24
|
-
<div class="docked-node">
|
|
133
|
+
<div class="docked-node" data-docked-node="true">
|
|
25
134
|
<div class="docked-node-header">
|
|
26
135
|
<span class="node-type-badge">{TYPE_LABELS[node.type] ?? node.type}</span>
|
|
27
136
|
{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
137
|
<div class="docked-node-controls">
|
|
37
138
|
<button
|
|
38
139
|
type="button"
|
|
@@ -57,9 +158,7 @@ export function DockedNode({ node }: { node: CanvasNodeState }) {
|
|
|
57
158
|
</div>
|
|
58
159
|
</div>
|
|
59
160
|
{!node.collapsed && (
|
|
60
|
-
<div class=
|
|
61
|
-
{renderDockedContent(node)}
|
|
62
|
-
</div>
|
|
161
|
+
<div class="docked-node-body">{renderDockedContent(node)}</div>
|
|
63
162
|
)}
|
|
64
163
|
</div>
|
|
65
164
|
);
|
|
@@ -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';
|
|
@@ -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
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { openWorkbenchFile } from '../state/intent-bridge';
|
|
2
|
-
import type
|
|
2
|
+
import { TYPE_LABELS, type CanvasNodeState } from '../types';
|
|
3
3
|
|
|
4
4
|
interface ContextCard {
|
|
5
5
|
key?: string;
|
|
@@ -30,6 +30,14 @@ export interface ContextNodeFallbackDisplay {
|
|
|
30
30
|
path: string;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
export interface PinnedContextDisplay {
|
|
34
|
+
id: string;
|
|
35
|
+
title: string;
|
|
36
|
+
summary: string;
|
|
37
|
+
kind: string;
|
|
38
|
+
path: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
33
41
|
export function normalizeContextCardDisplay(card: ContextCard): ContextCardDisplay {
|
|
34
42
|
const title = card.title || card.label || card.key || 'Context';
|
|
35
43
|
const summary = card.summary?.trim() || 'Available in startup context.';
|
|
@@ -90,6 +98,24 @@ export function normalizeContextNodeFallback(
|
|
|
90
98
|
};
|
|
91
99
|
}
|
|
92
100
|
|
|
101
|
+
export function normalizePinnedContextDisplay(node: CanvasNodeState): PinnedContextDisplay {
|
|
102
|
+
const title = asTrimmedString(node.data.title) || node.id;
|
|
103
|
+
const summary =
|
|
104
|
+
asTrimmedString(node.data.content) ||
|
|
105
|
+
asTrimmedString(node.data.excerpt) ||
|
|
106
|
+
asTrimmedString(node.data.description) ||
|
|
107
|
+
asTrimmedString(node.data.pageTitle) ||
|
|
108
|
+
'';
|
|
109
|
+
const path = asTrimmedString(node.data.path) || asTrimmedString(node.data.url);
|
|
110
|
+
return {
|
|
111
|
+
id: node.id,
|
|
112
|
+
title,
|
|
113
|
+
summary,
|
|
114
|
+
kind: TYPE_LABELS[node.type] ?? node.type,
|
|
115
|
+
path,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
93
119
|
function formatTokens(n: number | null): string {
|
|
94
120
|
if (n === null || !Number.isFinite(n) || n < 0) return '0';
|
|
95
121
|
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}m`;
|
|
@@ -107,9 +133,12 @@ function usageBarColor(utilization: number): string {
|
|
|
107
133
|
export function ContextNode({
|
|
108
134
|
node,
|
|
109
135
|
expanded = false,
|
|
110
|
-
|
|
136
|
+
pinnedNodes = [],
|
|
137
|
+
}: { node: CanvasNodeState; expanded?: boolean; pinnedNodes?: CanvasNodeState[] }) {
|
|
111
138
|
const cards = (node.data.cards as ContextCard[]) ?? [];
|
|
112
139
|
const auxTabs = (node.data.auxTabs as Array<{ id: string; url: string; reason?: string }>) ?? [];
|
|
140
|
+
const pinnedContext = pinnedNodes.map(normalizePinnedContextDisplay);
|
|
141
|
+
const hasPinnedContext = pinnedContext.length > 0;
|
|
113
142
|
const currentTokens =
|
|
114
143
|
typeof node.data.currentTokens === 'number' ? node.data.currentTokens : null;
|
|
115
144
|
const tokenLimit = typeof node.data.tokenLimit === 'number' ? node.data.tokenLimit : null;
|
|
@@ -120,7 +149,9 @@ export function ContextNode({
|
|
|
120
149
|
utilization !== null ? Math.max(0, Math.min(100, Math.round(utilization * 100))) : null;
|
|
121
150
|
const barColor = usageBarColor(utilization ?? 0);
|
|
122
151
|
const fallback =
|
|
123
|
-
cards.length === 0 && auxTabs.length === 0
|
|
152
|
+
!hasPinnedContext && cards.length === 0 && auxTabs.length === 0
|
|
153
|
+
? normalizeContextNodeFallback(node.data)
|
|
154
|
+
: null;
|
|
124
155
|
|
|
125
156
|
const openCard = async (card: ContextCard): Promise<void> => {
|
|
126
157
|
const path = typeof card.path === 'string' ? card.path.trim() : '';
|
|
@@ -181,7 +212,98 @@ export function ContextNode({
|
|
|
181
212
|
</div>
|
|
182
213
|
)}
|
|
183
214
|
|
|
184
|
-
{
|
|
215
|
+
{hasPinnedContext && (
|
|
216
|
+
<div>
|
|
217
|
+
<div
|
|
218
|
+
style={{
|
|
219
|
+
fontSize: '10px',
|
|
220
|
+
fontWeight: 600,
|
|
221
|
+
color: 'var(--c-muted)',
|
|
222
|
+
textTransform: 'uppercase',
|
|
223
|
+
letterSpacing: '0.04em',
|
|
224
|
+
marginBottom: '6px',
|
|
225
|
+
}}
|
|
226
|
+
>
|
|
227
|
+
Pinned Context ({pinnedContext.length})
|
|
228
|
+
</div>
|
|
229
|
+
{pinnedContext.map((display) => (
|
|
230
|
+
<div
|
|
231
|
+
key={display.id}
|
|
232
|
+
style={{
|
|
233
|
+
padding: '6px 8px',
|
|
234
|
+
background: 'var(--c-surface-subtle)',
|
|
235
|
+
borderRadius: '6px',
|
|
236
|
+
marginBottom: '4px',
|
|
237
|
+
borderLeft: '2px solid var(--c-accent)',
|
|
238
|
+
}}
|
|
239
|
+
>
|
|
240
|
+
<div style={{ fontWeight: 600, color: 'var(--c-text)', marginBottom: '2px' }}>
|
|
241
|
+
{display.title}
|
|
242
|
+
</div>
|
|
243
|
+
{display.summary && (
|
|
244
|
+
<div
|
|
245
|
+
style={{
|
|
246
|
+
color: 'var(--c-muted)',
|
|
247
|
+
fontSize: '10px',
|
|
248
|
+
lineHeight: 1.45,
|
|
249
|
+
marginBottom: '4px',
|
|
250
|
+
whiteSpace: 'pre-wrap',
|
|
251
|
+
}}
|
|
252
|
+
>
|
|
253
|
+
{display.summary}
|
|
254
|
+
</div>
|
|
255
|
+
)}
|
|
256
|
+
<div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap', marginTop: '4px' }}>
|
|
257
|
+
<span
|
|
258
|
+
style={{
|
|
259
|
+
fontSize: '9px',
|
|
260
|
+
padding: '1px 4px',
|
|
261
|
+
background: 'var(--c-surface-hover)',
|
|
262
|
+
color: 'var(--c-text-soft)',
|
|
263
|
+
borderRadius: '3px',
|
|
264
|
+
display: 'inline-block',
|
|
265
|
+
}}
|
|
266
|
+
>
|
|
267
|
+
{display.kind}
|
|
268
|
+
</span>
|
|
269
|
+
</div>
|
|
270
|
+
{display.path && (
|
|
271
|
+
<div style={{ marginTop: '6px' }}>
|
|
272
|
+
<div
|
|
273
|
+
style={{
|
|
274
|
+
color: 'var(--c-dim)',
|
|
275
|
+
fontSize: '10px',
|
|
276
|
+
wordBreak: 'break-all',
|
|
277
|
+
marginBottom: '6px',
|
|
278
|
+
}}
|
|
279
|
+
>
|
|
280
|
+
{display.path}
|
|
281
|
+
</div>
|
|
282
|
+
{display.path.startsWith('/') && (
|
|
283
|
+
<button
|
|
284
|
+
type="button"
|
|
285
|
+
onClick={() => void openWorkbenchFile(display.path)}
|
|
286
|
+
style={{
|
|
287
|
+
padding: '4px 8px',
|
|
288
|
+
fontSize: '10px',
|
|
289
|
+
background: 'var(--c-accent-12)',
|
|
290
|
+
border: '1px solid var(--c-accent-25)',
|
|
291
|
+
borderRadius: '4px',
|
|
292
|
+
color: 'var(--c-text-soft)',
|
|
293
|
+
cursor: 'pointer',
|
|
294
|
+
}}
|
|
295
|
+
>
|
|
296
|
+
Open in canvas
|
|
297
|
+
</button>
|
|
298
|
+
)}
|
|
299
|
+
</div>
|
|
300
|
+
)}
|
|
301
|
+
</div>
|
|
302
|
+
))}
|
|
303
|
+
</div>
|
|
304
|
+
)}
|
|
305
|
+
|
|
306
|
+
{!hasPinnedContext && cards.length > 0 && (
|
|
185
307
|
<div>
|
|
186
308
|
<div
|
|
187
309
|
style={{
|
|
@@ -305,7 +427,7 @@ export function ContextNode({
|
|
|
305
427
|
</div>
|
|
306
428
|
)}
|
|
307
429
|
|
|
308
|
-
{auxTabs.length > 0 && (
|
|
430
|
+
{!hasPinnedContext && auxTabs.length > 0 && (
|
|
309
431
|
<div>
|
|
310
432
|
<div
|
|
311
433
|
style={{
|
|
@@ -404,7 +526,7 @@ export function ContextNode({
|
|
|
404
526
|
</div>
|
|
405
527
|
)}
|
|
406
528
|
|
|
407
|
-
{!fallback && cards.length === 0 && auxTabs.length === 0 && (
|
|
529
|
+
{!hasPinnedContext && !fallback && cards.length === 0 && auxTabs.length === 0 && (
|
|
408
530
|
<div style={{ color: 'var(--c-dim)', fontStyle: 'italic' }}>No context loaded</div>
|
|
409
531
|
)}
|
|
410
532
|
</div>
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { useMemo } from 'preact/hooks';
|
|
2
|
+
import { getCanvasTokens } from '../theme/tokens';
|
|
3
|
+
import type { CanvasNodeState } from '../types';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Strip characters that could break out of a CSS custom-property value context
|
|
7
|
+
* before interpolating into a `<style>` block. The expected token shape is a
|
|
8
|
+
* CSS color (`#abc`, `rgb(...)`) or font-family list, neither of which needs
|
|
9
|
+
* `<`, `>`, `{`, `}`, `;`, or backticks. Defense-in-depth against a future
|
|
10
|
+
* scenario where theme tokens become runtime-editable.
|
|
11
|
+
*/
|
|
12
|
+
function sanitizeCssTokenValue(value: string): string {
|
|
13
|
+
return value.replace(/[<>{};`\\]/g, '').trim();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Build a `<style>` block that exposes canvas theme tokens to the iframe under
|
|
18
|
+
* both the canonical `--c-*` names and common `--color-*` aliases. Also sets
|
|
19
|
+
* sensible body defaults (font, bg, color) so authored HTML inherits the look.
|
|
20
|
+
*/
|
|
21
|
+
function buildThemeStyleBlock(): string {
|
|
22
|
+
const raw = getCanvasTokens();
|
|
23
|
+
const t = {
|
|
24
|
+
bg: sanitizeCssTokenValue(raw.bg),
|
|
25
|
+
panel: sanitizeCssTokenValue(raw.panel),
|
|
26
|
+
panelSoft: sanitizeCssTokenValue(raw.panelSoft),
|
|
27
|
+
line: sanitizeCssTokenValue(raw.line),
|
|
28
|
+
text: sanitizeCssTokenValue(raw.text),
|
|
29
|
+
textSoft: sanitizeCssTokenValue(raw.textSoft),
|
|
30
|
+
muted: sanitizeCssTokenValue(raw.muted),
|
|
31
|
+
dim: sanitizeCssTokenValue(raw.dim),
|
|
32
|
+
accent: sanitizeCssTokenValue(raw.accent),
|
|
33
|
+
ok: sanitizeCssTokenValue(raw.ok),
|
|
34
|
+
warn: sanitizeCssTokenValue(raw.warn),
|
|
35
|
+
warnAlt: sanitizeCssTokenValue(raw.warnAlt),
|
|
36
|
+
danger: sanitizeCssTokenValue(raw.danger),
|
|
37
|
+
purple: sanitizeCssTokenValue(raw.purple),
|
|
38
|
+
font: sanitizeCssTokenValue(raw.font),
|
|
39
|
+
mono: sanitizeCssTokenValue(raw.mono),
|
|
40
|
+
};
|
|
41
|
+
return `
|
|
42
|
+
:root {
|
|
43
|
+
--c-bg: ${t.bg};
|
|
44
|
+
--c-panel: ${t.panel};
|
|
45
|
+
--c-panel-soft: ${t.panelSoft};
|
|
46
|
+
--c-line: ${t.line};
|
|
47
|
+
--c-text: ${t.text};
|
|
48
|
+
--c-text-soft: ${t.textSoft};
|
|
49
|
+
--c-muted: ${t.muted};
|
|
50
|
+
--c-dim: ${t.dim};
|
|
51
|
+
--c-accent: ${t.accent};
|
|
52
|
+
--c-ok: ${t.ok};
|
|
53
|
+
--c-warn: ${t.warn};
|
|
54
|
+
--c-warn-alt: ${t.warnAlt};
|
|
55
|
+
--c-danger: ${t.danger};
|
|
56
|
+
--c-purple: ${t.purple};
|
|
57
|
+
|
|
58
|
+
/* Common aliases authored HTML might use. */
|
|
59
|
+
--color-bg: ${t.bg};
|
|
60
|
+
--color-panel: ${t.panel};
|
|
61
|
+
--color-surface: ${t.panelSoft};
|
|
62
|
+
--color-border: ${t.line};
|
|
63
|
+
--color-text: ${t.text};
|
|
64
|
+
--color-text-primary: ${t.text};
|
|
65
|
+
--color-text-secondary: ${t.textSoft};
|
|
66
|
+
--color-text-muted: ${t.muted};
|
|
67
|
+
--color-text-dim: ${t.dim};
|
|
68
|
+
--color-accent: ${t.accent};
|
|
69
|
+
--color-success: ${t.ok};
|
|
70
|
+
--color-warning: ${t.warn};
|
|
71
|
+
--color-danger: ${t.danger};
|
|
72
|
+
|
|
73
|
+
--font: ${t.font};
|
|
74
|
+
--font-sans: ${t.font};
|
|
75
|
+
--font-mono: ${t.mono};
|
|
76
|
+
|
|
77
|
+
color-scheme: dark light;
|
|
78
|
+
}
|
|
79
|
+
html, body {
|
|
80
|
+
margin: 0;
|
|
81
|
+
padding: 0;
|
|
82
|
+
background: ${t.bg};
|
|
83
|
+
color: ${t.text};
|
|
84
|
+
font-family: ${t.font || 'system-ui, sans-serif'};
|
|
85
|
+
font-size: 14px;
|
|
86
|
+
line-height: 1.5;
|
|
87
|
+
}
|
|
88
|
+
body { padding: 16px; box-sizing: border-box; }
|
|
89
|
+
a { color: ${t.accent}; }
|
|
90
|
+
`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Inject the theme style block into the user-supplied HTML. If the document has
|
|
95
|
+
* a `<head>`, inject at the top of head; otherwise wrap the content in a full
|
|
96
|
+
* document. Returns a complete HTML string suitable for `srcdoc`.
|
|
97
|
+
*/
|
|
98
|
+
function buildSrcDoc(userHtml: string): string {
|
|
99
|
+
const styleBlock = `<style data-pmx-canvas-theme>${buildThemeStyleBlock()}</style>`;
|
|
100
|
+
const trimmed = userHtml.trim();
|
|
101
|
+
const isFullDoc = /<html[\s>]/i.test(trimmed);
|
|
102
|
+
if (isFullDoc) {
|
|
103
|
+
if (/<head[\s>]/i.test(trimmed)) {
|
|
104
|
+
return trimmed.replace(/<head([^>]*)>/i, `<head$1>${styleBlock}`);
|
|
105
|
+
}
|
|
106
|
+
// Has <html> but no <head> — inject one.
|
|
107
|
+
return trimmed.replace(/<html([^>]*)>/i, `<html$1><head>${styleBlock}</head>`);
|
|
108
|
+
}
|
|
109
|
+
// Fragment — wrap in full document.
|
|
110
|
+
return `<!doctype html><html><head><meta charset="utf-8">${styleBlock}</head><body>${userHtml}</body></html>`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function HtmlNode({ node, expanded = false }: { node: CanvasNodeState; expanded?: boolean }) {
|
|
114
|
+
const html = typeof node.data.html === 'string'
|
|
115
|
+
? node.data.html
|
|
116
|
+
: typeof node.data.content === 'string'
|
|
117
|
+
? node.data.content
|
|
118
|
+
: '';
|
|
119
|
+
const srcDoc = useMemo(() => (html ? buildSrcDoc(html) : ''), [html]);
|
|
120
|
+
|
|
121
|
+
if (!html) {
|
|
122
|
+
return (
|
|
123
|
+
<div style={{ color: 'var(--c-dim)', fontStyle: 'italic', padding: '12px' }}>
|
|
124
|
+
No HTML content set
|
|
125
|
+
</div>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// SECURITY: sandbox is intentionally `allow-scripts` ONLY. Do NOT add
|
|
130
|
+
// `allow-same-origin` (would grant the iframe access to parent localStorage
|
|
131
|
+
// and credentialed requests to the canvas origin), `allow-top-navigation`
|
|
132
|
+
// (would let scripts redirect the parent window), or `allow-forms` (would
|
|
133
|
+
// let the iframe POST back to the host). The whole html-node tier assumes
|
|
134
|
+
// arbitrary author code runs inside this exact sandbox.
|
|
135
|
+
return (
|
|
136
|
+
<iframe
|
|
137
|
+
title={typeof node.data.title === 'string' ? node.data.title : 'HTML node'}
|
|
138
|
+
sandbox="allow-scripts"
|
|
139
|
+
srcdoc={srcDoc}
|
|
140
|
+
style={{
|
|
141
|
+
width: '100%',
|
|
142
|
+
height: '100%',
|
|
143
|
+
minHeight: expanded ? '70vh' : '300px',
|
|
144
|
+
border: 'none',
|
|
145
|
+
background: 'var(--c-bg)',
|
|
146
|
+
borderRadius: '6px',
|
|
147
|
+
display: 'block',
|
|
148
|
+
}}
|
|
149
|
+
/>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
@@ -1,8 +1,23 @@
|
|
|
1
1
|
import { PHASE_COLORS } from '../theme/tokens';
|
|
2
2
|
import type { CanvasNodeState } from '../types';
|
|
3
3
|
|
|
4
|
+
export function getStatusDisplayPhase(node: CanvasNodeState): string {
|
|
5
|
+
const phase = typeof node.data.phase === 'string' && node.data.phase.trim().length > 0
|
|
6
|
+
? node.data.phase.trim()
|
|
7
|
+
: '';
|
|
8
|
+
if (phase) return phase;
|
|
9
|
+
const content = typeof node.data.content === 'string' && node.data.content.trim().length > 0
|
|
10
|
+
? node.data.content.trim()
|
|
11
|
+
: '';
|
|
12
|
+
if (content) return content;
|
|
13
|
+
const status = typeof node.data.status === 'string' && node.data.status.trim().length > 0
|
|
14
|
+
? node.data.status.trim()
|
|
15
|
+
: '';
|
|
16
|
+
return status || 'idle';
|
|
17
|
+
}
|
|
18
|
+
|
|
4
19
|
export function StatusNode({ node }: { node: CanvasNodeState }) {
|
|
5
|
-
const phase = (node
|
|
20
|
+
const phase = getStatusDisplayPhase(node);
|
|
6
21
|
const detail = (node.data.detail as string) || '';
|
|
7
22
|
const message = (node.data.message as string) || '';
|
|
8
23
|
const level = (node.data.level as string) || 'ok';
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { PHASE_COLORS } from '../theme/tokens';
|
|
2
2
|
import type { CanvasNodeState } from '../types';
|
|
3
|
+
import { getStatusDisplayPhase } from './StatusNode';
|
|
3
4
|
|
|
4
5
|
export function StatusSummary({ node }: { node: CanvasNodeState }) {
|
|
5
|
-
const phase = (node
|
|
6
|
+
const phase = getStatusDisplayPhase(node);
|
|
6
7
|
const activeTool = node.data.activeTool as string | null;
|
|
7
8
|
const subagent = node.data.subagent as { state: string; name: string } | undefined;
|
|
8
9
|
const phaseColor = PHASE_COLORS[phase] ?? 'var(--c-muted)';
|