pmx-canvas 0.1.30 → 0.1.31

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.
@@ -199,6 +199,13 @@ declare class CanvasStateManager {
199
199
  private emptyPersistedState;
200
200
  /** Load canvas state from SQLite (or legacy JSON fallback). Call once on server startup. */
201
201
  loadFromDisk(options?: LoadFromDiskOptions): boolean;
202
+ /**
203
+ * Whether this workspace's canvas DB already holds saved state. Used to gate
204
+ * brand-new-workspace seeding (e.g. the default docked status/context widgets)
205
+ * so we never add nodes to a canvas that already has content. Reflects the
206
+ * pre-run persisted flag until the next save.
207
+ */
208
+ hasPersistedState(): boolean;
202
209
  /** Debounced save — coalesces rapid mutations into a single write. */
203
210
  private scheduleSave;
204
211
  flushToDisk(): void;
@@ -22,6 +22,12 @@ export declare const HTML_SURFACE_SANDBOX = "allow-scripts";
22
22
  export declare function normalizeSurfaceTheme(value: string | null | undefined): SurfaceTheme;
23
23
  export interface HtmlSurfaceOptions {
24
24
  theme: SurfaceTheme;
25
+ /**
26
+ * Tab/document title. Injected as `<title>` only when the author HTML does not
27
+ * already declare one, so a standalone "Open as site" tab shows the node title
28
+ * instead of falling back to the raw URL.
29
+ */
30
+ title?: string;
25
31
  /** Client nonce that authorizes parent → iframe theme-update messages. */
26
32
  themeToken?: string;
27
33
  presentation?: boolean;
@@ -7,12 +7,22 @@ import type { AxTimelineQuery } from './canvas-db.js';
7
7
  import { searchNodes } from './spatial-analysis.js';
8
8
  import { diffLayouts } from './mutation-history.js';
9
9
  import { fitCanvasView, gcCanvasSnapshots, listCanvasSnapshots } from './canvas-operations.js';
10
+ import { type SerializedCanvasNode } from './canvas-serialization.js';
10
11
  import type { HtmlPrimitiveKind } from './html-primitives.js';
11
12
  import { type WebArtifactBuildInput, type WebArtifactCanvasBuildResult } from './web-artifacts.js';
12
13
  import { type ExternalMcpTransportConfig } from './mcp-app-runtime.js';
13
14
  import { type DiagramPresetOpenInput } from './diagram-presets.js';
14
15
  import { type GraphNodeInput, type JsonRenderNodeInput, type JsonRenderSpec } from '../json-render/server.js';
15
16
  import type { CanvasAutomationWebViewOptions, CanvasAutomationWebViewStatus } from './server.js';
17
+ /**
18
+ * Node object returned by the SDK's create/get methods. It is the fully
19
+ * serialized node (adds `surfaceUrl`, `kind`, `title`, `content`, …) plus a
20
+ * `nodeId` alias for `id`, so the SDK return shape matches the HTTP/CLI
21
+ * `node`-create responses field-for-field.
22
+ */
23
+ export type SdkCanvasNode = SerializedCanvasNode & {
24
+ nodeId: string;
25
+ };
16
26
  export declare class PmxCanvas extends EventEmitter {
17
27
  private _port;
18
28
  private _server;
@@ -54,7 +64,7 @@ export declare class PmxCanvas extends EventEmitter {
54
64
  width?: number;
55
65
  height?: number;
56
66
  strictSize?: boolean;
57
- }): CanvasNodeState;
67
+ }): SdkCanvasNode;
58
68
  addWebpageNode(input: {
59
69
  title?: string;
60
70
  url: string;
@@ -262,7 +272,7 @@ export declare class PmxCanvas extends EventEmitter {
262
272
  nodeIds?: string[];
263
273
  }): ReturnType<typeof fitCanvasView>;
264
274
  getLayout(): CanvasLayout;
265
- getNode(id: string): CanvasNodeState | undefined;
275
+ getNode(id: string): SdkCanvasNode | undefined;
266
276
  search(query: string): ReturnType<typeof searchNodes>;
267
277
  getSpatialContext(): import("./spatial-analysis.js").SpatialContext;
268
278
  undo(): Promise<{
@@ -435,7 +445,7 @@ export declare class PmxCanvas extends EventEmitter {
435
445
  width?: number;
436
446
  height?: number;
437
447
  strictSize?: boolean;
438
- }): string;
448
+ }): SdkCanvasNode;
439
449
  addHtmlPrimitive(input: {
440
450
  kind: HtmlPrimitiveKind;
441
451
  title?: string;
@@ -83,6 +83,18 @@ export declare function hasWorkbenchSubscribers(): boolean;
83
83
  export declare function setPrimaryWorkbenchCanvasPromptHandler(handler: PrimaryWorkbenchCanvasPromptHandler | null): void;
84
84
  export declare function buildMacBrowserOpenScript(appName: string, url: string): string;
85
85
  export declare function openUrlInExternalBrowser(url: string): boolean;
86
+ /**
87
+ * Seed the docked status (left) + context (right) widgets so a freshly opened
88
+ * canvas shows them by default — the same nodes the agent-event path creates on
89
+ * demand (`status-main`, `context-main`), just present from the start.
90
+ *
91
+ * First-run only: we bail if the workspace canvas already has persisted state,
92
+ * so we never add them to a board with content, and — because first-run state is
93
+ * persisted on save — deleting or undocking them later is respected (they are
94
+ * not re-seeded). Create-if-missing keeps it idempotent if the agent path
95
+ * already made one. Returns true if anything was seeded.
96
+ */
97
+ export declare function ensureDefaultDockedNodes(): boolean;
86
98
  export declare function emitPrimaryWorkbenchEvent(event: string, payload?: PrimaryWorkbenchEventPayload): void;
87
99
  export declare function consumePrimaryWorkbenchIntents(limit?: number): PrimaryWorkbenchIntent[];
88
100
  export declare function getPrimaryWorkbenchUrl(workspaceRoot?: string): string | null;
package/docs/sdk.md CHANGED
@@ -15,7 +15,9 @@ import { createCanvas } from 'pmx-canvas';
15
15
  const canvas = createCanvas({ port: 4313 });
16
16
  await canvas.start({ open: true });
17
17
 
18
- // Add nodes — addNode returns the created node (with `.id`, geometry, and data)
18
+ // Add nodes — addNode/getNode/addHtmlNode return the created node: `.id`
19
+ // (plus a `.nodeId` alias), geometry, `.data`, and `.surfaceUrl` for
20
+ // surface-eligible types (html, json-render, graph, …).
19
21
  const n1 = canvas.addNode({ type: 'markdown', title: 'Plan', content: '# Step 1\nDo the thing.' });
20
22
  const n2 = canvas.addNode({ type: 'status', title: 'Build', content: 'passing' });
21
23
  const n3 = canvas.addNode({ type: 'file', content: 'src/index.ts' });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmx-canvas",
3
- "version": "0.1.30",
3
+ "version": "0.1.31",
4
4
  "description": "Spatial canvas workbench for coding agents — infinite 2D canvas with agent-native CLI, MCP integration, nodes, edges, file watching, and snapshots",
5
5
  "type": "module",
6
6
  "main": "./src/server/index.ts",
package/src/cli/index.ts CHANGED
@@ -6,6 +6,7 @@ import { fileURLToPath } from 'node:url';
6
6
  import { runAgentCli } from './agent.js';
7
7
  import { createCanvas } from '../server/index.js';
8
8
  import { seedDemoCanvas } from '../server/demo.js';
9
+ import { ensureDefaultDockedNodes } from '../server/server.js';
9
10
 
10
11
  const args = process.argv.slice(2);
11
12
 
@@ -599,7 +600,13 @@ Examples:
599
600
  process.exit(1);
600
601
  }
601
602
 
602
- if (demo && canvas.getLayout().nodes.length === 0) seedDemoCanvas();
603
+ if (demo && canvas.getLayout().nodes.length === 0) {
604
+ seedDemoCanvas();
605
+ } else if (!demo) {
606
+ // First-run only: dock a status (left) + context (right) widget by default so
607
+ // a fresh canvas isn't empty. No-op once the workspace has saved state.
608
+ ensureDefaultDockedNodes();
609
+ }
603
610
 
604
611
  console.log(`\n PMX Canvas running at http://localhost:${canvas.port}`);
605
612
  console.log(` Health: http://localhost:${canvas.port}/health\n`);
@@ -2,7 +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
+ import { closeAttentionHistory } from '../state/attention-store';
6
6
  import { getContextPinnedNodes, toggleCollapsed, undockNode } from '../state/canvas-store';
7
7
  import { TYPE_LABELS } from '../types';
8
8
  import type { CanvasNodeState } from '../types';
@@ -40,44 +40,44 @@ function ContextDockedNode({ node }: { node: CanvasNodeState }) {
40
40
  toggleCollapsed(node.id);
41
41
  };
42
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
43
  if (collapsed) {
44
+ // Collapsed = a menu-height pill in the right of the top HUD row, mirroring
45
+ // the docked status widget on the left so the bar reads as one continuous menu.
50
46
  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>
47
+ <div class="docked-node docked-node--collapsed" data-docked-node="true">
48
+ <div class="docked-node-header">
49
+ <span class="node-type-badge">Context</span>
50
+ {hasItems && (
51
+ <span class="docked-node-count" aria-hidden="true">
52
+ {count > 99 ? '99+' : count}
53
+ </span>
54
+ )}
55
+ <div class="docked-node-controls">
56
+ <button
57
+ type="button"
58
+ onClick={(e) => {
59
+ e.stopPropagation();
60
+ expand();
61
+ }}
62
+ title={hasItems ? `${count} item${count === 1 ? '' : 's'} in agent context — expand` : 'Expand agent context'}
63
+ aria-label={hasItems ? `Context — ${count} item${count === 1 ? '' : 's'}` : 'Expand agent context'}
64
+ >
65
+ {'▸'}
66
+ </button>
67
+ <button
68
+ type="button"
69
+ onClick={(e) => {
70
+ e.stopPropagation();
71
+ undockNode(node.id);
72
+ }}
73
+ title="Undock to canvas"
74
+ aria-label="Undock to canvas"
75
+ >
76
+ {'⊙'}
77
+ </button>
78
+ </div>
79
+ </div>
80
+ </div>
81
81
  );
82
82
  }
83
83
 
@@ -130,7 +130,7 @@ export function DockedNode({ node }: { node: CanvasNodeState }) {
130
130
  }
131
131
 
132
132
  return (
133
- <div class="docked-node" data-docked-node="true">
133
+ <div class={`docked-node${node.collapsed ? ' docked-node--collapsed' : ''}`} data-docked-node="true">
134
134
  <div class="docked-node-header">
135
135
  <span class="node-type-badge">{TYPE_LABELS[node.type] ?? node.type}</span>
136
136
  {node.type === 'status' && node.collapsed && <StatusSummary node={node} />}
@@ -56,6 +56,10 @@
56
56
  --mono: "IBM Plex Mono", "SF Mono", "Fira Code", monospace;
57
57
  --radius: 10px;
58
58
  --radius-sm: 6px;
59
+ /* Shared height for the top HUD row so the toolbar and the collapsed docked
60
+ status/context widgets that flank it line up to the same height. Matches the
61
+ toolbar's natural content height (icon buttons at 6px padding). */
62
+ --hud-bar-height: 44px;
59
63
  }
60
64
 
61
65
  :root[data-theme="light"] {
@@ -481,6 +485,8 @@ body,
481
485
  align-items: center;
482
486
  gap: 6px;
483
487
  padding: 6px 10px;
488
+ min-height: var(--hud-bar-height);
489
+ box-sizing: border-box;
484
490
  background: var(--c-panel-glass);
485
491
  backdrop-filter: blur(12px);
486
492
  border: 1px solid var(--c-line);
@@ -1409,6 +1415,38 @@ html.is-node-resizing .ext-app-preview-catcher {
1409
1415
  max-width: 320px;
1410
1416
  }
1411
1417
 
1418
+ /* Collapsed docked widget = a single menu-height pill that flanks the toolbar.
1419
+ Pinned to the same height as .canvas-toolbar so the top HUD row reads as one
1420
+ continuous bar (status on the left, context on the right). */
1421
+ .docked-node--collapsed {
1422
+ height: var(--hud-bar-height);
1423
+ box-sizing: border-box;
1424
+ justify-content: center;
1425
+ width: auto;
1426
+ /* Reset the base .docked-node min-width so the collapsed pill hugs its content
1427
+ (badge + count + controls) instead of stretching to a 200px bar. */
1428
+ min-width: 0;
1429
+ }
1430
+ .docked-node--collapsed .docked-node-header {
1431
+ height: 100%;
1432
+ padding: 0 10px;
1433
+ border-bottom: none;
1434
+ }
1435
+ .docked-node-count {
1436
+ min-width: 18px;
1437
+ height: 18px;
1438
+ padding: 0 5px;
1439
+ display: inline-flex;
1440
+ align-items: center;
1441
+ justify-content: center;
1442
+ border-radius: 9px;
1443
+ background: var(--c-accent);
1444
+ color: var(--c-contrast-fg);
1445
+ font-size: 10px;
1446
+ font-weight: 700;
1447
+ flex-shrink: 0;
1448
+ }
1449
+
1412
1450
  .docked-node-header {
1413
1451
  display: flex;
1414
1452
  align-items: center;
@@ -1948,62 +1986,6 @@ html.is-node-resizing .ext-app-preview-catcher {
1948
1986
  max-width: 200px;
1949
1987
  }
1950
1988
 
1951
- /* Context dock — collapsed pill mirrors Updates pill, sits above it */
1952
- .context-dock-tab {
1953
- position: fixed;
1954
- top: 92px;
1955
- right: 0;
1956
- display: flex;
1957
- align-items: center;
1958
- gap: 8px;
1959
- padding: 8px 12px 8px 14px;
1960
- background: color-mix(in srgb, var(--c-panel-glass) 96%, transparent);
1961
- backdrop-filter: blur(16px);
1962
- border: 1px solid color-mix(in srgb, var(--c-line) 82%, var(--c-accent) 18%);
1963
- border-right: 0;
1964
- border-radius: 14px 0 0 14px;
1965
- box-shadow: 0 12px 36px var(--c-shadow);
1966
- color: var(--c-text);
1967
- cursor: pointer;
1968
- font: inherit;
1969
- font-size: 11px;
1970
- font-weight: 600;
1971
- letter-spacing: 0.08em;
1972
- text-transform: uppercase;
1973
- z-index: 60;
1974
- }
1975
-
1976
- .context-dock-tab:hover {
1977
- border-color: color-mix(in srgb, var(--c-accent) 40%, var(--c-line) 60%);
1978
- color: var(--c-accent);
1979
- }
1980
-
1981
- .context-dock-tab svg {
1982
- display: block;
1983
- color: var(--c-accent);
1984
- flex-shrink: 0;
1985
- }
1986
-
1987
- .context-dock-tab-label {
1988
- white-space: nowrap;
1989
- }
1990
-
1991
- .context-dock-tab-badge {
1992
- min-width: 18px;
1993
- height: 18px;
1994
- padding: 0 5px;
1995
- display: inline-flex;
1996
- align-items: center;
1997
- justify-content: center;
1998
- border-radius: 9px;
1999
- background: var(--c-accent);
2000
- color: var(--c-contrast-fg);
2001
- font-size: 10px;
2002
- font-weight: 700;
2003
- letter-spacing: 0;
2004
- text-transform: none;
2005
- }
2006
-
2007
1989
  /* Context dock — expanded panel anchored top-right edge.
2008
1990
  Mutually exclusive with the Updates panel (see DockedNode.tsx and
2009
1991
  AttentionHistory.tsx) — opening one collapses the other, so they can both
@@ -271,7 +271,9 @@ class LocalCanvasAccess implements CanvasAccess {
271
271
  }
272
272
 
273
273
  async addHtmlNode(input: AddHtmlNodeInput): Promise<string> {
274
- return this.canvas.addHtmlNode(input);
274
+ // PmxCanvas.addHtmlNode returns the created node; the CanvasAccess contract
275
+ // is a bare id string, so extract it (mirrors addNode above).
276
+ return this.canvas.addHtmlNode(input).id;
275
277
  }
276
278
 
277
279
  async addHtmlPrimitive(input: AddHtmlPrimitiveInput): Promise<AddHtmlPrimitiveResult> {
@@ -442,18 +442,14 @@ class CanvasStateManager {
442
442
  }
443
443
 
444
444
  private normalizeNode(node: CanvasNodeState): CanvasNodeState {
445
- const normalized: CanvasNodeState = {
445
+ // Context nodes default to a right-docked, collapsed pill (see DockedNode.tsx),
446
+ // but that default is applied at CREATE time only — it must not be re-forced on
447
+ // every write, or the node could never be undocked. Undocking (dockPosition →
448
+ // null) is a deliberate user action and is respected here.
449
+ return {
446
450
  ...node,
447
451
  data: normalizeCanvasNodeData(node.type, node.data),
448
452
  };
449
- // Context nodes are always docked to the right side as a pill/panel widget
450
- // (see DockedNode.tsx). They start collapsed so the user sees the slim
451
- // pill first; expanding reveals the full context overview panel.
452
- if (normalized.type === 'context' && normalized.dockPosition !== 'right') {
453
- normalized.dockPosition = 'right';
454
- normalized.collapsed = true;
455
- }
456
- return normalized;
457
453
  }
458
454
 
459
455
  private nodeForRead(node: CanvasNodeState): CanvasNodeState {
@@ -930,6 +926,16 @@ class CanvasStateManager {
930
926
  return false;
931
927
  }
932
928
 
929
+ /**
930
+ * Whether this workspace's canvas DB already holds saved state. Used to gate
931
+ * brand-new-workspace seeding (e.g. the default docked status/context widgets)
932
+ * so we never add nodes to a canvas that already has content. Reflects the
933
+ * pre-run persisted flag until the next save.
934
+ */
935
+ hasPersistedState(): boolean {
936
+ return this._db ? isDbPopulated(this._db) : false;
937
+ }
938
+
933
939
  /** Debounced save — coalesces rapid mutations into a single write. */
934
940
  private scheduleSave(): void {
935
941
  if (!this._db) return;
@@ -1343,7 +1349,15 @@ class CanvasStateManager {
1343
1349
  }
1344
1350
 
1345
1351
  addNode(node: CanvasNodeState): void {
1346
- const cloned = structuredClone(this.normalizeNode(node));
1352
+ // Context nodes default to a right-docked, collapsed pill when created without
1353
+ // an explicit dock position. CREATE-time default only — once placed, updates
1354
+ // (including undock → dockPosition null) are respected (see normalizeNode).
1355
+ // Skip during suppressed replay (undo/redo re-add) so a deliberately-undocked
1356
+ // context node is restored verbatim instead of being snapped back to the dock.
1357
+ const seeded = node.type === 'context' && node.dockPosition == null && this._suppressRecordingDepth === 0
1358
+ ? { ...node, dockPosition: 'right' as const, collapsed: true }
1359
+ : node;
1360
+ const cloned = structuredClone(this.normalizeNode(seeded));
1347
1361
  this.nodes.set(node.id, cloned);
1348
1362
  this.scheduleSave();
1349
1363
  this.notifyChange('nodes');
@@ -126,8 +126,19 @@ window.PMX_AX = {
126
126
  </script>`;
127
127
  }
128
128
 
129
+ /** Escape a string for safe interpolation into element text (e.g. `<title>`). */
130
+ function escapeSurfaceHtml(value: string): string {
131
+ return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
132
+ }
133
+
129
134
  export interface HtmlSurfaceOptions {
130
135
  theme: SurfaceTheme;
136
+ /**
137
+ * Tab/document title. Injected as `<title>` only when the author HTML does not
138
+ * already declare one, so a standalone "Open as site" tab shows the node title
139
+ * instead of falling back to the raw URL.
140
+ */
141
+ title?: string;
131
142
  /** Client nonce that authorizes parent → iframe theme-update messages. */
132
143
  themeToken?: string;
133
144
  presentation?: boolean;
@@ -158,13 +169,22 @@ export function buildHtmlSurfaceDocument(userHtml: string, options: HtmlSurfaceO
158
169
  const presentationAttr = options.presentation ? ' data-pmx-presentation-mode="present"' : '';
159
170
  const trimmed = userHtml.trim();
160
171
  const isFullDoc = /<html[\s>]/i.test(trimmed);
172
+ // Only supply a fallback <title> when the author HTML does not already set a
173
+ // DOCUMENT title. Strip inline <svg>/<math> first so a nested accessibility
174
+ // <title> (e.g. <svg><title>…</title></svg>) doesn't suppress the fallback.
175
+ const withoutNestedTitles = trimmed
176
+ .replace(/<svg[\s\S]*?<\/svg>/gi, '')
177
+ .replace(/<math[\s\S]*?<\/math>/gi, '');
178
+ const titleTag = options.title && !/<title[\s>]/i.test(withoutNestedTitles)
179
+ ? `<title>${escapeSurfaceHtml(options.title)}</title>`
180
+ : '';
161
181
  if (isFullDoc) {
162
182
  const withTheme = trimmed.replace(
163
183
  /<html([^>]*)>/i,
164
184
  `<html$1 data-pmx-canvas-theme="${options.theme}" data-theme="${options.theme}"${presentationAttr}>`,
165
185
  );
166
- return injectIntoHead(withTheme, injectedHeadContent);
186
+ return injectIntoHead(withTheme, `${titleTag}${injectedHeadContent}`);
167
187
  }
168
188
  // Fragment — wrap in a full document.
169
- return `<!doctype html><html data-pmx-canvas-theme="${options.theme}" data-theme="${options.theme}"${presentationAttr}><head><meta charset="utf-8">${injectedHeadContent}</head><body>${userHtml}</body></html>`;
189
+ return `<!doctype html><html data-pmx-canvas-theme="${options.theme}" data-theme="${options.theme}"${presentationAttr}><head><meta charset="utf-8">${titleTag}${injectedHeadContent}</head><body>${userHtml}</body></html>`;
170
190
  }
@@ -74,6 +74,7 @@ import {
74
74
  } from './canvas-operations.js';
75
75
  import { validateCanvasLayout } from './canvas-validation.js';
76
76
  import { describeCanvasSchema, validateStructuredCanvasPayload } from './canvas-schema.js';
77
+ import { serializeCanvasNode, type SerializedCanvasNode } from './canvas-serialization.js';
77
78
  import { buildHtmlPrimitive, getHtmlPrimitiveSemanticMetadata, isHtmlPrimitiveKind, listHtmlPrimitiveDescriptors } from './html-primitives.js';
78
79
  import type { HtmlPrimitiveKind } from './html-primitives.js';
79
80
  import {
@@ -124,6 +125,19 @@ import type {
124
125
  PrimaryWorkbenchIntent,
125
126
  } from './server.js';
126
127
 
128
+ /**
129
+ * Node object returned by the SDK's create/get methods. It is the fully
130
+ * serialized node (adds `surfaceUrl`, `kind`, `title`, `content`, …) plus a
131
+ * `nodeId` alias for `id`, so the SDK return shape matches the HTTP/CLI
132
+ * `node`-create responses field-for-field.
133
+ */
134
+ export type SdkCanvasNode = SerializedCanvasNode & { nodeId: string };
135
+
136
+ /** Enrich a raw canvas node into the SDK return shape (surfaceUrl + nodeId). */
137
+ function toSdkNode(node: CanvasNodeState): SdkCanvasNode {
138
+ return { ...serializeCanvasNode(node), nodeId: node.id };
139
+ }
140
+
127
141
  export class PmxCanvas extends EventEmitter {
128
142
  private _port: number;
129
143
  private _server: string | null = null;
@@ -224,7 +238,7 @@ export class PmxCanvas extends EventEmitter {
224
238
  width?: number;
225
239
  height?: number;
226
240
  strictSize?: boolean;
227
- }): CanvasNodeState {
241
+ }): SdkCanvasNode {
228
242
  if (input.type === 'webpage') {
229
243
  throw new Error('Use addWebpageNode for webpage nodes so page content is fetched and cached on the server.');
230
244
  }
@@ -241,7 +255,7 @@ export class PmxCanvas extends EventEmitter {
241
255
  });
242
256
  const groupNode = canvasState.getNode(groupId);
243
257
  if (!groupNode) throw new Error(`Group node "${groupId}" was not created.`);
244
- return groupNode;
258
+ return toSdkNode(groupNode);
245
259
  }
246
260
  const { id, needsCodeGraphRecompute } = addCanvasNode({
247
261
  ...input,
@@ -277,7 +291,7 @@ export class PmxCanvas extends EventEmitter {
277
291
 
278
292
  const node = canvasState.getNode(id);
279
293
  if (!node) throw new Error(`Node "${id}" was not created.`);
280
- return node;
294
+ return toSdkNode(node);
281
295
  }
282
296
 
283
297
  async addWebpageNode(input: {
@@ -735,8 +749,9 @@ export class PmxCanvas extends EventEmitter {
735
749
  return canvasState.getLayout();
736
750
  }
737
751
 
738
- getNode(id: string): CanvasNodeState | undefined {
739
- return canvasState.getNode(id);
752
+ getNode(id: string): SdkCanvasNode | undefined {
753
+ const node = canvasState.getNode(id);
754
+ return node ? toSdkNode(node) : undefined;
740
755
  }
741
756
 
742
757
  search(query: string): ReturnType<typeof searchNodes> {
@@ -1027,7 +1042,7 @@ export class PmxCanvas extends EventEmitter {
1027
1042
  width?: number;
1028
1043
  height?: number;
1029
1044
  strictSize?: boolean;
1030
- }): string {
1045
+ }): SdkCanvasNode {
1031
1046
  const { id } = addCanvasNode({
1032
1047
  type: 'html',
1033
1048
  ...(typeof input.title === 'string' ? { title: input.title } : {}),
@@ -1050,7 +1065,9 @@ export class PmxCanvas extends EventEmitter {
1050
1065
  defaultHeight: 640,
1051
1066
  });
1052
1067
  emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
1053
- return id;
1068
+ const node = canvasState.getNode(id);
1069
+ if (!node) throw new Error(`HTML node "${id}" was not created.`);
1070
+ return toSdkNode(node);
1054
1071
  }
1055
1072
 
1056
1073
  addHtmlPrimitive(input: {
@@ -1432,8 +1432,12 @@ function handleNodeSurface(pathname: string, url: URL): Response {
1432
1432
  if (!html) return responseText('HTML node has no content', 404);
1433
1433
  const present = url.searchParams.get('present') === '1';
1434
1434
  const axCaps = resolveNodeAxCapabilities(node);
1435
+ const surfaceTitle = typeof node.data.title === 'string' && node.data.title.trim()
1436
+ ? node.data.title
1437
+ : node.id;
1435
1438
  const doc = buildHtmlSurfaceDocument(html, {
1436
1439
  theme,
1440
+ title: surfaceTitle,
1437
1441
  themeToken: url.searchParams.get('themeToken') ?? undefined,
1438
1442
  presentation: present,
1439
1443
  presentationExitToken: url.searchParams.get('presentToken') ?? undefined,
@@ -4482,6 +4486,57 @@ function syncContextNodeToCanvasState(
4482
4486
  canvasState.updateNode(id, { data: mergedData });
4483
4487
  }
4484
4488
 
4489
+ /**
4490
+ * Seed the docked status (left) + context (right) widgets so a freshly opened
4491
+ * canvas shows them by default — the same nodes the agent-event path creates on
4492
+ * demand (`status-main`, `context-main`), just present from the start.
4493
+ *
4494
+ * First-run only: we bail if the workspace canvas already has persisted state,
4495
+ * so we never add them to a board with content, and — because first-run state is
4496
+ * persisted on save — deleting or undocking them later is respected (they are
4497
+ * not re-seeded). Create-if-missing keeps it idempotent if the agent path
4498
+ * already made one. Returns true if anything was seeded.
4499
+ */
4500
+ export function ensureDefaultDockedNodes(): boolean {
4501
+ if (canvasState.hasPersistedState()) return false;
4502
+ let seeded = false;
4503
+ // NOTE: these node specs mirror the agent-event create paths below
4504
+ // (`canvas-status` for status-main, `syncContextNodeToCanvasState` for
4505
+ // context-main) — keep geometry/dock defaults in sync if you change them.
4506
+ if (!canvasState.getNode('status-main')) {
4507
+ canvasState.addNode({
4508
+ id: 'status-main',
4509
+ type: 'status',
4510
+ position: { x: 40, y: 80 },
4511
+ size: { width: 300, height: 120 },
4512
+ zIndex: 0,
4513
+ collapsed: true,
4514
+ pinned: false,
4515
+ dockPosition: 'left',
4516
+ data: { phase: 'idle', message: '', elapsed: 0 },
4517
+ });
4518
+ seeded = true;
4519
+ }
4520
+ if (!canvasState.getNode('context-main')) {
4521
+ canvasState.addNode({
4522
+ id: 'context-main',
4523
+ type: 'context',
4524
+ position: { x: 1130, y: 80 },
4525
+ size: { width: 320, height: 400 },
4526
+ zIndex: 1,
4527
+ collapsed: true,
4528
+ pinned: false,
4529
+ dockPosition: 'right',
4530
+ data: { cards: [], auxTabs: [] },
4531
+ });
4532
+ seeded = true;
4533
+ }
4534
+ if (seeded) {
4535
+ emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
4536
+ }
4537
+ return seeded;
4538
+ }
4539
+
4485
4540
  // Maps responseNodeId -> thread prompt node ID for O(1) routing of response events
4486
4541
  const serverResponseToThreadMap = new Map<string, string>();
4487
4542