pmx-canvas 0.1.14 → 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 +94 -0
- package/Readme.md +108 -1058
- package/dist/canvas/global.css +141 -0
- package/dist/canvas/index.js +129 -79
- package/dist/json-render/index.css +1 -1
- package/dist/types/client/nodes/HtmlNode.d.ts +5 -0
- 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 +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/DockedNode.tsx +110 -12
- 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/HtmlNode.tsx +151 -0
- 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 +1 -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 +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 +74 -13
- package/src/server/web-artifacts.ts +116 -3
- package/src/shared/canvas-node-kind.ts +14 -0
|
@@ -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';
|
|
@@ -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
|
}
|
|
@@ -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
|
+
}
|
|
@@ -277,6 +277,28 @@ export function toggleCollapsed(id: string): void {
|
|
|
277
277
|
updateNode(id, { collapsed: !existing.collapsed });
|
|
278
278
|
}
|
|
279
279
|
|
|
280
|
+
// Collapse every docked context node. Used to enforce mutual exclusion between
|
|
281
|
+
// the Context side panel and the Updates side panel (they share the same
|
|
282
|
+
// right-edge anchor and would otherwise visually collide).
|
|
283
|
+
export function collapseDockedContextNodes(): void {
|
|
284
|
+
for (const node of nodes.value.values()) {
|
|
285
|
+
if (node.type === 'context' && node.dockPosition === 'right' && !node.collapsed) {
|
|
286
|
+
updateNode(node.id, { collapsed: true });
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// True iff at least one docked context node is currently expanded. Used by the
|
|
292
|
+
// Updates pill to hide itself while the Context panel is open.
|
|
293
|
+
export const hasOpenDockedContextPanel = computed(() => {
|
|
294
|
+
for (const node of nodes.value.values()) {
|
|
295
|
+
if (node.type === 'context' && node.dockPosition === 'right' && !node.collapsed) {
|
|
296
|
+
return true;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return false;
|
|
300
|
+
});
|
|
301
|
+
|
|
280
302
|
export function dockNode(id: string, position: 'left' | 'right'): void {
|
|
281
303
|
const existing = nodes.value.get(id);
|
|
282
304
|
if (!existing) return;
|
|
@@ -411,7 +433,7 @@ export function cancelViewportAnimation(): void {
|
|
|
411
433
|
// ── Persistence ───────────────────────────────────────────────
|
|
412
434
|
const STORAGE_KEY = 'pmx-canvas-layout';
|
|
413
435
|
|
|
414
|
-
export function persistLayout(): void {
|
|
436
|
+
export function persistLayout(options: { recordHistory?: boolean } = {}): void {
|
|
415
437
|
try {
|
|
416
438
|
const allNodes = Array.from(nodes.value.values());
|
|
417
439
|
const nodeUpdates = allNodes.map((n) => ({
|
|
@@ -444,7 +466,7 @@ export function persistLayout(): void {
|
|
|
444
466
|
contextPinnedNodeIds: Array.from(contextPinnedNodeIds.value),
|
|
445
467
|
};
|
|
446
468
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(layout));
|
|
447
|
-
void pushCanvasUpdate(nodeUpdates);
|
|
469
|
+
void pushCanvasUpdate(nodeUpdates, options);
|
|
448
470
|
} catch (error) {
|
|
449
471
|
logCanvasStoreError('persistLayout', error);
|
|
450
472
|
}
|
|
@@ -101,7 +101,7 @@ export async function openWorkbenchFile(path: string): Promise<{ ok: boolean }>
|
|
|
101
101
|
|
|
102
102
|
/** Fetch canvas state from server. */
|
|
103
103
|
export async function fetchCanvasState(): Promise<Record<string, unknown>> {
|
|
104
|
-
return requestJson('fetchCanvasState', '/api/canvas/state', {});
|
|
104
|
+
return requestJson('fetchCanvasState', '/api/canvas/state?includeBlobs=true', {});
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
/** Fetch available slash commands for prompt completion. */
|
|
@@ -148,11 +148,12 @@ export async function pushCanvasUpdate(
|
|
|
148
148
|
collapsed?: boolean;
|
|
149
149
|
dockPosition?: 'left' | 'right' | null;
|
|
150
150
|
}>,
|
|
151
|
+
options: { recordHistory?: boolean } = {},
|
|
151
152
|
): Promise<void> {
|
|
152
153
|
await requestBestEffort('pushCanvasUpdate', '/api/canvas/update', {
|
|
153
154
|
method: 'POST',
|
|
154
155
|
headers: { 'Content-Type': 'application/json' },
|
|
155
|
-
body: JSON.stringify({ updates }),
|
|
156
|
+
body: JSON.stringify({ updates, ...(options.recordHistory === false ? { recordHistory: false } : {}) }),
|
|
156
157
|
});
|
|
157
158
|
}
|
|
158
159
|
|
|
@@ -286,7 +287,7 @@ export interface CanvasSnapshotInfo {
|
|
|
286
287
|
}
|
|
287
288
|
|
|
288
289
|
export async function listSnapshots(): Promise<CanvasSnapshotInfo[]> {
|
|
289
|
-
return requestJson<CanvasSnapshotInfo[]>('listSnapshots', '/api/canvas/snapshots', []);
|
|
290
|
+
return requestJson<CanvasSnapshotInfo[]>('listSnapshots', '/api/canvas/snapshots?all=true', []);
|
|
290
291
|
}
|
|
291
292
|
|
|
292
293
|
export async function saveSnapshot(name: string): Promise<{ ok: boolean; snapshot?: CanvasSnapshotInfo }> {
|
|
@@ -83,6 +83,7 @@ const DEFAULT_POSITIONS: Record<
|
|
|
83
83
|
trace: { x: 40, y: 900, w: 200, h: 56 },
|
|
84
84
|
file: { x: 380, y: 80, w: 720, h: 600 },
|
|
85
85
|
image: { x: 380, y: 80, w: 720, h: 520 },
|
|
86
|
+
html: { x: 380, y: 80, w: 720, h: 640 },
|
|
86
87
|
group: { x: 220, y: 60, w: 840, h: 560 },
|
|
87
88
|
prompt: { x: 380, y: 1260, w: 520, h: 400 },
|
|
88
89
|
response: { x: 380, y: 1480, w: 720, h: 400 },
|
|
@@ -1844,6 +1844,147 @@ body,
|
|
|
1844
1844
|
max-width: 200px;
|
|
1845
1845
|
}
|
|
1846
1846
|
|
|
1847
|
+
/* Context dock — collapsed pill mirrors Updates pill, sits above it */
|
|
1848
|
+
.context-dock-tab {
|
|
1849
|
+
position: fixed;
|
|
1850
|
+
top: 92px;
|
|
1851
|
+
right: 0;
|
|
1852
|
+
display: flex;
|
|
1853
|
+
align-items: center;
|
|
1854
|
+
gap: 8px;
|
|
1855
|
+
padding: 8px 12px 8px 14px;
|
|
1856
|
+
background: color-mix(in srgb, var(--c-panel-glass) 96%, transparent);
|
|
1857
|
+
backdrop-filter: blur(16px);
|
|
1858
|
+
border: 1px solid color-mix(in srgb, var(--c-line) 82%, var(--c-accent) 18%);
|
|
1859
|
+
border-right: 0;
|
|
1860
|
+
border-radius: 14px 0 0 14px;
|
|
1861
|
+
box-shadow: 0 12px 36px var(--c-shadow);
|
|
1862
|
+
color: var(--c-text);
|
|
1863
|
+
cursor: pointer;
|
|
1864
|
+
font: inherit;
|
|
1865
|
+
font-size: 11px;
|
|
1866
|
+
font-weight: 600;
|
|
1867
|
+
letter-spacing: 0.08em;
|
|
1868
|
+
text-transform: uppercase;
|
|
1869
|
+
z-index: 60;
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
.context-dock-tab:hover {
|
|
1873
|
+
border-color: color-mix(in srgb, var(--c-accent) 40%, var(--c-line) 60%);
|
|
1874
|
+
color: var(--c-accent);
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
.context-dock-tab svg {
|
|
1878
|
+
display: block;
|
|
1879
|
+
color: var(--c-accent);
|
|
1880
|
+
flex-shrink: 0;
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
.context-dock-tab-label {
|
|
1884
|
+
white-space: nowrap;
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
.context-dock-tab-badge {
|
|
1888
|
+
min-width: 18px;
|
|
1889
|
+
height: 18px;
|
|
1890
|
+
padding: 0 5px;
|
|
1891
|
+
display: inline-flex;
|
|
1892
|
+
align-items: center;
|
|
1893
|
+
justify-content: center;
|
|
1894
|
+
border-radius: 9px;
|
|
1895
|
+
background: var(--c-accent);
|
|
1896
|
+
color: var(--c-contrast-fg);
|
|
1897
|
+
font-size: 10px;
|
|
1898
|
+
font-weight: 700;
|
|
1899
|
+
letter-spacing: 0;
|
|
1900
|
+
text-transform: none;
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1903
|
+
/* Context dock — expanded panel anchored top-right edge.
|
|
1904
|
+
Mutually exclusive with the Updates panel (see DockedNode.tsx and
|
|
1905
|
+
AttentionHistory.tsx) — opening one collapses the other, so they can both
|
|
1906
|
+
share the same right: 18px anchor without overlapping. */
|
|
1907
|
+
.context-dock-panel {
|
|
1908
|
+
position: fixed;
|
|
1909
|
+
top: 92px;
|
|
1910
|
+
right: 18px;
|
|
1911
|
+
width: min(360px, calc(100vw - 24px));
|
|
1912
|
+
max-height: calc(100vh - 110px);
|
|
1913
|
+
display: flex;
|
|
1914
|
+
flex-direction: column;
|
|
1915
|
+
gap: 10px;
|
|
1916
|
+
padding: 14px;
|
|
1917
|
+
background: color-mix(in srgb, var(--c-panel-glass) 96%, transparent);
|
|
1918
|
+
backdrop-filter: blur(16px);
|
|
1919
|
+
border: 1px solid color-mix(in srgb, var(--c-line) 82%, var(--c-accent) 18%);
|
|
1920
|
+
border-radius: 18px;
|
|
1921
|
+
box-shadow: 0 14px 36px var(--c-shadow), 0 0 0 1px color-mix(in srgb, var(--c-accent) 8%, transparent);
|
|
1922
|
+
z-index: 10001;
|
|
1923
|
+
overflow: hidden;
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
.context-dock-header {
|
|
1927
|
+
display: flex;
|
|
1928
|
+
align-items: flex-start;
|
|
1929
|
+
justify-content: space-between;
|
|
1930
|
+
gap: 8px;
|
|
1931
|
+
padding: 2px 2px 4px;
|
|
1932
|
+
flex-shrink: 0;
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
.context-dock-header-text {
|
|
1936
|
+
display: flex;
|
|
1937
|
+
flex-direction: column;
|
|
1938
|
+
gap: 2px;
|
|
1939
|
+
min-width: 0;
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
.context-dock-title {
|
|
1943
|
+
font-size: 12px;
|
|
1944
|
+
font-weight: 700;
|
|
1945
|
+
letter-spacing: 0.08em;
|
|
1946
|
+
text-transform: uppercase;
|
|
1947
|
+
color: var(--c-text);
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
.context-dock-subtitle {
|
|
1951
|
+
font-size: 11px;
|
|
1952
|
+
color: var(--c-dim);
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
.context-dock-controls {
|
|
1956
|
+
display: flex;
|
|
1957
|
+
gap: 4px;
|
|
1958
|
+
flex-shrink: 0;
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
.context-dock-icon-button {
|
|
1962
|
+
width: 22px;
|
|
1963
|
+
height: 22px;
|
|
1964
|
+
border: 0;
|
|
1965
|
+
border-radius: 6px;
|
|
1966
|
+
background: transparent;
|
|
1967
|
+
color: var(--c-dim);
|
|
1968
|
+
font-size: 16px;
|
|
1969
|
+
line-height: 1;
|
|
1970
|
+
cursor: pointer;
|
|
1971
|
+
display: grid;
|
|
1972
|
+
place-items: center;
|
|
1973
|
+
padding: 0;
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
.context-dock-icon-button:hover {
|
|
1977
|
+
color: var(--c-text);
|
|
1978
|
+
background: var(--c-surface-hover);
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1981
|
+
.context-dock-body {
|
|
1982
|
+
flex: 1 1 auto;
|
|
1983
|
+
min-height: 0;
|
|
1984
|
+
overflow-y: auto;
|
|
1985
|
+
padding-right: 2px;
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1847
1988
|
.attention-history {
|
|
1848
1989
|
position: fixed;
|
|
1849
1990
|
top: 146px;
|
package/src/client/types.ts
CHANGED
|
@@ -20,6 +20,7 @@ export interface CanvasNodeState {
|
|
|
20
20
|
| 'trace'
|
|
21
21
|
| 'file'
|
|
22
22
|
| 'image'
|
|
23
|
+
| 'html'
|
|
23
24
|
| 'group';
|
|
24
25
|
position: { x: number; y: number };
|
|
25
26
|
size: { width: number; height: number };
|
|
@@ -58,6 +59,7 @@ export const TYPE_LABELS: Record<CanvasNodeState['type'], string> = {
|
|
|
58
59
|
trace: 'TRACE',
|
|
59
60
|
file: 'FILE',
|
|
60
61
|
image: 'IMG',
|
|
62
|
+
html: 'HTML',
|
|
61
63
|
group: 'GROUP',
|
|
62
64
|
};
|
|
63
65
|
|
|
@@ -72,6 +74,7 @@ export const EXPANDABLE_TYPES = new Set<CanvasNodeState['type']>([
|
|
|
72
74
|
'ledger',
|
|
73
75
|
'file',
|
|
74
76
|
'image',
|
|
77
|
+
'html',
|
|
75
78
|
]);
|
|
76
79
|
|
|
77
80
|
export const EXCALIDRAW_SERVER_NAME = 'Excalidraw';
|