pmx-canvas 0.6.4 → 0.7.0
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/.github/extensions/pmx-canvas/extension.mjs +67 -3
- package/CHANGELOG.md +73 -0
- package/Readme.md +35 -5
- package/dist/canvas/global.css +498 -28
- package/dist/canvas/index.js +110 -95
- package/dist/canvas/mermaid-entry.js +258 -258
- package/dist/types/cli/agent.d.ts +4 -0
- package/dist/types/cli/commands/backup.d.ts +1 -0
- package/dist/types/cli/commands/boards.d.ts +1 -0
- package/dist/types/cli/commands/export.d.ts +1 -0
- package/dist/types/cli/commands/record.d.ts +12 -0
- package/dist/types/client/canvas/BoardCopyDialog.d.ts +5 -0
- package/dist/types/client/canvas/BoardFolderDialog.d.ts +7 -0
- package/dist/types/client/canvas/BoardLinkDialog.d.ts +3 -0
- package/dist/types/client/canvas/BoardSwitcher.d.ts +8 -0
- package/dist/types/client/canvas/ExportDialog.d.ts +7 -0
- package/dist/types/client/canvas/HomeView.d.ts +9 -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/AttachmentNode.d.ts +6 -0
- package/dist/types/client/nodes/BoardNode.d.ts +5 -0
- package/dist/types/client/nodes/McpAppNode.d.ts +24 -1
- package/dist/types/client/state/boards-store.d.ts +79 -0
- package/dist/types/client/state/canvas-store.d.ts +2 -0
- package/dist/types/client/state/intent-bridge.d.ts +1 -0
- package/dist/types/client/state/presentation.d.ts +3 -0
- package/dist/types/client/state/session-store.d.ts +7 -0
- package/dist/types/client/types.d.ts +2 -2
- package/dist/types/json-render/catalog.d.ts +3 -3
- package/dist/types/json-render/charts/definitions.d.ts +1 -1
- package/dist/types/json-render/server.d.ts +8 -0
- package/dist/types/mcp/canvas-access.d.ts +10 -3
- package/dist/types/mcp/context-uri-template.d.ts +6 -0
- package/dist/types/server/agent-presence.d.ts +1 -1
- package/dist/types/server/attribution.d.ts +11 -0
- package/dist/types/server/ax-context.d.ts +3 -1
- package/dist/types/server/ax-interaction.d.ts +2 -2
- package/dist/types/server/ax-state.d.ts +13 -3
- package/dist/types/server/backup.d.ts +36 -0
- package/dist/types/server/board-export.d.ts +45 -0
- package/dist/types/server/canvas-db.d.ts +65 -9
- package/dist/types/server/canvas-operations.d.ts +45 -0
- package/dist/types/server/canvas-provenance.d.ts +1 -1
- package/dist/types/server/canvas-schema.d.ts +1 -0
- package/dist/types/server/canvas-state.d.ts +146 -3
- package/dist/types/server/canvas-validation.d.ts +3 -0
- package/dist/types/server/context-brief.d.ts +111 -0
- package/dist/types/server/context-reads.d.ts +55 -0
- package/dist/types/server/document-import.d.ts +66 -0
- package/dist/types/server/html-surface.d.ts +1 -1
- package/dist/types/server/index.d.ts +107 -4
- package/dist/types/server/mutation-history.d.ts +1 -0
- package/dist/types/server/operations/invoker.d.ts +2 -0
- package/dist/types/server/operations/mcp.d.ts +7 -1
- package/dist/types/server/operations/ops/app.d.ts +2 -0
- package/dist/types/server/operations/ops/ax-shared.d.ts +2 -2
- package/dist/types/server/operations/ops/backup.d.ts +2 -0
- package/dist/types/server/operations/ops/boards.d.ts +10 -0
- package/dist/types/server/operations/ops/export.d.ts +2 -0
- package/dist/types/server/operations/ops/imports.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 +4 -2
- package/dist/types/server/operations/ops/query.d.ts +2 -0
- package/dist/types/server/operations/registry.d.ts +9 -1
- package/dist/types/server/operations/types.d.ts +1 -1
- package/dist/types/server/server.d.ts +1 -0
- package/dist/types/server/trace-manager.d.ts +7 -0
- package/dist/types/server/web-artifacts.d.ts +3 -0
- package/dist/types/server/webpage-node.d.ts +22 -1
- package/dist/types/server/workbench-auth.d.ts +3 -0
- package/dist/types/shared/boards.d.ts +6 -0
- package/dist/types/shared/tour.d.ts +72 -0
- package/docs/RELEASE.md +5 -0
- package/docs/cli.md +157 -0
- package/docs/http-api.md +277 -6
- package/docs/mcp.md +96 -7
- package/docs/node-types.md +106 -0
- package/docs/plans/plan-010-attributed-ask-answers.md +39 -0
- package/docs/plans/plan-011-context-read-instrumentation.md +54 -0
- package/docs/plans/plan-012-many-boards-stage-1.md +98 -0
- package/docs/plans/plan-013-static-board-export.md +47 -0
- package/docs/plans/plan-014-connected-board-memory.md +140 -0
- package/docs/product-vision-2026-09.md +118 -18
- package/docs/sdk.md +50 -0
- package/package.json +1 -1
- package/skills/pmx-canvas/SKILL.md +62 -5
- package/skills/pmx-canvas/references/full-reference.md +44 -8
- package/skills/pmx-canvas/references/github-copilot-app-adapter.md +3 -0
- package/skills/pmx-canvas/references/installing-pmx-canvas.md +4 -4
- package/skills/pmx-canvas-orchestration/SKILL.md +5 -0
- package/skills/pmx-canvas-testing/SKILL.md +4 -0
- package/src/cli/agent.ts +9 -0
- package/src/cli/commands/ax.ts +13 -0
- package/src/cli/commands/backup.ts +66 -0
- package/src/cli/commands/boards.ts +71 -0
- package/src/cli/commands/export.ts +30 -0
- package/src/cli/commands/nodes.ts +14 -1
- package/src/cli/commands/pins.ts +6 -2
- package/src/cli/commands/pump.ts +3 -0
- package/src/cli/commands/query.ts +33 -7
- package/src/cli/commands/record.ts +218 -0
- package/src/cli/commands/view.ts +7 -5
- package/src/cli/index.ts +21 -9
- package/src/client/App.tsx +28 -4
- package/src/client/canvas/AgentPresenceLayer.tsx +1 -1
- package/src/client/canvas/BoardCopyDialog.tsx +153 -0
- package/src/client/canvas/BoardFolderDialog.tsx +120 -0
- package/src/client/canvas/BoardLinkDialog.tsx +93 -0
- package/src/client/canvas/BoardSwitcher.tsx +149 -0
- package/src/client/canvas/CanvasNode.tsx +8 -0
- package/src/client/canvas/CanvasViewport.tsx +3 -0
- package/src/client/canvas/CommandPalette.tsx +107 -4
- package/src/client/canvas/ContextMenu.tsx +4 -0
- package/src/client/canvas/ExpandedNodeOverlay.tsx +3 -0
- package/src/client/canvas/ExportDialog.tsx +197 -0
- package/src/client/canvas/HomeView.tsx +277 -0
- package/src/client/canvas/IntentLayer.tsx +9 -2
- package/src/client/canvas/Presentation.tsx +108 -0
- package/src/client/canvas/SessionPanel.tsx +28 -1
- package/src/client/canvas/ToolRail.tsx +38 -3
- package/src/client/canvas/TopBar.tsx +49 -7
- package/src/client/canvas/import-files.ts +19 -1
- package/src/client/canvas/kind-colors.ts +1 -0
- package/src/client/canvas/use-node-drag.ts +8 -3
- package/src/client/icons.tsx +2 -0
- package/src/client/mermaid-entry.ts +39 -0
- package/src/client/nodes/AttachmentNode.tsx +148 -0
- package/src/client/nodes/BoardNode.tsx +35 -0
- package/src/client/nodes/FileNode.tsx +6 -0
- package/src/client/nodes/HtmlNode.tsx +12 -13
- package/src/client/nodes/MarkdownNode.tsx +47 -19
- package/src/client/nodes/McpAppNode.tsx +105 -13
- package/src/client/nodes/MermaidNode.tsx +3 -2
- package/src/client/nodes/ax-node-actions.ts +2 -5
- package/src/client/nodes/use-ax-surface-bridge.ts +1 -1
- package/src/client/state/boards-store.ts +167 -0
- package/src/client/state/canvas-store.ts +22 -0
- package/src/client/state/intent-bridge.ts +30 -2
- package/src/client/state/intent-store.ts +5 -1
- package/src/client/state/node-factory.ts +1 -0
- package/src/client/state/presentation.ts +7 -0
- package/src/client/state/session-store.ts +8 -0
- package/src/client/state/sse-bridge.ts +52 -5
- package/src/client/theme/global.css +498 -28
- package/src/client/types.ts +2 -0
- package/src/json-render/server.ts +45 -9
- package/src/mcp/canvas-access.ts +59 -22
- package/src/mcp/context-uri-template.ts +28 -0
- package/src/mcp/server.ts +122 -15
- package/src/server/agent-context.ts +116 -29
- package/src/server/agent-presence.ts +15 -9
- package/src/server/attribution.ts +29 -0
- package/src/server/ax-context.ts +4 -2
- package/src/server/ax-interaction.ts +1 -0
- package/src/server/ax-state-manager.ts +72 -3
- package/src/server/ax-state.ts +41 -0
- package/src/server/backup.ts +178 -0
- package/src/server/board-export.ts +686 -0
- package/src/server/canvas-db.ts +612 -105
- package/src/server/canvas-operations.ts +183 -31
- package/src/server/canvas-provenance.ts +1 -0
- package/src/server/canvas-schema.ts +22 -0
- package/src/server/canvas-state.ts +812 -48
- package/src/server/canvas-validation.ts +9 -1
- package/src/server/context-brief.ts +372 -0
- package/src/server/context-reads.ts +238 -0
- package/src/server/demo-state.json +670 -9
- package/src/server/document-import.ts +223 -0
- package/src/server/html-primitives.ts +199 -91
- package/src/server/html-surface.ts +8 -3
- package/src/server/index.ts +243 -5
- package/src/server/mutation-history.ts +10 -13
- package/src/server/operations/composites.ts +29 -5
- package/src/server/operations/http.ts +9 -3
- package/src/server/operations/index.ts +8 -0
- package/src/server/operations/invoker.ts +14 -3
- package/src/server/operations/mcp.ts +50 -11
- package/src/server/operations/ops/app.ts +15 -0
- package/src/server/operations/ops/ax-read.ts +10 -6
- package/src/server/operations/ops/ax-timeline.ts +75 -0
- package/src/server/operations/ops/backup.ts +134 -0
- package/src/server/operations/ops/batch.ts +20 -4
- package/src/server/operations/ops/boards.ts +351 -0
- package/src/server/operations/ops/export.ts +74 -0
- package/src/server/operations/ops/ext-app.ts +4 -0
- package/src/server/operations/ops/groups.ts +14 -7
- package/src/server/operations/ops/imports.ts +220 -0
- package/src/server/operations/ops/json-render.ts +37 -5
- package/src/server/operations/ops/nodes.ts +99 -21
- package/src/server/operations/ops/query.ts +206 -9
- package/src/server/operations/ops/viewport.ts +32 -0
- package/src/server/operations/registry.ts +129 -31
- package/src/server/operations/types.ts +1 -1
- package/src/server/scope-fence.ts +1 -1
- package/src/server/server.ts +155 -8
- package/src/server/spatial-analysis.ts +9 -17
- package/src/server/trace-manager.ts +19 -0
- package/src/server/web-artifacts.ts +11 -3
- package/src/server/webpage-node.ts +173 -13
- package/src/server/workbench-auth.ts +11 -0
- package/src/server/workboard.ts +3 -3
- package/src/shared/boards.ts +18 -0
- package/src/shared/node-sizes.ts +1 -0
- package/src/shared/tour.ts +87 -0
|
@@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
|
|
|
3
3
|
import { expandNode, updateNodeData } from '../state/canvas-store';
|
|
4
4
|
import { fetchFile, renderMarkdown, saveFile, updateNodeFromClient } from '../state/intent-bridge';
|
|
5
5
|
import type { CanvasNodeState } from '../types';
|
|
6
|
+
import { activeBoard, setBoardReadme } from '../state/boards-store';
|
|
6
7
|
import { MdFormatBar } from './MdFormatBar';
|
|
7
8
|
import { handleFormatShortcut, handleTab } from './md-format';
|
|
8
9
|
import { InlineMarkdownEditor } from './InlineMarkdownEditor';
|
|
@@ -82,6 +83,7 @@ export function toggleTaskMarker(markdown: string, index: number): string {
|
|
|
82
83
|
}
|
|
83
84
|
|
|
84
85
|
export function MarkdownNode({ node, expanded = false }: { node: CanvasNodeState; expanded?: boolean }) {
|
|
86
|
+
const board = activeBoard();
|
|
85
87
|
const path = node.data.path as string;
|
|
86
88
|
const [content, setContent] = useState('');
|
|
87
89
|
const [rendered, setRendered] = useState('');
|
|
@@ -362,26 +364,52 @@ export function MarkdownNode({ node, expanded = false }: { node: CanvasNodeState
|
|
|
362
364
|
// ── Card preview ──────────────────────────────────────────────
|
|
363
365
|
|
|
364
366
|
return (
|
|
365
|
-
<div
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
367
|
+
<div class="md-card-preview">
|
|
368
|
+
<div class="md-card-content">
|
|
369
|
+
{reviewBanner}
|
|
370
|
+
<RenderedMarkdown
|
|
371
|
+
html={rendered}
|
|
372
|
+
style={{ padding: rendered ? '0' : '12px', color: rendered ? undefined : 'var(--c-dim)' }}
|
|
373
|
+
// Task checkboxes are live on the card itself — no need to expand to tick one.
|
|
374
|
+
onTaskToggle={(index) => {
|
|
375
|
+
const next = toggleTaskMarker(content, index);
|
|
376
|
+
if (next !== content) handleInlineSave(next);
|
|
377
|
+
}}
|
|
378
|
+
/>
|
|
379
|
+
{!loaded && <div style={{ color: 'var(--c-dim)', fontStyle: 'italic', padding: '12px' }}>Loading…</div>}
|
|
380
|
+
{loaded && !rendered && (
|
|
381
|
+
<div style={{ color: 'var(--c-dim)', fontStyle: 'italic', padding: '12px' }}>Empty node</div>
|
|
382
|
+
)}
|
|
383
|
+
</div>
|
|
384
|
+
{node.data.source && typeof node.data.source === 'object' && 'attachmentId' in node.data.source && (
|
|
385
|
+
<div class="md-import-source">
|
|
386
|
+
Source material ·{' '}
|
|
387
|
+
<a
|
|
388
|
+
href={`/api/canvas/attachments/${encodeURIComponent(String(node.data.source.attachmentId))}/bytes`}
|
|
389
|
+
target="_blank"
|
|
390
|
+
rel="noopener noreferrer"
|
|
391
|
+
>
|
|
392
|
+
{String((node.data.source as Record<string, unknown>).filename ?? 'Original')}
|
|
393
|
+
</a>
|
|
394
|
+
{' · '}
|
|
395
|
+
{String((node.data.source as Record<string, unknown>).reference ?? 'Location not supplied')}
|
|
396
|
+
</div>
|
|
379
397
|
)}
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
398
|
+
<div class="md-card-actions">
|
|
399
|
+
{board && (
|
|
400
|
+
<button
|
|
401
|
+
type="button"
|
|
402
|
+
class="md-readme-btn"
|
|
403
|
+
aria-pressed={board.readmeNodeId === node.id}
|
|
404
|
+
onClick={() => void setBoardReadme(board.id, board.readmeNodeId === node.id ? null : node.id)}
|
|
405
|
+
>
|
|
406
|
+
{board.readmeNodeId === node.id ? 'README' : 'Set as README'}
|
|
407
|
+
</button>
|
|
408
|
+
)}
|
|
409
|
+
<button type="button" class="md-card-edit" onClick={() => expandNode(node.id)}>
|
|
410
|
+
Edit
|
|
411
|
+
</button>
|
|
412
|
+
</div>
|
|
385
413
|
</div>
|
|
386
414
|
);
|
|
387
415
|
}
|
|
@@ -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',
|
|
@@ -19,11 +19,8 @@ export async function runNodeAxInteraction(
|
|
|
19
19
|
sourceSurface: 'native-node',
|
|
20
20
|
...(payload ? { payload } : {}),
|
|
21
21
|
});
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
} else {
|
|
25
|
-
showToast('remove', 'AX action failed', res.error ?? res.code ?? 'Unknown error', [node.id]);
|
|
26
|
-
}
|
|
22
|
+
// A refusal is toasted by the bridge with the server's reason.
|
|
23
|
+
if (res.ok) showToast('context', successTitle, '', [node.id]);
|
|
27
24
|
}
|
|
28
25
|
|
|
29
26
|
/** Shared style for the small inline AX action button on native nodes. */
|
|
@@ -50,8 +50,8 @@ export function useAxSurfaceBridge(options: {
|
|
|
50
50
|
? { payload: interaction.payload as Record<string, unknown> }
|
|
51
51
|
: {}),
|
|
52
52
|
}).then((res) => {
|
|
53
|
+
// A refusal is toasted by the bridge with the server's reason.
|
|
53
54
|
if (res.ok) showToast('context', 'AX interaction', interactionType, [nodeId]);
|
|
54
|
-
else showToast('remove', 'AX interaction rejected', res.error ?? res.code ?? '', [nodeId]);
|
|
55
55
|
iframeRef.current?.contentWindow?.postMessage(
|
|
56
56
|
{
|
|
57
57
|
source: AX_SURFACE_ACK_SOURCE,
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { signal } from '@preact/signals';
|
|
2
|
+
import { requestJson } from './intent-bridge';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Many boards (plan 012): the workspace's boards and which one is open. The
|
|
6
|
+
* server is the authority; this mirrors GET /api/canvas/boards and the
|
|
7
|
+
* `boards-changed` SSE frame. `activeBoardId === null` is Home.
|
|
8
|
+
*/
|
|
9
|
+
export interface BoardSummary {
|
|
10
|
+
id: string;
|
|
11
|
+
name: string;
|
|
12
|
+
category: string | null;
|
|
13
|
+
createdAt: string;
|
|
14
|
+
lastOpenedAt: string | null;
|
|
15
|
+
nodeCount: number;
|
|
16
|
+
readmeNodeId?: string | null;
|
|
17
|
+
summary?: string | null;
|
|
18
|
+
pinnedTitles?: Array<{ nodeId: string; title: string }>;
|
|
19
|
+
links?: Array<{ nodeId: string; boardId: string; title: string | null; missing: boolean }>;
|
|
20
|
+
backlinks?: Array<{ boardId: string; title: string; nodeId: string }>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface BoardsPayload {
|
|
24
|
+
activeBoardId: string | null;
|
|
25
|
+
boards: BoardSummary[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const boardList = signal<BoardSummary[]>([]);
|
|
29
|
+
export const activeBoardId = signal<string | null>(null);
|
|
30
|
+
/** False until the first list arrives — Home must not flash before the server answers. */
|
|
31
|
+
export const boardsLoaded = signal(false);
|
|
32
|
+
|
|
33
|
+
let onBoardSwitched: (() => void) | null = null;
|
|
34
|
+
let boardsRevision = 0;
|
|
35
|
+
let openRequestSequence = 0;
|
|
36
|
+
let openRequestQueue: Promise<void> = Promise.resolve();
|
|
37
|
+
|
|
38
|
+
/** The SSE bridge resyncs the canvas when the open board changes under it. */
|
|
39
|
+
export function setBoardSwitchHandler(handler: (() => void) | null): void {
|
|
40
|
+
onBoardSwitched = handler;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function applyBoards(data: { activeBoardId?: unknown; boards?: unknown; reloaded?: unknown } | null): void {
|
|
44
|
+
if (!data || !Array.isArray(data.boards)) return;
|
|
45
|
+
boardsRevision += 1;
|
|
46
|
+
const nextActive = typeof data.activeBoardId === 'string' ? data.activeBoardId : null;
|
|
47
|
+
// `reloaded`: the library was restored, so even the same board id holds new content.
|
|
48
|
+
const switched = boardsLoaded.value && (nextActive !== activeBoardId.value || data.reloaded === true);
|
|
49
|
+
boardList.value = data.boards as BoardSummary[];
|
|
50
|
+
activeBoardId.value = nextActive;
|
|
51
|
+
boardsLoaded.value = true;
|
|
52
|
+
if (switched) onBoardSwitched?.();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function loadBoards(): Promise<void> {
|
|
56
|
+
const revision = boardsRevision;
|
|
57
|
+
const payload = await requestJson<BoardsPayload | null>('loadBoards', '/api/canvas/boards', null);
|
|
58
|
+
if (revision === boardsRevision) applyBoards(payload);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function post(
|
|
62
|
+
action: string,
|
|
63
|
+
url: string,
|
|
64
|
+
method: string,
|
|
65
|
+
body?: unknown,
|
|
66
|
+
revision = boardsRevision,
|
|
67
|
+
): Promise<BoardsPayload | null> {
|
|
68
|
+
const payload = await requestJson<BoardsPayload | null>(action, url, null, {
|
|
69
|
+
method,
|
|
70
|
+
headers: { 'Content-Type': 'application/json' },
|
|
71
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
72
|
+
});
|
|
73
|
+
return revision === boardsRevision ? payload : null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Open a board, or Home with null. */
|
|
77
|
+
export async function openBoard(id: string | null): Promise<void> {
|
|
78
|
+
const sequence = ++openRequestSequence;
|
|
79
|
+
const revision = boardsRevision;
|
|
80
|
+
let payload: BoardsPayload | null = null;
|
|
81
|
+
const request = openRequestQueue.then(async () => {
|
|
82
|
+
payload = await post('openBoard', '/api/canvas/boards/open', 'POST', { id }, revision);
|
|
83
|
+
});
|
|
84
|
+
openRequestQueue = request.catch(() => undefined);
|
|
85
|
+
await request;
|
|
86
|
+
// post drops replies superseded by SSE; also ignore an older open request.
|
|
87
|
+
if (sequence === openRequestSequence) applyBoards(payload);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function createAndOpenBoard(name: string): Promise<void> {
|
|
91
|
+
const sequence = ++openRequestSequence;
|
|
92
|
+
const created = await requestJson<{ board?: BoardSummary } | null>('createBoard', '/api/canvas/boards', null, {
|
|
93
|
+
method: 'POST',
|
|
94
|
+
headers: { 'Content-Type': 'application/json' },
|
|
95
|
+
body: JSON.stringify({ name }),
|
|
96
|
+
});
|
|
97
|
+
if (created?.board && sequence === openRequestSequence) await openBoard(created.board.id);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface BoardCopyCard {
|
|
101
|
+
id: string;
|
|
102
|
+
type: string;
|
|
103
|
+
title: string | null;
|
|
104
|
+
reusable: boolean;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface BoardCopyPreview {
|
|
108
|
+
sourceBoardId: string;
|
|
109
|
+
readmeNodeId: string | null;
|
|
110
|
+
cards: BoardCopyCard[];
|
|
111
|
+
edges: Array<{ id: string; from: string; to: string; type: string }>;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function previewBoardCopy(sourceBoardId: string): Promise<BoardCopyPreview | null> {
|
|
115
|
+
return requestJson<BoardCopyPreview | null>('previewBoardCopy', '/api/canvas/boards/from', null, {
|
|
116
|
+
method: 'POST',
|
|
117
|
+
headers: { 'Content-Type': 'application/json' },
|
|
118
|
+
body: JSON.stringify({ sourceBoardId, name: 'Preview', preview: true }),
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function createBoardFrom(opts: {
|
|
123
|
+
sourceBoardId: string;
|
|
124
|
+
name: string;
|
|
125
|
+
category?: string;
|
|
126
|
+
nodeIds: string[];
|
|
127
|
+
includeReadme: boolean;
|
|
128
|
+
includeStructure: boolean;
|
|
129
|
+
}): Promise<BoardSummary | null> {
|
|
130
|
+
const result = await requestJson<{ board?: BoardSummary } | null>(
|
|
131
|
+
'createBoardFrom',
|
|
132
|
+
'/api/canvas/boards/from',
|
|
133
|
+
null,
|
|
134
|
+
{
|
|
135
|
+
method: 'POST',
|
|
136
|
+
headers: { 'Content-Type': 'application/json' },
|
|
137
|
+
body: JSON.stringify(opts),
|
|
138
|
+
},
|
|
139
|
+
);
|
|
140
|
+
await loadBoards();
|
|
141
|
+
return result?.board ?? null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Rename and/or re-file a board; `category: null` removes it from its category. */
|
|
145
|
+
export async function updateBoard(id: string, patch: { name?: string; category?: string | null }): Promise<void> {
|
|
146
|
+
const payload = await post('updateBoard', `/api/canvas/boards/${encodeURIComponent(id)}`, 'PATCH', {
|
|
147
|
+
...patch,
|
|
148
|
+
...(patch.category === null ? { category: '' } : {}),
|
|
149
|
+
});
|
|
150
|
+
applyBoards(payload);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export async function setBoardReadme(id: string, readmeNodeId: string | null): Promise<void> {
|
|
154
|
+
const payload = await post('setBoardReadme', `/api/canvas/boards/${encodeURIComponent(id)}`, 'PATCH', {
|
|
155
|
+
readmeNodeId,
|
|
156
|
+
});
|
|
157
|
+
applyBoards(payload);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export async function deleteBoard(id: string): Promise<void> {
|
|
161
|
+
const payload = await post('deleteBoard', `/api/canvas/boards/${encodeURIComponent(id)}`, 'DELETE');
|
|
162
|
+
applyBoards(payload);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function activeBoard(): BoardSummary | null {
|
|
166
|
+
return boardList.value.find((board) => board.id === activeBoardId.value) ?? null;
|
|
167
|
+
}
|
|
@@ -144,6 +144,28 @@ export function clearSelection(): void {
|
|
|
144
144
|
selectedNodeIds.value = new Set();
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
+
/** Clear transient UI state that must never follow a node id onto another board. */
|
|
148
|
+
export function resetCanvasInteractionState(): void {
|
|
149
|
+
if (expandedCloseTimer !== null) {
|
|
150
|
+
clearTimeout(expandedCloseTimer);
|
|
151
|
+
expandedCloseTimer = null;
|
|
152
|
+
}
|
|
153
|
+
batch(() => {
|
|
154
|
+
activeNodeId.value = null;
|
|
155
|
+
expandedNodeId.value = null;
|
|
156
|
+
pendingExpandedNodeCloseId.value = null;
|
|
157
|
+
pendingCloseInitialCheckpointAt = undefined;
|
|
158
|
+
selectedNodeIds.value = new Set();
|
|
159
|
+
selectedEdgeId.value = null;
|
|
160
|
+
pendingConnection.value = null;
|
|
161
|
+
draggingEdge.value = null;
|
|
162
|
+
searchHighlightIds.value = null;
|
|
163
|
+
dragDropTarget.value = null;
|
|
164
|
+
contextPinnedNodeIds.value = new Set();
|
|
165
|
+
});
|
|
166
|
+
dropSuppressedForDrag = false;
|
|
167
|
+
}
|
|
168
|
+
|
|
147
169
|
// ── Groups v2 (rail-chrome-v2 phase 7, item 20) ──────────────────────
|
|
148
170
|
|
|
149
171
|
/**
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { canvasArea } from '../canvas/canvas-area';
|
|
2
|
+
import { showToast } from './attention-bridge';
|
|
2
3
|
import type { AgentPresenceSnapshot } from '../../shared/agent-presence.js';
|
|
3
4
|
function logRequestError(action: string, error: unknown): void {
|
|
4
5
|
console.error(`[intent-bridge] ${action} failed`, error);
|
|
@@ -31,14 +32,38 @@ function withWorkbenchMarker(init?: RequestInit): RequestInit {
|
|
|
31
32
|
headers['x-pmx-source'] = tabAgentKey;
|
|
32
33
|
} else {
|
|
33
34
|
headers['X-PMX-Workbench'] = '1';
|
|
35
|
+
const token = (window as Window & { __PMX_WORKBENCH_TOKEN?: unknown }).__PMX_WORKBENCH_TOKEN;
|
|
36
|
+
if (typeof token === 'string') headers['X-PMX-Workbench-Token'] = token;
|
|
34
37
|
}
|
|
35
38
|
return { ...init, headers };
|
|
36
39
|
}
|
|
37
40
|
|
|
41
|
+
/**
|
|
42
|
+
* A write the server refused (fence, edit lock, validation) must not fail
|
|
43
|
+
* silently: the human sees the server's reason. Reads stay quiet.
|
|
44
|
+
*/
|
|
45
|
+
function reportRefusedWrite(res: Response, init: RequestInit | undefined, body: unknown): void {
|
|
46
|
+
if (res.ok || (init?.method ?? 'GET').toUpperCase() === 'GET') return;
|
|
47
|
+
const error =
|
|
48
|
+
body !== null && typeof body === 'object' && 'error' in body && typeof body.error === 'string' ? body.error : '';
|
|
49
|
+
showToast('remove', 'Change refused', error || `The server answered ${res.status}.`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function readJsonBody(res: Response): Promise<unknown> {
|
|
53
|
+
try {
|
|
54
|
+
return await res.json();
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
38
60
|
export async function requestJson<T>(action: string, url: string, fallback: T, init?: RequestInit): Promise<T> {
|
|
39
61
|
try {
|
|
40
62
|
const res = await fetch(url, withWorkbenchMarker(init));
|
|
41
|
-
|
|
63
|
+
const body = await readJsonBody(res);
|
|
64
|
+
reportRefusedWrite(res, init, body);
|
|
65
|
+
if (body === null) throw new Error(`${res.status} response had no JSON body`);
|
|
66
|
+
return body as T;
|
|
42
67
|
} catch (error) {
|
|
43
68
|
logRequestError(action, error);
|
|
44
69
|
return fallback;
|
|
@@ -48,6 +73,7 @@ export async function requestJson<T>(action: string, url: string, fallback: T, i
|
|
|
48
73
|
export async function requestOk(action: string, url: string, init?: RequestInit): Promise<{ ok: boolean }> {
|
|
49
74
|
try {
|
|
50
75
|
const res = await fetch(url, withWorkbenchMarker(init));
|
|
76
|
+
if (!res.ok) reportRefusedWrite(res, init, await readJsonBody(res));
|
|
51
77
|
return { ok: res.ok };
|
|
52
78
|
} catch (error) {
|
|
53
79
|
logRequestError(action, error);
|
|
@@ -57,7 +83,8 @@ export async function requestOk(action: string, url: string, init?: RequestInit)
|
|
|
57
83
|
|
|
58
84
|
export async function requestBestEffort(action: string, url: string, init?: RequestInit): Promise<void> {
|
|
59
85
|
try {
|
|
60
|
-
await fetch(url, withWorkbenchMarker(init));
|
|
86
|
+
const res = await fetch(url, withWorkbenchMarker(init));
|
|
87
|
+
if (!res.ok) reportRefusedWrite(res, init, await readJsonBody(res));
|
|
61
88
|
} catch (error) {
|
|
62
89
|
logRequestError(action, error);
|
|
63
90
|
}
|
|
@@ -262,6 +289,7 @@ export async function createNodeFromClient(opts: {
|
|
|
262
289
|
y?: number;
|
|
263
290
|
width?: number;
|
|
264
291
|
height?: number;
|
|
292
|
+
boardId?: string;
|
|
265
293
|
}): Promise<{ ok: boolean; id?: string }> {
|
|
266
294
|
return requestJson(
|
|
267
295
|
'createNodeFromClient',
|
|
@@ -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
|
|
|
@@ -27,6 +27,7 @@ export const DEFAULT_POSITIONS: Record<CanvasNodeState['type'], { x: number; y:
|
|
|
27
27
|
webpage: { x: 380, y: 80, w: 520, h: 420 },
|
|
28
28
|
'json-render': { x: 380, y: 720, w: 840, h: 620 },
|
|
29
29
|
graph: { x: 380, y: 720, w: 760, h: 520 },
|
|
30
|
+
board: { x: 380, y: 80, w: 360, h: 160 },
|
|
30
31
|
ledger: { x: 1130, y: 520, w: 320, h: 280 },
|
|
31
32
|
trace: { x: 40, y: 900, w: 200, h: 56 },
|
|
32
33
|
file: { x: 380, y: 80, w: 720, h: 600 },
|
|
@@ -26,6 +26,8 @@ export interface ApprovalGateView {
|
|
|
26
26
|
createdAt: string;
|
|
27
27
|
/** Unattended-approval TTL: when a pending gate auto-holds. */
|
|
28
28
|
expiresAt: string | null;
|
|
29
|
+
resolvedBy?: { actor: 'human' | 'agent' | 'system' | 'unknown'; source: string; agentId?: string } | null;
|
|
30
|
+
selfAnswer?: boolean;
|
|
29
31
|
}
|
|
30
32
|
export interface AxEventView {
|
|
31
33
|
id: string;
|
|
@@ -106,6 +108,9 @@ export async function setScopeFence(nodeIds: string[] | null): Promise<boolean>
|
|
|
106
108
|
|
|
107
109
|
/** Auto-held gates: the policy said no on the human's behalf; they can be reopened. */
|
|
108
110
|
export const heldGates = computed(() => sessionGates.value.filter((gate) => gate.status === 'held'));
|
|
111
|
+
export const resolvedGates = computed(() =>
|
|
112
|
+
sessionGates.value.filter((gate) => gate.status === 'approved' || gate.status === 'rejected'),
|
|
113
|
+
);
|
|
109
114
|
|
|
110
115
|
export interface AxTimelineView {
|
|
111
116
|
events: AxEventView[];
|
|
@@ -386,6 +391,9 @@ export function applySessionReceipt(data: Record<string, unknown>): void {
|
|
|
386
391
|
const counts = data.counts as Partial<SessionReceipt['counts']> | undefined;
|
|
387
392
|
const snapshot = data.snapshot as SessionReceipt['snapshot'] | undefined;
|
|
388
393
|
if (typeof data.label !== 'string' || typeof data.endedAt !== 'string') return;
|
|
394
|
+
// Keep history/timeline intact, but only interrupt for a changed top-level
|
|
395
|
+
// session. Quiet endings must not replace an existing useful receipt.
|
|
396
|
+
if (data.unchanged === true || typeof data.parentAgentId === 'string') return;
|
|
389
397
|
sessionReceipt.value = {
|
|
390
398
|
label: data.label,
|
|
391
399
|
endedAt: data.endedAt,
|