pmx-canvas 0.6.5 → 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 +54 -0
- package/Readme.md +29 -5
- package/dist/canvas/global.css +440 -24
- package/dist/canvas/index.js +90 -75
- 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/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 +68 -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 +53 -4
- 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-orchestration/SKILL.md +5 -0
- package/skills/pmx-canvas-testing/SKILL.md +4 -0
- package/src/cli/agent.ts +4 -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 +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/index.ts +19 -9
- package/src/client/App.tsx +21 -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/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/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 +148 -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/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 +49 -5
- package/src/client/theme/global.css +440 -24
- 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 +220 -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
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { exportDialogOpen } from './ExportDialog';
|
|
2
|
+
import { activeBoardId } from '../state/boards-store';
|
|
3
|
+
import { BoardSwitcher } from './BoardSwitcher';
|
|
1
4
|
import type { ComponentChildren } from 'preact';
|
|
2
5
|
import { presenting } from '../state/presentation';
|
|
3
6
|
import { useEffect, useState } from 'preact/hooks';
|
|
@@ -264,8 +267,8 @@ export function TopBar() {
|
|
|
264
267
|
const isTraceOn = traceEnabled.value;
|
|
265
268
|
const traceNodeCount = Array.from(nodes.value.values()).filter((n) => n.type === 'trace').length;
|
|
266
269
|
|
|
267
|
-
//
|
|
268
|
-
//
|
|
270
|
+
// Until the board list arrives, the switcher shows the workspace basename
|
|
271
|
+
// (fetched once from /health).
|
|
269
272
|
const [workspaceName, setWorkspaceName] = useState<string>('');
|
|
270
273
|
useEffect(() => {
|
|
271
274
|
let cancelled = false;
|
|
@@ -314,9 +317,7 @@ export function TopBar() {
|
|
|
314
317
|
>
|
|
315
318
|
<span class={`connection-dot ${degraded ?? status}`} aria-label={`Canvas status: ${statusTitle}`} />
|
|
316
319
|
</BarHint>
|
|
317
|
-
<
|
|
318
|
-
{workspaceName || 'PMX Canvas'}
|
|
319
|
-
</span>
|
|
320
|
+
<BoardSwitcher fallbackName={workspaceName || 'PMX Canvas'} />
|
|
320
321
|
<span class="top-bar-meta hud-collapsible-text">{sessionId.value ? sessionId.value.slice(0, 12) : '…'}</span>
|
|
321
322
|
<span class="top-bar-meta hud-collapsible-text">{countsLabel}</span>
|
|
322
323
|
|
|
@@ -330,20 +331,35 @@ export function TopBar() {
|
|
|
330
331
|
|
|
331
332
|
<div class="top-bar-sep" />
|
|
332
333
|
|
|
333
|
-
|
|
334
|
-
label="
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
334
|
+
{activeBoardId.value && (
|
|
335
|
+
<BarHint label="Export board" body="One HTML file of this board that anyone can open, no install needed.">
|
|
336
|
+
<button
|
|
337
|
+
type="button"
|
|
338
|
+
class="present-button"
|
|
339
|
+
onClick={() => {
|
|
340
|
+
exportDialogOpen.value = true;
|
|
341
|
+
}}
|
|
342
|
+
>
|
|
343
|
+
Export
|
|
344
|
+
</button>
|
|
345
|
+
</BarHint>
|
|
346
|
+
)}
|
|
347
|
+
{activeBoardId.value && (
|
|
348
|
+
<BarHint
|
|
349
|
+
label="Present board"
|
|
350
|
+
body="Arrow keys or Space step through stops; Esc exits. Without a saved tour, groups are read top-to-bottom, left-to-right."
|
|
343
351
|
>
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
352
|
+
<button
|
|
353
|
+
type="button"
|
|
354
|
+
class="present-button"
|
|
355
|
+
onClick={() => {
|
|
356
|
+
presenting.value = true;
|
|
357
|
+
}}
|
|
358
|
+
>
|
|
359
|
+
Present
|
|
360
|
+
</button>
|
|
361
|
+
</BarHint>
|
|
362
|
+
)}
|
|
347
363
|
<BarHint label="Zoom out" shortcut={modChord('\u2212')}>
|
|
348
364
|
<button type="button" class="top-bar-btn" onClick={() => zoomByFactor(1 / 1.25)} aria-label="Zoom out">
|
|
349
365
|
<IconZoomOut />
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import { createNodeFromClient } from '../state/intent-bridge';
|
|
1
|
+
import { createNodeFromClient, requestJson } from '../state/intent-bridge';
|
|
2
|
+
import { activeBoardId } from '../state/boards-store';
|
|
3
|
+
import { showToast } from '../state/attention-bridge';
|
|
2
4
|
|
|
3
5
|
const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'bmp', 'ico', 'avif']);
|
|
4
6
|
const MD_EXTS = new Set(['md', 'mdx', 'markdown']);
|
|
@@ -17,6 +19,7 @@ export function nodeTypeFromFilename(name: string): 'image' | 'markdown' | 'file
|
|
|
17
19
|
* markdown, everything else a file node with the text inlined.
|
|
18
20
|
*/
|
|
19
21
|
export async function importFiles(files: File[], baseWx: number, baseWy: number): Promise<void> {
|
|
22
|
+
const boardId = activeBoardId.value;
|
|
20
23
|
const nodeW = 400;
|
|
21
24
|
const nodeH = 300;
|
|
22
25
|
const spacing = 20;
|
|
@@ -32,12 +35,24 @@ export async function importFiles(files: File[], baseWx: number, baseWy: number)
|
|
|
32
35
|
const type = nodeTypeFromFilename(file.name);
|
|
33
36
|
const fileName = file.name;
|
|
34
37
|
|
|
38
|
+
if (/\.(pdf|pptx?|xlsx?|docx?|od[pts])$/i.test(fileName)) {
|
|
39
|
+
if (!boardId || file.size === 0 || file.size > 20 * 1024 * 1024) {
|
|
40
|
+
showToast('remove', 'File not attached', 'Open a board and choose a nonempty file under 20 MiB.');
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
const query = new URLSearchParams({ boardId, name: fileName, mime: file.type, x: String(wx), y: String(wy) });
|
|
44
|
+
showToast('context', 'Attaching document', fileName);
|
|
45
|
+
await requestJson('attachDocument', `/api/canvas/attachments?${query}`, null, { method: 'POST', body: file });
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (activeBoardId.value !== boardId) return;
|
|
35
49
|
if (type === 'image') {
|
|
36
50
|
const reader = new FileReader();
|
|
37
51
|
const dataUri: string = await new Promise((resolve) => {
|
|
38
52
|
reader.onload = () => resolve(reader.result as string);
|
|
39
53
|
reader.readAsDataURL(file);
|
|
40
54
|
});
|
|
55
|
+
if (activeBoardId.value !== boardId) return;
|
|
41
56
|
await createNodeFromClient({
|
|
42
57
|
type: 'image',
|
|
43
58
|
title: fileName,
|
|
@@ -46,9 +61,11 @@ export async function importFiles(files: File[], baseWx: number, baseWy: number)
|
|
|
46
61
|
y: wy,
|
|
47
62
|
width: nodeW,
|
|
48
63
|
height: nodeH,
|
|
64
|
+
...(boardId ? { boardId } : {}),
|
|
49
65
|
});
|
|
50
66
|
} else {
|
|
51
67
|
const text = await file.text();
|
|
68
|
+
if (activeBoardId.value !== boardId) return;
|
|
52
69
|
const isWide = type === 'markdown' || type === 'file';
|
|
53
70
|
await createNodeFromClient({
|
|
54
71
|
type,
|
|
@@ -58,6 +75,7 @@ export async function importFiles(files: File[], baseWx: number, baseWy: number)
|
|
|
58
75
|
y: wy,
|
|
59
76
|
width: isWide ? 720 : nodeW,
|
|
60
77
|
height: isWide ? 500 : nodeH,
|
|
78
|
+
...(boardId ? { boardId } : {}),
|
|
61
79
|
});
|
|
62
80
|
}
|
|
63
81
|
}
|
|
@@ -7,6 +7,7 @@ export const KIND_COLOR: Record<CanvasNodeState['type'], string> = {
|
|
|
7
7
|
webpage: 'var(--c-warn)',
|
|
8
8
|
'json-render': 'var(--c-ok)',
|
|
9
9
|
graph: 'var(--c-purple)',
|
|
10
|
+
board: 'var(--c-accent)',
|
|
10
11
|
prompt: 'var(--c-accent)',
|
|
11
12
|
response: 'var(--c-ok)',
|
|
12
13
|
status: 'var(--c-warn)',
|
package/src/client/icons.tsx
CHANGED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { useEffect, useState } from 'preact/hooks';
|
|
2
|
+
import type { DocumentImport } from '../../server/document-import';
|
|
3
|
+
import type { CanvasNodeState } from '../types';
|
|
4
|
+
import { activeBoardId } from '../state/boards-store';
|
|
5
|
+
import { requestJson } from '../state/intent-bridge';
|
|
6
|
+
import { expandNode, collapseExpandedNode } from '../state/canvas-store';
|
|
7
|
+
|
|
8
|
+
/** Originals stay useful even when the connected host cannot extract them. */
|
|
9
|
+
export function AttachmentNode({ node, expanded = false }: { node: CanvasNodeState; expanded?: boolean }) {
|
|
10
|
+
const attachmentId = String(node.data.attachmentId);
|
|
11
|
+
const boardId = activeBoardId.value;
|
|
12
|
+
const [job, setJob] = useState<DocumentImport | null>(null);
|
|
13
|
+
const [review, setReview] = useState(expanded);
|
|
14
|
+
const [consent, setConsent] = useState(false);
|
|
15
|
+
const [busy, setBusy] = useState(false);
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
let current = true;
|
|
18
|
+
const read = async () => {
|
|
19
|
+
const result = await requestJson<{ imports?: DocumentImport[] } | null>(
|
|
20
|
+
'readImports',
|
|
21
|
+
`/api/canvas/imports?boardId=${encodeURIComponent(boardId ?? '')}`,
|
|
22
|
+
null,
|
|
23
|
+
);
|
|
24
|
+
if (current) setJob(result?.imports?.find((entry) => entry.attachmentId === attachmentId) ?? null);
|
|
25
|
+
};
|
|
26
|
+
void read();
|
|
27
|
+
const timer = window.setInterval(() => void read(), 2000);
|
|
28
|
+
return () => {
|
|
29
|
+
current = false;
|
|
30
|
+
window.clearInterval(timer);
|
|
31
|
+
};
|
|
32
|
+
}, [attachmentId, boardId]);
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
if (!review) return;
|
|
35
|
+
const onKey = (event: KeyboardEvent) => {
|
|
36
|
+
if (event.key === 'Escape') {
|
|
37
|
+
event.stopPropagation();
|
|
38
|
+
setReview(false);
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
window.addEventListener('keydown', onKey, true);
|
|
42
|
+
return () => window.removeEventListener('keydown', onKey, true);
|
|
43
|
+
}, [review]);
|
|
44
|
+
const act = async (action: 'request' | 'cancel' | 'commit') => {
|
|
45
|
+
if (!job) return;
|
|
46
|
+
setBusy(true);
|
|
47
|
+
const result = await requestJson<{ import?: DocumentImport } | null>(
|
|
48
|
+
`import.${action}`,
|
|
49
|
+
`/api/canvas/imports/${job.id}/${action}`,
|
|
50
|
+
null,
|
|
51
|
+
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ consent: true }) },
|
|
52
|
+
);
|
|
53
|
+
setBusy(false);
|
|
54
|
+
if (result?.import) {
|
|
55
|
+
setJob(result.import);
|
|
56
|
+
setConsent(false);
|
|
57
|
+
setReview(false);
|
|
58
|
+
if (expanded && action === 'commit') collapseExpandedNode();
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
const canRequest = job && ['attached', 'cancelled', 'unavailable'].includes(job.status);
|
|
62
|
+
return (
|
|
63
|
+
<div class="attachment-node" onPointerDown={(event) => event.stopPropagation()}>
|
|
64
|
+
<div class="attachment-status">
|
|
65
|
+
{job?.status === 'requested'
|
|
66
|
+
? 'Waiting for agent'
|
|
67
|
+
: job?.status === 'drafted'
|
|
68
|
+
? 'Ready for review'
|
|
69
|
+
: job?.status === 'committed'
|
|
70
|
+
? 'Markdown added'
|
|
71
|
+
: 'Original attached'}
|
|
72
|
+
</div>
|
|
73
|
+
<a href={`/api/canvas/attachments/${attachmentId}/bytes`} target="_blank" rel="noopener noreferrer">
|
|
74
|
+
Download original · {Math.ceil(Number(node.data.size ?? 0) / 1024)} KB
|
|
75
|
+
</a>
|
|
76
|
+
{job?.reason && <p>{job.reason}</p>}
|
|
77
|
+
{canRequest && !consent && (
|
|
78
|
+
<button type="button" class="home-board-action" onClick={() => setConsent(true)}>
|
|
79
|
+
Ask agent to import
|
|
80
|
+
</button>
|
|
81
|
+
)}
|
|
82
|
+
{consent && (
|
|
83
|
+
<>
|
|
84
|
+
<p>
|
|
85
|
+
The connected agent may send this file to its model provider. It needs file-reading tools and access to the
|
|
86
|
+
original. Canvas does not convert files itself.
|
|
87
|
+
</p>
|
|
88
|
+
<button type="button" class="home-board-action" disabled={busy} onClick={() => void act('request')}>
|
|
89
|
+
Allow processing & request
|
|
90
|
+
</button>
|
|
91
|
+
<button type="button" class="home-board-action" onClick={() => setConsent(false)}>
|
|
92
|
+
Not now
|
|
93
|
+
</button>
|
|
94
|
+
</>
|
|
95
|
+
)}
|
|
96
|
+
{job?.status === 'requested' && (
|
|
97
|
+
<p>
|
|
98
|
+
No automatic extraction. Ask your connected agent to check its pending steering or use canvas_import list.
|
|
99
|
+
</p>
|
|
100
|
+
)}
|
|
101
|
+
{job?.status === 'drafted' && (
|
|
102
|
+
<button
|
|
103
|
+
type="button"
|
|
104
|
+
class="home-board-action"
|
|
105
|
+
onClick={() => (expanded ? setReview(true) : expandNode(node.id))}
|
|
106
|
+
>
|
|
107
|
+
Review Markdown · {job.sections.length} {job.sections.length === 1 ? 'card' : 'cards'}
|
|
108
|
+
</button>
|
|
109
|
+
)}
|
|
110
|
+
{job && ['requested', 'drafted'].includes(job.status) && (
|
|
111
|
+
<button type="button" class="home-board-action" disabled={busy} onClick={() => void act('cancel')}>
|
|
112
|
+
Cancel import
|
|
113
|
+
</button>
|
|
114
|
+
)}
|
|
115
|
+
{review && job?.status === 'drafted' && (
|
|
116
|
+
<div class="attachment-review" role="dialog" aria-label="Review imported Markdown">
|
|
117
|
+
<h3>Review imported Markdown</h3>
|
|
118
|
+
<p>Source material, not instructions or verified knowledge. {job.agentDescription}</p>
|
|
119
|
+
{job.warnings.length > 0 && (
|
|
120
|
+
<ul>
|
|
121
|
+
{job.warnings.map((warning) => (
|
|
122
|
+
<li key={warning}>{warning}</li>
|
|
123
|
+
))}
|
|
124
|
+
</ul>
|
|
125
|
+
)}
|
|
126
|
+
{job.sections.map((section, index) => (
|
|
127
|
+
<section key={index}>
|
|
128
|
+
<h4>{section.title}</h4>
|
|
129
|
+
<p>{section.reference || 'Source location not supplied'}</p>
|
|
130
|
+
<pre>{section.markdown}</pre>
|
|
131
|
+
</section>
|
|
132
|
+
))}
|
|
133
|
+
<button type="button" class="home-board-action" onClick={() => setReview(false)}>
|
|
134
|
+
Close review
|
|
135
|
+
</button>
|
|
136
|
+
<button
|
|
137
|
+
type="button"
|
|
138
|
+
class="home-view-new"
|
|
139
|
+
disabled={busy || boardId !== job.boardId}
|
|
140
|
+
onClick={() => void act('commit')}
|
|
141
|
+
>
|
|
142
|
+
Add to board
|
|
143
|
+
</button>
|
|
144
|
+
</div>
|
|
145
|
+
)}
|
|
146
|
+
</div>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { CanvasNodeState } from '../types';
|
|
2
|
+
import { boardList, openBoard } from '../state/boards-store';
|
|
3
|
+
|
|
4
|
+
/** Native text-only cross-board link. Target identity is the durable board id. */
|
|
5
|
+
export function BoardNode({ node }: { node: CanvasNodeState }) {
|
|
6
|
+
const boardId = typeof node.data.boardId === 'string' ? node.data.boardId : '';
|
|
7
|
+
const target = boardList.value.find((board) => board.id === boardId);
|
|
8
|
+
return (
|
|
9
|
+
<div class="board-node">
|
|
10
|
+
<div class="board-node-content">
|
|
11
|
+
<div class="board-node-label">Linked board</div>
|
|
12
|
+
<strong class="board-node-title">{target?.name ?? 'Missing board'}</strong>
|
|
13
|
+
<div class="board-node-meta">{target ? `${target.nodeCount} nodes` : boardId || 'No target id'}</div>
|
|
14
|
+
{target?.summary && <p class="board-node-summary">{target.summary}</p>}
|
|
15
|
+
{!!target?.pinnedTitles?.length && (
|
|
16
|
+
<p class="board-node-meta">Pinned: {target.pinnedTitles.map((pin) => pin.title).join(', ')}</p>
|
|
17
|
+
)}
|
|
18
|
+
{!!target?.backlinks?.length && (
|
|
19
|
+
<p class="board-node-meta">
|
|
20
|
+
Linked from: {[...new Set(target.backlinks.map((link) => link.title))].join(', ')}
|
|
21
|
+
</p>
|
|
22
|
+
)}
|
|
23
|
+
</div>
|
|
24
|
+
<button
|
|
25
|
+
class="board-node-open"
|
|
26
|
+
type="button"
|
|
27
|
+
disabled={!target}
|
|
28
|
+
onClick={() => target && void openBoard(target.id)}
|
|
29
|
+
>
|
|
30
|
+
{target ? 'Open board' : 'Target unavailable'}
|
|
31
|
+
{target && <span aria-hidden="true">↗</span>}
|
|
32
|
+
</button>
|
|
33
|
+
</div>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
@@ -4,6 +4,7 @@ import { iframeMode } from '../state/iframe-mode';
|
|
|
4
4
|
import { fetchFile, updateNodeFromClient } from '../state/intent-bridge';
|
|
5
5
|
import type { CanvasNodeState } from '../types';
|
|
6
6
|
import { runNodeAxInteraction } from './ax-node-actions';
|
|
7
|
+
import { AttachmentNode } from './AttachmentNode';
|
|
7
8
|
|
|
8
9
|
/** Guess a language label from a file extension for display. */
|
|
9
10
|
function langFromPath(path: string): string {
|
|
@@ -134,6 +135,11 @@ export function formatBytes(bytes: number): string {
|
|
|
134
135
|
}
|
|
135
136
|
|
|
136
137
|
export function FileNode({ node, expanded = false }: { node: CanvasNodeState; expanded?: boolean }) {
|
|
138
|
+
if (typeof node.data.attachmentId === 'string') return <AttachmentNode node={node} expanded={expanded} />;
|
|
139
|
+
return <TextFileNode node={node} expanded={expanded} />;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function TextFileNode({ node, expanded = false }: { node: CanvasNodeState; expanded?: boolean }) {
|
|
137
143
|
const filePath = (node.data.path as string) || (node.data.content as string) || '';
|
|
138
144
|
const title = (node.data.title as string) || filePath.split('/').pop() || 'File';
|
|
139
145
|
const cachedContent = node.data.fileContent as string | undefined;
|
|
@@ -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
|
}
|
|
@@ -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
|
+
}
|