pmx-canvas 0.4.8 → 0.5.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/extension.mjs +154 -5
- package/.github/extensions/pmx-canvas/steering-delivery.mjs +79 -0
- package/CHANGELOG.md +139 -0
- package/Readme.md +72 -23
- package/dist/canvas/global.css +3069 -796
- package/dist/canvas/index.js +104 -104
- package/dist/json-render/index.css +1 -1
- package/dist/types/cli/agent.d.ts +1 -0
- package/dist/types/cli/commands/pump.d.ts +9 -0
- package/dist/types/client/App.d.ts +1 -1
- package/dist/types/client/canvas/AgentPresenceLayer.d.ts +11 -0
- package/dist/types/client/canvas/BarHint.d.ts +22 -0
- package/dist/types/client/canvas/CanvasNode.d.ts +5 -0
- package/dist/types/client/canvas/CanvasViewport.d.ts +4 -4
- package/dist/types/client/canvas/CommandBar.d.ts +10 -0
- package/dist/types/client/canvas/ConnectionBanner.d.ts +13 -0
- package/dist/types/client/canvas/ContextMenu.d.ts +6 -0
- package/dist/types/client/canvas/EdgeLayer.d.ts +2 -1
- package/dist/types/client/canvas/EmptyState.d.ts +11 -0
- package/dist/types/client/canvas/ExternalWriters.d.ts +5 -0
- package/dist/types/client/canvas/HumanPresenceLayer.d.ts +8 -0
- package/dist/types/client/canvas/Minimap.d.ts +12 -1
- package/dist/types/client/canvas/ScopeFenceLayer.d.ts +8 -0
- package/dist/types/client/canvas/SelectionBar.d.ts +6 -0
- package/dist/types/client/canvas/SessionPanel.d.ts +1 -0
- package/dist/types/client/canvas/SessionReceipt.d.ts +17 -0
- package/dist/types/client/canvas/TextPrompt.d.ts +25 -0
- package/dist/types/client/canvas/ToolRail.d.ts +21 -0
- package/dist/types/client/canvas/TopBar.d.ts +7 -0
- package/dist/types/client/canvas/canvas-area.d.ts +25 -0
- package/dist/types/client/canvas/create-in-view.d.ts +18 -0
- package/dist/types/client/canvas/import-files.d.ts +8 -0
- package/dist/types/client/canvas/kind-colors.d.ts +3 -0
- package/dist/types/client/canvas/use-focus-trap.d.ts +14 -0
- package/dist/types/client/canvas/use-now.d.ts +6 -0
- package/dist/types/client/icons.d.ts +11 -4
- package/dist/types/client/nodes/GroupNode.d.ts +6 -1
- package/dist/types/client/nodes/MarkdownNode.d.ts +2 -0
- package/dist/types/client/nodes/inline-editor-commands.d.ts +3 -4
- package/dist/types/client/state/canvas-store.d.ts +49 -5
- package/dist/types/client/state/human-store.d.ts +26 -0
- package/dist/types/client/state/iframe-mode.d.ts +1 -0
- package/dist/types/client/state/intent-bridge.d.ts +37 -5
- package/dist/types/client/state/node-factory.d.ts +0 -1
- package/dist/types/client/state/presence-store.d.ts +78 -0
- package/dist/types/client/state/session-store.d.ts +176 -0
- package/dist/types/client/types.d.ts +2 -1
- package/dist/types/client/utils/platform.d.ts +2 -0
- package/dist/types/server/agent-presence.d.ts +192 -0
- package/dist/types/server/ax-gate-ttl.d.ts +6 -0
- package/dist/types/server/ax-interaction.d.ts +4 -10
- package/dist/types/server/ax-state-manager.d.ts +21 -2
- package/dist/types/server/ax-state.d.ts +49 -3
- package/dist/types/server/ax-wait.d.ts +13 -0
- package/dist/types/server/canvas-db.d.ts +9 -1
- package/dist/types/server/canvas-operations.d.ts +1 -0
- package/dist/types/server/canvas-state.d.ts +27 -13
- package/dist/types/server/canvas-validation.d.ts +0 -14
- package/dist/types/server/html-surface.d.ts +1 -1
- package/dist/types/server/human-presence.d.ts +40 -0
- package/dist/types/server/index.d.ts +29 -3
- package/dist/types/server/intent-registry.d.ts +7 -0
- package/dist/types/server/mutation-history.d.ts +15 -2
- package/dist/types/server/operations/invoker.d.ts +13 -2
- package/dist/types/server/operations/ops/ax-presence.d.ts +2 -0
- package/dist/types/server/operations/ops/human-presence.d.ts +3 -0
- package/dist/types/server/operations/registry.d.ts +11 -0
- package/dist/types/server/operations/types.d.ts +1 -1
- package/dist/types/server/scope-fence.d.ts +62 -0
- package/dist/types/server/server.d.ts +0 -12
- package/dist/types/shared/agent-presence.d.ts +130 -0
- package/dist/types/shared/approval-gates.d.ts +19 -0
- package/dist/types/shared/auto-arrange.d.ts +0 -1
- package/dist/types/shared/ax-kinds.d.ts +11 -0
- package/dist/types/shared/human-presence.d.ts +31 -0
- package/dist/types/shared/iframe-probe.d.ts +8 -0
- package/dist/types/shared/scope-fence.d.ts +21 -0
- package/docs/RELEASE.md +6 -0
- package/docs/ax-host-adapter-contract.md +30 -14
- package/docs/cli.md +37 -1
- package/docs/environment.md +3 -0
- package/docs/http-api.md +222 -5
- package/docs/mcp.md +6 -5
- package/docs/node-types.md +3 -1
- package/docs/plans/plan-009-tech-debt-backlog.md +13 -0
- package/docs/screenshot.png +0 -0
- package/docs/screenshots/multi-agent-live.png +0 -0
- package/docs/screenshots/workbench-live-dark.png +0 -0
- package/docs/screenshots/workbench-live-light.png +0 -0
- package/docs/sdk.md +1 -1
- package/package.json +1 -1
- package/skills/pmx-canvas/SKILL.md +103 -16
- package/skills/pmx-canvas/references/codex-app-adapter.md +28 -0
- package/skills/pmx-canvas/references/full-reference.md +55 -16
- package/skills/pmx-canvas/references/github-copilot-app-adapter.md +41 -24
- package/skills/pmx-canvas-orchestration/SKILL.md +56 -2
- package/skills/pmx-canvas-testing/SKILL.md +17 -0
- package/src/cli/agent.ts +1 -0
- package/src/cli/commands/copilot.ts +15 -8
- package/src/cli/commands/nodes.ts +1 -19
- package/src/cli/commands/pump.ts +252 -0
- package/src/cli/index.ts +1 -5
- package/src/cli/shared.ts +1 -1
- package/src/client/App.tsx +209 -585
- package/src/client/canvas/AgentPresenceLayer.tsx +74 -0
- package/src/client/canvas/AttentionHistory.tsx +0 -10
- package/src/client/canvas/BarHint.tsx +49 -0
- package/src/client/canvas/CanvasNode.tsx +488 -136
- package/src/client/canvas/CanvasViewport.tsx +190 -94
- package/src/client/canvas/CommandBar.tsx +140 -0
- package/src/client/canvas/CommandPalette.tsx +133 -58
- package/src/client/canvas/ConnectionBanner.tsx +65 -0
- package/src/client/canvas/ContextMenu.tsx +88 -27
- package/src/client/canvas/ContextPinBar.tsx +2 -2
- package/src/client/canvas/EdgeLayer.tsx +121 -38
- package/src/client/canvas/EmptyState.tsx +93 -0
- package/src/client/canvas/ExpandedNodeOverlay.tsx +64 -118
- package/src/client/canvas/ExternalWriters.tsx +308 -0
- package/src/client/canvas/FocusFieldLayer.tsx +12 -1
- package/src/client/canvas/HumanPresenceLayer.tsx +45 -0
- package/src/client/canvas/IntentLayer.tsx +1 -1
- package/src/client/canvas/Minimap.tsx +117 -190
- package/src/client/canvas/ScopeFenceLayer.tsx +38 -0
- package/src/client/canvas/SelectionBar.tsx +175 -23
- package/src/client/canvas/SessionPanel.tsx +481 -0
- package/src/client/canvas/SessionReceipt.tsx +141 -0
- package/src/client/canvas/ShortcutOverlay.tsx +21 -6
- package/src/client/canvas/SnapshotPanel.tsx +166 -80
- package/src/client/canvas/TextPrompt.tsx +128 -0
- package/src/client/canvas/ToolRail.tsx +447 -0
- package/src/client/canvas/TopBar.tsx +308 -0
- package/src/client/canvas/auto-fit.ts +1 -5
- package/src/client/canvas/canvas-area.ts +41 -0
- package/src/client/canvas/create-in-view.ts +48 -0
- package/src/client/canvas/import-files.ts +64 -0
- package/src/client/canvas/kind-colors.ts +22 -0
- package/src/client/canvas/snap-guides.ts +1 -1
- package/src/client/canvas/use-focus-trap.ts +54 -0
- package/src/client/canvas/use-now.ts +17 -0
- package/src/client/canvas/use-pan-zoom.ts +13 -2
- package/src/client/icons.tsx +57 -18
- package/src/client/nodes/GroupNode.tsx +9 -28
- package/src/client/nodes/InlineFormatBar.tsx +4 -3
- package/src/client/nodes/InlineMarkdownEditor.tsx +27 -0
- package/src/client/nodes/MarkdownNode.tsx +59 -24
- package/src/client/nodes/MdFormatBar.tsx +2 -4
- package/src/client/nodes/inline-editor-commands.ts +17 -6
- package/src/client/nodes/use-surface-frame.ts +3 -1
- package/src/client/state/attention-bridge.ts +3 -0
- package/src/client/state/canvas-store.ts +273 -84
- package/src/client/state/human-store.ts +175 -0
- package/src/client/state/iframe-mode.ts +33 -9
- package/src/client/state/intent-bridge.ts +75 -13
- package/src/client/state/node-factory.ts +2 -8
- package/src/client/state/presence-store.ts +168 -0
- package/src/client/state/session-store.ts +419 -0
- package/src/client/state/sse-bridge.ts +93 -30
- package/src/client/theme/global.css +3069 -796
- package/src/client/types.ts +3 -1
- package/src/client/utils/platform.ts +6 -1
- package/src/mcp/canvas-access.ts +2 -2
- package/src/server/agent-presence.ts +755 -0
- package/src/server/ax-context.ts +45 -0
- package/src/server/ax-gate-ttl.ts +61 -0
- package/src/server/ax-interaction.ts +29 -1
- package/src/server/ax-state-manager.ts +78 -6
- package/src/server/ax-state.ts +82 -15
- package/src/server/ax-wait.ts +33 -0
- package/src/server/canvas-db.ts +103 -33
- package/src/server/canvas-operations.ts +17 -8
- package/src/server/canvas-state.ts +131 -58
- package/src/server/canvas-validation.ts +3 -53
- package/src/server/demo-state.json +2 -70
- package/src/server/html-surface.ts +11 -2
- package/src/server/human-presence.ts +160 -0
- package/src/server/index.ts +107 -4
- package/src/server/intent-registry.ts +13 -0
- package/src/server/mutation-history.ts +47 -2
- package/src/server/operations/composites.ts +6 -3
- package/src/server/operations/http.ts +10 -1
- package/src/server/operations/index.ts +4 -0
- package/src/server/operations/invoker.ts +33 -6
- package/src/server/operations/ops/ax-presence.ts +62 -0
- package/src/server/operations/ops/ax-read.ts +8 -0
- package/src/server/operations/ops/ax-state.ts +106 -1
- package/src/server/operations/ops/ax-timeline.ts +54 -5
- package/src/server/operations/ops/ax-work.ts +46 -0
- package/src/server/operations/ops/batch.ts +3 -1
- package/src/server/operations/ops/edges.ts +88 -2
- package/src/server/operations/ops/groups.ts +4 -3
- package/src/server/operations/ops/human-presence.ts +35 -0
- package/src/server/operations/ops/json-render.ts +11 -1
- package/src/server/operations/ops/nodes.ts +65 -16
- package/src/server/operations/ops/query.ts +1 -0
- package/src/server/operations/ops/snapshots.ts +16 -0
- package/src/server/operations/registry.ts +196 -1
- package/src/server/operations/types.ts +1 -1
- package/src/server/scope-fence.ts +207 -0
- package/src/server/server.ts +177 -79
- package/src/server/trace-manager.ts +0 -2
- package/src/server/web-artifacts.ts +0 -1
- package/src/shared/agent-presence.ts +162 -0
- package/src/shared/approval-gates.ts +35 -0
- package/src/shared/auto-arrange.ts +121 -13
- package/src/shared/ax-kinds.ts +23 -0
- package/src/shared/human-presence.ts +30 -0
- package/src/shared/iframe-probe.ts +8 -0
- package/src/shared/scope-fence.ts +31 -0
- package/dist/types/client/canvas/DockedNode.d.ts +0 -4
- package/src/client/canvas/DockedNode.tsx +0 -165
|
@@ -4,12 +4,15 @@
|
|
|
4
4
|
* When the canvas page itself runs inside an iframe (ampcode.com embeds the
|
|
5
5
|
* portal URL on *.onamp.dev), Chrome refuses to load child iframes from `src`
|
|
6
6
|
* URLs — even same-origin relative ones — leaving every iframe-backed node a
|
|
7
|
-
* gray "refused to connect" placeholder.
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
7
|
+
* gray "refused to connect" placeholder. Some TOP-LEVEL hosts (the Claude
|
|
8
|
+
* Code desktop browser) block sub-frame requests the same way. Inline
|
|
9
|
+
* documents (`srcdoc`) still render. This module probes the real behavior
|
|
10
|
+
* once per boot: a hidden 1px iframe navigates to the tiny
|
|
11
|
+
* /api/canvas/iframe-probe document, which posts a handshake message back —
|
|
12
|
+
* `load` alone is no signal, because blocked frames fire it on their error
|
|
13
|
+
* page. Embedded pages block on the probe; top-level pages default to `src`
|
|
14
|
+
* and self-heal to fetch() + `srcdoc` when the background probe fails (see
|
|
15
|
+
* nodes/use-surface-frame.ts and nodes/iframe-document-url.ts).
|
|
13
16
|
*
|
|
14
17
|
* `src` stays the default everywhere it works: URL-loaded surfaces keep real
|
|
15
18
|
* document URLs, and the frame-document transport exists precisely because
|
|
@@ -19,6 +22,7 @@
|
|
|
19
22
|
* Force a mode with `?iframe-mode=srcdoc` (or `=src`) for debugging.
|
|
20
23
|
*/
|
|
21
24
|
import { signal } from '@preact/signals';
|
|
25
|
+
import { IFRAME_PROBE_MESSAGE_SOURCE } from '../../shared/iframe-probe.js';
|
|
22
26
|
|
|
23
27
|
export type IframeMode = 'src' | 'srcdoc';
|
|
24
28
|
|
|
@@ -48,11 +52,22 @@ export function probeSrcIframes(timeoutMs = PROBE_TIMEOUT_MS): Promise<boolean>
|
|
|
48
52
|
test.setAttribute('sandbox', 'allow-scripts');
|
|
49
53
|
const done = (ok: boolean) => {
|
|
50
54
|
window.clearTimeout(timer);
|
|
55
|
+
window.removeEventListener('message', onMessage);
|
|
51
56
|
test.remove();
|
|
52
57
|
resolve(ok);
|
|
53
58
|
};
|
|
59
|
+
// `load` proves nothing: a host that blocks the request (the Claude Code
|
|
60
|
+
// desktop browser cancels sub-frame loads with ERR_BLOCKED_BY_CLIENT)
|
|
61
|
+
// still fires `load` on the error page it commits. Only the probe
|
|
62
|
+
// document executing and posting its handshake back proves src works —
|
|
63
|
+
// and only from OUR frame, so a surface iframe cannot spoof the probe.
|
|
64
|
+
const onMessage = (event: MessageEvent) => {
|
|
65
|
+
if (event.source !== test.contentWindow) return;
|
|
66
|
+
if ((event.data as { source?: unknown } | null)?.source !== IFRAME_PROBE_MESSAGE_SOURCE) return;
|
|
67
|
+
done(true);
|
|
68
|
+
};
|
|
54
69
|
const timer = window.setTimeout(() => done(false), timeoutMs);
|
|
55
|
-
|
|
70
|
+
window.addEventListener('message', onMessage);
|
|
56
71
|
test.addEventListener('error', () => done(false));
|
|
57
72
|
test.src = IFRAME_PROBE_PATH;
|
|
58
73
|
(document.body ?? document.documentElement).appendChild(test);
|
|
@@ -87,7 +102,9 @@ function isAmpOrbHost(): boolean {
|
|
|
87
102
|
* normal path pays zero probe latency before surfaces mount. Only embedded
|
|
88
103
|
* documents run the probe (`embedded` is overridable for tests).
|
|
89
104
|
*/
|
|
90
|
-
export function resolveIframeMode(
|
|
105
|
+
export function resolveIframeMode(
|
|
106
|
+
opts: { embedded?: boolean; ampOrb?: boolean; probeTimeoutMs?: number } = {},
|
|
107
|
+
): Promise<IframeMode> {
|
|
91
108
|
if (iframeMode.value) return Promise.resolve(iframeMode.value);
|
|
92
109
|
if (pending) return pending;
|
|
93
110
|
const forced = forcedIframeMode();
|
|
@@ -96,14 +113,21 @@ export function resolveIframeMode(opts: { embedded?: boolean; ampOrb?: boolean }
|
|
|
96
113
|
return Promise.resolve(forced);
|
|
97
114
|
}
|
|
98
115
|
if (!(opts.embedded ?? isEmbeddedDocument())) {
|
|
116
|
+
// Top-level pages default to src with zero latency — but some top-level
|
|
117
|
+
// hosts (the Claude Code desktop browser) block sub-frame requests
|
|
118
|
+
// outright, so verify in the background and self-heal: surfaces read the
|
|
119
|
+
// signal and re-render into fetch()+srcdoc when it flips.
|
|
99
120
|
iframeMode.value = 'src';
|
|
121
|
+
void probeSrcIframes(opts.probeTimeoutMs).then((srcWorks) => {
|
|
122
|
+
if (!srcWorks && iframeMode.value === 'src') iframeMode.value = 'srcdoc';
|
|
123
|
+
});
|
|
100
124
|
return Promise.resolve('src');
|
|
101
125
|
}
|
|
102
126
|
if (opts.ampOrb ?? isAmpOrbHost()) {
|
|
103
127
|
iframeMode.value = 'srcdoc';
|
|
104
128
|
return Promise.resolve('srcdoc');
|
|
105
129
|
}
|
|
106
|
-
pending = probeSrcIframes().then((srcWorks) => {
|
|
130
|
+
pending = probeSrcIframes(opts.probeTimeoutMs).then((srcWorks) => {
|
|
107
131
|
const mode: IframeMode = srcWorks ? 'src' : 'srcdoc';
|
|
108
132
|
iframeMode.value = mode;
|
|
109
133
|
return mode;
|
|
@@ -1,17 +1,41 @@
|
|
|
1
|
+
import { canvasArea } from '../canvas/canvas-area';
|
|
2
|
+
import type { AgentPresenceSnapshot } from '../../shared/agent-presence.js';
|
|
1
3
|
function logRequestError(action: string, error: unknown): void {
|
|
2
4
|
console.error(`[intent-bridge] ${action} failed`, error);
|
|
3
5
|
}
|
|
4
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Per-tab identity: an AGENT driving the workbench UI from its own pane opens
|
|
9
|
+
* `/workbench?agent=<key>` and every write from that tab is booked as that
|
|
10
|
+
* agent — before this, UI-driven agent tests carried the human marker and the
|
|
11
|
+
* human was told "you rejected a gate" they never saw (0.5.0 readiness).
|
|
12
|
+
*/
|
|
13
|
+
export const tabAgentKey: string | null = (() => {
|
|
14
|
+
try {
|
|
15
|
+
const value = new URLSearchParams(window.location.search).get('agent')?.trim();
|
|
16
|
+
return value ? value.slice(0, 64) : null;
|
|
17
|
+
} catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
})();
|
|
21
|
+
|
|
5
22
|
/**
|
|
6
23
|
* Every workbench-originated request carries this marker so the server never
|
|
7
24
|
* synthesizes an auto-ghost for it — a human dragging or editing in the
|
|
8
|
-
* browser is not agent activity (the ghost cursor shows AGENT moves).
|
|
25
|
+
* browser is not agent activity (the ghost cursor shows AGENT moves). A tab
|
|
26
|
+
* opened with `?agent=<key>` identifies as that agent instead.
|
|
9
27
|
*/
|
|
10
28
|
function withWorkbenchMarker(init?: RequestInit): RequestInit {
|
|
11
|
-
|
|
29
|
+
const headers = { ...(init?.headers as Record<string, string> | undefined) };
|
|
30
|
+
if (tabAgentKey) {
|
|
31
|
+
headers['x-pmx-source'] = tabAgentKey;
|
|
32
|
+
} else {
|
|
33
|
+
headers['X-PMX-Workbench'] = '1';
|
|
34
|
+
}
|
|
35
|
+
return { ...init, headers };
|
|
12
36
|
}
|
|
13
37
|
|
|
14
|
-
async function requestJson<T>(action: string, url: string, fallback: T, init?: RequestInit): Promise<T> {
|
|
38
|
+
export async function requestJson<T>(action: string, url: string, fallback: T, init?: RequestInit): Promise<T> {
|
|
15
39
|
try {
|
|
16
40
|
const res = await fetch(url, withWorkbenchMarker(init));
|
|
17
41
|
return (await res.json()) as T;
|
|
@@ -21,7 +45,7 @@ async function requestJson<T>(action: string, url: string, fallback: T, init?: R
|
|
|
21
45
|
}
|
|
22
46
|
}
|
|
23
47
|
|
|
24
|
-
async function requestOk(action: string, url: string, init?: RequestInit): Promise<{ ok: boolean }> {
|
|
48
|
+
export async function requestOk(action: string, url: string, init?: RequestInit): Promise<{ ok: boolean }> {
|
|
25
49
|
try {
|
|
26
50
|
const res = await fetch(url, withWorkbenchMarker(init));
|
|
27
51
|
return { ok: res.ok };
|
|
@@ -31,7 +55,7 @@ async function requestOk(action: string, url: string, init?: RequestInit): Promi
|
|
|
31
55
|
}
|
|
32
56
|
}
|
|
33
57
|
|
|
34
|
-
async function requestBestEffort(action: string, url: string, init?: RequestInit): Promise<void> {
|
|
58
|
+
export async function requestBestEffort(action: string, url: string, init?: RequestInit): Promise<void> {
|
|
35
59
|
try {
|
|
36
60
|
await fetch(url, withWorkbenchMarker(init));
|
|
37
61
|
} catch (error) {
|
|
@@ -200,7 +224,6 @@ export async function pushCanvasUpdate(
|
|
|
200
224
|
position?: { x: number; y: number };
|
|
201
225
|
size?: { width: number; height: number };
|
|
202
226
|
collapsed?: boolean;
|
|
203
|
-
dockPosition?: 'left' | 'right' | null;
|
|
204
227
|
}>,
|
|
205
228
|
options: { recordHistory?: boolean } = {},
|
|
206
229
|
): Promise<void> {
|
|
@@ -260,10 +283,11 @@ export async function updateNodeFromClient(
|
|
|
260
283
|
size?: { width: number; height: number };
|
|
261
284
|
collapsed?: boolean;
|
|
262
285
|
pinned?: boolean;
|
|
263
|
-
dockPosition?: 'left' | 'right' | null;
|
|
264
286
|
title?: string;
|
|
265
287
|
content?: string;
|
|
266
288
|
data?: Record<string, unknown>;
|
|
289
|
+
/** Groups: replace the membership list (positions preserved). */
|
|
290
|
+
children?: string[];
|
|
267
291
|
},
|
|
268
292
|
): Promise<{ ok: boolean; id?: string }> {
|
|
269
293
|
return requestJson(
|
|
@@ -331,6 +355,11 @@ export async function fetchAxSurfaceState(): Promise<unknown> {
|
|
|
331
355
|
return requestJson<unknown>('fetchAxSurfaceState', '/api/canvas/ax/surface-snapshot', null);
|
|
332
356
|
}
|
|
333
357
|
|
|
358
|
+
/** Connect-time agent-presence read; live updates arrive as `agent-presence` SSE frames. */
|
|
359
|
+
export async function fetchAgentPresence(): Promise<Partial<AgentPresenceSnapshot> | null> {
|
|
360
|
+
return requestJson<Partial<AgentPresenceSnapshot> | null>('fetchAgentPresence', '/api/canvas/ax/presence', null);
|
|
361
|
+
}
|
|
362
|
+
|
|
334
363
|
/** Ask the server to open a node's surface in the system browser. */
|
|
335
364
|
export async function openNodeInSystemBrowserRequest(
|
|
336
365
|
nodeId: string,
|
|
@@ -390,8 +419,11 @@ export async function updateViewportFromClient(
|
|
|
390
419
|
*/
|
|
391
420
|
function clientWindowSize(): { clientWidth: number; clientHeight: number } | Record<string, never> {
|
|
392
421
|
if (typeof window === 'undefined') return {};
|
|
393
|
-
|
|
394
|
-
|
|
422
|
+
// The CANVAS REGION, not the window — with the rail + top-bar shell a
|
|
423
|
+
// window-sized fit would hide nodes under the chrome.
|
|
424
|
+
const area = canvasArea();
|
|
425
|
+
const clientWidth = Math.round(area.width);
|
|
426
|
+
const clientHeight = Math.round(area.height);
|
|
395
427
|
if (!clientWidth || !clientHeight) return {};
|
|
396
428
|
return { clientWidth, clientHeight };
|
|
397
429
|
}
|
|
@@ -438,8 +470,12 @@ export async function createGroupFromClient(opts: {
|
|
|
438
470
|
);
|
|
439
471
|
}
|
|
440
472
|
|
|
441
|
-
/** Add nodes to an existing group. */
|
|
442
|
-
export async function addToGroupFromClient(
|
|
473
|
+
/** Add nodes to an existing group; `childLayout` packs the children (the header's auto-arrange). */
|
|
474
|
+
export async function addToGroupFromClient(
|
|
475
|
+
groupId: string,
|
|
476
|
+
childIds: string[],
|
|
477
|
+
childLayout?: 'grid' | 'column' | 'flow',
|
|
478
|
+
): Promise<{ ok: boolean }> {
|
|
443
479
|
return requestJson(
|
|
444
480
|
'addToGroupFromClient',
|
|
445
481
|
'/api/canvas/group/add',
|
|
@@ -447,12 +483,21 @@ export async function addToGroupFromClient(groupId: string, childIds: string[]):
|
|
|
447
483
|
{
|
|
448
484
|
method: 'POST',
|
|
449
485
|
headers: { 'Content-Type': 'application/json' },
|
|
450
|
-
body: JSON.stringify({ groupId, childIds }),
|
|
486
|
+
body: JSON.stringify({ groupId, childIds, ...(childLayout ? { childLayout } : {}) }),
|
|
451
487
|
},
|
|
452
488
|
);
|
|
453
489
|
}
|
|
454
490
|
|
|
455
|
-
/**
|
|
491
|
+
/** Replace a group's membership (positions preserved) — how a single child leaves. */
|
|
492
|
+
export async function setGroupChildrenFromClient(groupId: string, children: string[]): Promise<{ ok: boolean }> {
|
|
493
|
+
return updateNodeFromClient(groupId, { children });
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Ungroup = dissolve (children released, frame removed). The SAME server op
|
|
498
|
+
* an agent's `canvas_group { action: "ungroup" }` runs, so both sides get the
|
|
499
|
+
* identical board and one undo step.
|
|
500
|
+
*/
|
|
456
501
|
export async function ungroupFromClient(groupId: string): Promise<{ ok: boolean }> {
|
|
457
502
|
return requestJson(
|
|
458
503
|
'ungroupFromClient',
|
|
@@ -501,6 +546,23 @@ export async function deleteSnapshot(id: string): Promise<{ ok: boolean }> {
|
|
|
501
546
|
return requestJson('deleteSnapshot', `/api/canvas/snapshots/${id}`, { ok: false }, { method: 'DELETE' });
|
|
502
547
|
}
|
|
503
548
|
|
|
549
|
+
/** Patch an edge in place (label/type/style/animated) via the server. */
|
|
550
|
+
export async function updateEdgeFromClient(
|
|
551
|
+
edgeId: string,
|
|
552
|
+
patch: { label?: string; type?: string; style?: string; animated?: boolean },
|
|
553
|
+
): Promise<{ ok: boolean }> {
|
|
554
|
+
return requestJson(
|
|
555
|
+
'updateEdgeFromClient',
|
|
556
|
+
`/api/canvas/edge/${encodeURIComponent(edgeId)}`,
|
|
557
|
+
{ ok: false },
|
|
558
|
+
{
|
|
559
|
+
method: 'PATCH',
|
|
560
|
+
headers: { 'Content-Type': 'application/json' },
|
|
561
|
+
body: JSON.stringify(patch),
|
|
562
|
+
},
|
|
563
|
+
);
|
|
564
|
+
}
|
|
565
|
+
|
|
504
566
|
/** Remove a canvas edge via the server. */
|
|
505
567
|
export async function removeEdgeFromClient(edgeId: string): Promise<{ ok: boolean }> {
|
|
506
568
|
return requestJson(
|
|
@@ -13,12 +13,8 @@ import type { CanvasNodeState } from '../types';
|
|
|
13
13
|
// /api/canvas/node) and get the SERVER's `defaultNodeSize`
|
|
14
14
|
// (src/server/operations/ops/nodes.ts), which differs from this table for
|
|
15
15
|
// most types (e.g. its fallback is 360x200). The server half of H5
|
|
16
|
-
// (`syncEventToCanvasState
|
|
17
|
-
//
|
|
18
|
-
// - The server seeds `status-main`/`context-main` docked AND collapsed
|
|
19
|
-
// (`ensureDefaultDockedNodes` in src/server/server.ts); the SSE bridge
|
|
20
|
-
// creates the same ids expanded, with `context-main` undocked. Client
|
|
21
|
-
// behavior is preserved as-is.
|
|
16
|
+
// (`syncEventToCanvasState`) is deliberately deferred — do not "fix" that
|
|
17
|
+
// mismatch from this file.
|
|
22
18
|
// - sse-bridge's prompt literal hardcoded `height: 400` beside a table entry
|
|
23
19
|
// of the same value; both now read the table (no rendering change).
|
|
24
20
|
|
|
@@ -56,7 +52,6 @@ export function makeNodeState(
|
|
|
56
52
|
options: {
|
|
57
53
|
position?: { x: number; y: number };
|
|
58
54
|
size?: { width: number; height: number };
|
|
59
|
-
dockPosition?: 'left' | 'right' | null;
|
|
60
55
|
} = {},
|
|
61
56
|
): CanvasNodeState {
|
|
62
57
|
const defaults = DEFAULT_POSITIONS[type];
|
|
@@ -68,7 +63,6 @@ export function makeNodeState(
|
|
|
68
63
|
zIndex: type === 'status' ? 0 : 1,
|
|
69
64
|
collapsed: false,
|
|
70
65
|
pinned: false,
|
|
71
|
-
dockPosition: options.dockPosition ?? null,
|
|
72
66
|
data,
|
|
73
67
|
};
|
|
74
68
|
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { computed, signal } from '@preact/signals';
|
|
2
|
+
import type { PmxAxIntent } from '../../shared/ax-intent.js';
|
|
3
|
+
import { intents } from './intent-store';
|
|
4
|
+
import {
|
|
5
|
+
HUMAN_STARTED_SESSION_LABEL,
|
|
6
|
+
type AgentActivityEntry,
|
|
7
|
+
type AgentPresence,
|
|
8
|
+
type AgentPresenceSnapshot,
|
|
9
|
+
CONTEXT_BUDGET_DEFAULT_TOKENS,
|
|
10
|
+
type ContextBudget,
|
|
11
|
+
externalWriters,
|
|
12
|
+
isSessionActive,
|
|
13
|
+
} from '../../shared/agent-presence.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Agent presence (rail-chrome-v2 phase 2). Fed by the server's `agent-presence`
|
|
17
|
+
* SSE snapshot (every change, including TTL expiry) and the connect-time read,
|
|
18
|
+
* so this store never runs its own expiry ticker.
|
|
19
|
+
*
|
|
20
|
+
* `sessionActive` is the ONE selector every agent surface reads — the session
|
|
21
|
+
* panel, command bar, presence layer, and top-bar chip all mount on it.
|
|
22
|
+
*/
|
|
23
|
+
export const agentPresences = signal<AgentPresence[]>([]);
|
|
24
|
+
export const contextBudget = signal<ContextBudget>({ used: 0, total: CONTEXT_BUDGET_DEFAULT_TOKENS });
|
|
25
|
+
/** Recent agent writes, newest first — the External Steering activity feed. */
|
|
26
|
+
export const agentActivity = signal<AgentActivityEntry[]>([]);
|
|
27
|
+
|
|
28
|
+
/** External Steering chrome (phase 6): the feed popover and the writers sheet. */
|
|
29
|
+
export const activityFeedOpen = signal(false);
|
|
30
|
+
export const writersSheetOpen = signal(false);
|
|
31
|
+
/** Feed filter: a writer's sessionId, or null for all. */
|
|
32
|
+
export const activityFilter = signal<string | null>(null);
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Per-writer identity colors from the accent set, agent-violet first,
|
|
36
|
+
* assigned in order of first appearance and stable for the page's life — a
|
|
37
|
+
* writer that fades and returns keeps its color.
|
|
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
|
+
export function writerColor(sessionId: string): string {
|
|
42
|
+
const known = writerColors.get(sessionId);
|
|
43
|
+
if (known) return known;
|
|
44
|
+
const color = WRITER_PALETTE[writerColors.size % WRITER_PALETTE.length]!;
|
|
45
|
+
writerColors.set(sessionId, color);
|
|
46
|
+
return color;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Avatar initial: first letter of the label, upper-cased. */
|
|
50
|
+
export function writerInitial(label: string): string {
|
|
51
|
+
const first = label
|
|
52
|
+
.trim()
|
|
53
|
+
.replace(/^[^a-z0-9]+/i, '')
|
|
54
|
+
.charAt(0);
|
|
55
|
+
return (first || label.trim().charAt(0) || '?').toUpperCase();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Compact relative age for feed rows and the writers sheet: now · 12s · 4m · 2h. */
|
|
59
|
+
export function relativeAge(iso: string, now = Date.now()): string {
|
|
60
|
+
const ms = now - Date.parse(iso);
|
|
61
|
+
if (!Number.isFinite(ms) || ms < 5_000) return 'now';
|
|
62
|
+
if (ms < 60_000) return `${Math.floor(ms / 1000)}s`;
|
|
63
|
+
if (ms < 3_600_000) return `${Math.floor(ms / 60_000)}m`;
|
|
64
|
+
return `${Math.floor(ms / 3_600_000)}h`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Master gate for every agent surface: an attached session exists. */
|
|
68
|
+
export const sessionActive = computed(() => isSessionActive(agentPresences.value));
|
|
69
|
+
|
|
70
|
+
/** Live writers with no attached session — the External Steering mode. */
|
|
71
|
+
export const externalWriterPresences = computed(() => externalWriters(agentPresences.value));
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Connected agents the human can ADDRESS from the composer: every live
|
|
75
|
+
* presence (attached sessions first). `label` is the display name; `value` is
|
|
76
|
+
* the CONSUMER key the steer must be targeted at — the identity the agent
|
|
77
|
+
* claims deliveries with. For an adapter session that is its source key
|
|
78
|
+
* (Copilot attaches as source "copilot" labelled "GitHub Copilot"); for an
|
|
79
|
+
* adopted human-started session the sessionId is "browser", so the writer's
|
|
80
|
+
* label IS its consumer key. Addressing the display label instead would
|
|
81
|
+
* produce steering nobody can ever claim. The un-adopted placeholder is
|
|
82
|
+
* excluded: no consumer will ever claim as "Agent session".
|
|
83
|
+
*/
|
|
84
|
+
export const steerableAgents = computed(() => {
|
|
85
|
+
const seen = new Set<string>();
|
|
86
|
+
return (
|
|
87
|
+
[...agentPresences.value]
|
|
88
|
+
.filter((presence) => presence.label !== HUMAN_STARTED_SESSION_LABEL)
|
|
89
|
+
.sort((a, b) => Number(b.attached) - Number(a.attached))
|
|
90
|
+
// The picker is the connected-writer ROSTER: every live writer is listed,
|
|
91
|
+
// but only those a steer can actually REACH are selectable — attached
|
|
92
|
+
// sessions, or external writers whose consumer key has claimed
|
|
93
|
+
// deliveries. A one-shot writer (curl, the CLI under Codex) is presence
|
|
94
|
+
// without an inbox: shown, disabled, with the reason.
|
|
95
|
+
.map((presence) => ({
|
|
96
|
+
value: presence.sessionId === 'browser' ? presence.label : presence.sessionId,
|
|
97
|
+
label: presence.label,
|
|
98
|
+
attached: presence.attached,
|
|
99
|
+
steerable: presence.attached || presence.steerable === true,
|
|
100
|
+
pendingSteers: presence.pendingSteers ?? 0,
|
|
101
|
+
// Proof-of-polling: its consumer key claimed the queue within the last
|
|
102
|
+
// 5 minutes. Attached ≠ polling — a desktop session can look steerable
|
|
103
|
+
// while nothing reads its inbox (round-2 Codex finding).
|
|
104
|
+
polling: presence.lastClaimAt !== undefined && Date.now() - Date.parse(presence.lastClaimAt) < 5 * 60_000,
|
|
105
|
+
}))
|
|
106
|
+
.filter((agent) => (seen.has(agent.value) ? false : (seen.add(agent.value), true)))
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
/** The attached session's presence (first if several hosts attached). */
|
|
111
|
+
/** Every attached session, in presence order — the top bar shows one chip each. */
|
|
112
|
+
export const attachedSessions = computed<AgentPresence[]>(() =>
|
|
113
|
+
agentPresences.value.filter((presence) => presence.attached),
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
export const activeSession = computed<AgentPresence | null>(
|
|
117
|
+
() => agentPresences.value.find((presence) => presence.attached) ?? null,
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Nodes an agent is mutating RIGHT NOW — drives the shimmer. Derived from the
|
|
122
|
+
* intent store's in-flight ghosts (move / edit / remove target an existing
|
|
123
|
+
* node) rather than a parallel source; gated on `sessionActive` at the use
|
|
124
|
+
* site so the quiet board never shimmers.
|
|
125
|
+
*/
|
|
126
|
+
export function mutatingNodeIdsFrom(intentList: Iterable<Pick<PmxAxIntent, 'kind' | 'nodeId'>>): Set<string> {
|
|
127
|
+
const ids = new Set<string>();
|
|
128
|
+
for (const intent of intentList) {
|
|
129
|
+
if (intent.nodeId && intent.kind !== 'create') ids.add(intent.nodeId);
|
|
130
|
+
}
|
|
131
|
+
return ids;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export const mutatingNodeIds = computed(() => mutatingNodeIdsFrom(intents.value.values()));
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Where a presence's cursor sits in WORLD coordinates: an explicit cursor
|
|
138
|
+
* wins; otherwise the node the agent last touched (anchored near its title
|
|
139
|
+
* bar's right end, like a collaborator hovering the node); otherwise null.
|
|
140
|
+
*/
|
|
141
|
+
export function presenceWorldPosition(
|
|
142
|
+
presence: Pick<AgentPresence, 'cursor' | 'focusNodeId'>,
|
|
143
|
+
nodeById: (id: string) => { position: { x: number; y: number }; size: { width: number } } | undefined,
|
|
144
|
+
): { x: number; y: number } | null {
|
|
145
|
+
if (presence.cursor) return { x: presence.cursor.x, y: presence.cursor.y };
|
|
146
|
+
if (!presence.focusNodeId) return null;
|
|
147
|
+
const node = nodeById(presence.focusNodeId);
|
|
148
|
+
if (!node) return null;
|
|
149
|
+
return { x: node.position.x + Math.max(24, node.size.width - 28), y: node.position.y + 16 };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function applyPresenceSnapshot(snapshot: Partial<AgentPresenceSnapshot> | null | undefined): void {
|
|
153
|
+
if (!snapshot) return;
|
|
154
|
+
if (Array.isArray(snapshot.presences)) agentPresences.value = snapshot.presences;
|
|
155
|
+
if (snapshot.budget && Number.isFinite(snapshot.budget.used) && Number.isFinite(snapshot.budget.total)) {
|
|
156
|
+
contextBudget.value = { used: snapshot.budget.used, total: snapshot.budget.total };
|
|
157
|
+
}
|
|
158
|
+
if (Array.isArray(snapshot.activity)) agentActivity.value = snapshot.activity;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function resetPresence(): void {
|
|
162
|
+
agentPresences.value = [];
|
|
163
|
+
contextBudget.value = { used: 0, total: CONTEXT_BUDGET_DEFAULT_TOKENS };
|
|
164
|
+
agentActivity.value = [];
|
|
165
|
+
activityFeedOpen.value = false;
|
|
166
|
+
writersSheetOpen.value = false;
|
|
167
|
+
activityFilter.value = null;
|
|
168
|
+
}
|