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.
- package/CHANGELOG.md +58 -0
- package/dist/canvas/global.css +38 -56
- package/dist/canvas/index.js +30 -30
- package/dist/types/server/canvas-state.d.ts +7 -0
- package/dist/types/server/html-surface.d.ts +6 -0
- package/dist/types/server/index.d.ts +13 -3
- package/dist/types/server/server.d.ts +12 -0
- package/docs/sdk.md +3 -1
- package/package.json +1 -1
- package/src/cli/index.ts +8 -1
- package/src/client/canvas/DockedNode.tsx +38 -38
- package/src/client/theme/global.css +38 -56
- package/src/mcp/canvas-access.ts +3 -1
- package/src/server/canvas-state.ts +24 -10
- package/src/server/html-surface.ts +22 -2
- package/src/server/index.ts +24 -7
- package/src/server/server.ts +55 -0
|
@@ -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
|
-
}):
|
|
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):
|
|
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
|
-
}):
|
|
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
|
|
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.
|
|
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)
|
|
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 {
|
|
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
|
-
<
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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=
|
|
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
|
package/src/mcp/canvas-access.ts
CHANGED
|
@@ -271,7 +271,9 @@ class LocalCanvasAccess implements CanvasAccess {
|
|
|
271
271
|
}
|
|
272
272
|
|
|
273
273
|
async addHtmlNode(input: AddHtmlNodeInput): Promise<string> {
|
|
274
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
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
|
}
|
package/src/server/index.ts
CHANGED
|
@@ -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
|
-
}):
|
|
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):
|
|
739
|
-
|
|
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
|
-
}):
|
|
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
|
-
|
|
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: {
|
package/src/server/server.ts
CHANGED
|
@@ -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
|
|