pmx-canvas 0.6.3 → 0.6.5
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 +31 -0
- package/Readme.md +27 -6
- package/dist/canvas/global.css +90 -4
- package/dist/canvas/index.js +93 -91
- package/dist/canvas/mermaid-entry.js +258 -258
- package/dist/types/cli/agent.d.ts +1 -0
- package/dist/types/cli/commands/record.d.ts +12 -0
- package/dist/types/client/canvas/FeedbackDialog.d.ts +4 -0
- package/dist/types/client/canvas/Presentation.d.ts +13 -0
- package/dist/types/client/canvas/use-node-drag.d.ts +2 -1
- package/dist/types/client/nodes/McpAppNode.d.ts +24 -1
- package/dist/types/client/state/presentation.d.ts +3 -0
- package/dist/types/client/types.d.ts +1 -1
- package/dist/types/json-render/catalog.d.ts +2 -2
- package/dist/types/json-render/server.d.ts +2 -0
- package/dist/types/server/canvas-db.d.ts +2 -0
- package/dist/types/server/canvas-operations.d.ts +22 -0
- package/dist/types/server/canvas-schema.d.ts +1 -0
- package/dist/types/server/canvas-state.d.ts +6 -0
- package/dist/types/server/canvas-validation.d.ts +3 -0
- package/dist/types/server/html-surface.d.ts +1 -1
- package/dist/types/server/index.d.ts +4 -0
- package/dist/types/server/operations/ops/app.d.ts +2 -0
- package/dist/types/server/operations/ops/json-render.d.ts +2 -0
- package/dist/types/server/operations/ops/nodes.d.ts +3 -1
- package/dist/types/server/web-artifacts.d.ts +3 -0
- package/dist/types/shared/tour.d.ts +72 -0
- package/docs/cli.md +96 -2
- package/docs/http-api.md +79 -4
- package/docs/mcp.md +25 -1
- package/docs/node-types.md +123 -1
- package/docs/product-context-vision-2026-09.md +1 -1
- package/docs/product-vision-2026-09.md +135 -42
- package/docs/product-vision-review-2026-09.md +277 -0
- package/package.json +1 -1
- package/skills/pmx-canvas/SKILL.md +19 -4
- package/skills/pmx-canvas/evals/evals.json +46 -0
- package/skills/pmx-canvas/references/full-reference.md +1 -1
- package/skills/pmx-canvas/references/installing-pmx-canvas.md +4 -4
- package/src/cli/agent.ts +5 -0
- package/src/cli/commands/nodes.ts +10 -0
- package/src/cli/commands/record.ts +218 -0
- package/src/cli/index.ts +2 -0
- package/src/client/App.tsx +7 -1
- package/src/client/canvas/AgentPresenceLayer.tsx +1 -1
- package/src/client/canvas/CanvasNode.tsx +8 -0
- package/src/client/canvas/FeedbackDialog.tsx +86 -0
- package/src/client/canvas/IntentLayer.tsx +9 -2
- package/src/client/canvas/Presentation.tsx +108 -0
- package/src/client/canvas/ToolRail.tsx +225 -189
- package/src/client/canvas/TopBar.tsx +28 -2
- package/src/client/canvas/use-node-drag.ts +8 -3
- package/src/client/mermaid-entry.ts +39 -0
- package/src/client/nodes/HtmlNode.tsx +12 -13
- package/src/client/nodes/McpAppNode.tsx +105 -13
- package/src/client/nodes/MermaidNode.tsx +3 -2
- package/src/client/state/intent-store.ts +5 -1
- package/src/client/state/presentation.ts +7 -0
- package/src/client/state/session-store.ts +3 -0
- package/src/client/state/sse-bridge.ts +3 -0
- package/src/client/theme/global.css +90 -4
- package/src/json-render/server.ts +29 -0
- package/src/server/agent-presence.ts +15 -9
- package/src/server/canvas-db.ts +15 -0
- package/src/server/canvas-operations.ts +115 -22
- package/src/server/canvas-schema.ts +10 -0
- package/src/server/canvas-state.ts +39 -4
- package/src/server/canvas-validation.ts +9 -1
- package/src/server/demo-state.json +11 -7
- package/src/server/html-primitives.ts +199 -91
- package/src/server/html-surface.ts +8 -3
- package/src/server/index.ts +12 -0
- package/src/server/operations/composites.ts +3 -1
- package/src/server/operations/ops/app.ts +10 -0
- package/src/server/operations/ops/batch.ts +12 -3
- package/src/server/operations/ops/groups.ts +14 -7
- package/src/server/operations/ops/json-render.ts +37 -5
- package/src/server/operations/ops/nodes.ts +42 -16
- package/src/server/operations/ops/viewport.ts +32 -0
- package/src/server/operations/registry.ts +6 -0
- package/src/server/scope-fence.ts +1 -1
- package/src/server/server.ts +9 -6
- package/src/server/web-artifacts.ts +9 -2
- package/src/server/workboard.ts +3 -3
- package/src/shared/tour.ts +87 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { RefObject } from 'preact';
|
|
2
|
+
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
|
2
3
|
import { HTML_SURFACE_PUSH_SOURCE } from '../../shared/ax-surface-protocol.js';
|
|
3
4
|
import { canvasThemeScheme } from '../../shared/themes.js';
|
|
4
5
|
import type { CanvasNodeState } from '../types';
|
|
@@ -54,6 +55,106 @@ export function isSameOriginFrameDocumentUrl(url: string, origin = window.locati
|
|
|
54
55
|
}
|
|
55
56
|
}
|
|
56
57
|
|
|
58
|
+
type ViewerFrameSource = { src?: string; srcdoc?: string };
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Keep the last painted viewer in front while its replacement navigates. Viewer
|
|
62
|
+
* specs are immutable documents, so updates normally change `?v=` and reload the
|
|
63
|
+
* iframe; putting the new document in a hidden sibling avoids exposing Chromium's
|
|
64
|
+
* white navigation paint. Two animation frames after load gives the new document
|
|
65
|
+
* a composited paint before the old one is removed.
|
|
66
|
+
*/
|
|
67
|
+
export function RefreshingViewerFrame({
|
|
68
|
+
source,
|
|
69
|
+
iframeRef,
|
|
70
|
+
onLoad,
|
|
71
|
+
title,
|
|
72
|
+
className = 'mcp-app-frame',
|
|
73
|
+
sandbox = 'allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox',
|
|
74
|
+
allow = 'clipboard-read; clipboard-write',
|
|
75
|
+
tabIndex,
|
|
76
|
+
}: {
|
|
77
|
+
source: ViewerFrameSource;
|
|
78
|
+
iframeRef: RefObject<HTMLIFrameElement>;
|
|
79
|
+
onLoad: () => void;
|
|
80
|
+
title: string;
|
|
81
|
+
className?: string;
|
|
82
|
+
sandbox?: string;
|
|
83
|
+
allow?: string;
|
|
84
|
+
tabIndex?: number;
|
|
85
|
+
}) {
|
|
86
|
+
const sourceKey = source.src ?? source.srcdoc ?? '';
|
|
87
|
+
const [painted, setPainted] = useState({ key: sourceKey, source });
|
|
88
|
+
const [pending, setPending] = useState<{ key: string; source: ViewerFrameSource } | null>(null);
|
|
89
|
+
const promotionRef = useRef(0);
|
|
90
|
+
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
promotionRef.current += 1;
|
|
93
|
+
if (!sourceKey || sourceKey === painted.key) {
|
|
94
|
+
setPending(null);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (!painted.key) {
|
|
98
|
+
setPainted({ key: sourceKey, source });
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
setPending({ key: sourceKey, source });
|
|
102
|
+
}, [sourceKey, painted.key]);
|
|
103
|
+
|
|
104
|
+
useEffect(
|
|
105
|
+
() => () => {
|
|
106
|
+
promotionRef.current += 1;
|
|
107
|
+
},
|
|
108
|
+
[],
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
const frame = (entry: { key: string; source: ViewerFrameSource }, isPending: boolean) => (
|
|
112
|
+
<iframe
|
|
113
|
+
key={entry.key}
|
|
114
|
+
ref={isPending ? undefined : iframeRef}
|
|
115
|
+
{...entry.source}
|
|
116
|
+
class={className}
|
|
117
|
+
sandbox={sandbox}
|
|
118
|
+
allow={allow}
|
|
119
|
+
tabIndex={tabIndex}
|
|
120
|
+
loading={iframeMode.value === 'srcdoc' ? undefined : 'lazy'}
|
|
121
|
+
onLoad={(event) => {
|
|
122
|
+
if (!isPending) {
|
|
123
|
+
onLoad();
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const token = ++promotionRef.current;
|
|
127
|
+
const loadedFrame = event.currentTarget;
|
|
128
|
+
requestAnimationFrame(() =>
|
|
129
|
+
requestAnimationFrame(() => {
|
|
130
|
+
if (promotionRef.current !== token) return;
|
|
131
|
+
iframeRef.current = loadedFrame;
|
|
132
|
+
setPainted(entry);
|
|
133
|
+
setPending(null);
|
|
134
|
+
onLoad();
|
|
135
|
+
}),
|
|
136
|
+
);
|
|
137
|
+
}}
|
|
138
|
+
style={{
|
|
139
|
+
position: 'absolute',
|
|
140
|
+
inset: 0,
|
|
141
|
+
width: '100%',
|
|
142
|
+
height: '100%',
|
|
143
|
+
visibility: isPending ? 'hidden' : 'visible',
|
|
144
|
+
background: 'var(--c-panel-soft)',
|
|
145
|
+
}}
|
|
146
|
+
title={title}
|
|
147
|
+
/>
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
return (
|
|
151
|
+
<div class="mcp-app-frame-stack">
|
|
152
|
+
{frame(painted, false)}
|
|
153
|
+
{pending && frame(pending, true)}
|
|
154
|
+
</div>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
57
158
|
export function McpAppNode({ node, expanded = false }: { node: CanvasNodeState; expanded?: boolean }) {
|
|
58
159
|
if (node.data.mode === 'ext-app') {
|
|
59
160
|
return <ExtAppFrame node={node} expanded={expanded} />;
|
|
@@ -180,19 +281,10 @@ function McpAppViewer({ node, expanded }: { node: CanvasNodeState; expanded: boo
|
|
|
180
281
|
{/* Plain iframe-backed viewers stay on an opaque origin. Hosted ext-apps use
|
|
181
282
|
the explicit postMessage bridge instead, which is the only path that needs
|
|
182
283
|
app/host RPC and broader capabilities. */}
|
|
183
|
-
<
|
|
184
|
-
|
|
185
|
-
{
|
|
186
|
-
class="mcp-app-frame"
|
|
187
|
-
sandbox="allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox"
|
|
188
|
-
allow="clipboard-read; clipboard-write"
|
|
189
|
-
// Lazy loading only helps src mode (defers the HTTP request for
|
|
190
|
-
// off-screen nodes). In srcdoc mode the content is already inline —
|
|
191
|
-
// lazy would just delay PAINTING until the node scrolls near the
|
|
192
|
-
// viewport, which reads as "blank until opened" (Amp portal report).
|
|
193
|
-
loading={iframeMode.value === 'srcdoc' ? undefined : 'lazy'}
|
|
284
|
+
<RefreshingViewerFrame
|
|
285
|
+
source={surfaceFrame}
|
|
286
|
+
iframeRef={iframeRef}
|
|
194
287
|
onLoad={pushAxState}
|
|
195
|
-
style={{ flex: 1, minHeight: 0, width: '100%' }}
|
|
196
288
|
title={`MCP App: ${sourceServer}`}
|
|
197
289
|
/>
|
|
198
290
|
</div>
|
|
@@ -21,7 +21,8 @@ export function MermaidNode({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
21
21
|
// Per-mount nonce for the content-height reporter (node grows to fit content).
|
|
22
22
|
const frameToken = useMemo(() => `frame-${crypto.randomUUID()}`, []);
|
|
23
23
|
const source = typeof node.data.content === 'string' ? node.data.content : '';
|
|
24
|
-
const
|
|
24
|
+
const fit = node.data.fit === 'none' ? 'none' : 'contain';
|
|
25
|
+
const v = useMemo(() => surfaceContentHash(`${fit}:${source}`), [source, fit]);
|
|
25
26
|
|
|
26
27
|
// `theme` is intentionally excluded from the deps: live theme changes are
|
|
27
28
|
// pushed via postMessage below (no reload), while `v` reloads the frame when
|
|
@@ -70,7 +71,7 @@ export function MermaidNode({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
70
71
|
style={{
|
|
71
72
|
width: '100%',
|
|
72
73
|
height: '100%',
|
|
73
|
-
minHeight: expanded ? '70vh' : '
|
|
74
|
+
minHeight: expanded ? '70vh' : '0',
|
|
74
75
|
border: 'none',
|
|
75
76
|
background: 'var(--c-bg)',
|
|
76
77
|
borderRadius: '6px',
|
|
@@ -83,9 +83,13 @@ function setPhase(id: string, phase: IntentPhase, ms: number, settledNodeId?: st
|
|
|
83
83
|
next.set(id, { ...current, phase, ...(settledNodeId ? { settledNodeId } : {}) });
|
|
84
84
|
writeIntents(next);
|
|
85
85
|
clearExitTimer(id);
|
|
86
|
+
// The layer removes ghosts on animationend, after the browser actually paints
|
|
87
|
+
// the morph. A wall-clock deadline equal to the CSS duration can delete a
|
|
88
|
+
// ghost before its delayed first paint or transition completes on a busy tab.
|
|
89
|
+
// Retain a backstop for hidden tabs or disabled animations.
|
|
86
90
|
exitTimers.set(
|
|
87
91
|
id,
|
|
88
|
-
setTimeout(() => removeIntent(id), ms),
|
|
92
|
+
setTimeout(() => removeIntent(id), ms + 1500),
|
|
89
93
|
);
|
|
90
94
|
}
|
|
91
95
|
|
|
@@ -386,6 +386,9 @@ export function applySessionReceipt(data: Record<string, unknown>): void {
|
|
|
386
386
|
const counts = data.counts as Partial<SessionReceipt['counts']> | undefined;
|
|
387
387
|
const snapshot = data.snapshot as SessionReceipt['snapshot'] | undefined;
|
|
388
388
|
if (typeof data.label !== 'string' || typeof data.endedAt !== 'string') return;
|
|
389
|
+
// Keep history/timeline intact, but only interrupt for a changed top-level
|
|
390
|
+
// session. Quiet endings must not replace an existing useful receipt.
|
|
391
|
+
if (data.unchanged === true || typeof data.parentAgentId === 'string') return;
|
|
389
392
|
sessionReceipt.value = {
|
|
390
393
|
label: data.label,
|
|
391
394
|
endedAt: data.endedAt,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { findOpenCanvasPosition } from '../utils/placement.js';
|
|
2
|
+
import { ownsCamera } from './presentation';
|
|
2
3
|
import { normalizeExtAppToolResult } from '../utils/ext-app-tool-result.js';
|
|
3
4
|
import type { CanvasAnnotation, CanvasEdge, CanvasNodeState } from '../types';
|
|
4
5
|
import {
|
|
@@ -945,6 +946,7 @@ function reconnectDelayMs(attempt: number): number {
|
|
|
945
946
|
}
|
|
946
947
|
|
|
947
948
|
function handleCanvasFocusNode(data: Record<string, unknown>): void {
|
|
949
|
+
if (ownsCamera()) return;
|
|
948
950
|
const nodeId = data.nodeId as string;
|
|
949
951
|
if (nodeId && nodes.value.has(nodeId)) {
|
|
950
952
|
if (data.noPan === true) {
|
|
@@ -956,6 +958,7 @@ function handleCanvasFocusNode(data: Record<string, unknown>): void {
|
|
|
956
958
|
}
|
|
957
959
|
|
|
958
960
|
function handleCanvasViewportUpdate(data: Record<string, unknown>): void {
|
|
961
|
+
if (ownsCamera()) return;
|
|
959
962
|
const viewport = data.viewport as Record<string, unknown> | undefined;
|
|
960
963
|
if (!viewport) return;
|
|
961
964
|
const x = typeof viewport.x === 'number' ? viewport.x : 0;
|
|
@@ -1210,13 +1210,13 @@ body,
|
|
|
1210
1210
|
font-size: 9px;
|
|
1211
1211
|
padding: 1px 6px;
|
|
1212
1212
|
}
|
|
1213
|
-
/*
|
|
1213
|
+
/* Parked idle cursors leave the board (user feedback, 0.5.1 cycle): with many agents
|
|
1214
1214
|
attached, a wall of parked "Idle" pills covers real node content. The
|
|
1215
1215
|
element stays MOUNTED — counts, twin-merge, and cursor-follow logic are
|
|
1216
1216
|
untouched, and visibility (not display) keeps its layout box so position
|
|
1217
|
-
polls still resolve — but it paints nothing
|
|
1218
|
-
|
|
1219
|
-
.agent-cursor.phase-idle {
|
|
1217
|
+
polls still resolve — but it paints nothing. An explicitly reported cursor
|
|
1218
|
+
remains visible even while idle so cursor-only movement cannot disappear. */
|
|
1219
|
+
.agent-cursor.phase-idle:not(.has-explicit-cursor) {
|
|
1220
1220
|
opacity: 0;
|
|
1221
1221
|
visibility: hidden;
|
|
1222
1222
|
transition:
|
|
@@ -4211,6 +4211,33 @@ html.is-node-resizing .ext-app-preview-catcher {
|
|
|
4211
4211
|
}
|
|
4212
4212
|
|
|
4213
4213
|
/* MCP app iframe */
|
|
4214
|
+
.mcp-app-frame-stack {
|
|
4215
|
+
position: relative;
|
|
4216
|
+
flex: 1;
|
|
4217
|
+
min-width: 0;
|
|
4218
|
+
min-height: 0;
|
|
4219
|
+
background: var(--c-panel-soft);
|
|
4220
|
+
overflow: hidden;
|
|
4221
|
+
border-radius: 0 0 calc(var(--radius) - 1px) calc(var(--radius) - 1px);
|
|
4222
|
+
}
|
|
4223
|
+
|
|
4224
|
+
.mcp-app-frame-stack:has(.html-node-frame) {
|
|
4225
|
+
width: 100%;
|
|
4226
|
+
height: 100%;
|
|
4227
|
+
min-height: 300px;
|
|
4228
|
+
background: var(--c-bg);
|
|
4229
|
+
border-radius: 6px;
|
|
4230
|
+
}
|
|
4231
|
+
|
|
4232
|
+
.mcp-app-frame-stack:has(.html-node-frame-presentation) {
|
|
4233
|
+
min-height: 0;
|
|
4234
|
+
border-radius: 0;
|
|
4235
|
+
}
|
|
4236
|
+
|
|
4237
|
+
.mcp-app-frame-stack:has(.html-node-frame-expanded) {
|
|
4238
|
+
min-height: 70vh;
|
|
4239
|
+
}
|
|
4240
|
+
|
|
4214
4241
|
.mcp-app-frame {
|
|
4215
4242
|
width: 100%;
|
|
4216
4243
|
height: 100%;
|
|
@@ -4474,6 +4501,38 @@ html.is-node-resizing .ext-app-preview-catcher {
|
|
|
4474
4501
|
font-weight: 600;
|
|
4475
4502
|
}
|
|
4476
4503
|
|
|
4504
|
+
.feedback-backdrop {
|
|
4505
|
+
position: fixed;
|
|
4506
|
+
padding: 24px 0;
|
|
4507
|
+
align-items: center;
|
|
4508
|
+
}
|
|
4509
|
+
.feedback-dialog {
|
|
4510
|
+
width: min(480px, calc(100% - 32px));
|
|
4511
|
+
max-height: calc(100dvh - 48px);
|
|
4512
|
+
overflow-y: auto;
|
|
4513
|
+
color: var(--c-text);
|
|
4514
|
+
}
|
|
4515
|
+
.feedback-dialog label {
|
|
4516
|
+
display: flex;
|
|
4517
|
+
flex-direction: column;
|
|
4518
|
+
gap: 6px;
|
|
4519
|
+
font-size: 12px;
|
|
4520
|
+
}
|
|
4521
|
+
.feedback-note {
|
|
4522
|
+
margin: 0;
|
|
4523
|
+
color: var(--c-muted);
|
|
4524
|
+
font-size: 12px;
|
|
4525
|
+
line-height: 1.5;
|
|
4526
|
+
}
|
|
4527
|
+
.feedback-dialog textarea {
|
|
4528
|
+
resize: vertical;
|
|
4529
|
+
min-height: 100px;
|
|
4530
|
+
}
|
|
4531
|
+
.feedback-dialog button:disabled {
|
|
4532
|
+
opacity: 0.5;
|
|
4533
|
+
cursor: not-allowed;
|
|
4534
|
+
}
|
|
4535
|
+
|
|
4477
4536
|
/* Selection action bar — floating bottom-center of the canvas region
|
|
4478
4537
|
(rail-chrome-v2 phase 7, item 13). */
|
|
4479
4538
|
/* In a session the command bar owns the bottom-center slot — lift the
|
|
@@ -6776,3 +6835,30 @@ html.is-node-dragging .intent-ghost,
|
|
|
6776
6835
|
html.is-node-dragging .intent-line-layer {
|
|
6777
6836
|
opacity: 0.5;
|
|
6778
6837
|
}
|
|
6838
|
+
|
|
6839
|
+
.present-button {
|
|
6840
|
+
color: var(--c-text);
|
|
6841
|
+
background: var(--c-bg);
|
|
6842
|
+
border: 1px solid var(--c-line);
|
|
6843
|
+
border-radius: 6px;
|
|
6844
|
+
padding: 4px 12px;
|
|
6845
|
+
cursor: pointer;
|
|
6846
|
+
}
|
|
6847
|
+
.is-presenting > :not(.app-main):not(.presentation-error),
|
|
6848
|
+
.is-presenting .app-main > :not(.canvas-region),
|
|
6849
|
+
.is-presenting .canvas-region > :not(.canvas-viewport) {
|
|
6850
|
+
display: none !important;
|
|
6851
|
+
}
|
|
6852
|
+
.is-presenting .canvas-viewport { pointer-events: none; }
|
|
6853
|
+
.presentation-error {
|
|
6854
|
+
position: fixed;
|
|
6855
|
+
bottom: 16px;
|
|
6856
|
+
left: 16px;
|
|
6857
|
+
z-index: 100;
|
|
6858
|
+
max-width: calc(100% - 32px);
|
|
6859
|
+
padding: 10px 16px;
|
|
6860
|
+
border: 1px solid var(--c-line);
|
|
6861
|
+
border-radius: 6px;
|
|
6862
|
+
background: var(--c-panel);
|
|
6863
|
+
color: var(--c-text);
|
|
6864
|
+
}
|
|
@@ -542,6 +542,35 @@ export function normalizeAndValidateJsonRenderSpec(spec: unknown): JsonRenderSpe
|
|
|
542
542
|
return propsValidation.data as JsonRenderSpec;
|
|
543
543
|
}
|
|
544
544
|
|
|
545
|
+
/** shadcn 0.21 ignores these props unless the renderer supplies a two-way binding. */
|
|
546
|
+
export function jsonRenderFormWarnings(spec: JsonRenderSpec): string[] {
|
|
547
|
+
const bindingOnlyProps: Record<string, string> = {
|
|
548
|
+
Input: 'value',
|
|
549
|
+
Textarea: 'value',
|
|
550
|
+
Select: 'value',
|
|
551
|
+
Radio: 'value',
|
|
552
|
+
Slider: 'value',
|
|
553
|
+
ToggleGroup: 'value',
|
|
554
|
+
ButtonGroup: 'selected',
|
|
555
|
+
Tabs: 'value',
|
|
556
|
+
};
|
|
557
|
+
const warnings: string[] = [];
|
|
558
|
+
for (const [key, rawElement] of Object.entries(spec.elements)) {
|
|
559
|
+
const element = asRecord(rawElement);
|
|
560
|
+
const type = typeof element?.type === 'string' ? element.type : '';
|
|
561
|
+
const prop = bindingOnlyProps[type];
|
|
562
|
+
if (!prop) continue;
|
|
563
|
+
const value = asRecord(element?.props)?.[prop];
|
|
564
|
+
if (value === undefined || value === null) continue;
|
|
565
|
+
const expression = asRecord(value);
|
|
566
|
+
if (hasString(expression?.$bindState) || hasString(expression?.$bindItem)) continue;
|
|
567
|
+
warnings.push(
|
|
568
|
+
`elements.${key}.props.${prop}: ${type} ignores an unbound ${prop} (including literal values and $state expressions). Use { "$bindState": "/form/field" } and seed spec.state.form.field with the initial value; inside a repeat use $bindItem with seeded item data.${type === 'Tabs' ? ' For an uncontrolled initial tab, use defaultValue instead.' : ''}`,
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
return warnings;
|
|
572
|
+
}
|
|
573
|
+
|
|
545
574
|
export function normalizeGraphType(value: string): GraphChartType {
|
|
546
575
|
const normalized = value.toLowerCase().replace(/[-_\s]/g, '');
|
|
547
576
|
return GRAPH_TYPE_ALIASES[normalized] ?? 'LineChart';
|
|
@@ -198,6 +198,8 @@ const presenceSetSchema = z.object(PRESENCE_SET_SHAPE);
|
|
|
198
198
|
|
|
199
199
|
interface StoredPresence extends AgentPresence {
|
|
200
200
|
lastSeenMs: number;
|
|
201
|
+
/** Last explicit phase report; cursor/focus heartbeats must not keep a stale phase alive. */
|
|
202
|
+
phaseSetMs: number;
|
|
201
203
|
/** Explicit phases (`thinking`) hold until the next touch; derived `tooling` decays. */
|
|
202
204
|
toolingUntilMs: number | null;
|
|
203
205
|
lastActivityNodeId: string | null;
|
|
@@ -381,6 +383,7 @@ export class AgentPresenceRegistry {
|
|
|
381
383
|
contextUsage: null,
|
|
382
384
|
lastSeenAt: new Date(now).toISOString(),
|
|
383
385
|
lastSeenMs: now,
|
|
386
|
+
phaseSetMs: now,
|
|
384
387
|
toolingUntilMs: null,
|
|
385
388
|
startSnapshotId: null,
|
|
386
389
|
};
|
|
@@ -458,10 +461,12 @@ export class AgentPresenceRegistry {
|
|
|
458
461
|
}
|
|
459
462
|
stored.startSnapshotId = inheritedSnapshotId ?? this.onSessionStart(this.publicView(stored, now));
|
|
460
463
|
}
|
|
461
|
-
if (
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
//
|
|
464
|
+
if (input.attached === false) {
|
|
465
|
+
if (wasAttached)
|
|
466
|
+
this.onSessionEnd(this.publicView(stored, now), stored.startSnapshotId, input.endedBy ?? 'agent');
|
|
467
|
+
// An explicit detach always removes the presence. In particular, a
|
|
468
|
+
// worker that reports attached:false must not be refreshed into another
|
|
469
|
+
// full activity TTL as an external writer.
|
|
465
470
|
this.presences.delete(key);
|
|
466
471
|
this.dropAliasesTo(key);
|
|
467
472
|
this.maybeStopSweeper();
|
|
@@ -481,6 +486,7 @@ export class AgentPresenceRegistry {
|
|
|
481
486
|
if (input.detail !== undefined) stored.detail = input.detail;
|
|
482
487
|
} else if (input.phase !== undefined) {
|
|
483
488
|
stored.phase = input.phase;
|
|
489
|
+
stored.phaseSetMs = now;
|
|
484
490
|
stored.detail = input.detail !== undefined ? input.detail : input.phase === 'tooling' ? stored.detail : null;
|
|
485
491
|
stored.toolingUntilMs = input.phase === 'tooling' ? now + PRESENCE_TOOLING_SETTLE_MS : null;
|
|
486
492
|
} else if (input.detail !== undefined) {
|
|
@@ -607,6 +613,7 @@ export class AgentPresenceRegistry {
|
|
|
607
613
|
private publicView(stored: StoredPresence, now: number): AgentPresence {
|
|
608
614
|
const {
|
|
609
615
|
lastSeenMs: _lastSeenMs,
|
|
616
|
+
phaseSetMs: _phaseSetMs,
|
|
610
617
|
toolingUntilMs,
|
|
611
618
|
startSnapshotId: _startSnapshotId,
|
|
612
619
|
lastActivityNodeId,
|
|
@@ -691,11 +698,10 @@ export class AgentPresenceRegistry {
|
|
|
691
698
|
changed = true;
|
|
692
699
|
continue;
|
|
693
700
|
}
|
|
694
|
-
//
|
|
695
|
-
//
|
|
696
|
-
//
|
|
697
|
-
|
|
698
|
-
if (presence.phase === 'thinking' && now - presence.lastSeenMs > PRESENCE_THINKING_SETTLE_MS) {
|
|
701
|
+
// Cursor/focus heartbeats keep the writer live, but cannot keep a stale
|
|
702
|
+
// explicit phase alive. Only another explicit phase report resets this
|
|
703
|
+
// clock; operation-derived tooling remains its own short overlay.
|
|
704
|
+
if (presence.phase === 'thinking' && now - presence.phaseSetMs > PRESENCE_THINKING_SETTLE_MS) {
|
|
699
705
|
presence.phase = 'idle';
|
|
700
706
|
presence.detail = null;
|
|
701
707
|
changed = true;
|
package/src/server/canvas-db.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { Database } from 'bun:sqlite';
|
|
9
|
+
import { tourSchema, type Tour } from '../shared/tour.js';
|
|
9
10
|
import { existsSync, mkdirSync } from 'node:fs';
|
|
10
11
|
import { dirname } from 'node:path';
|
|
11
12
|
import { gzipSync, gunzipSync } from 'node:zlib';
|
|
@@ -260,6 +261,7 @@ function parsePersistedAxState(raw: string | null | undefined): PmxAxState {
|
|
|
260
261
|
// ── Persisted State Interface ───────────────────────────────────
|
|
261
262
|
|
|
262
263
|
export interface PersistedCanvasState {
|
|
264
|
+
tour?: Tour;
|
|
263
265
|
version: number;
|
|
264
266
|
theme?: CanvasTheme;
|
|
265
267
|
viewport: ViewportState;
|
|
@@ -270,6 +272,11 @@ export interface PersistedCanvasState {
|
|
|
270
272
|
ax?: PmxAxState;
|
|
271
273
|
}
|
|
272
274
|
|
|
275
|
+
function readTour(raw: string | undefined): Tour | undefined {
|
|
276
|
+
if (!raw || raw === 'null') return undefined;
|
|
277
|
+
return tourSchema.parse(JSON.parse(raw));
|
|
278
|
+
}
|
|
279
|
+
|
|
273
280
|
// ── Database Management ─────────────────────────────────────────
|
|
274
281
|
|
|
275
282
|
export function openCanvasDb(dbPath: string): Database {
|
|
@@ -394,6 +401,7 @@ export function saveStateToDB(db: Database, state: PersistedCanvasState): void {
|
|
|
394
401
|
WHERE value IS NOT excluded.value`,
|
|
395
402
|
);
|
|
396
403
|
upsertMeta.run('theme', normalizeCanvasTheme(state.theme));
|
|
404
|
+
upsertMeta.run('tour', JSON.stringify(state.tour ?? null));
|
|
397
405
|
upsertMeta.run('viewport_x', String(state.viewport.x));
|
|
398
406
|
upsertMeta.run('viewport_y', String(state.viewport.y));
|
|
399
407
|
upsertMeta.run('viewport_scale', String(state.viewport.scale));
|
|
@@ -610,6 +618,7 @@ export function loadStateFromDB(db: Database): PersistedCanvasState | null {
|
|
|
610
618
|
return {
|
|
611
619
|
version: 1,
|
|
612
620
|
theme,
|
|
621
|
+
tour: readTour(db.query<{ value: string }, [string]>('SELECT value FROM meta WHERE key = ?').get('tour')?.value),
|
|
613
622
|
viewport,
|
|
614
623
|
nodes,
|
|
615
624
|
edges,
|
|
@@ -632,6 +641,11 @@ export function saveSnapshotToDB(db: Database, snapshot: CanvasSnapshot, state:
|
|
|
632
641
|
state.edges.length,
|
|
633
642
|
]);
|
|
634
643
|
|
|
644
|
+
db.run('INSERT INTO snapshot_meta (snapshot_id, key, value) VALUES (?, ?, ?)', [
|
|
645
|
+
snapshot.id,
|
|
646
|
+
'tour',
|
|
647
|
+
JSON.stringify(state.tour ?? null),
|
|
648
|
+
]);
|
|
635
649
|
// Insert snapshot viewport meta
|
|
636
650
|
db.run('INSERT INTO snapshot_meta (snapshot_id, key, value) VALUES (?, ?, ?)', [
|
|
637
651
|
snapshot.id,
|
|
@@ -859,6 +873,7 @@ export function loadSnapshotFromDB(
|
|
|
859
873
|
snapshot,
|
|
860
874
|
state: {
|
|
861
875
|
version: 1,
|
|
876
|
+
tour: readTour(metaMap.get('tour')),
|
|
862
877
|
viewport,
|
|
863
878
|
nodes,
|
|
864
879
|
edges,
|