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.
Files changed (85) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/Readme.md +27 -6
  3. package/dist/canvas/global.css +90 -4
  4. package/dist/canvas/index.js +93 -91
  5. package/dist/canvas/mermaid-entry.js +258 -258
  6. package/dist/types/cli/agent.d.ts +1 -0
  7. package/dist/types/cli/commands/record.d.ts +12 -0
  8. package/dist/types/client/canvas/FeedbackDialog.d.ts +4 -0
  9. package/dist/types/client/canvas/Presentation.d.ts +13 -0
  10. package/dist/types/client/canvas/use-node-drag.d.ts +2 -1
  11. package/dist/types/client/nodes/McpAppNode.d.ts +24 -1
  12. package/dist/types/client/state/presentation.d.ts +3 -0
  13. package/dist/types/client/types.d.ts +1 -1
  14. package/dist/types/json-render/catalog.d.ts +2 -2
  15. package/dist/types/json-render/server.d.ts +2 -0
  16. package/dist/types/server/canvas-db.d.ts +2 -0
  17. package/dist/types/server/canvas-operations.d.ts +22 -0
  18. package/dist/types/server/canvas-schema.d.ts +1 -0
  19. package/dist/types/server/canvas-state.d.ts +6 -0
  20. package/dist/types/server/canvas-validation.d.ts +3 -0
  21. package/dist/types/server/html-surface.d.ts +1 -1
  22. package/dist/types/server/index.d.ts +4 -0
  23. package/dist/types/server/operations/ops/app.d.ts +2 -0
  24. package/dist/types/server/operations/ops/json-render.d.ts +2 -0
  25. package/dist/types/server/operations/ops/nodes.d.ts +3 -1
  26. package/dist/types/server/web-artifacts.d.ts +3 -0
  27. package/dist/types/shared/tour.d.ts +72 -0
  28. package/docs/cli.md +96 -2
  29. package/docs/http-api.md +79 -4
  30. package/docs/mcp.md +25 -1
  31. package/docs/node-types.md +123 -1
  32. package/docs/product-context-vision-2026-09.md +1 -1
  33. package/docs/product-vision-2026-09.md +135 -42
  34. package/docs/product-vision-review-2026-09.md +277 -0
  35. package/package.json +1 -1
  36. package/skills/pmx-canvas/SKILL.md +19 -4
  37. package/skills/pmx-canvas/evals/evals.json +46 -0
  38. package/skills/pmx-canvas/references/full-reference.md +1 -1
  39. package/skills/pmx-canvas/references/installing-pmx-canvas.md +4 -4
  40. package/src/cli/agent.ts +5 -0
  41. package/src/cli/commands/nodes.ts +10 -0
  42. package/src/cli/commands/record.ts +218 -0
  43. package/src/cli/index.ts +2 -0
  44. package/src/client/App.tsx +7 -1
  45. package/src/client/canvas/AgentPresenceLayer.tsx +1 -1
  46. package/src/client/canvas/CanvasNode.tsx +8 -0
  47. package/src/client/canvas/FeedbackDialog.tsx +86 -0
  48. package/src/client/canvas/IntentLayer.tsx +9 -2
  49. package/src/client/canvas/Presentation.tsx +108 -0
  50. package/src/client/canvas/ToolRail.tsx +225 -189
  51. package/src/client/canvas/TopBar.tsx +28 -2
  52. package/src/client/canvas/use-node-drag.ts +8 -3
  53. package/src/client/mermaid-entry.ts +39 -0
  54. package/src/client/nodes/HtmlNode.tsx +12 -13
  55. package/src/client/nodes/McpAppNode.tsx +105 -13
  56. package/src/client/nodes/MermaidNode.tsx +3 -2
  57. package/src/client/state/intent-store.ts +5 -1
  58. package/src/client/state/presentation.ts +7 -0
  59. package/src/client/state/session-store.ts +3 -0
  60. package/src/client/state/sse-bridge.ts +3 -0
  61. package/src/client/theme/global.css +90 -4
  62. package/src/json-render/server.ts +29 -0
  63. package/src/server/agent-presence.ts +15 -9
  64. package/src/server/canvas-db.ts +15 -0
  65. package/src/server/canvas-operations.ts +115 -22
  66. package/src/server/canvas-schema.ts +10 -0
  67. package/src/server/canvas-state.ts +39 -4
  68. package/src/server/canvas-validation.ts +9 -1
  69. package/src/server/demo-state.json +11 -7
  70. package/src/server/html-primitives.ts +199 -91
  71. package/src/server/html-surface.ts +8 -3
  72. package/src/server/index.ts +12 -0
  73. package/src/server/operations/composites.ts +3 -1
  74. package/src/server/operations/ops/app.ts +10 -0
  75. package/src/server/operations/ops/batch.ts +12 -3
  76. package/src/server/operations/ops/groups.ts +14 -7
  77. package/src/server/operations/ops/json-render.ts +37 -5
  78. package/src/server/operations/ops/nodes.ts +42 -16
  79. package/src/server/operations/ops/viewport.ts +32 -0
  80. package/src/server/operations/registry.ts +6 -0
  81. package/src/server/scope-fence.ts +1 -1
  82. package/src/server/server.ts +9 -6
  83. package/src/server/web-artifacts.ts +9 -2
  84. package/src/server/workboard.ts +3 -3
  85. package/src/shared/tour.ts +87 -0
@@ -1,4 +1,5 @@
1
- import { useEffect, useMemo, useRef } from 'preact/hooks';
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
- <iframe
184
- ref={iframeRef}
185
- {...surfaceFrame}
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 v = useMemo(() => surfaceContentHash(source), [source]);
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' : '300px',
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
 
@@ -0,0 +1,7 @@
1
+ import { signal } from '@preact/signals';
2
+
3
+ export const presenting = signal(false);
4
+ export const recordingCamera = signal(false);
5
+ export function ownsCamera(): boolean {
6
+ return presenting.value || recordingCamera.value;
7
+ }
@@ -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
- /* Idle cursors leave the board (user feedback, 0.5.1 cycle): with many agents
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; any non-idle phase fades it
1218
- back in. Placed after .is-worker so opacity: 0 wins that collision. */
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 (wasAttached && input.attached === false) {
462
- this.onSessionEnd(this.publicView(stored, now), stored.startSnapshotId, input.endedBy ?? 'agent');
463
- // An ended session is gone — like `session-end` on the activity feed, it
464
- // must not linger as an "external writer" until the activity TTL.
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
- // Explicit thinking that nothing refreshed settles to idle: the host set
695
- // it at turn start and never sent the turn-end idle (three "Thinking"
696
- // chips on a silent board). Any touch bumps lastSeenMs, so an agent
697
- // that IS doing canvas work keeps its thinking chip.
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;
@@ -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,