pmx-canvas 0.7.3 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/extensions/pmx-canvas/THIRD_PARTY_LICENSES.txt +331 -0
- package/.github/extensions/pmx-canvas/extension.mjs +25 -172
- package/.github/extensions/pmx-canvas/mcp-bootstrap.ts +198 -0
- package/.github/extensions/pmx-canvas/runtime.d.mts +41 -0
- package/.github/extensions/pmx-canvas/runtime.json +3 -0
- package/.github/extensions/pmx-canvas/runtime.mjs +194 -0
- package/.github/extensions/pmx-canvas/start-mcp.mjs +51 -0
- package/.github/extensions/pmx-canvas/tools.json +2560 -0
- package/CHANGELOG.md +47 -0
- package/Readme.md +21 -7
- package/dist/canvas/fonts/OFL.txt +93 -0
- package/dist/canvas/fonts/ibm-plex-mono-latin-400-normal.woff2 +0 -0
- package/dist/canvas/fonts/ibm-plex-mono-latin-500-normal.woff2 +0 -0
- package/dist/canvas/fonts/ibm-plex-mono-latin-ext-400-normal.woff2 +0 -0
- package/dist/canvas/fonts/ibm-plex-mono-latin-ext-500-normal.woff2 +0 -0
- package/dist/canvas/fonts/ibm-plex-sans-latin-400-normal.woff2 +0 -0
- package/dist/canvas/fonts/ibm-plex-sans-latin-500-normal.woff2 +0 -0
- package/dist/canvas/fonts/ibm-plex-sans-latin-600-normal.woff2 +0 -0
- package/dist/canvas/fonts/ibm-plex-sans-latin-ext-400-normal.woff2 +0 -0
- package/dist/canvas/fonts/ibm-plex-sans-latin-ext-500-normal.woff2 +0 -0
- package/dist/canvas/fonts/ibm-plex-sans-latin-ext-600-normal.woff2 +0 -0
- package/dist/canvas/global.css +723 -672
- package/dist/canvas/index.js +90 -88
- package/dist/canvas/mcp-app.html +7878 -0
- package/dist/canvas/mermaid-entry.js +1 -1
- package/dist/canvas/surface-theme.css +151 -46
- package/dist/json-render/index.css +1 -1
- package/dist/types/cli/commands/record.d.ts +1 -1
- package/dist/types/client/canvas/NodeContextMark.d.ts +24 -0
- package/dist/types/client/canvas/SessionReceipt.d.ts +3 -1
- package/dist/types/client/icons.d.ts +1 -2
- package/dist/types/client/mcp-app/index.d.ts +1 -0
- package/dist/types/client/nodes/ax-node-actions.d.ts +2 -2
- package/dist/types/client/nodes/markdown-sanitize.d.ts +2 -0
- package/dist/types/client/nodes/use-workbench-asset.d.ts +2 -0
- package/dist/types/client/state/attention-store.d.ts +0 -5
- package/dist/types/client/state/context-status-store.d.ts +53 -0
- package/dist/types/client/state/intent-bridge.d.ts +3 -0
- package/dist/types/client/state/near-pin-store.d.ts +16 -0
- package/dist/types/client/state/presence-store.d.ts +5 -0
- package/dist/types/client/state/session-store.d.ts +33 -3
- package/dist/types/client/state/workbench-transport.d.ts +16 -0
- package/dist/types/client/theme/tokens.d.ts +1 -3
- package/dist/types/client/types.d.ts +9 -0
- package/dist/types/json-render/server.d.ts +1 -1
- package/dist/types/mcp/app-server.d.ts +3 -0
- package/dist/types/mcp/canvas-access.d.ts +1 -1
- package/dist/types/mcp/canvas-app.d.ts +4 -0
- package/dist/types/mcp/workbench-app.d.ts +4 -0
- package/dist/types/server/agent-presence.d.ts +1 -1
- package/dist/types/server/ax-state.d.ts +1 -1
- package/dist/types/server/canvas-db.d.ts +8 -0
- package/dist/types/server/canvas-geometry.d.ts +25 -0
- package/dist/types/server/canvas-operations.d.ts +1 -11
- package/dist/types/server/canvas-state.d.ts +25 -3
- package/dist/types/server/context-brief.d.ts +8 -1
- package/dist/types/server/context-reads.d.ts +30 -7
- package/dist/types/server/index.d.ts +6 -1
- package/dist/types/server/mcp-app-runtime.d.ts +1 -0
- package/dist/types/server/operations/http.d.ts +2 -0
- package/dist/types/server/operations/invoker.d.ts +4 -2
- package/dist/types/server/operations/ops/nodes.d.ts +0 -6
- package/dist/types/server/server.d.ts +1 -0
- package/dist/types/server/spatial-analysis.d.ts +2 -0
- package/dist/types/shared/agent-context.d.ts +22 -0
- package/dist/types/shared/agent-presence.d.ts +3 -10
- package/dist/types/shared/canvas-app.d.ts +54 -0
- package/dist/types/shared/themes.d.ts +1 -1
- package/docs/RELEASE.md +7 -3
- package/docs/agent-plugin.md +123 -0
- package/docs/ax-state-contract.md +40 -0
- package/docs/cli.md +7 -2
- package/docs/design/AgentContext.dc.html +186 -0
- package/docs/design/Before.dc.html +208 -0
- package/docs/design/BoardPins.dc.html +151 -0
- package/docs/design/Chrome.dc.html +208 -0
- package/docs/design/Context.dc.html +239 -0
- package/docs/design/Flows.dc.html +181 -0
- package/docs/design/Home.dc.html +209 -0
- package/docs/design/HomeGraph.dc.html +211 -0
- package/docs/design/HomeMap.dc.html +117 -0
- package/docs/design/HomeTeam.dc.html +176 -0
- package/docs/design/Light.dc.html +28 -0
- package/docs/design/Main.dc.html +280 -0
- package/docs/design/NearPin.dc.html +260 -0
- package/docs/design/Pane600.dc.html +120 -0
- package/docs/design/Relations.dc.html +297 -0
- package/docs/design/Themes.dc.html +121 -0
- package/docs/design/TokenSystem.dc.html +136 -0
- package/docs/design/Tokens.dc.html +163 -0
- package/docs/design/Types1.dc.html +177 -0
- package/docs/design/Types2.dc.html +226 -0
- package/docs/design/Types3.dc.html +195 -0
- package/docs/design/Uses.dc.html +152 -0
- package/docs/design/Vision.dc.html +116 -0
- package/docs/design/Zoom.dc.html +82 -0
- package/docs/design/canvas.json +274 -0
- package/docs/design.md +214 -0
- package/docs/environment.md +1 -1
- package/docs/http-api.md +80 -15
- package/docs/mcp.md +198 -3
- package/docs/node-types.md +34 -10
- package/docs/plans/plan-009-tech-debt-backlog.md +1 -1
- package/docs/plans/plan-015-design-foundation-wave-0.md +177 -0
- package/docs/plans/plan-016-context-made-visible-wave-1.md +148 -0
- package/docs/product-vision-2026-09.md +47 -7
- package/docs/screenshot.png +0 -0
- package/docs/sdk.md +1 -0
- package/package.json +10 -4
- package/plugins/pmx-canvas/LICENSE +21 -0
- package/plugins/pmx-canvas/THIRD_PARTY_LICENSES.txt +331 -0
- package/plugins/pmx-canvas/assets/preview.png +0 -0
- package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/extension.mjs +930 -0
- package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/runtime.json +3 -0
- package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/runtime.mjs +194 -0
- package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/steering-delivery.mjs +79 -0
- package/plugins/pmx-canvas/mcp.json +12 -0
- package/plugins/pmx-canvas/plugin.json +24 -0
- package/plugins/pmx-canvas/runtime.json +3 -0
- package/plugins/pmx-canvas/runtime.mjs +194 -0
- package/plugins/pmx-canvas/start-mcp.mjs +51 -0
- package/plugins/pmx-canvas/tools.json +2560 -0
- package/skills/pmx-canvas/SKILL.md +10 -7
- package/skills/pmx-canvas/references/full-reference.md +11 -7
- package/skills/pmx-canvas/references/github-copilot-app-adapter.md +20 -8
- package/skills/pmx-canvas/references/installing-pmx-canvas.md +6 -6
- package/skills/pmx-canvas-testing/SKILL.md +5 -4
- package/src/cli/commands/copilot.ts +5 -0
- package/src/cli/commands/view.ts +1 -1
- package/src/cli/index.ts +8 -3
- package/src/client/App.tsx +6 -1
- package/src/client/canvas/AgentPresenceLayer.tsx +3 -3
- package/src/client/canvas/AnnotationLayer.tsx +1 -1
- package/src/client/canvas/CanvasNode.tsx +32 -49
- package/src/client/canvas/CanvasViewport.tsx +0 -2
- package/src/client/canvas/CommandPalette.tsx +3 -2
- package/src/client/canvas/ContextMenu.tsx +11 -0
- package/src/client/canvas/EdgeLayer.tsx +1 -1
- package/src/client/canvas/ExportDialog.tsx +33 -6
- package/src/client/canvas/IntentLayer.tsx +2 -2
- package/src/client/canvas/Minimap.tsx +12 -7
- package/src/client/canvas/NodeContextMark.tsx +156 -0
- package/src/client/canvas/SessionPanel.tsx +4 -7
- package/src/client/canvas/SessionReceipt.tsx +114 -17
- package/src/client/canvas/ShortcutOverlay.tsx +6 -1
- package/src/client/canvas/SnapshotPanel.tsx +11 -2
- package/src/client/canvas/ToolRail.tsx +12 -9
- package/src/client/canvas/TopBar.tsx +16 -11
- package/src/client/icons.tsx +2 -12
- package/src/client/mcp-app/index.tsx +137 -0
- package/src/client/mcp-app/style.css +10 -0
- package/src/client/nodes/AttachmentNode.tsx +8 -1
- package/src/client/nodes/AxStepControls.tsx +1 -1
- package/src/client/nodes/ContextNode.tsx +9 -9
- package/src/client/nodes/ExtAppFrame.tsx +13 -8
- package/src/client/nodes/FileNode.tsx +8 -5
- package/src/client/nodes/ImageNode.tsx +4 -2
- package/src/client/nodes/InlineMarkdownEditor.tsx +2 -1
- package/src/client/nodes/LedgerNode.tsx +2 -2
- package/src/client/nodes/MarkdownNode.tsx +44 -9
- package/src/client/nodes/McpAppNode.tsx +6 -4
- package/src/client/nodes/PromptNode.tsx +5 -17
- package/src/client/nodes/ResponseNode.tsx +3 -15
- package/src/client/nodes/StatusNode.tsx +2 -2
- package/src/client/nodes/StatusSummary.tsx +3 -1
- package/src/client/nodes/TraceNode.tsx +6 -4
- package/src/client/nodes/WebpageNode.tsx +1 -1
- package/src/client/nodes/ax-node-actions.ts +2 -2
- package/src/client/nodes/iframe-document-url.ts +3 -2
- package/src/client/nodes/markdown-sanitize.ts +10 -0
- package/src/client/nodes/surface-url.ts +10 -0
- package/src/client/nodes/use-surface-frame.ts +3 -1
- package/src/client/nodes/use-workbench-asset.ts +26 -0
- package/src/client/state/attention-bridge.ts +0 -8
- package/src/client/state/attention-store.ts +0 -17
- package/src/client/state/canvas-store.ts +4 -2
- package/src/client/state/context-status-store.ts +66 -0
- package/src/client/state/human-store.ts +2 -0
- package/src/client/state/iframe-mode.ts +5 -0
- package/src/client/state/intent-bridge.ts +10 -4
- package/src/client/state/near-pin-store.ts +53 -0
- package/src/client/state/presence-store.ts +5 -10
- package/src/client/state/session-store.ts +106 -9
- package/src/client/state/sse-bridge.ts +26 -7
- package/src/client/state/theme-override.ts +1 -1
- package/src/client/state/workbench-transport.ts +36 -0
- package/src/client/theme/global.css +723 -672
- package/src/client/theme/surface-theme.css +151 -46
- package/src/client/theme/tokens.ts +7 -11
- package/src/client/types.ts +10 -0
- package/src/json-render/server.ts +71 -14
- package/src/mcp/app-server.ts +64 -0
- package/src/mcp/canvas-access.ts +14 -11
- package/src/mcp/canvas-app.ts +318 -0
- package/src/mcp/server.ts +1 -1
- package/src/mcp/workbench-app.ts +211 -0
- package/src/server/agent-presence.ts +45 -6
- package/src/server/ax-context.ts +1 -1
- package/src/server/ax-state.ts +1 -1
- package/src/server/board-export.ts +5 -2
- package/src/server/canvas-db.ts +46 -12
- package/src/server/canvas-geometry.ts +64 -0
- package/src/server/canvas-operations.ts +6 -24
- package/src/server/canvas-state.ts +105 -15
- package/src/server/canvas-validation.ts +19 -4
- package/src/server/context-brief.ts +39 -2
- package/src/server/context-reads.ts +86 -15
- package/src/server/demo-state.json +31 -31
- package/src/server/html-surface.ts +4 -5
- package/src/server/index.ts +19 -3
- package/src/server/mcp-app-runtime.ts +11 -0
- package/src/server/operations/composites.ts +4 -3
- package/src/server/operations/http.ts +4 -4
- package/src/server/operations/invoker.ts +13 -4
- package/src/server/operations/ops/ax-read.ts +1 -1
- package/src/server/operations/ops/ax-timeline.ts +37 -0
- package/src/server/operations/ops/boards.ts +1 -1
- package/src/server/operations/ops/canvas-wire.ts +3 -6
- package/src/server/operations/ops/intent.ts +31 -2
- package/src/server/operations/ops/json-render.ts +1 -1
- package/src/server/operations/ops/nodes.ts +8 -76
- package/src/server/operations/ops/query.ts +8 -2
- package/src/server/operations/ops/snapshots.ts +4 -1
- package/src/server/operations/ops/viewport.ts +29 -5
- package/src/server/operations/registry.ts +38 -4
- package/src/server/scope-fence.ts +55 -10
- package/src/server/server.ts +83 -5
- package/src/server/spatial-analysis.ts +2 -2
- package/src/server/workboard.ts +6 -1
- package/src/{server → shared}/agent-context.ts +2 -2
- package/src/shared/agent-presence.ts +3 -15
- package/src/shared/canvas-app.ts +62 -0
- package/src/shared/themes.ts +5 -5
- package/src/client/canvas/FocusFieldLayer.tsx +0 -116
- package/src/client/canvas/kind-colors.ts +0 -23
|
@@ -3,6 +3,7 @@ import { IconClose } from '../icons';
|
|
|
3
3
|
import { updateNodeData } from '../state/canvas-store';
|
|
4
4
|
import { fetchSlashCommands, renderMarkdown, submitCanvasPrompt, submitThreadReply } from '../state/intent-bridge';
|
|
5
5
|
import type { CanvasNodeState } from '../types';
|
|
6
|
+
import { sanitizeMarkdownHtml } from './markdown-sanitize';
|
|
6
7
|
|
|
7
8
|
// Cached slash commands — fetched once on first use.
|
|
8
9
|
let cachedCommands: Array<{ name: string; description: string }> | null = null;
|
|
@@ -27,19 +28,6 @@ interface ThreadTurn {
|
|
|
27
28
|
status?: string;
|
|
28
29
|
}
|
|
29
30
|
|
|
30
|
-
/** Strip dangerous HTML from rendered markdown to prevent XSS. */
|
|
31
|
-
function sanitizeHtml(html: string): string {
|
|
32
|
-
return html
|
|
33
|
-
.replace(/<script[\s>][\s\S]*?<\/script>/gi, '')
|
|
34
|
-
.replace(/<iframe[\s>][\s\S]*?<\/iframe>/gi, '')
|
|
35
|
-
.replace(/<object[\s>][\s\S]*?<\/object>/gi, '')
|
|
36
|
-
.replace(/<embed[\s>][\s\S]*?(?:\/>|<\/embed>)/gi, '')
|
|
37
|
-
.replace(/<link[\s>][\s\S]*?(?:\/>|<\/link>)/gi, '')
|
|
38
|
-
.replace(/\bon\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, '')
|
|
39
|
-
.replace(/href\s*=\s*"javascript:[^"]*"/gi, 'href="#"')
|
|
40
|
-
.replace(/href\s*=\s*'javascript:[^']*'/gi, "href='#'");
|
|
41
|
-
}
|
|
42
|
-
|
|
43
31
|
function RenderedMarkdown({ html }: { html: string }) {
|
|
44
32
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
45
33
|
|
|
@@ -50,7 +38,7 @@ function RenderedMarkdown({ html }: { html: string }) {
|
|
|
50
38
|
if (!html) return;
|
|
51
39
|
|
|
52
40
|
const template = document.createElement('template');
|
|
53
|
-
template.innerHTML = html;
|
|
41
|
+
template.innerHTML = sanitizeMarkdownHtml(html);
|
|
54
42
|
container.append(template.content.cloneNode(true));
|
|
55
43
|
}, [html]);
|
|
56
44
|
|
|
@@ -181,7 +169,7 @@ export function PromptNode({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
181
169
|
Promise.all(
|
|
182
170
|
assistantTurns.map(async ({ turn, index }) => {
|
|
183
171
|
const html = await renderMarkdown(turn.text);
|
|
184
|
-
return { index, html:
|
|
172
|
+
return { index, html: sanitizeMarkdownHtml(html) };
|
|
185
173
|
}),
|
|
186
174
|
).then((results) => {
|
|
187
175
|
if (cancelled) return;
|
|
@@ -328,7 +316,7 @@ export function PromptNode({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
328
316
|
border: '1px solid var(--c-line)',
|
|
329
317
|
borderRadius: 'var(--radius-sm)',
|
|
330
318
|
color: 'var(--c-text)',
|
|
331
|
-
fontFamily: 'var(--font)',
|
|
319
|
+
fontFamily: 'var(--font-ui)',
|
|
332
320
|
fontSize: '13px',
|
|
333
321
|
lineHeight: '1.5',
|
|
334
322
|
padding: '8px 10px',
|
|
@@ -529,7 +517,7 @@ export function PromptNode({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
529
517
|
border: '1px solid var(--c-line)',
|
|
530
518
|
borderRadius: 'var(--radius-sm)',
|
|
531
519
|
color: 'var(--c-text)',
|
|
532
|
-
fontFamily: 'var(--font)',
|
|
520
|
+
fontFamily: 'var(--font-ui)',
|
|
533
521
|
fontSize: '12px',
|
|
534
522
|
lineHeight: '1.5',
|
|
535
523
|
padding: '6px 8px',
|
|
@@ -1,19 +1,7 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
|
|
2
2
|
import { renderMarkdown, submitCanvasPrompt } from '../state/intent-bridge';
|
|
3
3
|
import type { CanvasNodeState } from '../types';
|
|
4
|
-
|
|
5
|
-
/** Strip dangerous HTML from rendered markdown to prevent XSS. */
|
|
6
|
-
function sanitizeHtml(html: string): string {
|
|
7
|
-
return html
|
|
8
|
-
.replace(/<script[\s>][\s\S]*?<\/script>/gi, '')
|
|
9
|
-
.replace(/<iframe[\s>][\s\S]*?<\/iframe>/gi, '')
|
|
10
|
-
.replace(/<object[\s>][\s\S]*?<\/object>/gi, '')
|
|
11
|
-
.replace(/<embed[\s>][\s\S]*?(?:\/>|<\/embed>)/gi, '')
|
|
12
|
-
.replace(/<link[\s>][\s\S]*?(?:\/>|<\/link>)/gi, '')
|
|
13
|
-
.replace(/\bon\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, '')
|
|
14
|
-
.replace(/href\s*=\s*"javascript:[^"]*"/gi, 'href="#"')
|
|
15
|
-
.replace(/href\s*=\s*'javascript:[^']*'/gi, "href='#'");
|
|
16
|
-
}
|
|
4
|
+
import { sanitizeMarkdownHtml } from './markdown-sanitize';
|
|
17
5
|
|
|
18
6
|
function RenderedMarkdown({ html }: { html: string }) {
|
|
19
7
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
@@ -25,7 +13,7 @@ function RenderedMarkdown({ html }: { html: string }) {
|
|
|
25
13
|
if (!html) return;
|
|
26
14
|
|
|
27
15
|
const template = document.createElement('template');
|
|
28
|
-
template.innerHTML = html;
|
|
16
|
+
template.innerHTML = sanitizeMarkdownHtml(html);
|
|
29
17
|
container.append(template.content.cloneNode(true));
|
|
30
18
|
}, [html]);
|
|
31
19
|
|
|
@@ -48,7 +36,7 @@ export function ResponseNode({ node, expanded = false }: { node: CanvasNodeState
|
|
|
48
36
|
}
|
|
49
37
|
let cancelled = false;
|
|
50
38
|
renderMarkdown(content).then((html) => {
|
|
51
|
-
if (!cancelled) setRendered(
|
|
39
|
+
if (!cancelled) setRendered(sanitizeMarkdownHtml(html));
|
|
52
40
|
});
|
|
53
41
|
return () => {
|
|
54
42
|
cancelled = true;
|
|
@@ -64,9 +64,9 @@ export function StatusNode({ node }: { node: CanvasNodeState }) {
|
|
|
64
64
|
|
|
65
65
|
{/* Active tool */}
|
|
66
66
|
{activeTool && (
|
|
67
|
-
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', color: 'var(--c-
|
|
67
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', color: 'var(--c-agent)' }}>
|
|
68
68
|
<span style={{ fontSize: '10px' }}>⚙</span>
|
|
69
|
-
<span style={{ fontFamily: 'var(--
|
|
69
|
+
<span style={{ fontFamily: 'var(--font-code)', fontSize: '11px' }}>{activeTool}</span>
|
|
70
70
|
</div>
|
|
71
71
|
)}
|
|
72
72
|
|
|
@@ -23,7 +23,9 @@ export function StatusSummary({ node }: { node: CanvasNodeState }) {
|
|
|
23
23
|
/>
|
|
24
24
|
<span style={{ color: phaseColor, fontSize: '10px', textTransform: 'uppercase', fontWeight: 600 }}>{phase}</span>
|
|
25
25
|
{activeTool && (
|
|
26
|
-
<span style={{ color: 'var(--c-
|
|
26
|
+
<span style={{ color: 'var(--c-agent)', fontSize: '10px', fontFamily: 'var(--font-code)' }}>
|
|
27
|
+
⚙ {activeTool}
|
|
28
|
+
</span>
|
|
27
29
|
)}
|
|
28
30
|
{subagent && subagent.state !== 'completed' && (
|
|
29
31
|
<span style={{ color: 'var(--c-subagent)', fontSize: '10px' }}>⠉ {subagent.name}</span>
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import type { CanvasNodeState } from '../types';
|
|
2
2
|
import { buildTraceDisplayModel } from './trace-model';
|
|
3
3
|
|
|
4
|
+
// Categories carry no colour (docs/design.md, "Kinds carry no colour"); only a
|
|
5
|
+
// subagent's work takes its meaning colour.
|
|
4
6
|
const CATEGORY_COLORS: Record<string, string> = {
|
|
5
|
-
mcp: 'var(--c-
|
|
6
|
-
file: 'var(--c-
|
|
7
|
-
subagent: 'var(--c-
|
|
7
|
+
mcp: 'var(--c-muted)',
|
|
8
|
+
file: 'var(--c-muted)',
|
|
9
|
+
subagent: 'var(--c-subagent)',
|
|
8
10
|
other: 'var(--c-muted)',
|
|
9
11
|
};
|
|
10
12
|
|
|
@@ -68,7 +70,7 @@ export function TraceNode({ node }: { node: CanvasNodeState }) {
|
|
|
68
70
|
>
|
|
69
71
|
<div
|
|
70
72
|
style={{
|
|
71
|
-
fontFamily: 'var(--
|
|
73
|
+
fontFamily: 'var(--font-code)',
|
|
72
74
|
fontSize: '11px',
|
|
73
75
|
fontWeight: 600,
|
|
74
76
|
color: catColor,
|
|
@@ -56,7 +56,7 @@ export function WebpageNode({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
56
56
|
return <div style={{ color: 'var(--c-dim)', fontStyle: 'italic', padding: '12px' }}>No webpage URL set</div>;
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
const statusTone = status === 'ready' ? 'var(--c-ok)' : status === 'error' ? 'var(--c-danger)' : 'var(--c-
|
|
59
|
+
const statusTone = status === 'ready' ? 'var(--c-ok)' : status === 'error' ? 'var(--c-danger)' : 'var(--c-muted)';
|
|
60
60
|
|
|
61
61
|
return (
|
|
62
62
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px', height: '100%' }}>
|
|
@@ -27,8 +27,8 @@ export async function runNodeAxInteraction(
|
|
|
27
27
|
export const axNodeActionButtonStyle = {
|
|
28
28
|
padding: '3px 8px',
|
|
29
29
|
fontSize: '10px',
|
|
30
|
-
background: 'var(--c-accent
|
|
31
|
-
border: '1px solid var(--c-accent
|
|
30
|
+
background: 'color-mix(in srgb, var(--c-accent) 12%, transparent)',
|
|
31
|
+
border: '1px solid color-mix(in srgb, var(--c-accent) 25%, transparent)',
|
|
32
32
|
borderRadius: '4px',
|
|
33
33
|
color: 'var(--c-text-soft)',
|
|
34
34
|
cursor: 'pointer',
|
|
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
|
|
2
2
|
import { workbenchConnectionEpoch } from '../state/canvas-store';
|
|
3
3
|
import { iframeMode } from '../state/iframe-mode';
|
|
4
4
|
import { surfaceContentHash } from './surface-url';
|
|
5
|
+
import { workbenchFetch } from '../state/workbench-transport';
|
|
5
6
|
|
|
6
7
|
interface FrameDocumentCreateResponse {
|
|
7
8
|
ok: boolean;
|
|
@@ -21,7 +22,7 @@ function isFrameDocumentCreateResponse(value: unknown): value is FrameDocumentCr
|
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
export async function createIframeDocumentUrl(html: string, sandbox: string): Promise<string> {
|
|
24
|
-
const response = await
|
|
25
|
+
const response = await workbenchFetch('/api/canvas/frame-documents', {
|
|
25
26
|
method: 'POST',
|
|
26
27
|
headers: { 'Content-Type': 'application/json' },
|
|
27
28
|
body: JSON.stringify({ html, sandbox }),
|
|
@@ -83,7 +84,7 @@ export function useIframeDocument(
|
|
|
83
84
|
return;
|
|
84
85
|
}
|
|
85
86
|
let cancelled = false;
|
|
86
|
-
void
|
|
87
|
+
void workbenchFetch(src, { method: 'HEAD' })
|
|
87
88
|
.then((res) => {
|
|
88
89
|
if (cancelled) return;
|
|
89
90
|
if (res.ok) {
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import DOMPurify from 'dompurify';
|
|
2
|
+
|
|
3
|
+
/** Sanitize rendered Markdown before it enters the privileged workbench DOM. */
|
|
4
|
+
export function sanitizeMarkdownHtml(html: string): string {
|
|
5
|
+
return DOMPurify.sanitize(html, {
|
|
6
|
+
// Markdown does not need these active document surfaces. Keep regular
|
|
7
|
+
// inputs so marked's GFM task checkboxes remain interactive.
|
|
8
|
+
FORBID_TAGS: ['form', 'svg', 'math'],
|
|
9
|
+
});
|
|
10
|
+
}
|
|
@@ -2,6 +2,8 @@ import { canvasTheme } from '../state/canvas-store';
|
|
|
2
2
|
import { openNodeInSystemBrowserRequest } from '../state/intent-bridge';
|
|
3
3
|
import { canOpenNodeAsSurface } from '../../shared/surface.js';
|
|
4
4
|
import type { CanvasNodeState } from '../types';
|
|
5
|
+
import { isHostedWorkbench } from '../state/workbench-transport';
|
|
6
|
+
import { showToast } from '../state/attention-bridge';
|
|
5
7
|
|
|
6
8
|
/**
|
|
7
9
|
* Stable content hash (djb2) used to cache-bust the surface iframe `src` when a
|
|
@@ -52,6 +54,14 @@ export function canOpenAsSite(node: CanvasNodeState): boolean {
|
|
|
52
54
|
* and headless/disabled-browser environments.
|
|
53
55
|
*/
|
|
54
56
|
export async function openNodeAsSite(node: CanvasNodeState): Promise<void> {
|
|
57
|
+
if (isHostedWorkbench()) {
|
|
58
|
+
showToast(
|
|
59
|
+
'context',
|
|
60
|
+
'Open in focus mode',
|
|
61
|
+
'This surface has no public URL. Use Expand to view it here, or export the board to download it.',
|
|
62
|
+
);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
55
65
|
const url = nodeSurfaceUrl(node.id);
|
|
56
66
|
const res = await openNodeInSystemBrowserRequest(node.id, url);
|
|
57
67
|
if (!res.opened) window.open(url, '_blank', 'noopener');
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { useEffect, useState } from 'preact/hooks';
|
|
2
2
|
import { iframeMode } from '../state/iframe-mode';
|
|
3
|
+
import { isHostedWorkbench, workbenchFetch } from '../state/workbench-transport';
|
|
3
4
|
|
|
4
5
|
function isSameOriginUrl(url: string): boolean {
|
|
6
|
+
if (isHostedWorkbench()) return url.startsWith('/') && !url.startsWith('//');
|
|
5
7
|
try {
|
|
6
8
|
return new URL(url, window.location.origin).origin === window.location.origin;
|
|
7
9
|
} catch {
|
|
@@ -29,7 +31,7 @@ export function useSurfaceFrame(url: string): { src?: string; srcdoc?: string }
|
|
|
29
31
|
let cancelled = false;
|
|
30
32
|
// Ask the server to inline bundle assets (the mermaid renderer): hosts
|
|
31
33
|
// that force srcdoc mode block sub-frame subresource requests too.
|
|
32
|
-
void
|
|
34
|
+
void workbenchFetch(`${url}${url.includes('?') ? '&' : '?'}inline-assets=1`, { credentials: 'same-origin' })
|
|
33
35
|
.then((res) => (res.ok ? res.text() : Promise.reject(new Error(`HTTP ${res.status}`))))
|
|
34
36
|
.then((html) => {
|
|
35
37
|
if (!cancelled) setDoc({ url, html });
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { useEffect, useState } from 'preact/hooks';
|
|
2
|
+
import { isHostedWorkbench, workbenchFetch } from '../state/workbench-transport';
|
|
3
|
+
|
|
4
|
+
/** Asset bytes travel through the host, not a localhost URL in the user's browser. */
|
|
5
|
+
export function useWorkbenchAsset(path: string): string | undefined {
|
|
6
|
+
const hosted = isHostedWorkbench() && path.startsWith('/');
|
|
7
|
+
const [asset, setAsset] = useState<{ path: string; url: string } | null>(null);
|
|
8
|
+
useEffect(() => {
|
|
9
|
+
if (!hosted) return;
|
|
10
|
+
let cancelled = false;
|
|
11
|
+
void workbenchFetch(path)
|
|
12
|
+
.then(async (response) => {
|
|
13
|
+
if (!response.ok) throw new Error(`Asset read failed: ${response.status}`);
|
|
14
|
+
const reader = new FileReader();
|
|
15
|
+
reader.onload = () => {
|
|
16
|
+
if (!cancelled && typeof reader.result === 'string') setAsset({ path, url: reader.result });
|
|
17
|
+
};
|
|
18
|
+
reader.readAsDataURL(await response.blob());
|
|
19
|
+
})
|
|
20
|
+
.catch((error: unknown) => console.error('[workbench-asset]', error));
|
|
21
|
+
return () => {
|
|
22
|
+
cancelled = true;
|
|
23
|
+
};
|
|
24
|
+
}, [path, hosted]);
|
|
25
|
+
return hosted ? (asset?.path === path ? asset.url : undefined) : path;
|
|
26
|
+
}
|
|
@@ -11,7 +11,6 @@ import {
|
|
|
11
11
|
import {
|
|
12
12
|
pushAttentionHistory,
|
|
13
13
|
resetAttentionState,
|
|
14
|
-
setAttentionFocus,
|
|
15
14
|
setAttentionPulse,
|
|
16
15
|
setAttentionToast,
|
|
17
16
|
type AttentionEntry,
|
|
@@ -242,11 +241,6 @@ function entryFromEvent(event: SemanticWatchEvent): AttentionEntry | null {
|
|
|
242
241
|
}
|
|
243
242
|
}
|
|
244
243
|
|
|
245
|
-
function applyAttentionSnapshot(): void {
|
|
246
|
-
const snapshot = reducer.getAttentionSnapshot();
|
|
247
|
-
setAttentionFocus(snapshot.primaryFocusNodeIds, snapshot.secondaryFocusNodeIds, snapshot.regions);
|
|
248
|
-
}
|
|
249
|
-
|
|
250
244
|
function flushToastQueue(): void {
|
|
251
245
|
if (toastTimer !== null) return;
|
|
252
246
|
const next = toastQueue.shift() ?? null;
|
|
@@ -322,8 +316,6 @@ export function syncAttentionFromSse(message: SseMessage): void {
|
|
|
322
316
|
.map((event) => entryFromEvent(event))
|
|
323
317
|
.filter((entry): entry is AttentionEntry => entry !== null);
|
|
324
318
|
|
|
325
|
-
applyAttentionSnapshot();
|
|
326
|
-
|
|
327
319
|
for (const entry of entries) {
|
|
328
320
|
if (shouldSuppressEntry(entry)) continue;
|
|
329
321
|
pushAttentionHistory(entry);
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { signal } from '@preact/signals';
|
|
2
|
-
import type { SemanticAttentionRegion } from '../../shared/semantic-attention.js';
|
|
3
2
|
|
|
4
3
|
export type AttentionTone = 'context' | 'relationship' | 'group' | 'cluster' | 'neighborhood' | 'remove';
|
|
5
4
|
|
|
@@ -14,9 +13,6 @@ export interface AttentionEntry {
|
|
|
14
13
|
|
|
15
14
|
export const attentionToast = signal<AttentionEntry | null>(null);
|
|
16
15
|
export const attentionHistory = signal<AttentionEntry[]>([]);
|
|
17
|
-
export const attentionPrimaryNodeIds = signal<Set<string>>(new Set());
|
|
18
|
-
export const attentionSecondaryNodeIds = signal<Set<string>>(new Set());
|
|
19
|
-
export const attentionRegions = signal<SemanticAttentionRegion[]>([]);
|
|
20
16
|
export const attentionPulseNodeIds = signal<Set<string>>(new Set());
|
|
21
17
|
export const attentionHistoryOpen = signal<boolean>(false);
|
|
22
18
|
export const attentionHistoryUnread = signal<number>(0);
|
|
@@ -24,9 +20,6 @@ export const attentionHistoryUnread = signal<number>(0);
|
|
|
24
20
|
export function resetAttentionState(): void {
|
|
25
21
|
attentionToast.value = null;
|
|
26
22
|
attentionHistory.value = [];
|
|
27
|
-
attentionPrimaryNodeIds.value = new Set();
|
|
28
|
-
attentionSecondaryNodeIds.value = new Set();
|
|
29
|
-
attentionRegions.value = [];
|
|
30
23
|
attentionPulseNodeIds.value = new Set();
|
|
31
24
|
attentionHistoryOpen.value = false;
|
|
32
25
|
attentionHistoryUnread.value = 0;
|
|
@@ -41,16 +34,6 @@ export function closeAttentionHistory(): void {
|
|
|
41
34
|
attentionHistoryOpen.value = false;
|
|
42
35
|
}
|
|
43
36
|
|
|
44
|
-
export function setAttentionFocus(
|
|
45
|
-
primaryNodeIds: string[],
|
|
46
|
-
secondaryNodeIds: string[],
|
|
47
|
-
regions: SemanticAttentionRegion[],
|
|
48
|
-
): void {
|
|
49
|
-
attentionPrimaryNodeIds.value = new Set(primaryNodeIds);
|
|
50
|
-
attentionSecondaryNodeIds.value = new Set(secondaryNodeIds);
|
|
51
|
-
attentionRegions.value = regions;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
37
|
export function setAttentionToast(entry: AttentionEntry | null): void {
|
|
55
38
|
attentionToast.value = entry;
|
|
56
39
|
}
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
import { computeAutoArrange } from '../../shared/auto-arrange';
|
|
12
12
|
import { canvasArea, canvasAreaCenter, canvasFitInsets } from '../canvas/canvas-area';
|
|
13
13
|
import { pushCanvasUpdate, requestBestEffort, requestOk, updateViewportFromClient } from './intent-bridge';
|
|
14
|
+
import { isHostedWorkbench } from './workbench-transport';
|
|
14
15
|
|
|
15
16
|
function logCanvasStoreError(action: string, error: unknown): void {
|
|
16
17
|
console.error(`[canvas-store] ${action} failed`, error);
|
|
@@ -39,7 +40,7 @@ export const reconnectAttempt = signal<number>(0);
|
|
|
39
40
|
export const reconnectDelay = signal<number>(0);
|
|
40
41
|
export const sessionId = signal<string>('');
|
|
41
42
|
export const traceEnabled = signal<boolean>(false);
|
|
42
|
-
export const canvasTheme = signal<string>('
|
|
43
|
+
export const canvasTheme = signal<string>('harbor');
|
|
43
44
|
export const hasInitialServerLayout = signal<boolean>(false);
|
|
44
45
|
// Compact AX state snapshot (work items, focus, …) mirrored from the server and
|
|
45
46
|
// pushed into AX-enabled surfaces so authored boards can render the live queue.
|
|
@@ -775,7 +776,8 @@ export function persistLayout(options: { recordHistory?: boolean } = {}): void {
|
|
|
775
776
|
})),
|
|
776
777
|
contextPinnedNodeIds: Array.from(contextPinnedNodeIds.value),
|
|
777
778
|
};
|
|
778
|
-
localStorage
|
|
779
|
+
// Opaque MCP App frames have no localStorage; the daemon persists layout.
|
|
780
|
+
if (!isHostedWorkbench()) localStorage.setItem(STORAGE_KEY, JSON.stringify(layout));
|
|
779
781
|
void pushCanvasUpdate(nodeUpdates, options);
|
|
780
782
|
} catch (error) {
|
|
781
783
|
logCanvasStoreError('persistLayout', error);
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { signal } from '@preact/signals';
|
|
2
|
+
import { activeBoardId } from './boards-store';
|
|
3
|
+
import { requestJson } from './intent-bridge';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* What agents did with the board's context (docs/design/AgentContext.dc.html):
|
|
7
|
+
* per node, the latest agent read of its content and the revision it read;
|
|
8
|
+
* per pin, who pinned it, when and why. Server truth from
|
|
9
|
+
* `GET /api/canvas/ax/context-status`, refetched when a read lands
|
|
10
|
+
* (`context-status-changed`), when pins change, and on (re)connect.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface NodeReadStatus {
|
|
14
|
+
nodeId: string;
|
|
15
|
+
lastReadAt: string;
|
|
16
|
+
lastReadBy: string;
|
|
17
|
+
readRevision: number;
|
|
18
|
+
readCount: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ContextPinMeta {
|
|
22
|
+
pinnedBy: { actor: 'human' | 'agent' | 'system' | 'unknown'; source: string; agentId?: string };
|
|
23
|
+
pinnedAt: string;
|
|
24
|
+
reason?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface ContextStatusResponse {
|
|
28
|
+
ok?: boolean;
|
|
29
|
+
boardId?: string | null;
|
|
30
|
+
nodes?: NodeReadStatus[];
|
|
31
|
+
pins?: Record<string, ContextPinMeta>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const nodeReadStatus = signal<Map<string, NodeReadStatus>>(new Map());
|
|
35
|
+
export const contextPinMeta = signal<Record<string, ContextPinMeta>>({});
|
|
36
|
+
|
|
37
|
+
export function applyContextStatus(response: ContextStatusResponse): void {
|
|
38
|
+
nodeReadStatus.value = new Map((response.nodes ?? []).map((entry) => [entry.nodeId, entry]));
|
|
39
|
+
contextPinMeta.value = response.pins ?? {};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function refreshContextStatus(): Promise<void> {
|
|
43
|
+
const response = await requestJson<ContextStatusResponse>('fetchContextStatus', '/api/canvas/ax/context-status', {});
|
|
44
|
+
// A late answer for a board you have since left must not mark this one.
|
|
45
|
+
if (response.ok && response.boardId === activeBoardId.value) applyContextStatus(response);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** How an agent's context relates to one pinned node — the mark its header shows. */
|
|
49
|
+
export type PinnedReadState =
|
|
50
|
+
| { kind: 'not-read' }
|
|
51
|
+
| { kind: 'read'; by: string; at: string; count: number }
|
|
52
|
+
| { kind: 'changed'; by: string; at: string };
|
|
53
|
+
|
|
54
|
+
export function pinnedReadState(nodeId: string, currentRevision: number): PinnedReadState {
|
|
55
|
+
const read = nodeReadStatus.value.get(nodeId);
|
|
56
|
+
const pinnedAt = contextPinMeta.value[nodeId]?.pinnedAt;
|
|
57
|
+
// Pinned after the latest read: the agent has not loaded it since you pinned it.
|
|
58
|
+
if (!read || (pinnedAt && read.lastReadAt < pinnedAt)) return { kind: 'not-read' };
|
|
59
|
+
if (currentRevision > read.readRevision) return { kind: 'changed', by: read.lastReadBy, at: read.lastReadAt };
|
|
60
|
+
return { kind: 'read', by: read.lastReadBy, at: read.lastReadAt, count: read.readCount };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Display name for an attributed writer: its agent id, else its transport label. */
|
|
64
|
+
export function writerName(actor: { source: string; agentId?: string }): string {
|
|
65
|
+
return actor.agentId?.trim() || actor.source;
|
|
66
|
+
}
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
} from '../../shared/human-presence.js';
|
|
7
7
|
import { requestBestEffort, vetoGhostIntent } from './intent-bridge';
|
|
8
8
|
import { intents, removeIntent } from './intent-store';
|
|
9
|
+
import { isHostedWorkbench } from './workbench-transport';
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Human collaborator presence (rail-chrome-v2 phase 8). This tab reports its
|
|
@@ -64,6 +65,7 @@ let lastSentAt = 0;
|
|
|
64
65
|
let pending: ReturnType<typeof setTimeout> | null = null;
|
|
65
66
|
|
|
66
67
|
function post(body: Record<string, unknown>): void {
|
|
68
|
+
if (isHostedWorkbench()) return;
|
|
67
69
|
void requestBestEffort('humanPresence', '/api/canvas/human-presence', {
|
|
68
70
|
method: 'POST',
|
|
69
71
|
headers: { 'Content-Type': 'application/json' },
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
*/
|
|
24
24
|
import { signal } from '@preact/signals';
|
|
25
25
|
import { IFRAME_PROBE_MESSAGE_SOURCE } from '../../shared/iframe-probe.js';
|
|
26
|
+
import { isHostedWorkbench } from './workbench-transport';
|
|
26
27
|
|
|
27
28
|
export type IframeMode = 'src' | 'srcdoc';
|
|
28
29
|
|
|
@@ -105,6 +106,10 @@ function isAmpOrbHost(): boolean {
|
|
|
105
106
|
export function resolveIframeMode(
|
|
106
107
|
opts: { embedded?: boolean; ampOrb?: boolean; probeTimeoutMs?: number } = {},
|
|
107
108
|
): Promise<IframeMode> {
|
|
109
|
+
if (isHostedWorkbench()) {
|
|
110
|
+
iframeMode.value = 'srcdoc';
|
|
111
|
+
return Promise.resolve('srcdoc');
|
|
112
|
+
}
|
|
108
113
|
if (iframeMode.value) return Promise.resolve(iframeMode.value);
|
|
109
114
|
if (pending) return pending;
|
|
110
115
|
const forced = forcedIframeMode();
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { canvasArea } from '../canvas/canvas-area';
|
|
2
2
|
import { showToast } from './attention-bridge';
|
|
3
|
+
import { isHostedWorkbench, workbenchFetch } from './workbench-transport';
|
|
3
4
|
import type { AgentPresenceSnapshot } from '../../shared/agent-presence.js';
|
|
4
5
|
import type { CanvasEdge } from '../types';
|
|
5
6
|
import { addEdge, canvasInteractionEpoch, nodes } from './canvas-store';
|
|
7
|
+
import { boardsLoaded } from './boards-store';
|
|
6
8
|
function logRequestError(action: string, error: unknown): void {
|
|
7
9
|
console.error(`[intent-bridge] ${action} failed`, error);
|
|
8
10
|
}
|
|
@@ -29,6 +31,7 @@ export const tabAgentKey: string | null = (() => {
|
|
|
29
31
|
* opened with `?agent=<key>` identifies as that agent instead.
|
|
30
32
|
*/
|
|
31
33
|
function withWorkbenchMarker(init?: RequestInit): RequestInit {
|
|
34
|
+
if (isHostedWorkbench()) return init ?? {};
|
|
32
35
|
const headers = { ...(init?.headers as Record<string, string> | undefined) };
|
|
33
36
|
if (tabAgentKey) {
|
|
34
37
|
headers['x-pmx-source'] = tabAgentKey;
|
|
@@ -61,7 +64,7 @@ async function readJsonBody(res: Response): Promise<unknown> {
|
|
|
61
64
|
|
|
62
65
|
export async function requestJson<T>(action: string, url: string, fallback: T, init?: RequestInit): Promise<T> {
|
|
63
66
|
try {
|
|
64
|
-
const res = await
|
|
67
|
+
const res = await workbenchFetch(url, withWorkbenchMarker(init));
|
|
65
68
|
const body = await readJsonBody(res);
|
|
66
69
|
reportRefusedWrite(res, init, body);
|
|
67
70
|
if (body === null) throw new Error(`${res.status} response had no JSON body`);
|
|
@@ -74,7 +77,7 @@ export async function requestJson<T>(action: string, url: string, fallback: T, i
|
|
|
74
77
|
|
|
75
78
|
export async function requestOk(action: string, url: string, init?: RequestInit): Promise<{ ok: boolean }> {
|
|
76
79
|
try {
|
|
77
|
-
const res = await
|
|
80
|
+
const res = await workbenchFetch(url, withWorkbenchMarker(init));
|
|
78
81
|
if (!res.ok) reportRefusedWrite(res, init, await readJsonBody(res));
|
|
79
82
|
return { ok: res.ok };
|
|
80
83
|
} catch (error) {
|
|
@@ -85,7 +88,7 @@ export async function requestOk(action: string, url: string, init?: RequestInit)
|
|
|
85
88
|
|
|
86
89
|
export async function requestBestEffort(action: string, url: string, init?: RequestInit): Promise<void> {
|
|
87
90
|
try {
|
|
88
|
-
const res = await
|
|
91
|
+
const res = await workbenchFetch(url, withWorkbenchMarker(init));
|
|
89
92
|
if (!res.ok) reportRefusedWrite(res, init, await readJsonBody(res));
|
|
90
93
|
} catch (error) {
|
|
91
94
|
logRequestError(action, error);
|
|
@@ -328,8 +331,10 @@ export async function updateNodeFromClient(
|
|
|
328
331
|
data?: Record<string, unknown>;
|
|
329
332
|
/** Groups: replace the membership list (positions preserved). */
|
|
330
333
|
children?: string[];
|
|
334
|
+
expectedContentRevision?: number;
|
|
335
|
+
expectedBoardId?: string | null;
|
|
331
336
|
},
|
|
332
|
-
): Promise<{ ok: boolean; id?: string }> {
|
|
337
|
+
): Promise<{ ok: boolean; id?: string; contentRevision?: number }> {
|
|
333
338
|
return requestJson(
|
|
334
339
|
'updateNodeFromClient',
|
|
335
340
|
`/api/canvas/node/${encodeURIComponent(id)}`,
|
|
@@ -477,6 +482,7 @@ function clientWindowSize(): { clientWidth: number; clientHeight: number } | Rec
|
|
|
477
482
|
* window size before the human ever pans or zooms.
|
|
478
483
|
*/
|
|
479
484
|
export async function reportClientViewportSize(): Promise<void> {
|
|
485
|
+
if (isHostedWorkbench() && !boardsLoaded.value) return;
|
|
480
486
|
const size = clientWindowSize();
|
|
481
487
|
if (!('clientWidth' in size)) return;
|
|
482
488
|
await requestBestEffort('reportClientViewportSize', '/api/canvas/viewport', {
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { computed } from '@preact/signals';
|
|
2
|
+
import { findNeighborhoods } from '../../server/spatial-analysis.js';
|
|
3
|
+
import { contextPinnedNodeIds, nodes } from './canvas-store';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Near a pin (docs/design/NearPin.dc.html): each pinned node pulls up to five
|
|
7
|
+
* unpinned nodes within 600 px into the agent's brief as title + short
|
|
8
|
+
* summary. Same rule as the server's brief (`findNeighborhoods`), so the mark
|
|
9
|
+
* claims exactly what the agent receives.
|
|
10
|
+
*/
|
|
11
|
+
export interface NearPin {
|
|
12
|
+
pinNodeId: string;
|
|
13
|
+
pinTitle: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let lastKey = '';
|
|
17
|
+
let lastNear = new Map<string, NearPin[]>();
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Unpinned node id → the pins it is near, nearest first. Every drag frame
|
|
21
|
+
* moves a node, so the result keeps its identity while no node's set of pins
|
|
22
|
+
* changes: the chips subscribed to it re-render only when a neighbourhood does.
|
|
23
|
+
*/
|
|
24
|
+
export const nearPins = computed<Map<string, NearPin[]>>(() => {
|
|
25
|
+
const pins = contextPinnedNodeIds.value;
|
|
26
|
+
const near = new Map<string, NearPin[]>();
|
|
27
|
+
if (pins.size > 0) {
|
|
28
|
+
const nearest = new Map<string, Array<NearPin & { distance: number }>>();
|
|
29
|
+
for (const neighborhood of findNeighborhoods([...nodes.value.values()], pins)) {
|
|
30
|
+
const pinTitle = neighborhood.pinnedNodeTitle ?? neighborhood.pinnedNodeId;
|
|
31
|
+
for (const neighbor of neighborhood.neighbors) {
|
|
32
|
+
const list = nearest.get(neighbor.id) ?? [];
|
|
33
|
+
list.push({ pinNodeId: neighborhood.pinnedNodeId, pinTitle, distance: neighbor.distance });
|
|
34
|
+
nearest.set(neighbor.id, list);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
for (const [id, list] of nearest) {
|
|
38
|
+
list.sort((a, b) => a.distance - b.distance);
|
|
39
|
+
near.set(
|
|
40
|
+
id,
|
|
41
|
+
list.map(({ pinNodeId, pinTitle }) => ({ pinNodeId, pinTitle })),
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const key = [...near]
|
|
46
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
47
|
+
.map(([id, list]) => `${id}:${list.map((pin) => `${pin.pinNodeId}=${pin.pinTitle}`).join(',')}`)
|
|
48
|
+
.join('|');
|
|
49
|
+
if (key === lastKey) return lastNear;
|
|
50
|
+
lastKey = key;
|
|
51
|
+
lastNear = near;
|
|
52
|
+
return near;
|
|
53
|
+
});
|
|
@@ -32,18 +32,13 @@ export const writersSheetOpen = signal(false);
|
|
|
32
32
|
export const activityFilter = signal<string | null>(null);
|
|
33
33
|
|
|
34
34
|
/**
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
35
|
+
* A writer's colour says what it is, not who: agents take the agent violet,
|
|
36
|
+
* subagents (fleet workers) the subagent fuchsia. Writers are told apart by
|
|
37
|
+
* name and initial (docs/design.md, "Kinds carry no colour").
|
|
38
38
|
*/
|
|
39
|
-
const WRITER_PALETTE = ['var(--c-purple)', 'var(--c-accent)', 'var(--c-ok)', 'var(--c-warn)', 'var(--c-danger)'];
|
|
40
|
-
const writerColors = new Map<string, string>();
|
|
41
39
|
export function writerColor(sessionId: string): string {
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
const color = WRITER_PALETTE[writerColors.size % WRITER_PALETTE.length]!;
|
|
45
|
-
writerColors.set(sessionId, color);
|
|
46
|
-
return color;
|
|
40
|
+
const presence = agentPresences.value.find((entry) => entry.sessionId === sessionId);
|
|
41
|
+
return presence?.parentAgentId ? 'var(--c-subagent)' : 'var(--c-agent)';
|
|
47
42
|
}
|
|
48
43
|
|
|
49
44
|
/** Avatar initial: first letter of the label, upper-cased. */
|