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
@@ -1,4 +1,5 @@
1
1
  import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
2
+ import { IconClose } from '../icons';
2
3
  import { updateNodeData } from '../state/canvas-store';
3
4
  import { fetchSlashCommands, renderMarkdown, submitCanvasPrompt, submitThreadReply } from '../state/intent-bridge';
4
5
  import type { CanvasNodeState } from '../types';
@@ -98,6 +99,7 @@ function ErrorBanner({ message, onDismiss }: { message: string | null; onDismiss
98
99
  <button
99
100
  type="button"
100
101
  onClick={onDismiss}
102
+ aria-label="Dismiss error"
101
103
  style={{
102
104
  background: 'none',
103
105
  border: 'none',
@@ -107,7 +109,7 @@ function ErrorBanner({ message, onDismiss }: { message: string | null; onDismiss
107
109
  padding: '0 2px',
108
110
  }}
109
111
  >
110
- {'\u00d7'}
112
+ <IconClose size={14} />
111
113
  </button>
112
114
  </div>
113
115
  );
@@ -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
- enqueueToast(makeEntry(tone, title, detail, nodeIds));
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 {
@@ -41,7 +41,7 @@ import {
41
41
  import { agentActivity, applyPresenceSnapshot, sessionActive } from './presence-store';
42
42
  import { applyHumanSnapshot, startHumanPresence } from './human-store';
43
43
  import type { HumanPresenceSnapshot } from '../../shared/human-presence.js';
44
- import { applySessionReceipt, refreshTimeline } from './session-store';
44
+ import { applySessionReceipt, dismissSessionReceipt, refreshTimeline } from './session-store';
45
45
  import { initSessionThemeOverride, themeOverrideActive } from './theme-override';
46
46
  import { DEFAULT_POSITIONS, makeNodeState } from './node-factory';
47
47
  import { invalidateTokenCache } from '../theme/tokens';
@@ -479,6 +479,8 @@ function resyncForBoardSwitch(): void {
479
479
  hasInitialServerLayout.value = false;
480
480
  resetIntents();
481
481
  resetCanvasInteractionState();
482
+ resetAttentionBridge();
483
+ dismissSessionReceipt();
482
484
  axSurfaceState.value = null;
483
485
  void refreshAxSurface();
484
486
  const layoutAtRequest = layoutRevision;
@@ -760,6 +760,11 @@ body,
760
760
  border-radius: calc(3px * var(--node-chrome-scale, 1));
761
761
  }
762
762
 
763
+ .canvas-node .node-controls button svg {
764
+ width: 1em;
765
+ height: 1em;
766
+ }
767
+
763
768
  .canvas-node .node-controls button:hover {
764
769
  background: var(--c-surface-hover);
765
770
  color: var(--c-text);
@@ -1466,6 +1471,10 @@ body,
1466
1471
  animation: presence-pulse 1.4s ease-in-out infinite;
1467
1472
  }
1468
1473
  .session-collapse {
1474
+ display: inline-flex;
1475
+ align-items: center;
1476
+ justify-content: center;
1477
+ padding: 0;
1469
1478
  width: 26px;
1470
1479
  height: 26px;
1471
1480
  border: 1px solid transparent;
@@ -1682,8 +1691,9 @@ body,
1682
1691
  }
1683
1692
  }
1684
1693
  .session-section-chevron {
1694
+ display: inline-flex;
1695
+ flex-shrink: 0;
1685
1696
  color: var(--c-muted);
1686
- font-size: 13px;
1687
1697
  transition: transform 0.15s ease;
1688
1698
  }
1689
1699
  .session-section-chevron.is-open {
@@ -1966,8 +1976,13 @@ body,
1966
1976
  white-space: nowrap;
1967
1977
  }
1968
1978
  .command-bar-chip-unpin {
1969
- width: 14px;
1970
- height: 14px;
1979
+ display: inline-flex;
1980
+ align-items: center;
1981
+ justify-content: center;
1982
+ flex-shrink: 0;
1983
+ padding: 0;
1984
+ width: 20px;
1985
+ height: 20px;
1971
1986
  border: none;
1972
1987
  border-radius: 50%;
1973
1988
  background: none;
@@ -4784,7 +4799,7 @@ html.is-node-resizing .ext-app-preview-catcher {
4784
4799
  position: absolute;
4785
4800
  top: 18px;
4786
4801
  right: 18px;
4787
- max-width: min(260px, calc(100% - 24px));
4802
+ max-width: min(360px, calc(100% - 36px));
4788
4803
  display: inline-flex;
4789
4804
  align-items: center;
4790
4805
  gap: 8px;
@@ -4792,7 +4807,7 @@ html.is-node-resizing .ext-app-preview-catcher {
4792
4807
  background: color-mix(in srgb, var(--c-panel-glass) 96%, transparent);
4793
4808
  backdrop-filter: blur(14px);
4794
4809
  border: 1px solid color-mix(in srgb, var(--c-line) 76%, var(--c-warn) 24%);
4795
- border-radius: 999px;
4810
+ border-radius: 12px;
4796
4811
  box-shadow: 0 8px 22px var(--c-shadow);
4797
4812
  color: var(--c-text);
4798
4813
  font: inherit;
@@ -4831,15 +4846,27 @@ html.is-node-resizing .ext-app-preview-catcher {
4831
4846
  box-shadow: 0 0 0 2px color-mix(in srgb, currentColor 28%, transparent);
4832
4847
  }
4833
4848
 
4849
+ .attention-toast-copy {
4850
+ display: grid;
4851
+ gap: 4px;
4852
+ min-width: 0;
4853
+ text-align: left;
4854
+ overflow-wrap: anywhere;
4855
+ }
4856
+
4857
+ .attention-toast-detail {
4858
+ color: var(--c-text-soft);
4859
+ font-size: 12px;
4860
+ line-height: 1.45;
4861
+ max-height: 9em;
4862
+ overflow: auto;
4863
+ }
4864
+
4834
4865
  .attention-toast-title {
4835
4866
  font-size: 12px;
4836
4867
  font-weight: 600;
4837
4868
  line-height: 1.2;
4838
4869
  letter-spacing: -0.005em;
4839
- white-space: nowrap;
4840
- overflow: hidden;
4841
- text-overflow: ellipsis;
4842
- max-width: 200px;
4843
4870
  }
4844
4871
 
4845
4872
  .attention-history {
@@ -6841,12 +6868,23 @@ html.is-node-dragging .intent-line-layer {
6841
6868
  padding: 4px 12px;
6842
6869
  cursor: pointer;
6843
6870
  }
6844
- .is-presenting > :not(.app-main):not(.presentation-error),
6871
+ .is-presenting > :not(.app-main):not(.presentation-error):not(.presentation-navigation),
6845
6872
  .is-presenting .app-main > :not(.canvas-region),
6846
6873
  .is-presenting .canvas-region > :not(.canvas-viewport) {
6847
6874
  display: none !important;
6848
6875
  }
6849
6876
  .is-presenting .canvas-viewport { pointer-events: none; }
6877
+ .presentation-navigation {
6878
+ position: fixed;
6879
+ inset: 0;
6880
+ z-index: 99;
6881
+ }
6882
+ .presentation-exit {
6883
+ position: absolute;
6884
+ top: 16px;
6885
+ right: 16px;
6886
+ box-shadow: 0 2px 12px var(--c-shadow);
6887
+ }
6850
6888
  .presentation-error {
6851
6889
  position: fixed;
6852
6890
  bottom: 16px;
@@ -6873,7 +6911,7 @@ html.is-node-dragging .intent-line-layer {
6873
6911
  align-items: center;
6874
6912
  gap: 6px;
6875
6913
  max-width: 280px;
6876
- padding: 4px 8px;
6914
+ padding: 6px 8px;
6877
6915
  margin-left: -8px;
6878
6916
  background: none;
6879
6917
  border: 1px solid transparent;
@@ -6891,7 +6929,8 @@ html.is-node-dragging .intent-line-layer {
6891
6929
  white-space: nowrap;
6892
6930
  }
6893
6931
  .board-switcher-caret {
6894
- font-size: 10px;
6932
+ display: inline-flex;
6933
+ flex-shrink: 0;
6895
6934
  color: var(--c-text-soft);
6896
6935
  }
6897
6936
  .board-switcher-menu {
@@ -7104,10 +7143,17 @@ html.is-node-dragging .intent-line-layer {
7104
7143
  }
7105
7144
  .export-dialog-links {
7106
7145
  display: flex;
7146
+ flex-wrap: wrap;
7107
7147
  gap: 12px;
7148
+ margin-top: 12px;
7108
7149
  }
7109
7150
  .export-dialog-links a {
7110
- color: var(--c-accent);
7151
+ display: inline-flex;
7152
+ align-items: center;
7153
+ justify-content: center;
7154
+ box-sizing: border-box;
7155
+ text-decoration: none;
7156
+ word-break: normal;
7111
7157
  }
7112
7158
 
7113
7159
  /* ── Home categories ── */
@@ -7267,9 +7313,42 @@ html.is-node-dragging .intent-line-layer {
7267
7313
  .attachment-node p { margin: 0; color: var(--c-text-soft); line-height: 1.5; }
7268
7314
  .attachment-node a, .md-import-source a { color: var(--c-accent); }
7269
7315
  .attachment-status { font-weight: 600; }
7270
- .attachment-node .home-board-action { border-color: var(--c-line); color: var(--c-text); min-height: 34px; }
7271
- .attachment-node .home-board-action:hover { background: var(--c-panel); border-color: var(--c-accent); }
7316
+ .attachment-button,
7317
+ .presentation-exit,
7318
+ .export-dialog-links a {
7319
+ min-height: 38px;
7320
+ padding: 8px 14px;
7321
+ border: 1px solid var(--c-text-soft);
7322
+ border-radius: 8px;
7323
+ background: var(--c-panel);
7324
+ color: var(--c-text);
7325
+ font: inherit;
7326
+ font-weight: 600;
7327
+ cursor: pointer;
7328
+ }
7329
+ .attachment-button.is-primary,
7330
+ .export-dialog-links a.is-primary { background: var(--c-accent); border-color: var(--c-accent); color: var(--c-bg); }
7331
+ .attachment-button:hover:not(:disabled),
7332
+ .presentation-exit:hover,
7333
+ .export-dialog-links a:hover { filter: brightness(1.12); }
7334
+ .attachment-button:focus-visible,
7335
+ .presentation-exit:focus-visible,
7336
+ .export-dialog-links a:focus-visible { outline: 2px solid var(--c-accent); outline-offset: 3px; }
7337
+ .attachment-button:disabled { opacity: 0.5; cursor: default; }
7272
7338
  .attachment-review { width: 100%; border-top: 1px solid var(--c-line); padding-top: 12px; }
7339
+ .attachment-review-actions {
7340
+ position: sticky;
7341
+ bottom: -16px;
7342
+ display: flex;
7343
+ flex-wrap: wrap;
7344
+ justify-content: flex-end;
7345
+ gap: 12px;
7346
+ margin-top: 24px;
7347
+ padding: 16px 0;
7348
+ border-top: 1px solid var(--c-line);
7349
+ background: var(--c-panel);
7350
+ z-index: 1;
7351
+ }
7273
7352
  .attachment-review pre { white-space: pre-wrap; overflow-wrap: anywhere; font: 12px/1.6 var(--mono); padding: 12px; background: var(--c-bg); border-radius: 8px; }
7274
7353
  .attachment-review h4 { margin-bottom: 6px; }
7275
7354
  .md-import-source { padding: 6px 12px; font-size: 11px; overflow-wrap: anywhere; color: var(--c-text-soft); border-top: 1px solid var(--c-line); }
package/src/mcp/server.ts CHANGED
@@ -797,8 +797,8 @@ export async function startMcpServer(): Promise<void> {
797
797
  'canvas://boards',
798
798
  {
799
799
  description:
800
- 'The boards in this workspace with their categories, most recently opened first, and which one the ' +
801
- 'human has open (activeBoardId; null = Home). Your writes go to the open board; ask the human to open another.',
800
+ 'The boards in this workspace with their categories, most recently opened first, and which one is ' +
801
+ 'open (activeBoardId; null = Home). Writes go to the open board; use canvas_board action "open" to switch the shared workbench.',
802
802
  mimeType: 'application/json',
803
803
  },
804
804
  async () => {
@@ -278,10 +278,10 @@ export class AgentPresenceRegistry {
278
278
  private attributedKey(input: PresenceTouch): string {
279
279
  const own = presenceKey(input.source, input.agentId);
280
280
  // Identified writers keep their key; attaches run the twin-merge in touch().
281
- if (input.agentId?.trim() || input.attached === true || this.presences.get(own)?.attached) return own;
281
+ if (input.agentId?.trim() || this.presences.get(own)?.attached) return own;
282
282
  // A channel that attach-merged into a session stays that session's — its
283
- // writes AND its detach belong to the merged session, however many other
284
- // sessions are attached.
283
+ // writes, repeat attaches AND its detach belong to the merged session,
284
+ // however many other sessions are attached.
285
285
  const alias = this.aliases.get(own);
286
286
  if (alias && this.presences.get(alias)?.attached) return alias;
287
287
  if (input.attached !== undefined) return own;
@@ -338,15 +338,21 @@ export class AgentPresenceRegistry {
338
338
  touch(input: PresenceTouch, now = Date.now()): AgentPresence {
339
339
  let key = this.attributedKey(input);
340
340
  const own = presenceKey(input.source, input.agentId);
341
- if (input.attached === true && !input.agentId?.trim()) {
342
- // One agent, two channels: an attach whose label matches an ALREADY
343
- // attached session (Copilot's extension presence + its MCP server both
344
- // announcing "GitHub Copilot") is the same agent — merge instead of
345
- // showing a second session, and remember the alias so this channel's
346
- // later writes and detach land on the merged session too.
341
+ if (input.attached === true && !input.agentId?.trim() && key === own) {
342
+ // One agent, two channels: a transport attach belongs to the sole
343
+ // attached host even when the agent gives its task a different label.
344
+ // With several hosts, only the existing matching-label rule applies.
345
+ // Keep explicit agentIds separate, and preserve the host's consumer key
346
+ // so steering still reaches its poller. Browser placeholders are handled
347
+ // below, where their baseline and adopted-work rules are preserved.
347
348
  const label = input.label ?? input.source;
348
- const twin = [...this.presences.values()].find(
349
- (presence) => presence.attached && presence.sessionId !== own && !presence.agentId && presence.label === label,
349
+ const attached = [...this.presences.values()].filter((presence) => presence.attached);
350
+ const twin = attached.find(
351
+ (presence) =>
352
+ presence.sessionId !== own &&
353
+ !presence.agentId &&
354
+ (presence.label === label ||
355
+ (TRANSPORT_SOURCES.includes(input.source) && attached.length === 1 && presence.source !== 'browser')),
350
356
  );
351
357
  if (twin) {
352
358
  this.aliases.set(own, twin.sessionId);
@@ -11,8 +11,9 @@
11
11
  */
12
12
  import { existsSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs';
13
13
  import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
14
- import { Marked, type RendererThis, type Tokens } from 'marked';
14
+ import { Marked, Renderer, type RendererThis, type Tokens } from 'marked';
15
15
  import { canvasThemeScheme } from '../shared/themes.js';
16
+ import { delimiterForPath, parseDelimitedText } from '../shared/delimited-text.js';
16
17
  import { buildJsonRenderViewerHtml, escapeInlineScriptSource, readJsonRenderBundle } from '../json-render/server.js';
17
18
  import type { JsonRenderSpec } from '../json-render/server.js';
18
19
  import { getCanvasNodeTitle } from './canvas-serialization.js';
@@ -89,6 +90,9 @@ function createMarkdown(remoteImages: Set<string>, links: Set<string>): Marked {
89
90
  html({ text: raw }: Tokens.HTML | Tokens.Tag) {
90
91
  return escapeHtml(raw);
91
92
  },
93
+ table(this: RendererThis, token: Tokens.Table) {
94
+ return tableScroll(Renderer.prototype.table.call(this, token));
95
+ },
92
96
  link(this: RendererThis, { href, tokens }: Tokens.Link) {
93
97
  const inner = this.parser.parseInline(tokens);
94
98
  if (/^https?:/i.test(href)) links.add(href);
@@ -136,6 +140,24 @@ function pre(value: string, className = ''): string {
136
140
  return `<pre class="${className}">${escapeHtml(value)}</pre>`;
137
141
  }
138
142
 
143
+ function tableScroll(html: string): string {
144
+ return `<div class="table-scroll" role="region" aria-label="Table" tabindex="0">${html}</div>`;
145
+ }
146
+
147
+ function fileBody(value: string, name: string): string {
148
+ const delimiter = delimiterForPath(name);
149
+ if (delimiter) {
150
+ const table = parseDelimitedText(value, delimiter);
151
+ if (table.header.length > 1) {
152
+ // Match the workbench's bounded table preview without losing the remainder.
153
+ const preview = table.rows.slice(0, 500);
154
+ const html = `<table><thead><tr>${table.header.map((cell) => `<th scope="col">${escapeHtml(cell)}</th>`).join('')}</tr></thead><tbody>${preview.map((row) => `<tr>${row.map((cell) => `<td>${escapeHtml(cell)}</td>`).join('')}</tr>`).join('')}</tbody></table>`;
155
+ return `${tableScroll(html)}${table.rows.length > preview.length ? `<details><summary>Showing 500 of ${table.rows.length} rows — full text</summary>${pre(value, 'code')}</details>` : ''}`;
156
+ }
157
+ }
158
+ return pre(value, 'code');
159
+ }
160
+
139
161
  function rows(entries: Array<[string, unknown]>): string {
140
162
  const cells = entries
141
163
  .filter(([, value]) => value !== undefined && value !== null && value !== '')
@@ -258,20 +280,27 @@ async function collect(
258
280
  break;
259
281
  case 'status':
260
282
  card.html = rows([
261
- ['Phase', data.phase],
283
+ ['Phase', text(data.phase).trim() || text(data.content).trim() || text(data.status).trim() || 'idle'],
262
284
  ['Message', data.message],
263
285
  ['Detail', data.detail],
286
+ ['Active tool', data.activeTool],
264
287
  ]);
265
288
  break;
266
289
  case 'context': {
267
290
  const items = Array.isArray(data.cards) ? (data.cards as Array<Record<string, unknown>>) : [];
268
- card.html = `<ul class="list">${items
269
- .map((item) => `<li><strong>${escapeHtml(text(item.title))}</strong> ${escapeHtml(text(item.summary))}</li>`)
270
- .join('')}</ul>`;
291
+ card.html = `${rows([
292
+ ['Tokens', data.currentTokens],
293
+ ['Token limit', data.tokenLimit],
294
+ ['Messages', data.messagesLength],
295
+ ])}${
296
+ items.length
297
+ ? `<ul class="list">${items.map((item) => `<li><strong>${escapeHtml(text(item.title) || text(item.label))}</strong><p>${escapeHtml(text(item.summary))}</p></li>`).join('')}</ul>`
298
+ : `<p class="prose">${escapeHtml(text(data.content) || text(data.rendered).replace(/<[^>]+>/g, ' '))}</p>`
299
+ }`;
271
300
  break;
272
301
  }
273
302
  case 'ledger':
274
- card.html = `${text(data.content) ? pre(text(data.content)) : ''}${rows(
303
+ card.html = `${text(data.content) ? pre(text(data.content).replaceAll('\\n', '\n')) : ''}${rows(
275
304
  Object.entries(data).filter(
276
305
  ([key]) => !['title', 'content', '__type', 'strictSize', 'arrangeLocked'].includes(key),
277
306
  ),
@@ -279,10 +308,11 @@ async function collect(
279
308
  break;
280
309
  case 'trace':
281
310
  card.html = rows([
282
- ['Tool', data.toolName],
283
- ['Status', data.status],
311
+ ['Tool', text(data.toolName) || title],
312
+ ['Status', text(data.status) || 'running'],
284
313
  ['Duration', data.duration],
285
- ['Result', data.resultSummary],
314
+ ['Result', text(data.resultSummary) || text(data.content)],
315
+ ['Error', data.error],
286
316
  ]);
287
317
  break;
288
318
  case 'diff':
@@ -301,7 +331,7 @@ async function collect(
301
331
  .join('\n')}</pre>`;
302
332
  break;
303
333
  case 'prompt':
304
- card.html = pre(text(data.text));
334
+ card.html = `<div class="prose">${escapeHtml(text(data.text))}</div>`;
305
335
  break;
306
336
  case 'response':
307
337
  card.html = `<div class="md">${await markdown.parse(text(data.content))}</div>`;
@@ -327,9 +357,9 @@ async function collect(
327
357
  if (img) manifest.embeddedImages += 1;
328
358
  }
329
359
  card.html = img
330
- ? `<img class="image" src="${escapeHtml(img)}" alt="${escapeHtml(alt)}" referrerpolicy="no-referrer">${
331
- text(data.caption) ? `<p class="dim">${escapeHtml(text(data.caption))}</p>` : ''
332
- }`
360
+ ? `<figure class="image-figure"><img class="image" src="${escapeHtml(img)}" alt="${escapeHtml(alt)}" referrerpolicy="no-referrer">${
361
+ text(data.caption) ? `<figcaption class="dim">${escapeHtml(text(data.caption))}</figcaption>` : ''
362
+ }</figure>`
333
363
  : `<div class="placeholder">Image not available: ${escapeHtml(src)}</div>`;
334
364
  break;
335
365
  }
@@ -337,14 +367,14 @@ async function collect(
337
367
  const path = text(data.path);
338
368
  if (!path) {
339
369
  // Inline content the board itself holds, not a file on disk.
340
- card.html = pre(text(data.fileContent) || text(data.content), 'code');
370
+ card.html = fileBody(text(data.fileContent) || text(data.content), title);
341
371
  break;
342
372
  }
343
373
  const body = includeFiles ? fileText(node, path) : null;
344
374
  manifest.files.push({ nodeId: node.id, path, included: body !== null });
345
375
  card.html = `<p class="dim">${escapeHtml(path)}</p>${
346
376
  body !== null
347
- ? pre(body, 'code')
377
+ ? fileBody(body, path)
348
378
  : '<div class="placeholder">File contents not included in this export.</div>'
349
379
  }`;
350
380
  break;
@@ -527,7 +557,7 @@ function exportPage(input: {
527
557
  </div>
528
558
  </header>
529
559
  <main id="stage" aria-label="Board"><div id="world"><svg id="edges" aria-hidden="true"></svg></div></main>
530
- <div id="overlay" hidden><div class="sheet" role="dialog" aria-modal="true"><header><strong id="overlay-title"></strong><button type="button" id="overlay-close" aria-label="Close">×</button></header><div id="overlay-body"></div></div></div>
560
+ <div id="overlay" hidden><div class="sheet" role="dialog" aria-modal="true" aria-labelledby="overlay-title"><header><strong id="overlay-title"></strong><button type="button" id="overlay-close" aria-label="Close">×</button></header><div id="overlay-body"></div></div></div>
531
561
  <script type="application/json" id="pmx-board">${scriptJson(input.board)}</script>
532
562
  <script type="application/json" id="pmx-assets">${scriptJson(input.assets)}</script>
533
563
  <script>${EXPORT_VIEWER_JS}</script>
@@ -540,7 +570,7 @@ const EXPORT_CSS = `
540
570
  :root{--bg:#0b1016;--panel:#121a23;--line:#263241;--text:#e8edf2;--soft:#9aa7b4;--accent:#4bbcff;--add:#2ea66f;--del:#e25d6e}
541
571
  [data-scheme=light]{--bg:#f4f6f8;--panel:#ffffff;--line:#d5dbe1;--text:#17202a;--soft:#5b6875;--accent:#0a6fc2;--add:#1c7a4f;--del:#b3263a}
542
572
  *{box-sizing:border-box}
543
- html,body{margin:0;height:100%;background:var(--bg);color:var(--text);font:14px/1.45 system-ui,-apple-system,Segoe UI,sans-serif}
573
+ html,body{margin:0;height:100%;background:var(--bg);color:var(--text);font:15px/1.65 system-ui,-apple-system,Segoe UI,sans-serif}
544
574
  .bar{position:fixed;top:0;left:0;right:0;height:48px;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:0 16px;background:var(--panel);border-bottom:1px solid var(--line);z-index:5}
545
575
  .bar .name{display:flex;flex-direction:column;min-width:0}
546
576
  .bar .name strong,.bar .name span{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
@@ -548,6 +578,7 @@ html,body{margin:0;height:100%;background:var(--bg);color:var(--text);font:14px/
548
578
  .tools{display:flex;gap:6px;flex-shrink:0}
549
579
  button{font:inherit;color:var(--text);background:none;border:1px solid var(--line);border-radius:7px;padding:4px 10px;cursor:pointer}
550
580
  button:hover{border-color:var(--accent)}
581
+ button:focus-visible,a:focus-visible,[tabindex]:focus-visible{outline:2px solid var(--accent);outline-offset:-3px}
551
582
  #stage{position:fixed;top:48px;left:0;right:0;bottom:0;overflow:hidden;cursor:grab;touch-action:none}
552
583
  #stage.panning{cursor:grabbing}
553
584
  #world{position:absolute;left:0;top:0;transform-origin:0 0}
@@ -556,25 +587,43 @@ button:hover{border-color:var(--accent)}
556
587
  #edges text{fill:var(--soft);font-size:12px;text-anchor:middle}
557
588
  .card{position:absolute;display:flex;flex-direction:column;background:var(--panel);border:1px solid var(--line);border-radius:10px;overflow:hidden;box-shadow:0 6px 24px rgba(0,0,0,.18)}
558
589
  .card.group{background:color-mix(in srgb,var(--accent) 5%,transparent);border-style:dashed;box-shadow:none}
559
- .card-title{flex-shrink:0;padding:7px 10px;font-weight:600;font-size:13px;border-bottom:1px solid var(--line);cursor:zoom-in;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
590
+ .card-title{position:relative;flex-shrink:0;width:100%;text-align:left;padding:12px 42px 12px 18px;font-weight:600;font-size:13px;border:0;border-bottom:1px solid var(--line);border-radius:0;cursor:zoom-in;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;background:color-mix(in srgb,var(--soft) 5%,var(--panel))}
591
+ button.card-title:after{content:'↗';position:absolute;right:16px;color:var(--soft);font-size:16px}
560
592
  .card.group .card-title{border-bottom:none;cursor:default;color:var(--soft)}
561
- .card-body{flex:1;min-height:0;overflow:auto;padding:8px 10px}
562
- .card iframe,#overlay-body iframe{flex:1;width:100%;height:100%;border:0;background:var(--bg)}
563
- .md h1,.md h2,.md h3{margin:.4em 0 .3em;line-height:1.2}.md p{margin:.4em 0}.md img{max-width:100%}
564
- .md code,pre{font:12px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace}
565
- pre{margin:0;white-space:pre-wrap;word-break:break-word}
566
- pre.diff .add{color:var(--add)}pre.diff .del{color:var(--del)}pre.diff .hunk{color:var(--accent)}
567
- a{color:var(--accent)}
593
+ .card-body{flex:1;min-height:0;min-width:0;overflow:auto;padding:20px;overflow-wrap:anywhere;overscroll-behavior:contain;cursor:auto}
594
+ .card-body>:first-child,.md>:first-child{margin-top:0}.card-body>:last-child,.md>:last-child{margin-bottom:0}
595
+ .card iframe,#overlay-body iframe{flex:1;min-height:0;width:100%;height:100%;border:0;background:var(--bg)}
596
+ .md h1,.md h2,.md h3,.md h4,.md h5,.md h6{margin:1.4em 0 .6em;line-height:1.3;font-weight:650;letter-spacing:-.02em}
597
+ .md h1{font-size:26px}.md h2{font-size:21px}.md h3{font-size:18px}.md h4,.md h5,.md h6{font-size:15px}
598
+ .md p{margin:0 0 1em}.md img{max-width:100%;height:auto;border-radius:6px}
599
+ .md ul,.md ol{padding-left:1.6em;margin:0 0 1em}.md li+li{margin-top:.35em}.md li>ul,.md li>ol{margin-top:.4em;margin-bottom:.4em}
600
+ .md input[type=checkbox]{accent-color:var(--accent);margin-right:.5em}
601
+ .md blockquote{margin:1em 0;padding:10px 16px;border-left:3px solid var(--accent);background:color-mix(in srgb,var(--accent) 7%,var(--panel));color:var(--soft)}
602
+ .md blockquote>:last-child{margin-bottom:0}.md hr{border:0;border-top:1px solid var(--line);margin:24px 0}
603
+ .md code,pre{font:13px/1.65 ui-monospace,SFMono-Regular,Menlo,monospace;tab-size:2}
604
+ .md :not(pre)>code{padding:2px 5px;border:1px solid var(--line);border-radius:4px;background:color-mix(in srgb,var(--soft) 8%,var(--panel))}
605
+ pre{margin:0 0 16px;padding:14px 16px;border:1px solid var(--line);border-radius:8px;background:color-mix(in srgb,var(--soft) 5%,var(--panel));white-space:pre;overflow:auto;overflow-wrap:normal;overscroll-behavior:contain}
606
+ .prose{white-space:pre-wrap}.table-scroll{width:100%;max-width:100%;overflow:auto;margin:16px 0;border:1px solid var(--line);border-radius:8px;overscroll-behavior:contain}
607
+ .table-scroll table{border-collapse:separate;border-spacing:0;width:100%;font-size:13px;line-height:1.55;font-variant-numeric:tabular-nums}
608
+ .table-scroll th,.table-scroll td{padding:10px 14px;min-width:90px;max-width:36ch;border-bottom:1px solid var(--line);border-right:1px solid var(--line);vertical-align:top;overflow-wrap:normal;white-space:pre-wrap}
609
+ .table-scroll th{font-weight:650;background:color-mix(in srgb,var(--soft) 10%,var(--panel))}.table-scroll th:not([align]){text-align:left}
610
+ .table-scroll tr:nth-child(even) td{background:color-mix(in srgb,var(--soft) 4%,var(--panel))}
611
+ .table-scroll tr:last-child td{border-bottom:0}.table-scroll tr>:last-child{border-right:0}
612
+ pre.diff .add{color:var(--add);background:color-mix(in srgb,var(--add) 9%,transparent)}pre.diff .del{color:var(--del);background:color-mix(in srgb,var(--del) 9%,transparent)}pre.diff .hunk{color:var(--accent)}
613
+ a{color:var(--accent);text-underline-offset:3px}
568
614
  .dim{color:var(--soft)}
569
- .kv{border-collapse:collapse;font-size:12px}.kv th{text-align:left;color:var(--soft);padding:2px 10px 2px 0;vertical-align:top}.kv td{padding:2px 0}
570
- .list{margin:0;padding-left:18px}
571
- .image{max-width:100%;max-height:100%;object-fit:contain;display:block;margin:auto}
572
- .placeholder{padding:12px;border:1px dashed var(--line);border-radius:8px;color:var(--soft);font-size:12px}
615
+ .kv{border-collapse:collapse;width:100%;font-size:14px;table-layout:fixed}.kv th{text-align:left;color:var(--soft);width:30%;font-weight:500}.kv th,.kv td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:top;white-space:pre-wrap}.kv tr:last-child>*{border-bottom:0}
616
+ .list{list-style:none;margin:0;padding:0}.list li{padding:12px 0;border-bottom:1px solid var(--line)}.list li:last-child{border-bottom:0}.list p{margin:6px 0 0;color:var(--soft)}
617
+ .image-figure{margin:0;height:100%;display:flex;flex-direction:column;gap:12px}.image{flex:1;min-height:0;max-width:100%;width:100%;object-fit:contain;display:block}.image-figure figcaption{flex-shrink:0;font-size:13px;text-align:center}
618
+ .placeholder{padding:16px;border:1px dashed var(--line);border-radius:8px;color:var(--soft);font-size:14px;line-height:1.6}
619
+ details{margin-top:16px}summary{cursor:pointer;color:var(--soft);margin-bottom:12px}
573
620
  #overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;padding:24px;z-index:10}
574
621
  #overlay[hidden]{display:none}
575
622
  #overlay .sheet{width:min(1100px,100%);height:min(90vh,100%);display:flex;flex-direction:column;background:var(--panel);border:1px solid var(--line);border-radius:12px;overflow:hidden}
576
623
  #overlay .sheet>header{display:flex;justify-content:space-between;align-items:center;padding:10px 14px;border-bottom:1px solid var(--line)}
577
- #overlay-body{flex:1;min-height:0;overflow:auto;padding:14px;display:flex;flex-direction:column}
624
+ #overlay-body{flex:1;min-height:0;overflow:hidden;display:flex;flex-direction:column}
625
+ #overlay-body .card-body{padding:28px 32px}#overlay-body .md{max-width:960px;margin:0 auto}
626
+ @media(max-width:600px){#overlay{padding:8px}#overlay .sheet{height:100%}#overlay-body .card-body{padding:18px}.bar{padding:0 10px}.bar .name span{display:none}}
578
627
  `;
579
628
 
580
629
  const EXPORT_VIEWER_JS = `(() => {
@@ -606,12 +655,15 @@ for (const card of ordered) {
606
655
  const el = document.createElement('section');
607
656
  el.className = 'card' + (card.group ? ' group' : '');
608
657
  el.dataset.nodeId = card.id;
658
+ el.dataset.nodeType = card.kind;
609
659
  Object.assign(el.style, { left: card.x + 'px', top: card.y + 'px', width: card.w + 'px', height: card.h + 'px' });
610
- const title = document.createElement('div');
660
+ const title = document.createElement(card.group ? 'div' : 'button');
611
661
  title.className = 'card-title';
612
662
  title.textContent = card.title;
613
663
  el.appendChild(title);
614
664
  if (!card.group) {
665
+ title.type = 'button';
666
+ title.setAttribute('aria-label', 'Expand ' + card.title);
615
667
  el.appendChild(body(card));
616
668
  title.addEventListener('click', () => expand(card));
617
669
  }
@@ -653,6 +705,7 @@ const zoomAt = (factor, cx, cy) => {
653
705
  apply();
654
706
  };
655
707
  stage.addEventListener('wheel', (e) => {
708
+ if (e.target.closest('.card-body') && !e.ctrlKey && !e.metaKey) return;
656
709
  e.preventDefault();
657
710
  const r = stage.getBoundingClientRect();
658
711
  zoomAt(Math.exp(-e.deltaY * 0.0015), e.clientX - r.left, e.clientY - r.top);
@@ -673,11 +726,23 @@ stage.addEventListener('pointerup', () => { drag = null; stage.classList.remove(
673
726
  document.querySelector('[data-zoom=in]').addEventListener('click', () => zoomAt(1.25, stage.clientWidth / 2, stage.clientHeight / 2));
674
727
  document.querySelector('[data-zoom=out]').addEventListener('click', () => zoomAt(0.8, stage.clientWidth / 2, stage.clientHeight / 2));
675
728
  document.querySelector('[data-zoom=fit]').addEventListener('click', fit);
676
- const close = () => { overlay.hidden = true; document.getElementById('overlay-body').replaceChildren(); };
729
+ let invoker = null;
730
+ const close = () => {
731
+ if (overlay.hidden) return;
732
+ overlay.hidden = true;
733
+ document.getElementById('overlay-body').replaceChildren();
734
+ stage.inert = false;
735
+ document.querySelector('.bar').inert = false;
736
+ invoker?.focus({ preventScroll: true });
737
+ };
677
738
  function expand(card) {
739
+ invoker = document.activeElement;
678
740
  document.getElementById('overlay-title').textContent = card.title;
679
741
  document.getElementById('overlay-body').replaceChildren(body(card));
680
742
  overlay.hidden = false;
743
+ stage.inert = true;
744
+ document.querySelector('.bar').inert = true;
745
+ document.getElementById('overlay-close').focus();
681
746
  }
682
747
  document.getElementById('overlay-close').addEventListener('click', close);
683
748
  overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
@@ -44,6 +44,7 @@ import type { BackupFile, BackupStatus } from './backup.js';
44
44
  import type { ExportManifest } from './board-export.js';
45
45
  import { onFileNodeChanged } from './file-watcher.js';
46
46
  import { findOpenCanvasPosition, computeGroupBounds } from './placement.js';
47
+ import { overlapsAny } from '../shared/placement.js';
47
48
  import { searchNodes, buildSpatialContext } from './spatial-analysis.js';
48
49
  import { mutationHistory, diffLayouts, formatDiff } from './mutation-history.js';
49
50
  import { recomputeCodeGraph, buildCodeGraphSummary, formatCodeGraph } from './code-graph.js';
@@ -741,14 +742,19 @@ export class PmxCanvas extends EventEmitter {
741
742
  } {
742
743
  if (canvasState.activeBoardId !== input.boardId) throw new Error('The captured target board is not active.');
743
744
  assertInsideFence('node.add', createTarget(input));
745
+ const obstacles = canvasState.getLayout().nodes;
746
+ const requested = { x: input.x ?? 40, y: input.y ?? 80 };
747
+ const position = overlapsAny(requested, 440, 360, obstacles, 48)
748
+ ? findOpenCanvasPosition(obstacles, 440, 360, 48)
749
+ : requested;
750
+ assertInsideFence('node.add', createTarget(position));
744
751
  const attachment = canvasState.storeAttachment({ ...input, mime: input.mime ?? 'application/octet-stream' });
745
752
  if (!attachment) throw new Error('Board not found.');
746
753
  const { id: nodeId, node } = addCanvasNode({
747
754
  type: 'file',
748
755
  title: input.name,
749
756
  data: { attachmentId: attachment.id, mime: attachment.mime, size: attachment.size },
750
- x: input.x,
751
- y: input.y,
757
+ ...position,
752
758
  defaultWidth: 440,
753
759
  defaultHeight: 360,
754
760
  fileMode: 'auto',
@@ -840,11 +846,7 @@ export class PmxCanvas extends EventEmitter {
840
846
 
841
847
  /** Open a board, or Home with null. */
842
848
  async openBoard(id: string | null): Promise<void> {
843
- await executeOperation(
844
- 'board.open',
845
- { id },
846
- { source: 'sdk', fromWorkbench: true, humanAuthor: true, suppressAutoGhost: true },
847
- );
849
+ await executeOperation('board.open', { id }, { source: 'sdk', suppressAutoGhost: true });
848
850
  }
849
851
 
850
852
  async deleteBoard(id: string): Promise<void> {
@@ -151,13 +151,14 @@ export const compositeToolDefinitions: CompositeToolDefinition[] = [
151
151
  {
152
152
  toolName: 'canvas_board',
153
153
  description:
154
- 'Boards in this workspace — the library of the human\'s and your working memory, filed in categories on Home. Your writes always go to the board the human has open (activeBoardId); on Home (no board open) your first write opens their most recent board, or a new one when there are none. Action "list" returns every board (with its category), most recently opened first; "get" reads one (default: the open board); "create" makes a new empty board WITHOUT opening it (optional category); "update" renames a board, files it under a category (category "" removes it), or designates its README via readmeNodeId. As part of normal board authoring, read the board metadata; if readmeNodeId is null, choose or create a concise markdown introduction explaining its purpose and key context, then designate that node with "update". Preserve an existing README unless the human asks to change it; humans can override the designation manually. Only the human opens, switches or deletes boards — if you need another board, ask them in the chat to open it.',
155
- actionSummary: 'list | get | create | create-from | update',
154
+ 'Boards in this workspace — the library of the human\'s and your working memory, filed in categories on Home. Your writes always go to the open board (activeBoardId); on Home (no board open) your first write opens the most recent board, or a new one when there are none. Action "list" returns every board (with its category), most recently opened first; "get" reads one (default: the open board); "create" makes a new empty board WITHOUT opening it (optional category); "open" explicitly switches the shared workbench to id (null or omitted opens Home), changing the visible board and subsequent write target. After creating a board, open its returned id and verify activeBoardId before writing; another participant can switch the board later. "update" renames a board, files it under a category (category "" removes it), or designates its README via readmeNodeId. As part of normal board authoring, read the board metadata; if readmeNodeId is null, choose or create a concise markdown introduction explaining its purpose and key context, then designate that node with "update". Preserve an existing README unless the human asks to change it; humans can override the designation manually. Only the human deletes boards.',
155
+ actionSummary: 'list | get | create | create-from | open | update',
156
156
  actions: {
157
157
  list: 'board.list',
158
158
  get: 'board.get',
159
159
  create: 'board.create',
160
160
  'create-from': 'board.create-from',
161
+ open: 'board.open',
161
162
  update: 'board.update',
162
163
  },
163
164
  },