pmx-canvas 0.7.0 → 0.7.2
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 +149 -70
- package/CHANGELOG.md +32 -1
- package/Readme.md +40 -13
- package/dist/canvas/global.css +94 -15
- package/dist/canvas/index.js +76 -76
- package/dist/types/cli/daemon.d.ts +5 -5
- package/dist/types/cli/shared.d.ts +3 -1
- package/dist/types/client/canvas/ExternalWriters.d.ts +3 -3
- package/dist/types/client/icons.d.ts +15 -0
- package/dist/types/client/nodes/ExtAppFrame.d.ts +1 -1
- package/dist/types/client/nodes/FileNode.d.ts +0 -12
- package/dist/types/shared/delimited-text.d.ts +14 -0
- package/docs/cli.md +14 -2
- package/docs/http-api.md +12 -8
- package/docs/mcp.md +8 -1
- package/docs/sdk.md +2 -2
- package/package.json +1 -1
- package/skills/pmx-canvas/SKILL.md +35 -14
- package/skills/pmx-canvas/evals/evals.json +171 -0
- package/skills/pmx-canvas/references/full-reference.md +6 -1
- package/skills/pmx-canvas/references/github-copilot-app-adapter.md +17 -3
- 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-testing/SKILL.md +7 -2
- package/src/cli/commands/apps.ts +1 -1
- package/src/cli/commands/backup.ts +15 -4
- package/src/cli/commands/boards.ts +16 -3
- package/src/cli/commands/export.ts +9 -2
- package/src/cli/daemon.ts +37 -7
- package/src/cli/shared.ts +11 -2
- package/src/client/App.tsx +0 -1
- package/src/client/canvas/AttentionHistory.tsx +2 -1
- package/src/client/canvas/AttentionToast.tsx +5 -3
- package/src/client/canvas/BoardSwitcher.tsx +3 -3
- package/src/client/canvas/CanvasNode.tsx +17 -9
- package/src/client/canvas/CommandBar.tsx +2 -2
- package/src/client/canvas/ContextMenu.tsx +0 -1
- package/src/client/canvas/ContextPinBar.tsx +3 -1
- package/src/client/canvas/ExpandedNodeOverlay.tsx +3 -3
- package/src/client/canvas/ExportDialog.tsx +3 -1
- package/src/client/canvas/ExternalWriters.tsx +4 -3
- package/src/client/canvas/FeedbackDialog.tsx +2 -1
- package/src/client/canvas/IntentLayer.tsx +2 -2
- package/src/client/canvas/Presentation.tsx +39 -9
- package/src/client/canvas/SelectionBar.tsx +2 -4
- package/src/client/canvas/SessionPanel.tsx +6 -4
- package/src/client/canvas/SessionReceipt.tsx +2 -1
- package/src/client/canvas/SnapshotPanel.tsx +3 -2
- package/src/client/canvas/ToolRail.tsx +2 -1
- package/src/client/canvas/TopBar.tsx +2 -2
- package/src/client/icons.tsx +118 -1
- package/src/client/nodes/AttachmentNode.tsx +27 -30
- package/src/client/nodes/BoardNode.tsx +2 -1
- package/src/client/nodes/ExtAppFrame.tsx +3 -1
- package/src/client/nodes/FileNode.tsx +4 -78
- package/src/client/nodes/ImageNode.tsx +2 -1
- package/src/client/nodes/MarkdownNode.tsx +2 -1
- package/src/client/nodes/PromptNode.tsx +3 -1
- package/src/client/state/attention-bridge.ts +3 -1
- package/src/client/state/sse-bridge.ts +3 -1
- package/src/client/theme/global.css +94 -15
- package/src/mcp/server.ts +2 -2
- package/src/server/agent-presence.ts +17 -11
- package/src/server/board-export.ts +97 -32
- package/src/server/index.ts +9 -7
- package/src/server/operations/composites.ts +3 -2
- package/src/server/operations/ops/ax-presence.ts +7 -3
- package/src/server/operations/ops/boards.ts +12 -5
- package/src/server/operations/ops/imports.ts +19 -3
- package/src/server/operations/registry.ts +4 -13
- package/src/server/server.ts +10 -3
- package/src/shared/delimited-text.ts +75 -0
|
@@ -2,7 +2,7 @@ import { useEffect } from 'preact/hooks';
|
|
|
2
2
|
import { nodes } from '../state/canvas-store';
|
|
3
3
|
import { hoveredIntentId, intents, removeIntent, type ClientIntent } from '../state/intent-store';
|
|
4
4
|
import { vetoGhostIntent } from '../state/intent-bridge';
|
|
5
|
-
import { getNodeIcon } from '../icons';
|
|
5
|
+
import { getNodeIcon, IconClose } from '../icons';
|
|
6
6
|
import { TYPE_LABELS } from '../types';
|
|
7
7
|
import type { CanvasNodeState } from '../types';
|
|
8
8
|
|
|
@@ -102,7 +102,7 @@ function GhostInfo({ intent }: { intent: ClientIntent }) {
|
|
|
102
102
|
void vetoGhostIntent(intent);
|
|
103
103
|
}}
|
|
104
104
|
>
|
|
105
|
-
|
|
105
|
+
<IconClose size={14} />
|
|
106
106
|
</button>
|
|
107
107
|
)}
|
|
108
108
|
</div>
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { useEffect, useState } from 'preact/hooks';
|
|
1
|
+
import { useEffect, useRef, useState } from 'preact/hooks';
|
|
2
2
|
import { cameraSchema, interpolateCamera, resolveStop, type Camera, type Tour } from '../../shared/tour.js';
|
|
3
3
|
import { canvasArea } from './canvas-area';
|
|
4
4
|
import { cancelViewportAnimation, hasInitialServerLayout, nodes, viewport } from '../state/canvas-store';
|
|
5
5
|
import { presenting, recordingCamera } from '../state/presentation';
|
|
6
6
|
import { requestJson } from '../state/intent-bridge';
|
|
7
|
+
import { usePanZoom } from './use-pan-zoom';
|
|
7
8
|
|
|
8
9
|
declare global {
|
|
9
10
|
interface Window {
|
|
@@ -22,6 +23,17 @@ export function Presentation() {
|
|
|
22
23
|
const [error, setError] = useState('');
|
|
23
24
|
const active = presenting.value;
|
|
24
25
|
const ready = hasInitialServerLayout.value;
|
|
26
|
+
const frame = useRef(0);
|
|
27
|
+
const moveCamera = (camera: Camera) => {
|
|
28
|
+
cancelAnimationFrame(frame.current);
|
|
29
|
+
viewport.value = camera;
|
|
30
|
+
};
|
|
31
|
+
// A separate gesture surface keeps cards read-only and camera changes local.
|
|
32
|
+
const navigationRef = usePanZoom({
|
|
33
|
+
viewport,
|
|
34
|
+
onViewportChange: moveCamera,
|
|
35
|
+
onViewportCommit: moveCamera,
|
|
36
|
+
});
|
|
25
37
|
useEffect(() => {
|
|
26
38
|
const params = new URLSearchParams(location.search);
|
|
27
39
|
presenting.value = params.get('present') === '1';
|
|
@@ -76,20 +88,20 @@ export function Presentation() {
|
|
|
76
88
|
const from = { ...viewport.value };
|
|
77
89
|
const start = performance.now();
|
|
78
90
|
const duration = (stop.duration ?? 1) * 1000;
|
|
79
|
-
let frame = 0;
|
|
80
91
|
const tick = (now: number) => {
|
|
81
92
|
if (recordingCamera.value) return;
|
|
82
93
|
const t = duration === 0 ? 1 : Math.min(1, (now - start) / duration);
|
|
83
94
|
viewport.value = interpolateCamera(from, target, t, area.width, area.height, stop.easing, stop.pullback);
|
|
84
|
-
if (t < 1) frame = requestAnimationFrame(tick);
|
|
95
|
+
if (t < 1) frame.current = requestAnimationFrame(tick);
|
|
85
96
|
};
|
|
86
|
-
frame = requestAnimationFrame(tick);
|
|
87
|
-
return () => cancelAnimationFrame(frame);
|
|
97
|
+
frame.current = requestAnimationFrame(tick);
|
|
98
|
+
return () => cancelAnimationFrame(frame.current);
|
|
88
99
|
}, [active, index, tour]);
|
|
89
100
|
useEffect(() => {
|
|
90
101
|
if (!active) return;
|
|
91
102
|
const key = (event: KeyboardEvent) => {
|
|
92
103
|
event.stopImmediatePropagation();
|
|
104
|
+
if (event.target instanceof HTMLButtonElement && ['Enter', ' '].includes(event.key)) return;
|
|
93
105
|
if (['Escape', 'ArrowRight', 'ArrowDown', 'ArrowLeft', 'ArrowUp', ' '].includes(event.key))
|
|
94
106
|
event.preventDefault();
|
|
95
107
|
if (event.key === 'Escape') presenting.value = false;
|
|
@@ -100,9 +112,27 @@ export function Presentation() {
|
|
|
100
112
|
document.addEventListener('keydown', key, true);
|
|
101
113
|
return () => document.removeEventListener('keydown', key, true);
|
|
102
114
|
}, [active, tour]);
|
|
103
|
-
return active
|
|
104
|
-
|
|
105
|
-
{
|
|
106
|
-
|
|
115
|
+
return active ? (
|
|
116
|
+
<>
|
|
117
|
+
{!recordingCamera.value && (
|
|
118
|
+
<div
|
|
119
|
+
class="presentation-navigation"
|
|
120
|
+
ref={navigationRef}
|
|
121
|
+
onPointerDown={(event) => {
|
|
122
|
+
if (event.button === 1 || event.button === 2) cancelAnimationFrame(frame.current);
|
|
123
|
+
}}
|
|
124
|
+
onContextMenu={(event) => event.preventDefault()}
|
|
125
|
+
>
|
|
126
|
+
<button type="button" class="presentation-exit" onClick={() => (presenting.value = false)}>
|
|
127
|
+
Exit presentation
|
|
128
|
+
</button>
|
|
129
|
+
</div>
|
|
130
|
+
)}
|
|
131
|
+
{error && (
|
|
132
|
+
<div class="presentation-error" role="alert">
|
|
133
|
+
{error}
|
|
134
|
+
</div>
|
|
135
|
+
)}
|
|
136
|
+
</>
|
|
107
137
|
) : null;
|
|
108
138
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useCallback } from 'preact/hooks';
|
|
2
|
-
import { IconArrange } from '../icons';
|
|
2
|
+
import { IconArrange, IconClose } from '../icons';
|
|
3
3
|
import { attentionHistoryOpen } from '../state/attention-store';
|
|
4
4
|
import {
|
|
5
5
|
addContextPins,
|
|
@@ -8,7 +8,6 @@ import {
|
|
|
8
8
|
clearSelection,
|
|
9
9
|
contextPinnedNodeIds,
|
|
10
10
|
distributeSelection,
|
|
11
|
-
removeNode,
|
|
12
11
|
selectedNodeIds,
|
|
13
12
|
} from '../state/canvas-store';
|
|
14
13
|
import { createEdgeFromClient, createGroupFromClient, removeNodeFromClient } from '../state/intent-bridge';
|
|
@@ -51,7 +50,6 @@ export function SelectionBar() {
|
|
|
51
50
|
const handleDelete = useCallback(() => {
|
|
52
51
|
const ids = Array.from(selectedNodeIds.value);
|
|
53
52
|
for (const id of ids) {
|
|
54
|
-
removeNode(id);
|
|
55
53
|
void removeNodeFromClient(id);
|
|
56
54
|
}
|
|
57
55
|
clearSelection();
|
|
@@ -206,7 +204,7 @@ export function SelectionBar() {
|
|
|
206
204
|
</BarHint>
|
|
207
205
|
<BarHint label="Clear selection" shortcut="Esc" side="up">
|
|
208
206
|
<button type="button" class="selection-bar-icon" onClick={clearSelection} aria-label="Clear selection">
|
|
209
|
-
|
|
207
|
+
<IconClose size={14} />
|
|
210
208
|
</button>
|
|
211
209
|
</BarHint>
|
|
212
210
|
</div>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useEffect, useState } from 'preact/hooks';
|
|
2
|
+
import { IconChevronRight, IconUndo } from '../icons';
|
|
2
3
|
import { useNow } from './use-now';
|
|
3
4
|
import { agentIdentityHue, agentPhaseLabel } from '../../shared/agent-presence.js';
|
|
4
5
|
import { formatCountdown, gateRemainingMs } from '../../shared/approval-gates.js';
|
|
@@ -301,7 +302,7 @@ function TimelineRow({ entry }: { entry: TimelineEntry }) {
|
|
|
301
302
|
data-testid="timeline-undo"
|
|
302
303
|
onClick={() => void undoAgentEdit(entry)}
|
|
303
304
|
>
|
|
304
|
-
|
|
305
|
+
<IconUndo size={14} /> undo this edit
|
|
305
306
|
</button>
|
|
306
307
|
)}
|
|
307
308
|
{undone && <div class="session-timeline-undone">undone · steering sent</div>}
|
|
@@ -387,8 +388,9 @@ export function SessionPanel() {
|
|
|
387
388
|
class="session-collapse"
|
|
388
389
|
onClick={() => setManuallyOpened(false)}
|
|
389
390
|
title="Collapse session panel"
|
|
391
|
+
aria-label="Collapse session panel"
|
|
390
392
|
>
|
|
391
|
-
|
|
393
|
+
<IconChevronRight />
|
|
392
394
|
</button>
|
|
393
395
|
</header>
|
|
394
396
|
|
|
@@ -406,7 +408,7 @@ export function SessionPanel() {
|
|
|
406
408
|
Agent edit on top: {undoable.body}
|
|
407
409
|
</span>
|
|
408
410
|
<button type="button" class="session-undo-btn" onClick={() => void undoAgentEdit(undoable)}>
|
|
409
|
-
|
|
411
|
+
<IconUndo size={14} /> Undo
|
|
410
412
|
</button>
|
|
411
413
|
</div>
|
|
412
414
|
);
|
|
@@ -455,7 +457,7 @@ export function SessionPanel() {
|
|
|
455
457
|
{summary}
|
|
456
458
|
</span>
|
|
457
459
|
<span class={`session-section-chevron${showList ? ' is-open' : ''}`} aria-hidden="true">
|
|
458
|
-
|
|
460
|
+
<IconChevronRight size={14} />
|
|
459
461
|
</span>
|
|
460
462
|
</button>
|
|
461
463
|
{showList &&
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useState } from 'preact/hooks';
|
|
2
2
|
import { dismissSessionReceipt, sessionReceipt } from '../state/session-store';
|
|
3
|
+
import { IconClose } from '../icons';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Session receipt (rail-chrome-v2 phase 5, design item 2): a dismissible card
|
|
@@ -74,7 +75,7 @@ export function SessionReceipt({ onOpenSnapshots }: { onOpenSnapshots: () => voi
|
|
|
74
75
|
onClick={dismissSessionReceipt}
|
|
75
76
|
aria-label="Dismiss receipt"
|
|
76
77
|
>
|
|
77
|
-
|
|
78
|
+
<IconClose />
|
|
78
79
|
</button>
|
|
79
80
|
</div>
|
|
80
81
|
<div class="session-receipt-tiles">
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
|
|
2
|
+
import { IconClose } from '../icons';
|
|
2
3
|
import {
|
|
3
4
|
type CanvasSnapshotInfo,
|
|
4
5
|
deleteSnapshot,
|
|
@@ -183,7 +184,7 @@ export function SnapshotPanel({
|
|
|
183
184
|
aria-label="Delete snapshot"
|
|
184
185
|
disabled={restoringId !== null}
|
|
185
186
|
>
|
|
186
|
-
|
|
187
|
+
<IconClose size={14} />
|
|
187
188
|
</button>
|
|
188
189
|
</>
|
|
189
190
|
);
|
|
@@ -195,7 +196,7 @@ export function SnapshotPanel({
|
|
|
195
196
|
<span class="snapshot-panel-sub">snapshots + sessions</span>
|
|
196
197
|
<span class="snapshot-panel-spacer" />
|
|
197
198
|
<button type="button" class="snapshot-panel-close" onClick={onClose} title="Close" aria-label="Close history">
|
|
198
|
-
|
|
199
|
+
<IconClose />
|
|
199
200
|
</button>
|
|
200
201
|
</div>
|
|
201
202
|
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
IconSun,
|
|
22
22
|
IconTextAnnotation,
|
|
23
23
|
IconTrace,
|
|
24
|
+
IconUpload,
|
|
24
25
|
} from '../icons';
|
|
25
26
|
import {
|
|
26
27
|
autoArrange,
|
|
@@ -337,7 +338,7 @@ export function ToolRail({
|
|
|
337
338
|
<IconNodeFile size={15} />
|
|
338
339
|
</RailButton>
|
|
339
340
|
<RailButton label="Attach document" onClick={() => documentInput.current?.click()}>
|
|
340
|
-
<
|
|
341
|
+
<IconUpload size={15} />
|
|
341
342
|
</RailButton>
|
|
342
343
|
<input
|
|
343
344
|
ref={documentInput}
|
|
@@ -4,7 +4,7 @@ import { BoardSwitcher } from './BoardSwitcher';
|
|
|
4
4
|
import type { ComponentChildren } from 'preact';
|
|
5
5
|
import { presenting } from '../state/presentation';
|
|
6
6
|
import { useEffect, useState } from 'preact/hooks';
|
|
7
|
-
import { IconFitAll, IconZoomIn, IconZoomOut } from '../icons';
|
|
7
|
+
import { IconFitAll, IconZoomIn, IconZoomOut, IconClose } from '../icons';
|
|
8
8
|
import {
|
|
9
9
|
animateViewport,
|
|
10
10
|
connectionStatus,
|
|
@@ -121,7 +121,7 @@ function AgentChip() {
|
|
|
121
121
|
void endSession({ source: session.source, agentId: session.agentId });
|
|
122
122
|
}}
|
|
123
123
|
>
|
|
124
|
-
|
|
124
|
+
<IconClose size={14} />
|
|
125
125
|
</button>
|
|
126
126
|
</span>
|
|
127
127
|
</BarHint>
|
package/src/client/icons.tsx
CHANGED
|
@@ -11,16 +11,133 @@ const defaults = {
|
|
|
11
11
|
'stroke-width': '1.5',
|
|
12
12
|
'stroke-linecap': 'round',
|
|
13
13
|
'stroke-linejoin': 'round',
|
|
14
|
+
'aria-hidden': 'true',
|
|
14
15
|
} as const;
|
|
15
16
|
|
|
16
17
|
function Icon({ size = 16, children, ...rest }: IconProps & { children: JSX.Element | JSX.Element[] }): JSX.Element {
|
|
17
18
|
return (
|
|
18
|
-
<svg
|
|
19
|
+
<svg
|
|
20
|
+
width={size}
|
|
21
|
+
height={size}
|
|
22
|
+
viewBox="0 0 16 16"
|
|
23
|
+
style={{ verticalAlign: 'middle', flexShrink: 0 }}
|
|
24
|
+
{...defaults}
|
|
25
|
+
{...rest}
|
|
26
|
+
>
|
|
19
27
|
{children}
|
|
20
28
|
</svg>
|
|
21
29
|
);
|
|
22
30
|
}
|
|
23
31
|
|
|
32
|
+
export function IconClose(p: IconProps): JSX.Element {
|
|
33
|
+
return (
|
|
34
|
+
<Icon {...p}>
|
|
35
|
+
<path d="m4 4 8 8M12 4l-8 8" />
|
|
36
|
+
</Icon>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function IconPlus(p: IconProps): JSX.Element {
|
|
41
|
+
return (
|
|
42
|
+
<Icon {...p}>
|
|
43
|
+
<path d="M8 2v12M2 8h12" />
|
|
44
|
+
</Icon>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function IconPlusCircle(p: IconProps): JSX.Element {
|
|
49
|
+
return (
|
|
50
|
+
<Icon {...p}>
|
|
51
|
+
<circle cx="8" cy="8" r="6" />
|
|
52
|
+
<path d="M8 5v6M5 8h6" />
|
|
53
|
+
</Icon>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function IconChevronRight(p: IconProps): JSX.Element {
|
|
58
|
+
return (
|
|
59
|
+
<Icon {...p}>
|
|
60
|
+
<polyline points="6 3 11 8 6 13" />
|
|
61
|
+
</Icon>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function IconMore(p: IconProps): JSX.Element {
|
|
66
|
+
return (
|
|
67
|
+
<Icon {...p}>
|
|
68
|
+
<circle cx="3" cy="8" r="0.75" />
|
|
69
|
+
<circle cx="8" cy="8" r="0.75" />
|
|
70
|
+
<circle cx="13" cy="8" r="0.75" />
|
|
71
|
+
</Icon>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function IconExternalLink(p: IconProps): JSX.Element {
|
|
76
|
+
return (
|
|
77
|
+
<Icon {...p}>
|
|
78
|
+
<path d="M9 2h5v5M14 2 7 9M6 3H2v11h11v-4" />
|
|
79
|
+
</Icon>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function IconExpand(p: IconProps): JSX.Element {
|
|
84
|
+
return (
|
|
85
|
+
<Icon {...p}>
|
|
86
|
+
<path d="M9 2h5v5M14 2l-5 5M7 14H2V9M2 14l5-5" />
|
|
87
|
+
</Icon>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function IconArrowLeft(p: IconProps): JSX.Element {
|
|
92
|
+
return (
|
|
93
|
+
<Icon {...p}>
|
|
94
|
+
<path d="M14 8H2m5-5-5 5 5 5" />
|
|
95
|
+
</Icon>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function IconRefresh(p: IconProps): JSX.Element {
|
|
100
|
+
return (
|
|
101
|
+
<Icon {...p}>
|
|
102
|
+
<path d="M13.5 6A5.5 5.5 0 1 0 13 11M14 2v4h-4" />
|
|
103
|
+
</Icon>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function IconUndo(p: IconProps): JSX.Element {
|
|
108
|
+
return (
|
|
109
|
+
<Icon {...p}>
|
|
110
|
+
<path d="M6 2 2 6l4 4M2 6h7a4 4 0 0 1 0 8" />
|
|
111
|
+
</Icon>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function IconSparkle(p: IconProps): JSX.Element {
|
|
116
|
+
return (
|
|
117
|
+
<Icon {...p}>
|
|
118
|
+
<path d="m8 1 2 5 5 2-5 2-2 5-2-5-5-2 5-2Z" />
|
|
119
|
+
</Icon>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Upload into a tray — attach a document. */
|
|
124
|
+
export function IconUpload(p: IconProps): JSX.Element {
|
|
125
|
+
return (
|
|
126
|
+
<Icon {...p}>
|
|
127
|
+
<path d="M8 10V2M4.5 5.5 8 2l3.5 3.5M2 10v3.5h12V10" />
|
|
128
|
+
</Icon>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Dropdown disclosure, sized like the other toolbar icons. */
|
|
133
|
+
export function IconChevronDown(p: IconProps): JSX.Element {
|
|
134
|
+
return (
|
|
135
|
+
<Icon {...p}>
|
|
136
|
+
<polyline points="3 6 8 11 13 6" />
|
|
137
|
+
</Icon>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
24
141
|
/** Expand-arrows — fit all nodes */
|
|
25
142
|
export function IconFitAll(p: IconProps): JSX.Element {
|
|
26
143
|
return (
|
|
@@ -11,7 +11,6 @@ export function AttachmentNode({ node, expanded = false }: { node: CanvasNodeSta
|
|
|
11
11
|
const boardId = activeBoardId.value;
|
|
12
12
|
const [job, setJob] = useState<DocumentImport | null>(null);
|
|
13
13
|
const [review, setReview] = useState(expanded);
|
|
14
|
-
const [consent, setConsent] = useState(false);
|
|
15
14
|
const [busy, setBusy] = useState(false);
|
|
16
15
|
useEffect(() => {
|
|
17
16
|
let current = true;
|
|
@@ -53,7 +52,6 @@ export function AttachmentNode({ node, expanded = false }: { node: CanvasNodeSta
|
|
|
53
52
|
setBusy(false);
|
|
54
53
|
if (result?.import) {
|
|
55
54
|
setJob(result.import);
|
|
56
|
-
setConsent(false);
|
|
57
55
|
setReview(false);
|
|
58
56
|
if (expanded && action === 'commit') collapseExpandedNode();
|
|
59
57
|
}
|
|
@@ -74,22 +72,19 @@ export function AttachmentNode({ node, expanded = false }: { node: CanvasNodeSta
|
|
|
74
72
|
Download original · {Math.ceil(Number(node.data.size ?? 0) / 1024)} KB
|
|
75
73
|
</a>
|
|
76
74
|
{job?.reason && <p>{job.reason}</p>}
|
|
77
|
-
{canRequest &&
|
|
78
|
-
<button type="button" class="home-board-action" onClick={() => setConsent(true)}>
|
|
79
|
-
Ask agent to import
|
|
80
|
-
</button>
|
|
81
|
-
)}
|
|
82
|
-
{consent && (
|
|
75
|
+
{canRequest && (
|
|
83
76
|
<>
|
|
84
|
-
<p>
|
|
85
|
-
|
|
86
|
-
original. Canvas does not convert files itself.
|
|
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.
|
|
87
79
|
</p>
|
|
88
|
-
<button
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
|
93
88
|
</button>
|
|
94
89
|
</>
|
|
95
90
|
)}
|
|
@@ -98,17 +93,17 @@ export function AttachmentNode({ node, expanded = false }: { node: CanvasNodeSta
|
|
|
98
93
|
No automatic extraction. Ask your connected agent to check its pending steering or use canvas_import list.
|
|
99
94
|
</p>
|
|
100
95
|
)}
|
|
101
|
-
{job?.status === 'drafted' && (
|
|
96
|
+
{job?.status === 'drafted' && !review && (
|
|
102
97
|
<button
|
|
103
98
|
type="button"
|
|
104
|
-
class="
|
|
99
|
+
class="attachment-button is-primary"
|
|
105
100
|
onClick={() => (expanded ? setReview(true) : expandNode(node.id))}
|
|
106
101
|
>
|
|
107
102
|
Review Markdown · {job.sections.length} {job.sections.length === 1 ? 'card' : 'cards'}
|
|
108
103
|
</button>
|
|
109
104
|
)}
|
|
110
105
|
{job && ['requested', 'drafted'].includes(job.status) && (
|
|
111
|
-
<button type="button" class="
|
|
106
|
+
<button type="button" class="attachment-button" disabled={busy} onClick={() => void act('cancel')}>
|
|
112
107
|
Cancel import
|
|
113
108
|
</button>
|
|
114
109
|
)}
|
|
@@ -130,17 +125,19 @@ export function AttachmentNode({ node, expanded = false }: { node: CanvasNodeSta
|
|
|
130
125
|
<pre>{section.markdown}</pre>
|
|
131
126
|
</section>
|
|
132
127
|
))}
|
|
133
|
-
<
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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>
|
|
144
141
|
</div>
|
|
145
142
|
)}
|
|
146
143
|
</div>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { CanvasNodeState } from '../types';
|
|
2
2
|
import { boardList, openBoard } from '../state/boards-store';
|
|
3
|
+
import { IconExternalLink } from '../icons';
|
|
3
4
|
|
|
4
5
|
/** Native text-only cross-board link. Target identity is the durable board id. */
|
|
5
6
|
export function BoardNode({ node }: { node: CanvasNodeState }) {
|
|
@@ -28,7 +29,7 @@ export function BoardNode({ node }: { node: CanvasNodeState }) {
|
|
|
28
29
|
onClick={() => target && void openBoard(target.id)}
|
|
29
30
|
>
|
|
30
31
|
{target ? 'Open board' : 'Target unavailable'}
|
|
31
|
-
{target && <
|
|
32
|
+
{target && <IconExternalLink size={14} />}
|
|
32
33
|
</button>
|
|
33
34
|
</div>
|
|
34
35
|
);
|
|
@@ -2,6 +2,7 @@ import type { CallToolResult, ListToolsResult, RequestId, Tool } from '@modelcon
|
|
|
2
2
|
import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
3
3
|
import { AppBridge, PostMessageTransport, buildAllowAttribute } from '@modelcontextprotocol/ext-apps/app-bridge';
|
|
4
4
|
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
|
5
|
+
import { IconClose } from '../icons';
|
|
5
6
|
import {
|
|
6
7
|
AX_SURFACE_ACK_SOURCE,
|
|
7
8
|
AX_SURFACE_EMIT_SOURCE,
|
|
@@ -1429,6 +1430,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
1429
1430
|
<button
|
|
1430
1431
|
type="button"
|
|
1431
1432
|
onClick={() => setError(null)}
|
|
1433
|
+
aria-label="Dismiss error"
|
|
1432
1434
|
style={{
|
|
1433
1435
|
background: 'none',
|
|
1434
1436
|
border: 'none',
|
|
@@ -1438,7 +1440,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
1438
1440
|
padding: '0 2px',
|
|
1439
1441
|
}}
|
|
1440
1442
|
>
|
|
1441
|
-
|
|
1443
|
+
<IconClose size={14} />
|
|
1442
1444
|
</button>
|
|
1443
1445
|
</div>
|
|
1444
1446
|
)}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { useCallback, useEffect, useMemo, useState } from 'preact/hooks';
|
|
2
|
+
import { IconPlusCircle, IconRefresh } from '../icons';
|
|
3
|
+
import { delimiterForPath, parseDelimitedText } from '../../shared/delimited-text';
|
|
2
4
|
import { updateNodeData } from '../state/canvas-store';
|
|
3
5
|
import { iframeMode } from '../state/iframe-mode';
|
|
4
6
|
import { fetchFile, updateNodeFromClient } from '../state/intent-bridge';
|
|
@@ -44,82 +46,6 @@ function langFromPath(path: string): string {
|
|
|
44
46
|
/** Rendered-row ceiling for delimited files — a 200k-row CSV must not lock the browser. */
|
|
45
47
|
const MAX_TABLE_ROWS = 500;
|
|
46
48
|
|
|
47
|
-
export interface DelimitedTable {
|
|
48
|
-
header: string[];
|
|
49
|
-
/** Rectangular: every row padded (short) or truncated (long) to the header width. */
|
|
50
|
-
rows: string[][];
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** Column delimiter for a delimited-data path, or null when the file is not one. */
|
|
54
|
-
function delimiterForPath(path: string): string | null {
|
|
55
|
-
const lower = path.toLowerCase();
|
|
56
|
-
if (lower.endsWith('.csv')) return ',';
|
|
57
|
-
if (lower.endsWith('.tsv')) return '\t';
|
|
58
|
-
return null;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* RFC4180-ish delimited-text parser. Quoted fields may contain the delimiter,
|
|
63
|
-
* newlines, and `""` escapes; CRLF, LF and lone CR all end a record; a trailing
|
|
64
|
-
* newline produces no phantom empty row. Ragged records are squared off against
|
|
65
|
-
* the header width instead of throwing.
|
|
66
|
-
*/
|
|
67
|
-
export function parseDelimitedText(text: string, delimiter: string): DelimitedTable {
|
|
68
|
-
const records: string[][] = [];
|
|
69
|
-
let row: string[] = [];
|
|
70
|
-
let field = '';
|
|
71
|
-
let quoted = false;
|
|
72
|
-
// Tracks whether the current record consumed anything — distinguishes a real
|
|
73
|
-
// empty-quoted record from the dangling state after a terminating newline.
|
|
74
|
-
let started = false;
|
|
75
|
-
|
|
76
|
-
for (let i = 0; i < text.length; i++) {
|
|
77
|
-
const char = text[i];
|
|
78
|
-
started = true;
|
|
79
|
-
if (quoted) {
|
|
80
|
-
if (char === '"') {
|
|
81
|
-
if (text[i + 1] === '"') {
|
|
82
|
-
field += '"';
|
|
83
|
-
i++;
|
|
84
|
-
} else {
|
|
85
|
-
quoted = false;
|
|
86
|
-
}
|
|
87
|
-
} else if (char === '\r' && text[i + 1] === '\n') {
|
|
88
|
-
field += '\n';
|
|
89
|
-
i++;
|
|
90
|
-
} else {
|
|
91
|
-
field += char;
|
|
92
|
-
}
|
|
93
|
-
continue;
|
|
94
|
-
}
|
|
95
|
-
if (char === '"' && field === '') {
|
|
96
|
-
quoted = true;
|
|
97
|
-
} else if (char === delimiter) {
|
|
98
|
-
row.push(field);
|
|
99
|
-
field = '';
|
|
100
|
-
} else if (char === '\n' || char === '\r') {
|
|
101
|
-
if (char === '\r' && text[i + 1] === '\n') i++;
|
|
102
|
-
row.push(field);
|
|
103
|
-
field = '';
|
|
104
|
-
records.push(row);
|
|
105
|
-
row = [];
|
|
106
|
-
started = false;
|
|
107
|
-
} else {
|
|
108
|
-
field += char;
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
if (started) {
|
|
112
|
-
row.push(field);
|
|
113
|
-
records.push(row);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
const header = records.shift() ?? [];
|
|
117
|
-
return {
|
|
118
|
-
header,
|
|
119
|
-
rows: records.map((record) => Array.from({ length: header.length }, (_, i) => record[i] ?? '')),
|
|
120
|
-
};
|
|
121
|
-
}
|
|
122
|
-
|
|
123
49
|
/** Human-readable byte size, e.g. `4.2 MB`. */
|
|
124
50
|
export function formatBytes(bytes: number): string {
|
|
125
51
|
if (!Number.isFinite(bytes) || bytes < 0) return '';
|
|
@@ -349,7 +275,7 @@ function TextFileNode({ node, expanded = false }: { node: CanvasNodeState; expan
|
|
|
349
275
|
flexShrink: 0,
|
|
350
276
|
}}
|
|
351
277
|
>
|
|
352
|
-
|
|
278
|
+
<IconPlusCircle size={14} />
|
|
353
279
|
</button>
|
|
354
280
|
<button
|
|
355
281
|
type="button"
|
|
@@ -365,7 +291,7 @@ function TextFileNode({ node, expanded = false }: { node: CanvasNodeState; expan
|
|
|
365
291
|
flexShrink: 0,
|
|
366
292
|
}}
|
|
367
293
|
>
|
|
368
|
-
|
|
294
|
+
<IconRefresh size={14} />
|
|
369
295
|
</button>
|
|
370
296
|
</div>
|
|
371
297
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
|
|
2
|
+
import { IconRefresh } from '../icons';
|
|
2
3
|
import type { CanvasNodeState } from '../types';
|
|
3
4
|
import { getImageNodeWarnings } from './image-warnings';
|
|
4
5
|
|
|
@@ -140,7 +141,7 @@ export function ImageNode({ node, expanded = false }: { node: CanvasNodeState; e
|
|
|
140
141
|
{sizeLabel && <span>{sizeLabel}</span>}
|
|
141
142
|
{zoom !== 1 && (
|
|
142
143
|
<button type="button" class="image-node-zoom-reset" onClick={resetView} title="Reset zoom">
|
|
143
|
-
{zoomPct}%
|
|
144
|
+
{zoomPct}% <IconRefresh size={14} />
|
|
144
145
|
</button>
|
|
145
146
|
)}
|
|
146
147
|
</span>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { JSX } from 'preact';
|
|
2
2
|
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
|
|
3
|
+
import { IconArrowLeft } from '../icons';
|
|
3
4
|
import { expandNode, updateNodeData } from '../state/canvas-store';
|
|
4
5
|
import { fetchFile, renderMarkdown, saveFile, updateNodeFromClient } from '../state/intent-bridge';
|
|
5
6
|
import type { CanvasNodeState } from '../types';
|
|
@@ -254,7 +255,7 @@ export function MarkdownNode({ node, expanded = false }: { node: CanvasNodeState
|
|
|
254
255
|
<div class="md-editor-expanded" onKeyDown={handleKeyDown}>
|
|
255
256
|
<div class="md-editor-toolbar">
|
|
256
257
|
<button type="button" class="md-toolbar-btn" onClick={() => setSourceMode(false)}>
|
|
257
|
-
|
|
258
|
+
<IconArrowLeft size={14} /> Back to document
|
|
258
259
|
</button>
|
|
259
260
|
<div style={{ display: 'flex', gap: '6px', alignItems: 'center' }}>
|
|
260
261
|
{path && <span class="md-toolbar-path">{path.split('/').pop()}</span>}
|