pmx-canvas 0.6.5 → 0.7.1
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 +139 -14
- package/CHANGELOG.md +70 -0
- package/Readme.md +32 -6
- package/dist/canvas/global.css +510 -31
- package/dist/canvas/index.js +92 -77
- package/dist/types/cli/agent.d.ts +3 -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/daemon.d.ts +5 -5
- package/dist/types/cli/shared.d.ts +3 -1
- 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/nodes/AttachmentNode.d.ts +6 -0
- package/dist/types/client/nodes/BoardNode.d.ts +5 -0
- 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/session-store.d.ts +7 -0
- package/dist/types/client/types.d.ts +2 -2
- package/dist/types/json-render/catalog.d.ts +1 -1
- package/dist/types/json-render/charts/definitions.d.ts +1 -1
- package/dist/types/json-render/server.d.ts +6 -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 +63 -9
- package/dist/types/server/canvas-operations.d.ts +23 -0
- package/dist/types/server/canvas-provenance.d.ts +1 -1
- package/dist/types/server/canvas-state.d.ts +140 -3
- 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/index.d.ts +103 -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/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/nodes.d.ts +1 -1
- 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/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/docs/RELEASE.md +5 -0
- package/docs/cli.md +73 -0
- package/docs/http-api.md +198 -2
- package/docs/mcp.md +71 -6
- package/docs/node-types.md +26 -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 +68 -11
- package/skills/pmx-canvas/evals/evals.json +130 -0
- package/skills/pmx-canvas/references/full-reference.md +43 -7
- 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/references/tours-and-recording.md +69 -0
- package/skills/pmx-canvas-orchestration/SKILL.md +5 -0
- package/skills/pmx-canvas-testing/SKILL.md +11 -2
- package/src/cli/agent.ts +4 -0
- package/src/cli/commands/apps.ts +1 -1
- 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 +37 -0
- package/src/cli/commands/nodes.ts +4 -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/view.ts +7 -5
- package/src/cli/daemon.ts +37 -7
- package/src/cli/index.ts +19 -9
- package/src/cli/shared.ts +11 -2
- package/src/client/App.tsx +21 -4
- package/src/client/canvas/AttentionToast.tsx +5 -3
- 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 +0 -1
- package/src/client/canvas/CanvasViewport.tsx +3 -0
- package/src/client/canvas/CommandPalette.tsx +107 -4
- package/src/client/canvas/ContextMenu.tsx +4 -1
- package/src/client/canvas/ExpandedNodeOverlay.tsx +3 -0
- package/src/client/canvas/ExportDialog.tsx +199 -0
- package/src/client/canvas/HomeView.tsx +277 -0
- package/src/client/canvas/Presentation.tsx +39 -9
- package/src/client/canvas/SelectionBar.tsx +0 -2
- package/src/client/canvas/SessionPanel.tsx +28 -1
- package/src/client/canvas/ToolRail.tsx +38 -3
- package/src/client/canvas/TopBar.tsx +34 -18
- package/src/client/canvas/import-files.ts +19 -1
- package/src/client/canvas/kind-colors.ts +1 -0
- package/src/client/icons.tsx +2 -0
- package/src/client/nodes/AttachmentNode.tsx +145 -0
- package/src/client/nodes/BoardNode.tsx +35 -0
- package/src/client/nodes/FileNode.tsx +6 -0
- package/src/client/nodes/MarkdownNode.tsx +47 -19
- 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/attention-bridge.ts +3 -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/node-factory.ts +1 -0
- package/src/client/state/session-store.ts +5 -0
- package/src/client/state/sse-bridge.ts +52 -6
- package/src/client/theme/global.css +510 -31
- package/src/client/types.ts +2 -0
- package/src/json-render/server.ts +16 -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/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 +599 -107
- package/src/server/canvas-operations.ts +68 -9
- package/src/server/canvas-provenance.ts +1 -0
- package/src/server/canvas-schema.ts +12 -0
- package/src/server/canvas-state.ts +774 -45
- package/src/server/context-brief.ts +372 -0
- package/src/server/context-reads.ts +238 -0
- package/src/server/demo-state.json +659 -2
- package/src/server/document-import.ts +223 -0
- package/src/server/index.ts +231 -5
- package/src/server/mutation-history.ts +10 -13
- package/src/server/operations/composites.ts +26 -4
- 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 +5 -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 +8 -1
- 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/imports.ts +236 -0
- package/src/server/operations/ops/nodes.ts +57 -5
- package/src/server/operations/ops/query.ts +206 -9
- package/src/server/operations/registry.ts +123 -31
- package/src/server/operations/types.ts +1 -1
- package/src/server/server.ts +146 -2
- package/src/server/spatial-analysis.ts +9 -17
- package/src/server/trace-manager.ts +19 -0
- package/src/server/web-artifacts.ts +2 -1
- package/src/server/webpage-node.ts +173 -13
- package/src/server/workbench-auth.ts +11 -0
- package/src/shared/boards.ts +18 -0
- package/src/shared/node-sizes.ts +1 -0
|
@@ -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
|
+
}
|
|
@@ -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',
|
|
@@ -665,7 +669,6 @@ function buildNodeMenuItems(node: CanvasNodeState): MenuItem[] {
|
|
|
665
669
|
label: node.type === 'group' ? 'Delete frame (children stay)' : 'Delete',
|
|
666
670
|
danger: true,
|
|
667
671
|
action: () => {
|
|
668
|
-
removeNode(node.id);
|
|
669
672
|
void removeNodeFromClient(node.id);
|
|
670
673
|
},
|
|
671
674
|
});
|
|
@@ -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':
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { signal } from '@preact/signals';
|
|
2
|
+
import { useEffect, useState } from 'preact/hooks';
|
|
3
|
+
import { requestJson } from '../state/intent-bridge';
|
|
4
|
+
import { activeBoardId } from '../state/boards-store';
|
|
5
|
+
|
|
6
|
+
interface ExportManifest {
|
|
7
|
+
boardId: string;
|
|
8
|
+
boardName: string;
|
|
9
|
+
cards: number;
|
|
10
|
+
connections: number;
|
|
11
|
+
frames: number;
|
|
12
|
+
placeholders: Array<{ nodeId: string; title: string; reason: string }>;
|
|
13
|
+
files: Array<{ nodeId: string; path: string; included: boolean }>;
|
|
14
|
+
attachments: Array<{ nodeId: string; name: string }>;
|
|
15
|
+
sourceDerivedCards: Array<{ nodeId: string; included: boolean }>;
|
|
16
|
+
embeddedImages: number;
|
|
17
|
+
remoteImages: string[];
|
|
18
|
+
links: string[];
|
|
19
|
+
frameNetworkDestinations: string[];
|
|
20
|
+
embeddedCodeCanAccessNetwork: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface ExportResult {
|
|
24
|
+
path: string;
|
|
25
|
+
url: string;
|
|
26
|
+
bytes: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const exportDialogOpen = signal(false);
|
|
30
|
+
|
|
31
|
+
function sizeLabel(bytes: number): string {
|
|
32
|
+
return bytes >= 1024 * 1024 ? `${(bytes / 1024 / 1024).toFixed(1)} MB` : `${Math.ceil(bytes / 1024)} KB`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Export the open board as one HTML file (plan 013). An export is a share, so
|
|
37
|
+
* the dialog lists what goes into the file before it is written; file contents
|
|
38
|
+
* stay out unless the owner ticks them in.
|
|
39
|
+
*/
|
|
40
|
+
export function ExportDialog() {
|
|
41
|
+
const [includeFiles, setIncludeFiles] = useState(false);
|
|
42
|
+
const [includeDerivedText, setIncludeDerivedText] = useState(false);
|
|
43
|
+
const [manifest, setManifest] = useState<ExportManifest | null>(null);
|
|
44
|
+
const [result, setResult] = useState<ExportResult | null>(null);
|
|
45
|
+
const [busy, setBusy] = useState(false);
|
|
46
|
+
const open = exportDialogOpen.value;
|
|
47
|
+
const boardId = activeBoardId.value;
|
|
48
|
+
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
if (!open || !boardId) return;
|
|
51
|
+
let current = true;
|
|
52
|
+
setResult(null);
|
|
53
|
+
setManifest(null);
|
|
54
|
+
void requestJson<{ manifest?: ExportManifest } | null>(
|
|
55
|
+
'exportPreview',
|
|
56
|
+
`/api/canvas/export/preview?board=${encodeURIComponent(boardId)}&includeFiles=${includeFiles}&includeDerivedText=${includeDerivedText}`,
|
|
57
|
+
null,
|
|
58
|
+
).then((body) => {
|
|
59
|
+
if (current) setManifest(body?.manifest ?? null);
|
|
60
|
+
});
|
|
61
|
+
return () => {
|
|
62
|
+
current = false;
|
|
63
|
+
};
|
|
64
|
+
}, [open, includeFiles, includeDerivedText, boardId]);
|
|
65
|
+
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
setIncludeDerivedText(false);
|
|
68
|
+
}, [open, boardId]);
|
|
69
|
+
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
if (!open) return;
|
|
72
|
+
const onKey = (e: KeyboardEvent) => {
|
|
73
|
+
if (e.key !== 'Escape') return;
|
|
74
|
+
e.stopPropagation();
|
|
75
|
+
exportDialogOpen.value = false;
|
|
76
|
+
};
|
|
77
|
+
window.addEventListener('keydown', onKey, { capture: true });
|
|
78
|
+
return () => window.removeEventListener('keydown', onKey, { capture: true });
|
|
79
|
+
}, [open]);
|
|
80
|
+
|
|
81
|
+
if (!open) return null;
|
|
82
|
+
|
|
83
|
+
const write = async () => {
|
|
84
|
+
if (!manifest || manifest.boardId !== activeBoardId.value) return;
|
|
85
|
+
setBusy(true);
|
|
86
|
+
const body = await requestJson<ExportResult | null>('exportBoard', '/api/canvas/export', null, {
|
|
87
|
+
method: 'POST',
|
|
88
|
+
headers: { 'Content-Type': 'application/json' },
|
|
89
|
+
body: JSON.stringify({ board: manifest.boardId, includeFiles, includeDerivedText }),
|
|
90
|
+
});
|
|
91
|
+
setBusy(false);
|
|
92
|
+
if (body?.path && activeBoardId.value === manifest.boardId) setResult(body);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<div class="text-prompt-backdrop" onPointerDown={() => (exportDialogOpen.value = false)}>
|
|
97
|
+
<div
|
|
98
|
+
class="text-prompt export-dialog"
|
|
99
|
+
role="dialog"
|
|
100
|
+
aria-label="Export board"
|
|
101
|
+
data-testid="export-dialog"
|
|
102
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
103
|
+
>
|
|
104
|
+
<div class="text-prompt-title">Export {manifest ? `“${manifest.boardName}”` : 'board'}</div>
|
|
105
|
+
{!manifest ? (
|
|
106
|
+
<div class="export-dialog-note">Reading the board…</div>
|
|
107
|
+
) : (
|
|
108
|
+
<>
|
|
109
|
+
<div class="export-dialog-note">
|
|
110
|
+
One HTML file anyone can open without installing anything. It will contain:
|
|
111
|
+
</div>
|
|
112
|
+
<ul class="export-dialog-list">
|
|
113
|
+
<li>
|
|
114
|
+
{manifest.cards} card{manifest.cards !== 1 ? 's' : ''} and {manifest.connections} connection
|
|
115
|
+
{manifest.connections !== 1 ? 's' : ''}
|
|
116
|
+
</li>
|
|
117
|
+
{manifest.frames > 0 && (
|
|
118
|
+
<li>{manifest.frames} embedded pages, charts or diagrams, with the code that draws them</li>
|
|
119
|
+
)}
|
|
120
|
+
{manifest.embeddedImages > 0 && <li>{manifest.embeddedImages} local images</li>}
|
|
121
|
+
{manifest.placeholders.length > 0 && (
|
|
122
|
+
<li>
|
|
123
|
+
Shown as placeholders:{' '}
|
|
124
|
+
{manifest.placeholders.map((entry) => `${entry.title} (${entry.reason})`).join(', ')}
|
|
125
|
+
</li>
|
|
126
|
+
)}
|
|
127
|
+
{manifest.remoteImages.length > 0 && (
|
|
128
|
+
<li>Web images loaded when opened: {manifest.remoteImages.join(', ')}</li>
|
|
129
|
+
)}
|
|
130
|
+
{manifest.links.length > 0 && <li>Links included in cards: {manifest.links.join(', ')}</li>}
|
|
131
|
+
{manifest.frameNetworkDestinations.length > 0 && (
|
|
132
|
+
<li>
|
|
133
|
+
Network destinations referenced by embedded pages: {manifest.frameNetworkDestinations.join(', ')}
|
|
134
|
+
</li>
|
|
135
|
+
)}
|
|
136
|
+
{manifest.embeddedCodeCanAccessNetwork && (
|
|
137
|
+
<li>
|
|
138
|
+
Warning: embedded code can access the network. Scripts may contact destinations that cannot be listed
|
|
139
|
+
in advance.
|
|
140
|
+
</li>
|
|
141
|
+
)}
|
|
142
|
+
</ul>
|
|
143
|
+
{(manifest.attachments?.length ?? 0) > 0 && (
|
|
144
|
+
<p>Original attachments are excluded. Their filenames remain visible.</p>
|
|
145
|
+
)}
|
|
146
|
+
{(manifest.sourceDerivedCards?.length ?? 0) > 0 && (
|
|
147
|
+
<label class="export-dialog-files">
|
|
148
|
+
<input
|
|
149
|
+
type="checkbox"
|
|
150
|
+
checked={includeDerivedText}
|
|
151
|
+
onChange={(event) => setIncludeDerivedText(event.currentTarget.checked)}
|
|
152
|
+
/>
|
|
153
|
+
<span>Include imported document text ({manifest.sourceDerivedCards.length} cards)</span>
|
|
154
|
+
</label>
|
|
155
|
+
)}
|
|
156
|
+
{manifest.files.length > 0 && (
|
|
157
|
+
<label class="export-dialog-files">
|
|
158
|
+
<input
|
|
159
|
+
type="checkbox"
|
|
160
|
+
checked={includeFiles}
|
|
161
|
+
onChange={(e) => setIncludeFiles((e.target as HTMLInputElement).checked)}
|
|
162
|
+
/>
|
|
163
|
+
<span>
|
|
164
|
+
Include the contents of {manifest.files.length} local file
|
|
165
|
+
{manifest.files.length !== 1 ? 's' : ''}: {manifest.files.map((file) => file.path).join(', ')}
|
|
166
|
+
</span>
|
|
167
|
+
</label>
|
|
168
|
+
)}
|
|
169
|
+
</>
|
|
170
|
+
)}
|
|
171
|
+
{result && (
|
|
172
|
+
<div class="export-dialog-result" data-testid="export-result">
|
|
173
|
+
<div>
|
|
174
|
+
Saved ({sizeLabel(result.bytes)}): <code>{result.path}</code>
|
|
175
|
+
</div>
|
|
176
|
+
<div class="export-dialog-links">
|
|
177
|
+
<a href={result.url} target="_blank" rel="noopener noreferrer">
|
|
178
|
+
Open
|
|
179
|
+
</a>
|
|
180
|
+
<a class="is-primary" href={`${result.url}?download=1`}>
|
|
181
|
+
Download
|
|
182
|
+
</a>
|
|
183
|
+
</div>
|
|
184
|
+
</div>
|
|
185
|
+
)}
|
|
186
|
+
<div class="text-prompt-actions">
|
|
187
|
+
<button type="button" class="text-prompt-cancel" onClick={() => (exportDialogOpen.value = false)}>
|
|
188
|
+
{result ? 'Done' : 'Cancel'}
|
|
189
|
+
</button>
|
|
190
|
+
{!result && (
|
|
191
|
+
<button type="button" class="text-prompt-confirm" disabled={!manifest || busy} onClick={() => void write()}>
|
|
192
|
+
{busy ? 'Exporting…' : 'Export'}
|
|
193
|
+
</button>
|
|
194
|
+
)}
|
|
195
|
+
</div>
|
|
196
|
+
</div>
|
|
197
|
+
</div>
|
|
198
|
+
);
|
|
199
|
+
}
|