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
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'preact/hooks';
|
|
2
|
+
import { normalizeBoardCategory } from '../../shared/boards.js';
|
|
3
|
+
import { boardList, updateBoard, type BoardSummary } from '../state/boards-store';
|
|
4
|
+
import { useFocusTrap } from './use-focus-trap';
|
|
5
|
+
|
|
6
|
+
/** A destination picker: select an existing folder, optionally create a child, then move. */
|
|
7
|
+
export function BoardFolderDialog({
|
|
8
|
+
board,
|
|
9
|
+
folders,
|
|
10
|
+
onClose,
|
|
11
|
+
}: {
|
|
12
|
+
board: BoardSummary;
|
|
13
|
+
folders: string[];
|
|
14
|
+
onClose: () => void;
|
|
15
|
+
}) {
|
|
16
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
17
|
+
const [parent, setParent] = useState(board.category ?? '');
|
|
18
|
+
const [name, setName] = useState('');
|
|
19
|
+
const [busy, setBusy] = useState(false);
|
|
20
|
+
const [error, setError] = useState('');
|
|
21
|
+
useFocusTrap(ref, true);
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
const onEscape = (event: KeyboardEvent) => {
|
|
24
|
+
if (event.key !== 'Escape') return;
|
|
25
|
+
event.preventDefault();
|
|
26
|
+
event.stopPropagation();
|
|
27
|
+
if (!busy) onClose();
|
|
28
|
+
};
|
|
29
|
+
window.addEventListener('keydown', onEscape, true);
|
|
30
|
+
return () => window.removeEventListener('keydown', onEscape, true);
|
|
31
|
+
}, [busy, onClose]);
|
|
32
|
+
|
|
33
|
+
const destination = name.trim() ? [parent, name.trim()].filter(Boolean).join('/') : parent;
|
|
34
|
+
const move = async () => {
|
|
35
|
+
let category: string | null;
|
|
36
|
+
try {
|
|
37
|
+
if (name.includes('/')) throw new Error('Use one folder name; choose its parent above.');
|
|
38
|
+
category = normalizeBoardCategory(destination);
|
|
39
|
+
} catch (cause) {
|
|
40
|
+
setError(cause instanceof Error ? cause.message : String(cause));
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
setBusy(true);
|
|
44
|
+
setError('');
|
|
45
|
+
await updateBoard(board.id, { category });
|
|
46
|
+
if (boardList.value.find((entry) => entry.id === board.id)?.category === category) onClose();
|
|
47
|
+
else setError('Could not move this board. Try again.');
|
|
48
|
+
setBusy(false);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div class="text-prompt-backdrop" onPointerDown={() => !busy && onClose()}>
|
|
53
|
+
<div
|
|
54
|
+
ref={ref}
|
|
55
|
+
class="text-prompt home-folder-dialog"
|
|
56
|
+
role="dialog"
|
|
57
|
+
aria-modal="true"
|
|
58
|
+
aria-label={`Move ${board.name}`}
|
|
59
|
+
onPointerDown={(event) => event.stopPropagation()}
|
|
60
|
+
>
|
|
61
|
+
<div class="text-prompt-title">Move “{board.name}”</div>
|
|
62
|
+
<p class="home-folder-hint">Choose a folder, or create one inside it. The board keeps its content and links.</p>
|
|
63
|
+
<fieldset class="home-folder-options" disabled={busy}>
|
|
64
|
+
<legend>Destination folder</legend>
|
|
65
|
+
{['', ...folders].map((path) => (
|
|
66
|
+
<label
|
|
67
|
+
key={path}
|
|
68
|
+
class="home-folder-option"
|
|
69
|
+
style={{ paddingLeft: `${8 + Math.min(path.split('/').length - 1, 5) * 14}px` }}
|
|
70
|
+
>
|
|
71
|
+
<input
|
|
72
|
+
type="radio"
|
|
73
|
+
name="folder-destination"
|
|
74
|
+
value={path}
|
|
75
|
+
checked={parent === path}
|
|
76
|
+
onChange={() => {
|
|
77
|
+
setParent(path);
|
|
78
|
+
setError('');
|
|
79
|
+
}}
|
|
80
|
+
/>
|
|
81
|
+
<span>{path || 'Home (no folder)'}</span>
|
|
82
|
+
</label>
|
|
83
|
+
))}
|
|
84
|
+
</fieldset>
|
|
85
|
+
<label class="home-folder-new">
|
|
86
|
+
New folder inside {parent || 'Home'} <span class="home-folder-hint">(optional)</span>
|
|
87
|
+
<input
|
|
88
|
+
class="text-prompt-input"
|
|
89
|
+
aria-label="New folder name"
|
|
90
|
+
placeholder="e.g. Decisions"
|
|
91
|
+
maxLength={60}
|
|
92
|
+
value={name}
|
|
93
|
+
disabled={busy}
|
|
94
|
+
onInput={(event) => {
|
|
95
|
+
setName(event.currentTarget.value);
|
|
96
|
+
setError('');
|
|
97
|
+
}}
|
|
98
|
+
/>
|
|
99
|
+
</label>
|
|
100
|
+
<div class="home-folder-hint">
|
|
101
|
+
Move to: <strong>{destination || 'Home'}</strong>
|
|
102
|
+
</div>
|
|
103
|
+
{error && <p role="alert">{error}</p>}
|
|
104
|
+
<div class="text-prompt-actions">
|
|
105
|
+
<button type="button" class="text-prompt-cancel" disabled={busy} onClick={onClose}>
|
|
106
|
+
Cancel
|
|
107
|
+
</button>
|
|
108
|
+
<button
|
|
109
|
+
type="button"
|
|
110
|
+
class="text-prompt-confirm"
|
|
111
|
+
disabled={busy || destination === (board.category ?? '')}
|
|
112
|
+
onClick={() => void move()}
|
|
113
|
+
>
|
|
114
|
+
{busy ? 'Moving…' : 'Move board'}
|
|
115
|
+
</button>
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { useRef, useState } from 'preact/hooks';
|
|
2
|
+
import { activeBoardId, boardList } from '../state/boards-store';
|
|
3
|
+
import { requestJson } from '../state/intent-bridge';
|
|
4
|
+
import { canvasAreaCenter } from './canvas-area';
|
|
5
|
+
import { viewport } from '../state/canvas-store';
|
|
6
|
+
import { useFocusTrap } from './use-focus-trap';
|
|
7
|
+
|
|
8
|
+
export function BoardLinkDialog({ onClose }: { onClose: () => void }) {
|
|
9
|
+
const dialogRef = useRef<HTMLDivElement>(null);
|
|
10
|
+
const searchRef = useRef<HTMLInputElement>(null);
|
|
11
|
+
const [query, setQuery] = useState('');
|
|
12
|
+
const [error, setError] = useState('');
|
|
13
|
+
useFocusTrap(dialogRef, true, { initial: searchRef });
|
|
14
|
+
const choices = boardList.value.filter(
|
|
15
|
+
(board) => board.id !== activeBoardId.value && board.name.toLowerCase().includes(query.trim().toLowerCase()),
|
|
16
|
+
);
|
|
17
|
+
const create = async (boardId: string) => {
|
|
18
|
+
const target = boardList.value.find((board) => board.id === boardId);
|
|
19
|
+
if (!target) return setError('That board is no longer available.');
|
|
20
|
+
const center = canvasAreaCenter();
|
|
21
|
+
const v = viewport.value;
|
|
22
|
+
const result = await requestJson<{ ok?: boolean }>(
|
|
23
|
+
'createBoardLink',
|
|
24
|
+
'/api/canvas/node',
|
|
25
|
+
{},
|
|
26
|
+
{
|
|
27
|
+
method: 'POST',
|
|
28
|
+
headers: { 'Content-Type': 'application/json' },
|
|
29
|
+
body: JSON.stringify({
|
|
30
|
+
type: 'board',
|
|
31
|
+
title: target.name,
|
|
32
|
+
x: Math.round((center.x - v.x) / v.scale - 180),
|
|
33
|
+
y: Math.round((center.y - v.y) / v.scale - 100),
|
|
34
|
+
width: 360,
|
|
35
|
+
height: 200,
|
|
36
|
+
data: { boardId },
|
|
37
|
+
}),
|
|
38
|
+
},
|
|
39
|
+
);
|
|
40
|
+
if (!result.ok) return setError('Could not create the board link.');
|
|
41
|
+
onClose();
|
|
42
|
+
};
|
|
43
|
+
return (
|
|
44
|
+
<div class="text-prompt-backdrop" onMouseDown={onClose}>
|
|
45
|
+
<div
|
|
46
|
+
ref={dialogRef}
|
|
47
|
+
class="text-prompt board-link-dialog"
|
|
48
|
+
role="dialog"
|
|
49
|
+
aria-modal="true"
|
|
50
|
+
aria-labelledby="board-link-title"
|
|
51
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
52
|
+
>
|
|
53
|
+
<h2 id="board-link-title">Link to a board</h2>
|
|
54
|
+
<input
|
|
55
|
+
ref={searchRef}
|
|
56
|
+
type="search"
|
|
57
|
+
class="text-prompt-input"
|
|
58
|
+
aria-label="Find a board to link"
|
|
59
|
+
placeholder="Find a board…"
|
|
60
|
+
value={query}
|
|
61
|
+
onInput={(e) => setQuery(e.currentTarget.value)}
|
|
62
|
+
/>
|
|
63
|
+
{error && (
|
|
64
|
+
<div role="alert" class="board-copy-error">
|
|
65
|
+
{error}
|
|
66
|
+
</div>
|
|
67
|
+
)}
|
|
68
|
+
<ul class="board-link-results">
|
|
69
|
+
{choices.map((board) => (
|
|
70
|
+
<li key={board.id}>
|
|
71
|
+
<button type="button" onClick={() => void create(board.id)}>
|
|
72
|
+
<span>{board.name}</span>
|
|
73
|
+
<small>{board.nodeCount} nodes</small>
|
|
74
|
+
</button>
|
|
75
|
+
</li>
|
|
76
|
+
))}
|
|
77
|
+
{choices.length === 0 && (
|
|
78
|
+
<li class="board-copy-status">
|
|
79
|
+
{boardList.value.length <= 1
|
|
80
|
+
? 'Create another board first, then link it here.'
|
|
81
|
+
: 'No boards match your search.'}
|
|
82
|
+
</li>
|
|
83
|
+
)}
|
|
84
|
+
</ul>
|
|
85
|
+
<div class="text-prompt-actions">
|
|
86
|
+
<button type="button" onClick={onClose}>
|
|
87
|
+
Cancel
|
|
88
|
+
</button>
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { render, type VNode } from 'preact';
|
|
2
|
+
import { useLayoutEffect, useMemo, useRef, useState } from 'preact/hooks';
|
|
3
|
+
import { activeBoard, activeBoardId, boardList, boardsLoaded, openBoard } from '../state/boards-store';
|
|
4
|
+
import { IconLogo, IconArrange } from '../icons';
|
|
5
|
+
import { promptNewBoard } from './HomeView';
|
|
6
|
+
|
|
7
|
+
const RECENT_LIMIT = 8;
|
|
8
|
+
|
|
9
|
+
/** Escape the top bar's filter/clip without installing React-compat event hooks. */
|
|
10
|
+
function BoardMenuPortal({ children }: { children: VNode }) {
|
|
11
|
+
const host = useMemo(() => document.createElement('div'), []);
|
|
12
|
+
useLayoutEffect(() => {
|
|
13
|
+
document.body.appendChild(host);
|
|
14
|
+
return () => {
|
|
15
|
+
render(null, host);
|
|
16
|
+
host.remove();
|
|
17
|
+
};
|
|
18
|
+
}, [host]);
|
|
19
|
+
useLayoutEffect(() => render(children, host), [children, host]);
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The top bar's board identity (plan 012): the open board's name, or "Home".
|
|
25
|
+
* Clicking it lists recent boards, New board, and Home. The menu is fixed-
|
|
26
|
+
* positioned so the bar's overflow clip cannot cut it off.
|
|
27
|
+
*/
|
|
28
|
+
export function BoardSwitcher({ fallbackName }: { fallbackName: string }) {
|
|
29
|
+
const [open, setOpen] = useState(false);
|
|
30
|
+
const rootRef = useRef<HTMLSpanElement>(null);
|
|
31
|
+
const buttonRef = useRef<HTMLButtonElement>(null);
|
|
32
|
+
const menuRef = useRef<HTMLDivElement>(null);
|
|
33
|
+
|
|
34
|
+
useLayoutEffect(() => {
|
|
35
|
+
if (!open) return;
|
|
36
|
+
const onPointerDown = (e: PointerEvent) => {
|
|
37
|
+
if (e.target instanceof Node && !rootRef.current?.contains(e.target) && !menuRef.current?.contains(e.target))
|
|
38
|
+
setOpen(false);
|
|
39
|
+
};
|
|
40
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
41
|
+
if (e.key === 'Escape') {
|
|
42
|
+
e.stopPropagation();
|
|
43
|
+
setOpen(false);
|
|
44
|
+
buttonRef.current?.focus();
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
// Canvas gestures stop bubbling; dismiss before those handlers run.
|
|
48
|
+
document.addEventListener('pointerdown', onPointerDown, true);
|
|
49
|
+
document.addEventListener('keydown', onKeyDown, true);
|
|
50
|
+
return () => {
|
|
51
|
+
document.removeEventListener('pointerdown', onPointerDown, true);
|
|
52
|
+
document.removeEventListener('keydown', onKeyDown, true);
|
|
53
|
+
};
|
|
54
|
+
}, [open]);
|
|
55
|
+
|
|
56
|
+
const board = activeBoard();
|
|
57
|
+
const label = !boardsLoaded.value ? fallbackName : board ? board.name : 'Home';
|
|
58
|
+
const recent = boardList.value.filter((entry) => entry.id !== activeBoardId.value).slice(0, RECENT_LIMIT);
|
|
59
|
+
const anchor = buttonRef.current?.getBoundingClientRect();
|
|
60
|
+
|
|
61
|
+
const choose = (id: string | null) => {
|
|
62
|
+
setOpen(false);
|
|
63
|
+
void openBoard(id);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<span class="board-switcher" ref={rootRef}>
|
|
68
|
+
<button
|
|
69
|
+
ref={buttonRef}
|
|
70
|
+
type="button"
|
|
71
|
+
class="top-bar-title board-switcher-button"
|
|
72
|
+
aria-haspopup="menu"
|
|
73
|
+
aria-expanded={open}
|
|
74
|
+
aria-label={`Board: ${label}. Switch board`}
|
|
75
|
+
onClick={() => setOpen((value) => !value)}
|
|
76
|
+
>
|
|
77
|
+
<span class="board-switcher-name">{label}</span>
|
|
78
|
+
<span class="board-switcher-caret" aria-hidden="true">
|
|
79
|
+
▾
|
|
80
|
+
</span>
|
|
81
|
+
</button>
|
|
82
|
+
{open && anchor && (
|
|
83
|
+
<BoardMenuPortal>
|
|
84
|
+
<div
|
|
85
|
+
ref={menuRef}
|
|
86
|
+
class="toolbar-menu board-switcher-menu"
|
|
87
|
+
role="menu"
|
|
88
|
+
aria-label="Boards"
|
|
89
|
+
style={{
|
|
90
|
+
position: 'fixed',
|
|
91
|
+
left: `min(${anchor.left}px, max(12px, calc(100vw - 352px)))`,
|
|
92
|
+
top: `${anchor.bottom + 8}px`,
|
|
93
|
+
right: 'auto',
|
|
94
|
+
maxHeight: `min(70vh, calc(100dvh - ${anchor.bottom + 20}px))`,
|
|
95
|
+
}}
|
|
96
|
+
>
|
|
97
|
+
{recent.length > 0 && <div class="board-switcher-heading">Recent boards</div>}
|
|
98
|
+
<div class="board-switcher-recents">
|
|
99
|
+
{recent.map((entry) => (
|
|
100
|
+
<button
|
|
101
|
+
key={entry.id}
|
|
102
|
+
type="button"
|
|
103
|
+
role="menuitem"
|
|
104
|
+
class="toolbar-menu-item"
|
|
105
|
+
onClick={() => choose(entry.id)}
|
|
106
|
+
>
|
|
107
|
+
<span class="board-switcher-icon" aria-hidden="true">
|
|
108
|
+
<IconLogo size={18} />
|
|
109
|
+
</span>
|
|
110
|
+
<span class="board-switcher-item-copy">
|
|
111
|
+
<span class="board-switcher-item-name">{entry.name}</span>
|
|
112
|
+
<span class="board-switcher-item-folder">
|
|
113
|
+
{entry.category?.split('/').join(' / ') || 'Unfiled'}
|
|
114
|
+
</span>
|
|
115
|
+
</span>
|
|
116
|
+
<span class="board-switcher-item-meta">
|
|
117
|
+
{entry.nodeCount} {entry.nodeCount === 1 ? 'node' : 'nodes'}
|
|
118
|
+
</span>
|
|
119
|
+
</button>
|
|
120
|
+
))}
|
|
121
|
+
</div>
|
|
122
|
+
<div class="board-switcher-actions">
|
|
123
|
+
<button
|
|
124
|
+
type="button"
|
|
125
|
+
role="menuitem"
|
|
126
|
+
class="toolbar-menu-item"
|
|
127
|
+
onClick={() => {
|
|
128
|
+
setOpen(false);
|
|
129
|
+
void promptNewBoard();
|
|
130
|
+
}}
|
|
131
|
+
>
|
|
132
|
+
<span class="board-switcher-icon" aria-hidden="true">
|
|
133
|
+
+
|
|
134
|
+
</span>
|
|
135
|
+
New board…
|
|
136
|
+
</button>
|
|
137
|
+
<button type="button" role="menuitem" class="toolbar-menu-item" onClick={() => choose(null)}>
|
|
138
|
+
<span class="board-switcher-icon" aria-hidden="true">
|
|
139
|
+
<IconArrange size={18} />
|
|
140
|
+
</span>
|
|
141
|
+
All boards (Home)
|
|
142
|
+
</button>
|
|
143
|
+
</div>
|
|
144
|
+
</div>
|
|
145
|
+
</BoardMenuPortal>
|
|
146
|
+
)}
|
|
147
|
+
</span>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
@@ -187,6 +187,9 @@ export function CanvasNode({ node, children, onContextMenu }: CanvasNodeProps) {
|
|
|
187
187
|
viewport,
|
|
188
188
|
onMove: handleMove,
|
|
189
189
|
onDragEnd: handleDragEnd,
|
|
190
|
+
onClick: () => {
|
|
191
|
+
selectedNodeIds.value = new Set([node.id]);
|
|
192
|
+
},
|
|
190
193
|
});
|
|
191
194
|
|
|
192
195
|
// ── Resize ────────────────────────────────────────────
|
|
@@ -236,6 +239,11 @@ export function CanvasNode({ node, children, onContextMenu }: CanvasNodeProps) {
|
|
|
236
239
|
(e: PointerEvent) => {
|
|
237
240
|
if (e.button !== 0) return;
|
|
238
241
|
if (renaming) return;
|
|
242
|
+
// Title controls act on the node without selecting or dragging it.
|
|
243
|
+
if (e.target instanceof Element && e.target.closest('button, input')) {
|
|
244
|
+
e.stopPropagation();
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
239
247
|
if (canvasTool.value === 'connect') {
|
|
240
248
|
startEdgeDrag(e);
|
|
241
249
|
return;
|
|
@@ -46,6 +46,7 @@ import { HumanPresenceLayer } from './HumanPresenceLayer';
|
|
|
46
46
|
import { reportHumanCursor } from '../state/human-store';
|
|
47
47
|
import { ScopeFenceLayer } from './ScopeFenceLayer';
|
|
48
48
|
import { CanvasNode } from './CanvasNode';
|
|
49
|
+
import { BoardNode } from '../nodes/BoardNode';
|
|
49
50
|
import { EdgeLayer } from './EdgeLayer';
|
|
50
51
|
import { AnnotationLayer } from './AnnotationLayer';
|
|
51
52
|
import { activeGuides } from './snap-guides';
|
|
@@ -63,6 +64,8 @@ function renderNodeContent(node: CanvasNodeState) {
|
|
|
63
64
|
return <McpAppNode node={node} />;
|
|
64
65
|
case 'graph':
|
|
65
66
|
return <McpAppNode node={node} />;
|
|
67
|
+
case 'board':
|
|
68
|
+
return <BoardNode node={node} />;
|
|
66
69
|
case 'prompt':
|
|
67
70
|
return <PromptNode node={node} />;
|
|
68
71
|
case 'response':
|
|
@@ -17,6 +17,8 @@ import { TYPE_LABELS, type CanvasNodeState } from '../types';
|
|
|
17
17
|
import { invalidateTokenCache } from '../theme/tokens';
|
|
18
18
|
import { clearThemeOverride } from '../state/theme-override';
|
|
19
19
|
import { getNodeIcon, IconArrange, IconFitAll, IconMinimap, IconMoon, IconNodeMarkdown, IconSteer } from '../icons';
|
|
20
|
+
import { activeBoardId, openBoard } from '../state/boards-store';
|
|
21
|
+
import { requestJson } from '../state/intent-bridge';
|
|
20
22
|
|
|
21
23
|
import { modChord } from '../utils/platform';
|
|
22
24
|
import { useFocusTrap } from './use-focus-trap';
|
|
@@ -24,7 +26,7 @@ import { useFocusTrap } from './use-focus-trap';
|
|
|
24
26
|
// ── Types ───────────────────────────────────────────────────
|
|
25
27
|
interface PaletteItem {
|
|
26
28
|
id: string;
|
|
27
|
-
kind: 'node' | 'action';
|
|
29
|
+
kind: 'node' | 'action' | 'library';
|
|
28
30
|
label: string;
|
|
29
31
|
description?: string;
|
|
30
32
|
/** Keyboard shortcut shown as a kbd on action rows. */
|
|
@@ -35,6 +37,14 @@ interface PaletteItem {
|
|
|
35
37
|
action: () => void;
|
|
36
38
|
}
|
|
37
39
|
|
|
40
|
+
interface LibraryHit {
|
|
41
|
+
boardId: string;
|
|
42
|
+
boardTitle: string;
|
|
43
|
+
cardId: string;
|
|
44
|
+
title: string | null;
|
|
45
|
+
snippet: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
38
48
|
// ── Fuzzy match ─────────────────────────────────────────────
|
|
39
49
|
function fuzzyMatch(query: string, text: string): { match: boolean; score: number; indices: number[] } {
|
|
40
50
|
const q = query.toLowerCase();
|
|
@@ -106,6 +116,8 @@ function parseTypeFilter(query: string): { typeFilter: CanvasNodeState['type'] |
|
|
|
106
116
|
export function CommandPalette({ onClose, onToggleMinimap }: { onClose: () => void; onToggleMinimap: () => void }) {
|
|
107
117
|
const [query, setQuery] = useState('');
|
|
108
118
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
119
|
+
const [libraryHits, setLibraryHits] = useState<LibraryHit[]>([]);
|
|
120
|
+
const [libraryState, setLibraryState] = useState<'idle' | 'loading' | 'error'>('idle');
|
|
109
121
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
110
122
|
const listRef = useRef<HTMLDivElement>(null);
|
|
111
123
|
const dialogRef = useRef<HTMLDivElement>(null);
|
|
@@ -116,6 +128,39 @@ export function CommandPalette({ onClose, onToggleMinimap }: { onClose: () => vo
|
|
|
116
128
|
setTimeout(() => inputRef.current?.focus(), 30);
|
|
117
129
|
}, []);
|
|
118
130
|
|
|
131
|
+
useEffect(() => {
|
|
132
|
+
const q = query.trim();
|
|
133
|
+
if (q.length < 2) {
|
|
134
|
+
setLibraryHits([]);
|
|
135
|
+
setLibraryState('idle');
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
let active = true;
|
|
139
|
+
setLibraryState('loading');
|
|
140
|
+
const timer = window.setTimeout(
|
|
141
|
+
() =>
|
|
142
|
+
void requestJson<{ results?: LibraryHit[] } | null>(
|
|
143
|
+
'paletteLibrarySearch',
|
|
144
|
+
`/api/canvas/search?q=${encodeURIComponent(q)}&scope=library&limit=12`,
|
|
145
|
+
null,
|
|
146
|
+
).then((result) => {
|
|
147
|
+
if (!active) return;
|
|
148
|
+
if (!result) {
|
|
149
|
+
setLibraryHits([]);
|
|
150
|
+
setLibraryState('error');
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
setLibraryHits(result.results ?? []);
|
|
154
|
+
setLibraryState('idle');
|
|
155
|
+
}),
|
|
156
|
+
150,
|
|
157
|
+
);
|
|
158
|
+
return () => {
|
|
159
|
+
active = false;
|
|
160
|
+
window.clearTimeout(timer);
|
|
161
|
+
};
|
|
162
|
+
}, [query]);
|
|
163
|
+
|
|
119
164
|
// ── Build items ─────────────────────────────────────────
|
|
120
165
|
const buildItems = useCallback((): PaletteItem[] => {
|
|
121
166
|
const items: PaletteItem[] = [];
|
|
@@ -164,6 +209,15 @@ export function CommandPalette({ onClose, onToggleMinimap }: { onClose: () => vo
|
|
|
164
209
|
onClose();
|
|
165
210
|
},
|
|
166
211
|
},
|
|
212
|
+
{
|
|
213
|
+
label: 'Link to board…',
|
|
214
|
+
icon: getNodeIcon('board'),
|
|
215
|
+
iconTone: 'accent',
|
|
216
|
+
action: () => {
|
|
217
|
+
window.dispatchEvent(new CustomEvent('pmx-open-board-link'));
|
|
218
|
+
onClose();
|
|
219
|
+
},
|
|
220
|
+
},
|
|
167
221
|
...(sessionActive.value
|
|
168
222
|
? []
|
|
169
223
|
: [
|
|
@@ -275,9 +329,39 @@ export function CommandPalette({ onClose, onToggleMinimap }: { onClose: () => vo
|
|
|
275
329
|
// Sort by score descending
|
|
276
330
|
filtered.sort((a, b) => b.score - a.score);
|
|
277
331
|
}
|
|
332
|
+
for (const hit of libraryHits) {
|
|
333
|
+
filtered.push({
|
|
334
|
+
id: `library:${hit.boardId}:${hit.cardId}`,
|
|
335
|
+
kind: 'library',
|
|
336
|
+
label: hit.title || hit.boardTitle,
|
|
337
|
+
description: `${hit.boardTitle} · ${hit.snippet}`,
|
|
338
|
+
icon: getNodeIcon('board'),
|
|
339
|
+
score: 0,
|
|
340
|
+
indices: [],
|
|
341
|
+
action: () => {
|
|
342
|
+
void (async () => {
|
|
343
|
+
await openBoard(hit.boardId);
|
|
344
|
+
if (activeBoardId.value !== hit.boardId) return;
|
|
345
|
+
for (let attempt = 0; attempt < 40 && activeBoardId.value === hit.boardId; attempt++) {
|
|
346
|
+
if (nodes.value.has(hit.cardId)) {
|
|
347
|
+
focusNode(hit.cardId);
|
|
348
|
+
onClose();
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
await new Promise((resolve) => window.setTimeout(resolve, 25));
|
|
352
|
+
}
|
|
353
|
+
})();
|
|
354
|
+
},
|
|
355
|
+
});
|
|
356
|
+
}
|
|
278
357
|
// Grouped render order (design item 7): Actions first, then Jump to.
|
|
279
|
-
filtered = [
|
|
358
|
+
filtered = [
|
|
359
|
+
...filtered.filter((item) => item.kind === 'action'),
|
|
360
|
+
...filtered.filter((item) => item.kind === 'node'),
|
|
361
|
+
...filtered.filter((item) => item.kind === 'library'),
|
|
362
|
+
];
|
|
280
363
|
const firstNodeIndex = filtered.findIndex((item) => item.kind === 'node');
|
|
364
|
+
const firstLibraryIndex = filtered.findIndex((item) => item.kind === 'library');
|
|
281
365
|
|
|
282
366
|
// ── Sync spatial search highlights to canvas ──────────────
|
|
283
367
|
useEffect(() => {
|
|
@@ -376,10 +460,29 @@ export function CommandPalette({ onClose, onToggleMinimap }: { onClose: () => vo
|
|
|
376
460
|
<kbd class="command-palette-esc">esc</kbd>
|
|
377
461
|
</div>
|
|
378
462
|
<div class="command-palette-results" ref={listRef}>
|
|
379
|
-
{
|
|
463
|
+
{libraryState === 'loading' && (
|
|
464
|
+
<div class="command-palette-empty" role="status">
|
|
465
|
+
Searching all boards…
|
|
466
|
+
</div>
|
|
467
|
+
)}
|
|
468
|
+
{libraryState === 'error' && (
|
|
469
|
+
<div class="command-palette-empty" role="alert">
|
|
470
|
+
Library search is unavailable.
|
|
471
|
+
</div>
|
|
472
|
+
)}
|
|
473
|
+
{filtered.length === 0 && libraryState !== 'loading' && (
|
|
474
|
+
<div class="command-palette-empty">No matching nodes, boards, or actions</div>
|
|
475
|
+
)}
|
|
380
476
|
{filtered.map((item, i) => {
|
|
381
477
|
const Icon = item.icon;
|
|
382
|
-
const heading =
|
|
478
|
+
const heading =
|
|
479
|
+
i === 0 && item.kind === 'action'
|
|
480
|
+
? 'Actions'
|
|
481
|
+
: i === firstNodeIndex
|
|
482
|
+
? 'Jump to'
|
|
483
|
+
: i === firstLibraryIndex
|
|
484
|
+
? 'Across boards'
|
|
485
|
+
: null;
|
|
383
486
|
return (
|
|
384
487
|
<div key={item.id} class="command-palette-row">
|
|
385
488
|
{heading && <div class="command-palette-group">{heading}</div>}
|
|
@@ -437,6 +437,10 @@ function buildCanvasMenuItems(canvasX: number, canvasY: number): MenuItem[] {
|
|
|
437
437
|
});
|
|
438
438
|
},
|
|
439
439
|
},
|
|
440
|
+
{
|
|
441
|
+
label: 'Link to board…',
|
|
442
|
+
action: () => window.dispatchEvent(new CustomEvent('pmx-open-board-link')),
|
|
443
|
+
},
|
|
440
444
|
{ separator: true },
|
|
441
445
|
{
|
|
442
446
|
label: 'New group',
|
|
@@ -16,6 +16,7 @@ import { PromptNode } from '../nodes/PromptNode';
|
|
|
16
16
|
import { AxStepControls } from '../nodes/AxStepControls';
|
|
17
17
|
import { ResponseNode } from '../nodes/ResponseNode';
|
|
18
18
|
import { TraceNode } from '../nodes/TraceNode';
|
|
19
|
+
import { BoardNode } from '../nodes/BoardNode';
|
|
19
20
|
import {
|
|
20
21
|
collapseExpandedNode,
|
|
21
22
|
contextPinnedNodeIds,
|
|
@@ -41,6 +42,8 @@ function renderContent(node: CanvasNodeState, expanded: boolean) {
|
|
|
41
42
|
return <McpAppNode node={node} expanded={expanded} />;
|
|
42
43
|
case 'graph':
|
|
43
44
|
return <McpAppNode node={node} expanded={expanded} />;
|
|
45
|
+
case 'board':
|
|
46
|
+
return <BoardNode node={node} />;
|
|
44
47
|
case 'prompt':
|
|
45
48
|
return <PromptNode node={node} />;
|
|
46
49
|
case 'response':
|