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,145 @@
|
|
|
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 [busy, setBusy] = useState(false);
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
let current = true;
|
|
17
|
+
const read = async () => {
|
|
18
|
+
const result = await requestJson<{ imports?: DocumentImport[] } | null>(
|
|
19
|
+
'readImports',
|
|
20
|
+
`/api/canvas/imports?boardId=${encodeURIComponent(boardId ?? '')}`,
|
|
21
|
+
null,
|
|
22
|
+
);
|
|
23
|
+
if (current) setJob(result?.imports?.find((entry) => entry.attachmentId === attachmentId) ?? null);
|
|
24
|
+
};
|
|
25
|
+
void read();
|
|
26
|
+
const timer = window.setInterval(() => void read(), 2000);
|
|
27
|
+
return () => {
|
|
28
|
+
current = false;
|
|
29
|
+
window.clearInterval(timer);
|
|
30
|
+
};
|
|
31
|
+
}, [attachmentId, boardId]);
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (!review) return;
|
|
34
|
+
const onKey = (event: KeyboardEvent) => {
|
|
35
|
+
if (event.key === 'Escape') {
|
|
36
|
+
event.stopPropagation();
|
|
37
|
+
setReview(false);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
window.addEventListener('keydown', onKey, true);
|
|
41
|
+
return () => window.removeEventListener('keydown', onKey, true);
|
|
42
|
+
}, [review]);
|
|
43
|
+
const act = async (action: 'request' | 'cancel' | 'commit') => {
|
|
44
|
+
if (!job) return;
|
|
45
|
+
setBusy(true);
|
|
46
|
+
const result = await requestJson<{ import?: DocumentImport } | null>(
|
|
47
|
+
`import.${action}`,
|
|
48
|
+
`/api/canvas/imports/${job.id}/${action}`,
|
|
49
|
+
null,
|
|
50
|
+
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ consent: true }) },
|
|
51
|
+
);
|
|
52
|
+
setBusy(false);
|
|
53
|
+
if (result?.import) {
|
|
54
|
+
setJob(result.import);
|
|
55
|
+
setReview(false);
|
|
56
|
+
if (expanded && action === 'commit') collapseExpandedNode();
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
const canRequest = job && ['attached', 'cancelled', 'unavailable'].includes(job.status);
|
|
60
|
+
return (
|
|
61
|
+
<div class="attachment-node" onPointerDown={(event) => event.stopPropagation()}>
|
|
62
|
+
<div class="attachment-status">
|
|
63
|
+
{job?.status === 'requested'
|
|
64
|
+
? 'Waiting for agent'
|
|
65
|
+
: job?.status === 'drafted'
|
|
66
|
+
? 'Ready for review'
|
|
67
|
+
: job?.status === 'committed'
|
|
68
|
+
? 'Markdown added'
|
|
69
|
+
: 'Original attached'}
|
|
70
|
+
</div>
|
|
71
|
+
<a href={`/api/canvas/attachments/${attachmentId}/bytes`} target="_blank" rel="noopener noreferrer">
|
|
72
|
+
Download original · {Math.ceil(Number(node.data.size ?? 0) / 1024)} KB
|
|
73
|
+
</a>
|
|
74
|
+
{job?.reason && <p>{job.reason}</p>}
|
|
75
|
+
{canRequest && (
|
|
76
|
+
<>
|
|
77
|
+
<p id={`import-consent-${node.id}`}>
|
|
78
|
+
Asking the agent to import allows it to process this file with its tools and model provider.
|
|
79
|
+
</p>
|
|
80
|
+
<button
|
|
81
|
+
type="button"
|
|
82
|
+
class="attachment-button is-primary"
|
|
83
|
+
aria-describedby={`import-consent-${node.id}`}
|
|
84
|
+
disabled={busy}
|
|
85
|
+
onClick={() => void act('request')}
|
|
86
|
+
>
|
|
87
|
+
Ask agent to import
|
|
88
|
+
</button>
|
|
89
|
+
</>
|
|
90
|
+
)}
|
|
91
|
+
{job?.status === 'requested' && (
|
|
92
|
+
<p>
|
|
93
|
+
No automatic extraction. Ask your connected agent to check its pending steering or use canvas_import list.
|
|
94
|
+
</p>
|
|
95
|
+
)}
|
|
96
|
+
{job?.status === 'drafted' && !review && (
|
|
97
|
+
<button
|
|
98
|
+
type="button"
|
|
99
|
+
class="attachment-button is-primary"
|
|
100
|
+
onClick={() => (expanded ? setReview(true) : expandNode(node.id))}
|
|
101
|
+
>
|
|
102
|
+
Review Markdown · {job.sections.length} {job.sections.length === 1 ? 'card' : 'cards'}
|
|
103
|
+
</button>
|
|
104
|
+
)}
|
|
105
|
+
{job && ['requested', 'drafted'].includes(job.status) && (
|
|
106
|
+
<button type="button" class="attachment-button" disabled={busy} onClick={() => void act('cancel')}>
|
|
107
|
+
Cancel import
|
|
108
|
+
</button>
|
|
109
|
+
)}
|
|
110
|
+
{review && job?.status === 'drafted' && (
|
|
111
|
+
<div class="attachment-review" role="dialog" aria-label="Review imported Markdown">
|
|
112
|
+
<h3>Review imported Markdown</h3>
|
|
113
|
+
<p>Source material, not instructions or verified knowledge. {job.agentDescription}</p>
|
|
114
|
+
{job.warnings.length > 0 && (
|
|
115
|
+
<ul>
|
|
116
|
+
{job.warnings.map((warning) => (
|
|
117
|
+
<li key={warning}>{warning}</li>
|
|
118
|
+
))}
|
|
119
|
+
</ul>
|
|
120
|
+
)}
|
|
121
|
+
{job.sections.map((section, index) => (
|
|
122
|
+
<section key={index}>
|
|
123
|
+
<h4>{section.title}</h4>
|
|
124
|
+
<p>{section.reference || 'Source location not supplied'}</p>
|
|
125
|
+
<pre>{section.markdown}</pre>
|
|
126
|
+
</section>
|
|
127
|
+
))}
|
|
128
|
+
<div class="attachment-review-actions">
|
|
129
|
+
<button type="button" class="attachment-button" onClick={() => setReview(false)}>
|
|
130
|
+
Close review
|
|
131
|
+
</button>
|
|
132
|
+
<button
|
|
133
|
+
type="button"
|
|
134
|
+
class="attachment-button is-primary"
|
|
135
|
+
disabled={busy || boardId !== job.boardId}
|
|
136
|
+
onClick={() => void act('commit')}
|
|
137
|
+
>
|
|
138
|
+
Add to board
|
|
139
|
+
</button>
|
|
140
|
+
</div>
|
|
141
|
+
</div>
|
|
142
|
+
)}
|
|
143
|
+
</div>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
@@ -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,
|
|
@@ -274,7 +274,9 @@ function enqueueToast(entry: AttentionEntry): void {
|
|
|
274
274
|
|
|
275
275
|
/** Show a transient toast from arbitrary client code (e.g. AX action feedback). */
|
|
276
276
|
export function showToast(tone: AttentionTone, title: string, detail = '', nodeIds: string[] = []): void {
|
|
277
|
-
|
|
277
|
+
const entry = makeEntry(tone, title, detail, nodeIds);
|
|
278
|
+
pushAttentionHistory(entry);
|
|
279
|
+
enqueueToast(entry);
|
|
278
280
|
}
|
|
279
281
|
|
|
280
282
|
function pulseNodes(nodeIds: string[]): void {
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { signal } from '@preact/signals';
|
|
2
|
+
import { requestJson } from './intent-bridge';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Many boards (plan 012): the workspace's boards and which one is open. The
|
|
6
|
+
* server is the authority; this mirrors GET /api/canvas/boards and the
|
|
7
|
+
* `boards-changed` SSE frame. `activeBoardId === null` is Home.
|
|
8
|
+
*/
|
|
9
|
+
export interface BoardSummary {
|
|
10
|
+
id: string;
|
|
11
|
+
name: string;
|
|
12
|
+
category: string | null;
|
|
13
|
+
createdAt: string;
|
|
14
|
+
lastOpenedAt: string | null;
|
|
15
|
+
nodeCount: number;
|
|
16
|
+
readmeNodeId?: string | null;
|
|
17
|
+
summary?: string | null;
|
|
18
|
+
pinnedTitles?: Array<{ nodeId: string; title: string }>;
|
|
19
|
+
links?: Array<{ nodeId: string; boardId: string; title: string | null; missing: boolean }>;
|
|
20
|
+
backlinks?: Array<{ boardId: string; title: string; nodeId: string }>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface BoardsPayload {
|
|
24
|
+
activeBoardId: string | null;
|
|
25
|
+
boards: BoardSummary[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const boardList = signal<BoardSummary[]>([]);
|
|
29
|
+
export const activeBoardId = signal<string | null>(null);
|
|
30
|
+
/** False until the first list arrives — Home must not flash before the server answers. */
|
|
31
|
+
export const boardsLoaded = signal(false);
|
|
32
|
+
|
|
33
|
+
let onBoardSwitched: (() => void) | null = null;
|
|
34
|
+
let boardsRevision = 0;
|
|
35
|
+
let openRequestSequence = 0;
|
|
36
|
+
let openRequestQueue: Promise<void> = Promise.resolve();
|
|
37
|
+
|
|
38
|
+
/** The SSE bridge resyncs the canvas when the open board changes under it. */
|
|
39
|
+
export function setBoardSwitchHandler(handler: (() => void) | null): void {
|
|
40
|
+
onBoardSwitched = handler;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function applyBoards(data: { activeBoardId?: unknown; boards?: unknown; reloaded?: unknown } | null): void {
|
|
44
|
+
if (!data || !Array.isArray(data.boards)) return;
|
|
45
|
+
boardsRevision += 1;
|
|
46
|
+
const nextActive = typeof data.activeBoardId === 'string' ? data.activeBoardId : null;
|
|
47
|
+
// `reloaded`: the library was restored, so even the same board id holds new content.
|
|
48
|
+
const switched = boardsLoaded.value && (nextActive !== activeBoardId.value || data.reloaded === true);
|
|
49
|
+
boardList.value = data.boards as BoardSummary[];
|
|
50
|
+
activeBoardId.value = nextActive;
|
|
51
|
+
boardsLoaded.value = true;
|
|
52
|
+
if (switched) onBoardSwitched?.();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function loadBoards(): Promise<void> {
|
|
56
|
+
const revision = boardsRevision;
|
|
57
|
+
const payload = await requestJson<BoardsPayload | null>('loadBoards', '/api/canvas/boards', null);
|
|
58
|
+
if (revision === boardsRevision) applyBoards(payload);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function post(
|
|
62
|
+
action: string,
|
|
63
|
+
url: string,
|
|
64
|
+
method: string,
|
|
65
|
+
body?: unknown,
|
|
66
|
+
revision = boardsRevision,
|
|
67
|
+
): Promise<BoardsPayload | null> {
|
|
68
|
+
const payload = await requestJson<BoardsPayload | null>(action, url, null, {
|
|
69
|
+
method,
|
|
70
|
+
headers: { 'Content-Type': 'application/json' },
|
|
71
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
72
|
+
});
|
|
73
|
+
return revision === boardsRevision ? payload : null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Open a board, or Home with null. */
|
|
77
|
+
export async function openBoard(id: string | null): Promise<void> {
|
|
78
|
+
const sequence = ++openRequestSequence;
|
|
79
|
+
const revision = boardsRevision;
|
|
80
|
+
let payload: BoardsPayload | null = null;
|
|
81
|
+
const request = openRequestQueue.then(async () => {
|
|
82
|
+
payload = await post('openBoard', '/api/canvas/boards/open', 'POST', { id }, revision);
|
|
83
|
+
});
|
|
84
|
+
openRequestQueue = request.catch(() => undefined);
|
|
85
|
+
await request;
|
|
86
|
+
// post drops replies superseded by SSE; also ignore an older open request.
|
|
87
|
+
if (sequence === openRequestSequence) applyBoards(payload);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function createAndOpenBoard(name: string): Promise<void> {
|
|
91
|
+
const sequence = ++openRequestSequence;
|
|
92
|
+
const created = await requestJson<{ board?: BoardSummary } | null>('createBoard', '/api/canvas/boards', null, {
|
|
93
|
+
method: 'POST',
|
|
94
|
+
headers: { 'Content-Type': 'application/json' },
|
|
95
|
+
body: JSON.stringify({ name }),
|
|
96
|
+
});
|
|
97
|
+
if (created?.board && sequence === openRequestSequence) await openBoard(created.board.id);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface BoardCopyCard {
|
|
101
|
+
id: string;
|
|
102
|
+
type: string;
|
|
103
|
+
title: string | null;
|
|
104
|
+
reusable: boolean;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface BoardCopyPreview {
|
|
108
|
+
sourceBoardId: string;
|
|
109
|
+
readmeNodeId: string | null;
|
|
110
|
+
cards: BoardCopyCard[];
|
|
111
|
+
edges: Array<{ id: string; from: string; to: string; type: string }>;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function previewBoardCopy(sourceBoardId: string): Promise<BoardCopyPreview | null> {
|
|
115
|
+
return requestJson<BoardCopyPreview | null>('previewBoardCopy', '/api/canvas/boards/from', null, {
|
|
116
|
+
method: 'POST',
|
|
117
|
+
headers: { 'Content-Type': 'application/json' },
|
|
118
|
+
body: JSON.stringify({ sourceBoardId, name: 'Preview', preview: true }),
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function createBoardFrom(opts: {
|
|
123
|
+
sourceBoardId: string;
|
|
124
|
+
name: string;
|
|
125
|
+
category?: string;
|
|
126
|
+
nodeIds: string[];
|
|
127
|
+
includeReadme: boolean;
|
|
128
|
+
includeStructure: boolean;
|
|
129
|
+
}): Promise<BoardSummary | null> {
|
|
130
|
+
const result = await requestJson<{ board?: BoardSummary } | null>(
|
|
131
|
+
'createBoardFrom',
|
|
132
|
+
'/api/canvas/boards/from',
|
|
133
|
+
null,
|
|
134
|
+
{
|
|
135
|
+
method: 'POST',
|
|
136
|
+
headers: { 'Content-Type': 'application/json' },
|
|
137
|
+
body: JSON.stringify(opts),
|
|
138
|
+
},
|
|
139
|
+
);
|
|
140
|
+
await loadBoards();
|
|
141
|
+
return result?.board ?? null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Rename and/or re-file a board; `category: null` removes it from its category. */
|
|
145
|
+
export async function updateBoard(id: string, patch: { name?: string; category?: string | null }): Promise<void> {
|
|
146
|
+
const payload = await post('updateBoard', `/api/canvas/boards/${encodeURIComponent(id)}`, 'PATCH', {
|
|
147
|
+
...patch,
|
|
148
|
+
...(patch.category === null ? { category: '' } : {}),
|
|
149
|
+
});
|
|
150
|
+
applyBoards(payload);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export async function setBoardReadme(id: string, readmeNodeId: string | null): Promise<void> {
|
|
154
|
+
const payload = await post('setBoardReadme', `/api/canvas/boards/${encodeURIComponent(id)}`, 'PATCH', {
|
|
155
|
+
readmeNodeId,
|
|
156
|
+
});
|
|
157
|
+
applyBoards(payload);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export async function deleteBoard(id: string): Promise<void> {
|
|
161
|
+
const payload = await post('deleteBoard', `/api/canvas/boards/${encodeURIComponent(id)}`, 'DELETE');
|
|
162
|
+
applyBoards(payload);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function activeBoard(): BoardSummary | null {
|
|
166
|
+
return boardList.value.find((board) => board.id === activeBoardId.value) ?? null;
|
|
167
|
+
}
|
|
@@ -144,6 +144,28 @@ export function clearSelection(): void {
|
|
|
144
144
|
selectedNodeIds.value = new Set();
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
+
/** Clear transient UI state that must never follow a node id onto another board. */
|
|
148
|
+
export function resetCanvasInteractionState(): void {
|
|
149
|
+
if (expandedCloseTimer !== null) {
|
|
150
|
+
clearTimeout(expandedCloseTimer);
|
|
151
|
+
expandedCloseTimer = null;
|
|
152
|
+
}
|
|
153
|
+
batch(() => {
|
|
154
|
+
activeNodeId.value = null;
|
|
155
|
+
expandedNodeId.value = null;
|
|
156
|
+
pendingExpandedNodeCloseId.value = null;
|
|
157
|
+
pendingCloseInitialCheckpointAt = undefined;
|
|
158
|
+
selectedNodeIds.value = new Set();
|
|
159
|
+
selectedEdgeId.value = null;
|
|
160
|
+
pendingConnection.value = null;
|
|
161
|
+
draggingEdge.value = null;
|
|
162
|
+
searchHighlightIds.value = null;
|
|
163
|
+
dragDropTarget.value = null;
|
|
164
|
+
contextPinnedNodeIds.value = new Set();
|
|
165
|
+
});
|
|
166
|
+
dropSuppressedForDrag = false;
|
|
167
|
+
}
|
|
168
|
+
|
|
147
169
|
// ── Groups v2 (rail-chrome-v2 phase 7, item 20) ──────────────────────
|
|
148
170
|
|
|
149
171
|
/**
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { canvasArea } from '../canvas/canvas-area';
|
|
2
|
+
import { showToast } from './attention-bridge';
|
|
2
3
|
import type { AgentPresenceSnapshot } from '../../shared/agent-presence.js';
|
|
3
4
|
function logRequestError(action: string, error: unknown): void {
|
|
4
5
|
console.error(`[intent-bridge] ${action} failed`, error);
|
|
@@ -31,14 +32,38 @@ function withWorkbenchMarker(init?: RequestInit): RequestInit {
|
|
|
31
32
|
headers['x-pmx-source'] = tabAgentKey;
|
|
32
33
|
} else {
|
|
33
34
|
headers['X-PMX-Workbench'] = '1';
|
|
35
|
+
const token = (window as Window & { __PMX_WORKBENCH_TOKEN?: unknown }).__PMX_WORKBENCH_TOKEN;
|
|
36
|
+
if (typeof token === 'string') headers['X-PMX-Workbench-Token'] = token;
|
|
34
37
|
}
|
|
35
38
|
return { ...init, headers };
|
|
36
39
|
}
|
|
37
40
|
|
|
41
|
+
/**
|
|
42
|
+
* A write the server refused (fence, edit lock, validation) must not fail
|
|
43
|
+
* silently: the human sees the server's reason. Reads stay quiet.
|
|
44
|
+
*/
|
|
45
|
+
function reportRefusedWrite(res: Response, init: RequestInit | undefined, body: unknown): void {
|
|
46
|
+
if (res.ok || (init?.method ?? 'GET').toUpperCase() === 'GET') return;
|
|
47
|
+
const error =
|
|
48
|
+
body !== null && typeof body === 'object' && 'error' in body && typeof body.error === 'string' ? body.error : '';
|
|
49
|
+
showToast('remove', 'Change refused', error || `The server answered ${res.status}.`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function readJsonBody(res: Response): Promise<unknown> {
|
|
53
|
+
try {
|
|
54
|
+
return await res.json();
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
38
60
|
export async function requestJson<T>(action: string, url: string, fallback: T, init?: RequestInit): Promise<T> {
|
|
39
61
|
try {
|
|
40
62
|
const res = await fetch(url, withWorkbenchMarker(init));
|
|
41
|
-
|
|
63
|
+
const body = await readJsonBody(res);
|
|
64
|
+
reportRefusedWrite(res, init, body);
|
|
65
|
+
if (body === null) throw new Error(`${res.status} response had no JSON body`);
|
|
66
|
+
return body as T;
|
|
42
67
|
} catch (error) {
|
|
43
68
|
logRequestError(action, error);
|
|
44
69
|
return fallback;
|
|
@@ -48,6 +73,7 @@ export async function requestJson<T>(action: string, url: string, fallback: T, i
|
|
|
48
73
|
export async function requestOk(action: string, url: string, init?: RequestInit): Promise<{ ok: boolean }> {
|
|
49
74
|
try {
|
|
50
75
|
const res = await fetch(url, withWorkbenchMarker(init));
|
|
76
|
+
if (!res.ok) reportRefusedWrite(res, init, await readJsonBody(res));
|
|
51
77
|
return { ok: res.ok };
|
|
52
78
|
} catch (error) {
|
|
53
79
|
logRequestError(action, error);
|
|
@@ -57,7 +83,8 @@ export async function requestOk(action: string, url: string, init?: RequestInit)
|
|
|
57
83
|
|
|
58
84
|
export async function requestBestEffort(action: string, url: string, init?: RequestInit): Promise<void> {
|
|
59
85
|
try {
|
|
60
|
-
await fetch(url, withWorkbenchMarker(init));
|
|
86
|
+
const res = await fetch(url, withWorkbenchMarker(init));
|
|
87
|
+
if (!res.ok) reportRefusedWrite(res, init, await readJsonBody(res));
|
|
61
88
|
} catch (error) {
|
|
62
89
|
logRequestError(action, error);
|
|
63
90
|
}
|
|
@@ -262,6 +289,7 @@ export async function createNodeFromClient(opts: {
|
|
|
262
289
|
y?: number;
|
|
263
290
|
width?: number;
|
|
264
291
|
height?: number;
|
|
292
|
+
boardId?: string;
|
|
265
293
|
}): Promise<{ ok: boolean; id?: string }> {
|
|
266
294
|
return requestJson(
|
|
267
295
|
'createNodeFromClient',
|
|
@@ -27,6 +27,7 @@ export const DEFAULT_POSITIONS: Record<CanvasNodeState['type'], { x: number; y:
|
|
|
27
27
|
webpage: { x: 380, y: 80, w: 520, h: 420 },
|
|
28
28
|
'json-render': { x: 380, y: 720, w: 840, h: 620 },
|
|
29
29
|
graph: { x: 380, y: 720, w: 760, h: 520 },
|
|
30
|
+
board: { x: 380, y: 80, w: 360, h: 160 },
|
|
30
31
|
ledger: { x: 1130, y: 520, w: 320, h: 280 },
|
|
31
32
|
trace: { x: 40, y: 900, w: 200, h: 56 },
|
|
32
33
|
file: { x: 380, y: 80, w: 720, h: 600 },
|
|
@@ -26,6 +26,8 @@ export interface ApprovalGateView {
|
|
|
26
26
|
createdAt: string;
|
|
27
27
|
/** Unattended-approval TTL: when a pending gate auto-holds. */
|
|
28
28
|
expiresAt: string | null;
|
|
29
|
+
resolvedBy?: { actor: 'human' | 'agent' | 'system' | 'unknown'; source: string; agentId?: string } | null;
|
|
30
|
+
selfAnswer?: boolean;
|
|
29
31
|
}
|
|
30
32
|
export interface AxEventView {
|
|
31
33
|
id: string;
|
|
@@ -106,6 +108,9 @@ export async function setScopeFence(nodeIds: string[] | null): Promise<boolean>
|
|
|
106
108
|
|
|
107
109
|
/** Auto-held gates: the policy said no on the human's behalf; they can be reopened. */
|
|
108
110
|
export const heldGates = computed(() => sessionGates.value.filter((gate) => gate.status === 'held'));
|
|
111
|
+
export const resolvedGates = computed(() =>
|
|
112
|
+
sessionGates.value.filter((gate) => gate.status === 'approved' || gate.status === 'rejected'),
|
|
113
|
+
);
|
|
109
114
|
|
|
110
115
|
export interface AxTimelineView {
|
|
111
116
|
events: AxEventView[];
|