pmx-canvas 0.7.1 → 0.7.3

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 (76) hide show
  1. package/.github/extensions/pmx-canvas/extension.mjs +79 -61
  2. package/CHANGELOG.md +29 -1
  3. package/Readme.md +48 -9
  4. package/dist/canvas/global.css +144 -45
  5. package/dist/canvas/index.js +90 -90
  6. package/dist/json-render/index.js +55 -55
  7. package/dist/types/client/canvas/ChoiceMenu.d.ts +13 -0
  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/client/nodes/McpAppNode.d.ts +2 -1
  13. package/dist/types/client/nodes/viewer-runtime-state.d.ts +1 -0
  14. package/dist/types/client/state/canvas-store.d.ts +2 -0
  15. package/dist/types/client/state/intent-bridge.d.ts +1 -1
  16. package/dist/types/json-render/renderer/index.d.ts +2 -1
  17. package/dist/types/json-render/server.d.ts +2 -0
  18. package/dist/types/shared/ax-surface-protocol.d.ts +2 -0
  19. package/dist/types/shared/delimited-text.d.ts +14 -0
  20. package/docs/ax-host-adapter-contract.md +13 -2
  21. package/docs/cli.md +9 -2
  22. package/docs/http-api.md +12 -8
  23. package/docs/mcp.md +8 -1
  24. package/docs/sdk.md +2 -2
  25. package/package.json +1 -1
  26. package/skills/pmx-canvas/SKILL.md +41 -16
  27. package/skills/pmx-canvas/evals/evals.json +112 -2
  28. package/skills/pmx-canvas/references/full-reference.md +9 -4
  29. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +17 -3
  30. package/skills/pmx-canvas/references/installing-pmx-canvas.md +76 -4
  31. package/skills/pmx-canvas-testing/SKILL.md +49 -0
  32. package/src/cli/commands/backup.ts +15 -4
  33. package/src/cli/commands/boards.ts +16 -3
  34. package/src/client/canvas/AttentionHistory.tsx +2 -1
  35. package/src/client/canvas/BoardSwitcher.tsx +3 -3
  36. package/src/client/canvas/CanvasNode.tsx +211 -197
  37. package/src/client/canvas/CanvasViewport.tsx +6 -0
  38. package/src/client/canvas/ChoiceMenu.tsx +122 -0
  39. package/src/client/canvas/CommandBar.tsx +27 -25
  40. package/src/client/canvas/ContextPinBar.tsx +3 -1
  41. package/src/client/canvas/ExpandedNodeOverlay.tsx +3 -3
  42. package/src/client/canvas/ExternalWriters.tsx +4 -3
  43. package/src/client/canvas/FeedbackDialog.tsx +12 -9
  44. package/src/client/canvas/IntentLayer.tsx +2 -2
  45. package/src/client/canvas/SelectionBar.tsx +2 -2
  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 +48 -100
  50. package/src/client/canvas/TopBar.tsx +2 -2
  51. package/src/client/icons.tsx +118 -1
  52. package/src/client/nodes/BoardNode.tsx +2 -1
  53. package/src/client/nodes/ExtAppFrame.tsx +3 -1
  54. package/src/client/nodes/FileNode.tsx +4 -78
  55. package/src/client/nodes/ImageNode.tsx +2 -1
  56. package/src/client/nodes/MarkdownNode.tsx +2 -1
  57. package/src/client/nodes/McpAppNode.tsx +16 -0
  58. package/src/client/nodes/PromptNode.tsx +3 -1
  59. package/src/client/nodes/use-ax-surface-bridge.ts +4 -2
  60. package/src/client/nodes/viewer-runtime-state.ts +78 -0
  61. package/src/client/state/canvas-store.ts +3 -0
  62. package/src/client/state/intent-bridge.ts +14 -2
  63. package/src/client/theme/global.css +144 -45
  64. package/src/json-render/renderer/index.tsx +58 -38
  65. package/src/json-render/server.ts +8 -1
  66. package/src/mcp/server.ts +2 -2
  67. package/src/server/agent-presence.ts +17 -11
  68. package/src/server/board-export.ts +97 -32
  69. package/src/server/index.ts +9 -7
  70. package/src/server/operations/composites.ts +3 -2
  71. package/src/server/operations/ops/ax-presence.ts +7 -3
  72. package/src/server/operations/ops/boards.ts +12 -5
  73. package/src/server/operations/registry.ts +4 -13
  74. package/src/server/server.ts +13 -4
  75. package/src/shared/ax-surface-protocol.ts +3 -0
  76. package/src/shared/delimited-text.ts +75 -0
@@ -572,9 +572,23 @@ body,
572
572
  box-shadow: 0 4px 24px var(--c-shadow);
573
573
  display: flex;
574
574
  flex-direction: column;
575
- overflow: hidden;
575
+ overflow: visible;
576
576
  transition: box-shadow 0.15s ease;
577
- contain: layout style paint;
577
+ contain: layout style;
578
+ }
579
+
580
+ /* Clip card content, not the connection handles centered on its border. */
581
+ .node-content {
582
+ display: flex;
583
+ flex-direction: column;
584
+ flex: 1;
585
+ min-height: 0;
586
+ border-radius: inherit;
587
+ overflow: hidden;
588
+ contain: paint;
589
+ }
590
+ .group-node > .node-content {
591
+ display: contents;
578
592
  }
579
593
 
580
594
  .canvas-node:hover {
@@ -610,34 +624,42 @@ body,
610
624
  /* ── Connection port handles (drag-to-connect) ───────────── */
611
625
  .node-port {
612
626
  position: absolute;
613
- width: 10px;
614
- height: 10px;
615
- border-radius: 50%;
616
- background: var(--c-accent);
617
- border: 2px solid var(--c-panel);
627
+ /* The visible dot stays compact; its grab area stays usable at every zoom. */
628
+ width: calc(32px * var(--node-port-scale, 1));
629
+ height: calc(32px * var(--node-port-scale, 1));
630
+ display: grid;
631
+ place-items: center;
618
632
  opacity: 0;
619
633
  cursor: crosshair;
620
634
  z-index: 10;
621
- transition: opacity 0.15s, transform 0.15s;
622
- transform: translate(-50%, -50%) scale(0.6);
635
+ touch-action: none;
636
+ transition: opacity 0.15s;
623
637
  pointer-events: auto;
624
638
  }
625
- .canvas-node:hover .node-port {
639
+ .node-port::after {
640
+ content: '';
641
+ width: calc(6px * var(--node-port-scale, 1));
642
+ height: calc(6px * var(--node-port-scale, 1));
643
+ border-radius: 50%;
644
+ background: var(--c-accent);
645
+ border: calc(2px * var(--node-port-scale, 1)) solid var(--c-panel);
646
+ box-shadow: 0 0 0 calc(1px * var(--node-port-scale, 1)) var(--c-accent);
647
+ }
648
+ .canvas-node:hover .node-port,
649
+ .canvas-node.selected .node-port,
650
+ .canvas-node:focus-within .node-port {
626
651
  opacity: 1;
627
- transform: translate(-50%, -50%) scale(1);
628
652
  }
629
- .node-port:hover {
653
+ .node-port:hover::after {
630
654
  background: var(--c-accent-hover);
631
- transform: translate(-50%, -50%) scale(1.3);
632
655
  }
633
- .node-port-top { top: 0; left: 50%; }
634
- .node-port-bottom { bottom: 0; left: 50%; transform: translate(-50%, 50%) scale(0.6); }
635
- .node-port-left { left: 0; top: 50%; }
636
- .node-port-right { right: 0; top: 50%; transform: translate(50%, -50%) scale(0.6); }
637
- .canvas-node:hover .node-port-bottom { transform: translate(-50%, 50%) scale(1); }
638
- .canvas-node:hover .node-port-right { transform: translate(50%, -50%) scale(1); }
639
- .node-port-bottom:hover { transform: translate(-50%, 50%) scale(1.3); }
640
- .node-port-right:hover { transform: translate(50%, -50%) scale(1.3); }
656
+ /* Bias vertical grab areas outward so they do not steal title/footer clicks. */
657
+ .node-port-top { top: 0; left: 50%; transform: translate(-50%, -75%); }
658
+ .node-port-bottom { bottom: 0; left: 50%; transform: translate(-50%, 75%); }
659
+ .node-port-top::after { transform: translateY(calc(8px * var(--node-port-scale, 1))); }
660
+ .node-port-bottom::after { transform: translateY(calc(-8px * var(--node-port-scale, 1))); }
661
+ .node-port-left { left: 0; top: 50%; transform: translate(-50%, -50%); }
662
+ .node-port-right { right: 0; top: 50%; transform: translate(50%, -50%); }
641
663
 
642
664
  .canvas-node .node-titlebar {
643
665
  display: flex;
@@ -760,6 +782,11 @@ body,
760
782
  border-radius: calc(3px * var(--node-chrome-scale, 1));
761
783
  }
762
784
 
785
+ .canvas-node .node-controls button svg {
786
+ width: 1em;
787
+ height: 1em;
788
+ }
789
+
763
790
  .canvas-node .node-controls button:hover {
764
791
  background: var(--c-surface-hover);
765
792
  color: var(--c-text);
@@ -1466,6 +1493,10 @@ body,
1466
1493
  animation: presence-pulse 1.4s ease-in-out infinite;
1467
1494
  }
1468
1495
  .session-collapse {
1496
+ display: inline-flex;
1497
+ align-items: center;
1498
+ justify-content: center;
1499
+ padding: 0;
1469
1500
  width: 26px;
1470
1501
  height: 26px;
1471
1502
  border: 1px solid transparent;
@@ -1682,8 +1713,9 @@ body,
1682
1713
  }
1683
1714
  }
1684
1715
  .session-section-chevron {
1716
+ display: inline-flex;
1717
+ flex-shrink: 0;
1685
1718
  color: var(--c-muted);
1686
- font-size: 13px;
1687
1719
  transition: transform 0.15s ease;
1688
1720
  }
1689
1721
  .session-section-chevron.is-open {
@@ -1966,8 +1998,13 @@ body,
1966
1998
  white-space: nowrap;
1967
1999
  }
1968
2000
  .command-bar-chip-unpin {
1969
- width: 14px;
1970
- height: 14px;
2001
+ display: inline-flex;
2002
+ align-items: center;
2003
+ justify-content: center;
2004
+ flex-shrink: 0;
2005
+ padding: 0;
2006
+ width: 20px;
2007
+ height: 20px;
1971
2008
  border: none;
1972
2009
  border-radius: 50%;
1973
2010
  background: none;
@@ -1986,6 +2023,7 @@ body,
1986
2023
  color: var(--c-dim);
1987
2024
  }
1988
2025
  .command-bar-composer {
2026
+ position: relative;
1989
2027
  display: flex;
1990
2028
  align-items: center;
1991
2029
  gap: 10px;
@@ -1997,24 +2035,72 @@ body,
1997
2035
  box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5);
1998
2036
  }
1999
2037
  .command-bar-target {
2000
- appearance: none;
2001
- -webkit-appearance: none;
2002
2038
  max-width: 148px;
2003
- padding: 4px 18px 4px 8px;
2004
- border: 1px solid var(--c-line);
2005
- border-radius: 7px;
2006
- background: color-mix(in srgb, var(--c-panel) 80%, transparent)
2007
- url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='5'%3E%3Cpath d='M1 1l3 3 3-3' fill='none' stroke='%237a8699' stroke-width='1.4'/%3E%3C/svg%3E")
2008
- no-repeat right 6px center;
2009
- color: var(--c-text-soft);
2010
- font: 12px/1.3 var(--mono);
2011
- cursor: pointer;
2012
2039
  flex-shrink: 0;
2013
2040
  }
2014
- .command-bar-target:hover,
2015
- .command-bar-target:focus-visible {
2016
- border-color: var(--c-accent-25);
2041
+ .choice-menu {
2042
+ position: relative;
2043
+ min-width: 0;
2044
+ }
2045
+ .choice-menu-trigger {
2046
+ display: flex;
2047
+ align-items: center;
2048
+ justify-content: space-between;
2049
+ gap: 10px;
2050
+ width: 100%;
2051
+ min-height: 36px;
2052
+ padding: 8px 10px;
2053
+ border: 1px solid color-mix(in srgb, var(--c-line) 65%, var(--c-muted));
2054
+ border-radius: 8px;
2055
+ background: var(--c-panel-overlay);
2017
2056
  color: var(--c-text);
2057
+ font: 12px/1.35 var(--font);
2058
+ cursor: pointer;
2059
+ }
2060
+ .choice-menu-trigger > span {
2061
+ overflow: hidden;
2062
+ text-overflow: ellipsis;
2063
+ white-space: nowrap;
2064
+ }
2065
+ .choice-menu-trigger svg { flex-shrink: 0; }
2066
+ .choice-menu-trigger:hover,
2067
+ .choice-menu-trigger[aria-expanded='true'] { border-color: var(--c-accent); }
2068
+ .choice-menu-trigger:focus-visible,
2069
+ .choice-menu-options button:focus-visible {
2070
+ outline: 2px solid var(--c-accent);
2071
+ outline-offset: 2px;
2072
+ }
2073
+ .toolbar-menu.choice-menu-options {
2074
+ top: calc(100% + 6px);
2075
+ left: 0;
2076
+ right: auto;
2077
+ min-width: 100%;
2078
+ width: max-content;
2079
+ max-width: min(320px, calc(100vw - 64px));
2080
+ max-height: min(280px, 40vh);
2081
+ overflow-y: auto;
2082
+ z-index: 20;
2083
+ }
2084
+ .toolbar-menu.choice-menu-options.opens-above {
2085
+ top: auto;
2086
+ bottom: calc(100% + 6px);
2087
+ }
2088
+ .command-bar-target.choice-menu {
2089
+ position: static;
2090
+ }
2091
+ .command-bar-target .choice-menu-options {
2092
+ /* Anchor to the composer, not its inset trigger, to clear the session rail. */
2093
+ min-width: min(200px, 100%);
2094
+ max-width: 100%;
2095
+ }
2096
+ .choice-menu-options .toolbar-menu-item {
2097
+ min-height: 36px;
2098
+ white-space: normal;
2099
+ overflow-wrap: anywhere;
2100
+ }
2101
+ .choice-menu-options button:disabled {
2102
+ opacity: 0.5;
2103
+ cursor: not-allowed;
2018
2104
  }
2019
2105
 
2020
2106
  .command-bar-icon {
@@ -4483,13 +4569,24 @@ html.is-node-resizing .ext-app-preview-catcher {
4483
4569
  }
4484
4570
  .text-prompt-cancel,
4485
4571
  .text-prompt-confirm {
4486
- padding: 5px 12px;
4572
+ min-height: 36px;
4573
+ padding: 8px 14px;
4487
4574
  border-radius: 7px;
4488
- border: 1px solid var(--c-line);
4489
- background: none;
4490
- color: var(--c-text-soft);
4575
+ border: 1px solid color-mix(in srgb, var(--c-line) 65%, var(--c-muted));
4576
+ background: var(--c-panel-overlay);
4577
+ color: var(--c-text);
4491
4578
  cursor: pointer;
4492
4579
  font-size: 12px;
4580
+ font-weight: 600;
4581
+ }
4582
+ .text-prompt-cancel:hover {
4583
+ background: var(--c-accent-10);
4584
+ border-color: var(--c-muted);
4585
+ }
4586
+ .text-prompt-cancel:focus-visible,
4587
+ .text-prompt-confirm:focus-visible {
4588
+ outline: 2px solid var(--c-accent);
4589
+ outline-offset: 2px;
4493
4590
  }
4494
4591
  .text-prompt-confirm {
4495
4592
  background: var(--c-accent);
@@ -4509,7 +4606,8 @@ html.is-node-resizing .ext-app-preview-catcher {
4509
4606
  overflow-y: auto;
4510
4607
  color: var(--c-text);
4511
4608
  }
4512
- .feedback-dialog label {
4609
+ .feedback-dialog label,
4610
+ .feedback-field {
4513
4611
  display: flex;
4514
4612
  flex-direction: column;
4515
4613
  gap: 6px;
@@ -6896,7 +6994,7 @@ html.is-node-dragging .intent-line-layer {
6896
6994
  align-items: center;
6897
6995
  gap: 6px;
6898
6996
  max-width: 280px;
6899
- padding: 4px 8px;
6997
+ padding: 6px 8px;
6900
6998
  margin-left: -8px;
6901
6999
  background: none;
6902
7000
  border: 1px solid transparent;
@@ -6914,7 +7012,8 @@ html.is-node-dragging .intent-line-layer {
6914
7012
  white-space: nowrap;
6915
7013
  }
6916
7014
  .board-switcher-caret {
6917
- font-size: 10px;
7015
+ display: inline-flex;
7016
+ flex-shrink: 0;
6918
7017
  color: var(--c-text-soft);
6919
7018
  }
6920
7019
  .board-switcher-menu {
@@ -7,11 +7,14 @@
7
7
  * from an inline global injected by the server-side viewer route.
8
8
  */
9
9
 
10
- import type { Spec } from '@json-render/core';
11
- import { AX_SURFACE_EMIT_SOURCE, HTML_SURFACE_PUSH_SOURCE } from '../../shared/ax-surface-protocol.js';
12
- import { useEffect } from 'react';
10
+ import { createStateStore, type Spec, type StateStore } from '@json-render/core';
11
+ import {
12
+ AX_SURFACE_EMIT_SOURCE,
13
+ HTML_SURFACE_PUSH_SOURCE,
14
+ JSON_VIEWER_STATE_SOURCE,
15
+ } from '../../shared/ax-surface-protocol.js';
13
16
  import { createRoot } from 'react-dom/client';
14
- import { defineRegistry, JSONUIProvider, Renderer, useStateBinding } from '@json-render/react';
17
+ import { defineRegistry, JSONUIProvider, Renderer } from '@json-render/react';
15
18
  import { shadcnComponents } from '@json-render/shadcn';
16
19
  import { catalog } from '../catalog';
17
20
  import { chartComponents } from '../charts/components';
@@ -106,29 +109,10 @@ declare global {
106
109
  __PMX_CANVAS_JSON_RENDER_NODE_ID__?: string;
107
110
  __PMX_CANVAS_AX_TOKEN__?: string;
108
111
  __PMX_CANVAS_AX_STATE__?: unknown;
112
+ __PMX_CANVAS_UI_STATE_TOKEN__?: string;
109
113
  }
110
114
  }
111
115
 
112
- // Read-side AX bridge for json-render: keeps the spec-bound `/ax` state live as
113
- // the parent canvas pushes nonce-validated `ax-update` messages, so a declarative
114
- // board ({ "$state": "/ax/workItems" }) reflects the work queue in real time.
115
- function AxStateSync() {
116
- const [, setAx] = useStateBinding<unknown>('ax');
117
- useEffect(() => {
118
- const token = window.__PMX_CANVAS_AX_TOKEN__;
119
- if (!token) return undefined;
120
- function onMessage(event: MessageEvent) {
121
- const m = event.data as { source?: string; type?: string; token?: string; state?: unknown } | null;
122
- if (!m || m.source !== HTML_SURFACE_PUSH_SOURCE || m.type !== 'ax-update' || m.token !== token) return;
123
- window.__PMX_CANVAS_AX_STATE__ = m.state;
124
- setAx(m.state);
125
- }
126
- window.addEventListener('message', onMessage);
127
- return () => window.removeEventListener('message', onMessage);
128
- }, [setAx]);
129
- return null;
130
- }
131
-
132
116
  // AX interaction types a json-render spec can bind actions to. When an action
133
117
  // named like one of these fires, we forward it to the parent canvas (which
134
118
  // validates + submits through the capability-gated endpoint). Convention-based
@@ -188,7 +172,7 @@ function applyTheme(theme: unknown): void {
188
172
  document.documentElement.style.colorScheme = theme === 'light' ? 'light' : 'dark';
189
173
  }
190
174
 
191
- function App() {
175
+ function App({ store }: { store: StateStore }) {
192
176
  const spec = window.__PMX_CANVAS_JSON_RENDER_SPEC__;
193
177
 
194
178
  if (!spec) {
@@ -208,11 +192,6 @@ function App() {
208
192
  );
209
193
  }
210
194
 
211
- // Seed AX state under a reserved `/ax` key so specs can bind { "$state": "/ax/workItems" }.
212
- const axState = window.__PMX_CANVAS_AX_STATE__;
213
- const initialState =
214
- axState !== undefined && axState !== null ? { ...(spec.state ?? {}), ax: axState } : (spec.state ?? undefined);
215
-
216
195
  // Standalone "Open as site" tab (#65): fill the browser viewport instead of the
217
196
  // in-canvas card height. The chart child flex-grows; useChartFrameHeight measures
218
197
  // the full viewport in this mode. Embedded/expanded keep the padded min-height box.
@@ -229,13 +208,7 @@ function App() {
229
208
  : { minHeight: '100vh', padding: 16, boxSizing: 'border-box' as const };
230
209
  return (
231
210
  <div style={containerStyle}>
232
- <JSONUIProvider
233
- registry={registry}
234
- initialState={initialState}
235
- directives={pmxCanvasDirectives}
236
- handlers={buildAxHandlers()}
237
- >
238
- <AxStateSync />
211
+ <JSONUIProvider registry={registry} store={store} directives={pmxCanvasDirectives} handlers={buildAxHandlers()}>
239
212
  <div style={isSite ? { flex: 1, minHeight: 0 } : undefined}>
240
213
  <Renderer spec={spec} registry={registry} loading={false} />
241
214
  </div>
@@ -248,5 +221,52 @@ function App() {
248
221
  const root = document.getElementById('root');
249
222
  if (root) {
250
223
  syncPreferredTheme();
251
- createRoot(root).render(<App />);
224
+ const stateToken = window.__PMX_CANVAS_UI_STATE_TOKEN__;
225
+ let store: StateStore | undefined;
226
+ const mount = (restored: Record<string, unknown> | null) => {
227
+ if (store) return;
228
+ const seed = restored ?? window.__PMX_CANVAS_JSON_RENDER_SPEC__?.state ?? {};
229
+ const ax = window.__PMX_CANVAS_AX_STATE__;
230
+ store = createStateStore(ax != null ? { ...seed, ax } : seed);
231
+ if (stateToken) {
232
+ // subscribe fires synchronously at the input/action write, not in a React
233
+ // effect that can be lost on unmount. Never mirror the server-owned /ax.
234
+ store.subscribe(() => {
235
+ const { ax: _ax, ...state } = store!.getSnapshot();
236
+ window.parent.postMessage(
237
+ {
238
+ source: JSON_VIEWER_STATE_SOURCE,
239
+ type: 'snapshot',
240
+ token: stateToken,
241
+ nodeId: window.__PMX_CANVAS_JSON_RENDER_NODE_ID__,
242
+ state,
243
+ },
244
+ '*',
245
+ );
246
+ });
247
+ }
248
+ createRoot(root).render(<App store={store} />);
249
+ };
250
+ // Install before document load. The parent supplies a snapshot (or null) in
251
+ // onLoad; restore BEFORE mounting so watchers don't replay past user actions.
252
+ // Standalone viewers have no state token and render immediately.
253
+ window.addEventListener('message', (event: MessageEvent) => {
254
+ if (event.source !== window.parent) return;
255
+ const m = event.data as { source?: string; type?: string; token?: string; state?: unknown } | null;
256
+ if (!m) return;
257
+ if (m.source === JSON_VIEWER_STATE_SOURCE && m.type === 'restore' && stateToken && m.token === stateToken) {
258
+ if (m.state === null || (typeof m.state === 'object' && !Array.isArray(m.state)))
259
+ mount(m.state as Record<string, unknown> | null);
260
+ }
261
+ if (
262
+ m.source === HTML_SURFACE_PUSH_SOURCE &&
263
+ m.type === 'ax-update' &&
264
+ window.__PMX_CANVAS_AX_TOKEN__ &&
265
+ m.token === window.__PMX_CANVAS_AX_TOKEN__
266
+ ) {
267
+ window.__PMX_CANVAS_AX_STATE__ = m.state;
268
+ store?.set('/ax', m.state);
269
+ }
270
+ });
271
+ if (!stateToken) mount(null);
252
272
  }
@@ -979,6 +979,8 @@ export async function buildJsonRenderViewerHtml(options: {
979
979
  nodeId?: string;
980
980
  axToken?: string;
981
981
  axState?: unknown;
982
+ /** Independent nonce for tab-local runtime state across focus transitions. */
983
+ stateToken?: string;
982
984
  /** Nonce for the content-height reporter so the node can grow to fit the chart. */
983
985
  frameToken?: string;
984
986
  /** When true, charts render at their natural (intrinsic) height instead of
@@ -995,9 +997,14 @@ export async function buildJsonRenderViewerHtml(options: {
995
997
  ...(options.theme ? [`window.__PMX_CANVAS_JSON_RENDER_THEME__ = ${JSON.stringify(options.theme)};`] : []),
996
998
  ...(options.display ? [`window.__PMX_CANVAS_JSON_RENDER_DISPLAY__ = ${JSON.stringify(options.display)};`] : []),
997
999
  ...(options.devtools ? ['window.__PMX_CANVAS_JSON_RENDER_DEVTOOLS__ = true;'] : []),
1000
+ ...(options.nodeId
1001
+ ? [`window.__PMX_CANVAS_JSON_RENDER_NODE_ID__ = ${JSON.stringify(sanitizeAxValue(options.nodeId))};`]
1002
+ : []),
1003
+ ...(options.stateToken
1004
+ ? [`window.__PMX_CANVAS_UI_STATE_TOKEN__ = ${JSON.stringify(sanitizeAxValue(options.stateToken))};`]
1005
+ : []),
998
1006
  ...(options.nodeId && options.axToken
999
1007
  ? [
1000
- `window.__PMX_CANVAS_JSON_RENDER_NODE_ID__ = ${JSON.stringify(sanitizeAxValue(options.nodeId))};`,
1001
1008
  `window.__PMX_CANVAS_AX_TOKEN__ = ${JSON.stringify(sanitizeAxValue(options.axToken))};`,
1002
1009
  // Read-side AX state: seed for initial render + bound under /ax for specs.
1003
1010
  `window.__PMX_CANVAS_AX_STATE__ = ${JSON.stringify(options.axState ?? null).replace(/</g, '\\u003c')};`,
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);