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.
Files changed (72) hide show
  1. package/.github/extensions/pmx-canvas/extension.mjs +149 -70
  2. package/CHANGELOG.md +32 -1
  3. package/Readme.md +40 -13
  4. package/dist/canvas/global.css +94 -15
  5. package/dist/canvas/index.js +76 -76
  6. package/dist/types/cli/daemon.d.ts +5 -5
  7. package/dist/types/cli/shared.d.ts +3 -1
  8. package/dist/types/client/canvas/ExternalWriters.d.ts +3 -3
  9. package/dist/types/client/icons.d.ts +15 -0
  10. package/dist/types/client/nodes/ExtAppFrame.d.ts +1 -1
  11. package/dist/types/client/nodes/FileNode.d.ts +0 -12
  12. package/dist/types/shared/delimited-text.d.ts +14 -0
  13. package/docs/cli.md +14 -2
  14. package/docs/http-api.md +12 -8
  15. package/docs/mcp.md +8 -1
  16. package/docs/sdk.md +2 -2
  17. package/package.json +1 -1
  18. package/skills/pmx-canvas/SKILL.md +35 -14
  19. package/skills/pmx-canvas/evals/evals.json +171 -0
  20. package/skills/pmx-canvas/references/full-reference.md +6 -1
  21. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +17 -3
  22. package/skills/pmx-canvas/references/installing-pmx-canvas.md +4 -4
  23. package/skills/pmx-canvas/references/tours-and-recording.md +69 -0
  24. package/skills/pmx-canvas-testing/SKILL.md +7 -2
  25. package/src/cli/commands/apps.ts +1 -1
  26. package/src/cli/commands/backup.ts +15 -4
  27. package/src/cli/commands/boards.ts +16 -3
  28. package/src/cli/commands/export.ts +9 -2
  29. package/src/cli/daemon.ts +37 -7
  30. package/src/cli/shared.ts +11 -2
  31. package/src/client/App.tsx +0 -1
  32. package/src/client/canvas/AttentionHistory.tsx +2 -1
  33. package/src/client/canvas/AttentionToast.tsx +5 -3
  34. package/src/client/canvas/BoardSwitcher.tsx +3 -3
  35. package/src/client/canvas/CanvasNode.tsx +17 -9
  36. package/src/client/canvas/CommandBar.tsx +2 -2
  37. package/src/client/canvas/ContextMenu.tsx +0 -1
  38. package/src/client/canvas/ContextPinBar.tsx +3 -1
  39. package/src/client/canvas/ExpandedNodeOverlay.tsx +3 -3
  40. package/src/client/canvas/ExportDialog.tsx +3 -1
  41. package/src/client/canvas/ExternalWriters.tsx +4 -3
  42. package/src/client/canvas/FeedbackDialog.tsx +2 -1
  43. package/src/client/canvas/IntentLayer.tsx +2 -2
  44. package/src/client/canvas/Presentation.tsx +39 -9
  45. package/src/client/canvas/SelectionBar.tsx +2 -4
  46. package/src/client/canvas/SessionPanel.tsx +6 -4
  47. package/src/client/canvas/SessionReceipt.tsx +2 -1
  48. package/src/client/canvas/SnapshotPanel.tsx +3 -2
  49. package/src/client/canvas/ToolRail.tsx +2 -1
  50. package/src/client/canvas/TopBar.tsx +2 -2
  51. package/src/client/icons.tsx +118 -1
  52. package/src/client/nodes/AttachmentNode.tsx +27 -30
  53. package/src/client/nodes/BoardNode.tsx +2 -1
  54. package/src/client/nodes/ExtAppFrame.tsx +3 -1
  55. package/src/client/nodes/FileNode.tsx +4 -78
  56. package/src/client/nodes/ImageNode.tsx +2 -1
  57. package/src/client/nodes/MarkdownNode.tsx +2 -1
  58. package/src/client/nodes/PromptNode.tsx +3 -1
  59. package/src/client/state/attention-bridge.ts +3 -1
  60. package/src/client/state/sse-bridge.ts +3 -1
  61. package/src/client/theme/global.css +94 -15
  62. package/src/mcp/server.ts +2 -2
  63. package/src/server/agent-presence.ts +17 -11
  64. package/src/server/board-export.ts +97 -32
  65. package/src/server/index.ts +9 -7
  66. package/src/server/operations/composites.ts +3 -2
  67. package/src/server/operations/ops/ax-presence.ts +7 -3
  68. package/src/server/operations/ops/boards.ts +12 -5
  69. package/src/server/operations/ops/imports.ts +19 -3
  70. package/src/server/operations/registry.ts +4 -13
  71. package/src/server/server.ts +10 -3
  72. 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 && error ? (
104
- <div class="presentation-error" role="alert">
105
- {error}
106
- </div>
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
- ↩ undo this edit
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
- ↩ Undo
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
- <span aria-hidden="true">↥</span>
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>
@@ -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 width={size} height={size} viewBox="0 0 16 16" {...defaults} {...rest}>
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 && !consent && (
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
- 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.
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 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
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="home-board-action"
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="home-board-action" disabled={busy} onClick={() => void act('cancel')}>
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
- <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>
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 && <span aria-hidden="true">↗</span>}
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
- ← Back to document
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>}