pmx-canvas 0.4.7 → 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 +156 -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/ExtAppFrame.d.ts +32 -0
- 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 +23 -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 +54 -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 +109 -18
- 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 +57 -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/ExtAppFrame.tsx +79 -2
- 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 +237 -61
- 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
|
@@ -9,7 +9,8 @@ import {
|
|
|
9
9
|
type ViewportState,
|
|
10
10
|
} from '../types';
|
|
11
11
|
import { computeAutoArrange } from '../../shared/auto-arrange';
|
|
12
|
-
import {
|
|
12
|
+
import { canvasAreaCenter } from '../canvas/canvas-area';
|
|
13
|
+
import { pushCanvasUpdate, requestBestEffort, requestOk, updateViewportFromClient } from './intent-bridge';
|
|
13
14
|
|
|
14
15
|
function logCanvasStoreError(action: string, error: unknown): void {
|
|
15
16
|
console.error(`[canvas-store] ${action} failed`, error);
|
|
@@ -21,12 +22,21 @@ export const nodes = signal<Map<string, CanvasNodeState>>(new Map());
|
|
|
21
22
|
export const edges = signal<Map<string, CanvasEdge>>(new Map());
|
|
22
23
|
export const annotations = signal<Map<string, CanvasAnnotation>>(new Map());
|
|
23
24
|
export const activeNodeId = signal<string | null>(null);
|
|
25
|
+
/** Click-selected edge — Delete removes it, Escape/background click clears it. */
|
|
26
|
+
export const selectedEdgeId = signal<string | null>(null);
|
|
24
27
|
export const connectionStatus = signal<ConnectionStatus>('connecting');
|
|
25
28
|
// Bumped on every processed `connected` frame (SSE reconnects and poll snapshot
|
|
26
29
|
// resets alike). Consumers holding server-minted URLs revalidate on change —
|
|
27
30
|
// a server restart invalidates in-memory resources like frame documents
|
|
28
31
|
// (Finding S) while the browser keeps the stale URLs alive.
|
|
29
32
|
export const workbenchConnectionEpoch = signal<number>(0);
|
|
33
|
+
/**
|
|
34
|
+
* Degraded-connection detail (rail-chrome-v2 phase 7, design item 14):
|
|
35
|
+
* the reconnect attempt and the delay before the next try while the stream is
|
|
36
|
+
* down. Both reset to 0 when a transport is back.
|
|
37
|
+
*/
|
|
38
|
+
export const reconnectAttempt = signal<number>(0);
|
|
39
|
+
export const reconnectDelay = signal<number>(0);
|
|
30
40
|
export const sessionId = signal<string>('');
|
|
31
41
|
export const traceEnabled = signal<boolean>(false);
|
|
32
42
|
export const canvasTheme = signal<string>('dark');
|
|
@@ -56,6 +66,10 @@ export const draggingEdge = signal<{
|
|
|
56
66
|
fromY: number;
|
|
57
67
|
cursorX: number;
|
|
58
68
|
cursorY: number;
|
|
69
|
+
/** Node under the cursor that would receive the edge on release (item 15). */
|
|
70
|
+
targetId?: string | null;
|
|
71
|
+
/** L was pressed during the drag: ask for a label on release. */
|
|
72
|
+
withLabel?: boolean;
|
|
59
73
|
} | null>(null);
|
|
60
74
|
|
|
61
75
|
// ── Spatial search highlight (command palette live results) ──
|
|
@@ -67,6 +81,18 @@ export const selectedNodeIds = signal<Set<string>>(new Set());
|
|
|
67
81
|
// ── Context pins (persistent context for agent queries) ──────
|
|
68
82
|
export const contextPinnedNodeIds = signal<Set<string>>(new Set());
|
|
69
83
|
|
|
84
|
+
// ── Chrome tool state (rail) ─────────────────────────────────
|
|
85
|
+
// 'select': background drag lassos, clicks select. 'pan': dragging anywhere
|
|
86
|
+
// pans — the world layer goes pointer-inert so nodes cannot swallow the drag.
|
|
87
|
+
/** `connect` (rail-chrome-v2 item 15): drag from anywhere on a node to draw an edge. */
|
|
88
|
+
export type CanvasTool = 'select' | 'pan' | 'connect';
|
|
89
|
+
export const canvasTool = signal<CanvasTool>('select');
|
|
90
|
+
/** Held-Space temporary pan — same semantics as the pan tool while held. */
|
|
91
|
+
export const spacePanHeld = signal<boolean>(false);
|
|
92
|
+
export function isPanModeActive(): boolean {
|
|
93
|
+
return canvasTool.value === 'pan' || spacePanHeld.value;
|
|
94
|
+
}
|
|
95
|
+
|
|
70
96
|
export function getNeighborNodeIds(nodeId: string | null, edgeMap: Map<string, CanvasEdge>): Set<string> {
|
|
71
97
|
if (!nodeId) return new Set();
|
|
72
98
|
|
|
@@ -111,10 +137,228 @@ export function selectNodes(ids: string[]): void {
|
|
|
111
137
|
}
|
|
112
138
|
|
|
113
139
|
export function clearSelection(): void {
|
|
140
|
+
// Edge selection rides along: a background click (or any other selection
|
|
141
|
+
// reset) deselects the edge exactly like it deselects nodes.
|
|
142
|
+
selectedEdgeId.value = null;
|
|
114
143
|
if (selectedNodeIds.value.size === 0) return;
|
|
115
144
|
selectedNodeIds.value = new Set();
|
|
116
145
|
}
|
|
117
146
|
|
|
147
|
+
// ── Groups v2 (rail-chrome-v2 phase 7, item 20) ──────────────────────
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Membership feedback while a node is being dragged: the group it would join
|
|
151
|
+
* on release (`add`), or the parent it would leave (`remove`). Membership
|
|
152
|
+
* changes ONLY on release while this is set — never silently by geometry.
|
|
153
|
+
* Esc during the drag clears it and keeps it cleared for that drag.
|
|
154
|
+
*/
|
|
155
|
+
export const dragDropTarget = signal<{ nodeId: string; groupId: string; mode: 'add' | 'remove' } | null>(null);
|
|
156
|
+
let dropSuppressedForDrag = false;
|
|
157
|
+
|
|
158
|
+
export function suppressDropForDrag(): void {
|
|
159
|
+
dropSuppressedForDrag = true;
|
|
160
|
+
dragDropTarget.value = null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function endDropTracking(): void {
|
|
164
|
+
dropSuppressedForDrag = false;
|
|
165
|
+
dragDropTarget.value = null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function rectOf(node: CanvasNodeState): { x: number; y: number; w: number; h: number } {
|
|
169
|
+
return { x: node.position.x, y: node.position.y, w: node.size.width, h: node.size.height };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function contains(outer: CanvasNodeState, x: number, y: number): boolean {
|
|
173
|
+
const r = rectOf(outer);
|
|
174
|
+
return x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function fullyOutside(inner: CanvasNodeState, outer: CanvasNodeState): boolean {
|
|
178
|
+
const a = rectOf(inner);
|
|
179
|
+
const b = rectOf(outer);
|
|
180
|
+
return a.x + a.w < b.x || a.x > b.x + b.w || a.y + a.h < b.y || a.y > b.y + b.h;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Called on every drag move of `nodeId`: updates the drop target and grows a
|
|
185
|
+
* fit-mode parent frame live so a child dragged against it never clips
|
|
186
|
+
* (the server re-fits on persist; shrinking happens there).
|
|
187
|
+
*/
|
|
188
|
+
export function trackDragMembership(nodeId: string): void {
|
|
189
|
+
const node = nodes.value.get(nodeId);
|
|
190
|
+
if (!node || node.type === 'group') return;
|
|
191
|
+
const parentId = typeof node.data.parentGroup === 'string' ? node.data.parentGroup : null;
|
|
192
|
+
const parent = parentId ? nodes.value.get(parentId) : undefined;
|
|
193
|
+
const cx = node.position.x + node.size.width / 2;
|
|
194
|
+
const cy = node.position.y + node.size.height / 2;
|
|
195
|
+
let candidate: CanvasNodeState | null = null;
|
|
196
|
+
for (const other of nodes.value.values()) {
|
|
197
|
+
if (other.type !== 'group' || other.id === parentId || other.collapsed) continue;
|
|
198
|
+
if (contains(other, cx, cy)) candidate = other;
|
|
199
|
+
}
|
|
200
|
+
if (!dropSuppressedForDrag) {
|
|
201
|
+
const next = candidate
|
|
202
|
+
? { nodeId, groupId: candidate.id, mode: 'add' as const }
|
|
203
|
+
: parent && fullyOutside(node, parent)
|
|
204
|
+
? { nodeId, groupId: parent.id, mode: 'remove' as const }
|
|
205
|
+
: null;
|
|
206
|
+
const current = dragDropTarget.value;
|
|
207
|
+
if ((current?.groupId ?? null) !== (next?.groupId ?? null) || current?.mode !== next?.mode) {
|
|
208
|
+
dragDropTarget.value = next;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
if (parent && parent.data.frameMode !== 'manual' && !fullyOutside(node, parent)) {
|
|
212
|
+
const pad = 22;
|
|
213
|
+
const left = Math.min(parent.position.x, node.position.x - pad);
|
|
214
|
+
const top = Math.min(parent.position.y, node.position.y - pad - 32);
|
|
215
|
+
const right = Math.max(parent.position.x + parent.size.width, node.position.x + node.size.width + pad);
|
|
216
|
+
const bottom = Math.max(parent.position.y + parent.size.height, node.position.y + node.size.height + pad);
|
|
217
|
+
if (
|
|
218
|
+
left !== parent.position.x ||
|
|
219
|
+
top !== parent.position.y ||
|
|
220
|
+
right !== parent.position.x + parent.size.width ||
|
|
221
|
+
bottom !== parent.position.y + parent.size.height
|
|
222
|
+
) {
|
|
223
|
+
updateNodeWithOptions(
|
|
224
|
+
parent.id,
|
|
225
|
+
{ position: { x: left, y: top }, size: { width: right - left, height: bottom - top } },
|
|
226
|
+
{ skipGroupChildTranslation: true },
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Children of collapsed groups are hidden; edges to them point at the chip. */
|
|
233
|
+
export const hiddenByCollapsedGroup = computed(() => {
|
|
234
|
+
const hidden = new Map<string, string>();
|
|
235
|
+
for (const node of nodes.value.values()) {
|
|
236
|
+
if (node.type !== 'group' || !node.collapsed) continue;
|
|
237
|
+
const children = Array.isArray(node.data.children) ? node.data.children : [];
|
|
238
|
+
for (const childId of children) {
|
|
239
|
+
if (typeof childId === 'string') hidden.set(childId, node.id);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return hidden;
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
/** The node that stands in for `id` on the canvas: itself, or its collapsed group's chip. */
|
|
246
|
+
export function visibleNodeFor(id: string): CanvasNodeState | undefined {
|
|
247
|
+
const chipId = hiddenByCollapsedGroup.value.get(id);
|
|
248
|
+
return nodes.value.get(chipId ?? id);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Collapsed groups render as a chip; children keep their positions for restore. */
|
|
252
|
+
export function groupsOfSelection(): string[] {
|
|
253
|
+
const ids = new Set<string>();
|
|
254
|
+
for (const id of selectedNodeIds.value) {
|
|
255
|
+
const node = nodes.value.get(id);
|
|
256
|
+
if (!node) continue;
|
|
257
|
+
if (node.type === 'group') ids.add(node.id);
|
|
258
|
+
else if (typeof node.data.parentGroup === 'string') ids.add(node.data.parentGroup);
|
|
259
|
+
}
|
|
260
|
+
return [...ids];
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ── Selection bar geometry actions (rail-chrome-v2 phase 7, item 13) ──
|
|
264
|
+
// Each works on the current selection in world space and persists through the
|
|
265
|
+
// same path a drag does (store update + persistLayout → /api/canvas/update).
|
|
266
|
+
|
|
267
|
+
export function alignSelection(edge: 'left' | 'top'): void {
|
|
268
|
+
const selected = getSelectedNodes();
|
|
269
|
+
if (selected.length < 2) return;
|
|
270
|
+
const target = Math.min(...selected.map((n) => (edge === 'left' ? n.position.x : n.position.y)));
|
|
271
|
+
// Sharing an edge collapses nodes that used to sit side by side ONTO each
|
|
272
|
+
// other. Aligning must never destroy the layout: when the shared edge makes
|
|
273
|
+
// any pair overlap, flow the selection along the other axis in its current
|
|
274
|
+
// order instead — align-left of a row reads as "stack into a left-aligned
|
|
275
|
+
// column", which is what the gesture means.
|
|
276
|
+
const GAP = 24;
|
|
277
|
+
const positions = new Map<string, { x: number; y: number }>();
|
|
278
|
+
for (const node of selected) {
|
|
279
|
+
positions.set(node.id, edge === 'left' ? { x: target, y: node.position.y } : { x: node.position.x, y: target });
|
|
280
|
+
}
|
|
281
|
+
const overlaps = (
|
|
282
|
+
a: { x: number; y: number; w: number; h: number },
|
|
283
|
+
b: { x: number; y: number; w: number; h: number },
|
|
284
|
+
) => a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
|
|
285
|
+
const framed = selected.map((node) => {
|
|
286
|
+
const at = positions.get(node.id)!;
|
|
287
|
+
return { node, x: at.x, y: at.y, w: node.size.width, h: node.size.height };
|
|
288
|
+
});
|
|
289
|
+
const collides = framed.some((a, i) => framed.some((b, j) => j > i && overlaps(a, b)));
|
|
290
|
+
if (collides) {
|
|
291
|
+
const ordered = [...selected].sort((a, b) =>
|
|
292
|
+
edge === 'left' ? a.position.y - b.position.y : a.position.x - b.position.x,
|
|
293
|
+
);
|
|
294
|
+
let cursor =
|
|
295
|
+
edge === 'left' ? Math.min(...selected.map((n) => n.position.y)) : Math.min(...selected.map((n) => n.position.x));
|
|
296
|
+
for (const node of ordered) {
|
|
297
|
+
positions.set(node.id, edge === 'left' ? { x: target, y: cursor } : { x: cursor, y: target });
|
|
298
|
+
cursor += (edge === 'left' ? node.size.height : node.size.width) + GAP;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
batch(() => {
|
|
302
|
+
for (const node of selected) {
|
|
303
|
+
updateNode(node.id, { position: positions.get(node.id)! });
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
persistLayout();
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Even horizontal gaps between the selected nodes, first and last staying put. */
|
|
310
|
+
export function distributeSelection(): void {
|
|
311
|
+
const selected = getSelectedNodes().sort((a, b) => a.position.x - b.position.x);
|
|
312
|
+
if (selected.length < 3) return;
|
|
313
|
+
const first = selected[0]!;
|
|
314
|
+
const last = selected[selected.length - 1]!;
|
|
315
|
+
const span = last.position.x - (first.position.x + first.size.width);
|
|
316
|
+
const inner = selected.slice(1, -1);
|
|
317
|
+
const innerWidth = inner.reduce((sum, n) => sum + n.size.width, 0);
|
|
318
|
+
const gap = (span - innerWidth) / (inner.length + 1);
|
|
319
|
+
batch(() => {
|
|
320
|
+
if (gap < 8) {
|
|
321
|
+
// The selection doesn't fit between first and last (a column, or an
|
|
322
|
+
// already-tight row): keeping the ends pinned would SQUEEZE the middle
|
|
323
|
+
// into overlap. Flow everything after the first into a row instead.
|
|
324
|
+
let cursor = first.position.x + first.size.width + 24;
|
|
325
|
+
for (const node of selected.slice(1)) {
|
|
326
|
+
updateNode(node.id, { position: { x: cursor, y: node.position.y } });
|
|
327
|
+
cursor += node.size.width + 24;
|
|
328
|
+
}
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
let cursor = first.position.x + first.size.width + gap;
|
|
332
|
+
for (const node of inner) {
|
|
333
|
+
updateNode(node.id, { position: { x: cursor, y: node.position.y } });
|
|
334
|
+
cursor += node.size.width + gap;
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
persistLayout();
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Grid the selection in reading order from its own top-left corner. */
|
|
341
|
+
export function arrangeSelection(gap = 24): void {
|
|
342
|
+
const selected = getSelectedNodes().sort((a, b) => a.position.y - b.position.y || a.position.x - b.position.x);
|
|
343
|
+
if (selected.length < 2) return;
|
|
344
|
+
const originX = Math.min(...selected.map((n) => n.position.x));
|
|
345
|
+
const originY = Math.min(...selected.map((n) => n.position.y));
|
|
346
|
+
const cols = Math.ceil(Math.sqrt(selected.length));
|
|
347
|
+
const colWidth = Math.max(...selected.map((n) => n.size.width));
|
|
348
|
+
const rowHeight = Math.max(...selected.map((n) => n.size.height));
|
|
349
|
+
batch(() => {
|
|
350
|
+
selected.forEach((node, index) => {
|
|
351
|
+
updateNode(node.id, {
|
|
352
|
+
position: {
|
|
353
|
+
x: originX + (index % cols) * (colWidth + gap),
|
|
354
|
+
y: originY + Math.floor(index / cols) * (rowHeight + gap),
|
|
355
|
+
},
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
persistLayout();
|
|
360
|
+
}
|
|
361
|
+
|
|
118
362
|
export function getSelectedNodes(): CanvasNodeState[] {
|
|
119
363
|
const sel = selectedNodeIds.value;
|
|
120
364
|
if (sel.size === 0) return [];
|
|
@@ -155,21 +399,13 @@ export function replaceContextPinsFromServer(ids: string[]): void {
|
|
|
155
399
|
contextPinnedNodeIds.value = new Set(ids);
|
|
156
400
|
}
|
|
157
401
|
|
|
158
|
-
export function getContextPinnedNodes(): CanvasNodeState[] {
|
|
159
|
-
const pins = contextPinnedNodeIds.value;
|
|
160
|
-
if (pins.size === 0) return [];
|
|
161
|
-
return Array.from(pins)
|
|
162
|
-
.map((id) => nodes.value.get(id))
|
|
163
|
-
.filter((n): n is CanvasNodeState => n !== undefined);
|
|
164
|
-
}
|
|
165
|
-
|
|
166
402
|
function syncContextPinsToServer(ids: Set<string>): void {
|
|
167
|
-
|
|
403
|
+
// Through the bridge so the write carries the workbench marker — a bare
|
|
404
|
+
// fetch makes the human's pin look like an anonymous `api` agent.
|
|
405
|
+
void requestBestEffort('syncContextPinsToServer', '/api/canvas/context-pins', {
|
|
168
406
|
method: 'POST',
|
|
169
407
|
headers: { 'Content-Type': 'application/json' },
|
|
170
408
|
body: JSON.stringify({ nodeIds: Array.from(ids) }),
|
|
171
|
-
}).catch((error) => {
|
|
172
|
-
logCanvasStoreError('syncContextPinsToServer', error);
|
|
173
409
|
});
|
|
174
410
|
}
|
|
175
411
|
|
|
@@ -301,30 +537,19 @@ export async function createAnnotationFromClient(input: {
|
|
|
301
537
|
text?: string;
|
|
302
538
|
label?: string;
|
|
303
539
|
}): Promise<{ ok: boolean }> {
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
});
|
|
310
|
-
return { ok: res.ok };
|
|
311
|
-
} catch (error) {
|
|
312
|
-
logCanvasStoreError('createAnnotationFromClient', error);
|
|
313
|
-
return { ok: false };
|
|
314
|
-
}
|
|
540
|
+
return requestOk('createAnnotationFromClient', '/api/canvas/annotation', {
|
|
541
|
+
method: 'POST',
|
|
542
|
+
headers: { 'Content-Type': 'application/json' },
|
|
543
|
+
body: JSON.stringify(input),
|
|
544
|
+
});
|
|
315
545
|
}
|
|
316
546
|
|
|
317
547
|
export async function removeAnnotationFromClient(id: string): Promise<{ ok: boolean }> {
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
return { ok: res.ok };
|
|
324
|
-
} catch (error) {
|
|
325
|
-
logCanvasStoreError('removeAnnotationFromClient', error);
|
|
326
|
-
return { ok: false };
|
|
327
|
-
}
|
|
548
|
+
const result = await requestOk('removeAnnotationFromClient', `/api/canvas/annotation/${encodeURIComponent(id)}`, {
|
|
549
|
+
method: 'DELETE',
|
|
550
|
+
});
|
|
551
|
+
if (result.ok) removeAnnotation(id);
|
|
552
|
+
return result;
|
|
328
553
|
}
|
|
329
554
|
|
|
330
555
|
export function resizeNode(id: string, size: { width: number; height: number }): void {
|
|
@@ -336,7 +561,15 @@ export function resizeNode(id: string, size: { width: number; height: number }):
|
|
|
336
561
|
export function bringToFront(id: string): void {
|
|
337
562
|
const existing = nodes.value.get(id);
|
|
338
563
|
if (!existing) return;
|
|
339
|
-
|
|
564
|
+
batch(() => {
|
|
565
|
+
updateNode(id, { zIndex: maxZ++ });
|
|
566
|
+
// A group is a frame: it must never stack above its own children.
|
|
567
|
+
if (existing.type === 'group' && Array.isArray(existing.data.children)) {
|
|
568
|
+
for (const childId of existing.data.children) {
|
|
569
|
+
if (typeof childId === 'string' && nodes.value.has(childId)) updateNode(childId, { zIndex: maxZ++ });
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
});
|
|
340
573
|
activeNodeId.value = id;
|
|
341
574
|
}
|
|
342
575
|
|
|
@@ -344,48 +577,8 @@ export function toggleCollapsed(id: string): void {
|
|
|
344
577
|
const existing = nodes.value.get(id);
|
|
345
578
|
if (!existing) return;
|
|
346
579
|
updateNode(id, { collapsed: !existing.collapsed });
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
// Collapse every docked context node. Used to enforce mutual exclusion between
|
|
350
|
-
// the Context side panel and the Updates side panel (they share the same
|
|
351
|
-
// right-edge anchor and would otherwise visually collide).
|
|
352
|
-
export function collapseDockedContextNodes(): void {
|
|
353
|
-
for (const node of nodes.value.values()) {
|
|
354
|
-
if (node.type === 'context' && node.dockPosition === 'right' && !node.collapsed) {
|
|
355
|
-
updateNode(node.id, { collapsed: true });
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
// True iff at least one docked context node is currently expanded. Used by the
|
|
361
|
-
// Updates pill to hide itself while the Context panel is open.
|
|
362
|
-
export const hasOpenDockedContextPanel = computed(() => {
|
|
363
|
-
for (const node of nodes.value.values()) {
|
|
364
|
-
if (node.type === 'context' && node.dockPosition === 'right' && !node.collapsed) {
|
|
365
|
-
return true;
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
return false;
|
|
369
|
-
});
|
|
370
|
-
|
|
371
|
-
export function dockNode(id: string, position: 'left' | 'right'): void {
|
|
372
|
-
const existing = nodes.value.get(id);
|
|
373
|
-
if (!existing) return;
|
|
374
|
-
updateNode(id, { dockPosition: position });
|
|
375
|
-
persistLayout();
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
export function undockNode(id: string): void {
|
|
379
|
-
const existing = nodes.value.get(id);
|
|
380
|
-
if (!existing) return;
|
|
381
|
-
// Place at center of current viewport in world-space
|
|
382
|
-
const v = viewport.value;
|
|
383
|
-
const cx = (window.innerWidth / 2 - v.x) / v.scale;
|
|
384
|
-
const cy = (window.innerHeight / 2 - v.y) / v.scale;
|
|
385
|
-
updateNode(id, {
|
|
386
|
-
dockPosition: null,
|
|
387
|
-
position: { x: cx - existing.size.width / 2, y: cy - existing.size.height / 2 },
|
|
388
|
-
});
|
|
580
|
+
// Collapse is board state (a collapsed group hides its children for every
|
|
581
|
+
// viewer and survives reload), not a per-tab preference.
|
|
389
582
|
persistLayout();
|
|
390
583
|
}
|
|
391
584
|
|
|
@@ -480,8 +673,7 @@ export function zoomByFactor(factor: number, duration = 150): void {
|
|
|
480
673
|
const scale = Math.min(4, Math.max(0.1, v.scale * factor));
|
|
481
674
|
if (scale === v.scale) return;
|
|
482
675
|
const ratio = scale / v.scale;
|
|
483
|
-
const cx
|
|
484
|
-
const cy = window.innerHeight / 2;
|
|
676
|
+
const { x: cx, y: cy } = canvasAreaCenter();
|
|
485
677
|
animateViewport({ x: cx - ratio * (cx - v.x), y: cy - ratio * (cy - v.y), scale }, duration);
|
|
486
678
|
}
|
|
487
679
|
|
|
@@ -536,7 +728,6 @@ export function persistLayout(options: { recordHistory?: boolean } = {}): void {
|
|
|
536
728
|
position: n.position,
|
|
537
729
|
size: n.size,
|
|
538
730
|
collapsed: n.collapsed,
|
|
539
|
-
dockPosition: n.dockPosition,
|
|
540
731
|
}));
|
|
541
732
|
const layout = {
|
|
542
733
|
viewport: viewport.value,
|
|
@@ -547,7 +738,6 @@ export function persistLayout(options: { recordHistory?: boolean } = {}): void {
|
|
|
547
738
|
size: n.size,
|
|
548
739
|
collapsed: n.collapsed,
|
|
549
740
|
pinned: n.pinned,
|
|
550
|
-
dockPosition: n.dockPosition,
|
|
551
741
|
})),
|
|
552
742
|
edges: Array.from(edges.value.values()).map((e) => ({
|
|
553
743
|
id: e.id,
|
|
@@ -579,7 +769,6 @@ export function restoreLayout(): Map<string, Partial<CanvasNodeState>> | null {
|
|
|
579
769
|
size?: CanvasNodeState['size'];
|
|
580
770
|
collapsed?: boolean;
|
|
581
771
|
pinned?: boolean;
|
|
582
|
-
dockPosition?: CanvasNodeState['dockPosition'];
|
|
583
772
|
}>;
|
|
584
773
|
};
|
|
585
774
|
const savedNodes = Array.isArray(layout.nodes) ? layout.nodes : [];
|
|
@@ -593,7 +782,6 @@ export function restoreLayout(): Map<string, Partial<CanvasNodeState>> | null {
|
|
|
593
782
|
...(node.size ? { size: node.size } : {}),
|
|
594
783
|
...(node.collapsed !== undefined ? { collapsed: node.collapsed } : {}),
|
|
595
784
|
...(node.pinned !== undefined ? { pinned: node.pinned } : {}),
|
|
596
|
-
...(node.dockPosition !== undefined ? { dockPosition: node.dockPosition } : {}),
|
|
597
785
|
});
|
|
598
786
|
}
|
|
599
787
|
|
|
@@ -641,10 +829,11 @@ export function focusNode(id: string, options: { recordHistory?: boolean } = {})
|
|
|
641
829
|
const v = viewport.value;
|
|
642
830
|
const cx = node.position.x + node.size.width / 2;
|
|
643
831
|
const cy = node.position.y + node.size.height / 2;
|
|
832
|
+
const centre = canvasAreaCenter();
|
|
644
833
|
animateViewport(
|
|
645
834
|
{
|
|
646
|
-
x:
|
|
647
|
-
y:
|
|
835
|
+
x: centre.x - cx * v.scale,
|
|
836
|
+
y: centre.y - cy * v.scale,
|
|
648
837
|
scale: v.scale,
|
|
649
838
|
},
|
|
650
839
|
300,
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { computed, signal } from '@preact/signals';
|
|
2
|
+
import {
|
|
3
|
+
HUMAN_CURSOR_THROTTLE_MS,
|
|
4
|
+
type HumanPresence,
|
|
5
|
+
type HumanPresenceSnapshot,
|
|
6
|
+
} from '../../shared/human-presence.js';
|
|
7
|
+
import { requestBestEffort, vetoGhostIntent } from './intent-bridge';
|
|
8
|
+
import { intents, removeIntent } from './intent-store';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Human collaborator presence (rail-chrome-v2 phase 8). This tab reports its
|
|
12
|
+
* cursor (throttled) and the node it is holding; the server fans every tab
|
|
13
|
+
* out over `human-presence` SSE frames. `otherHumans` is what the presence
|
|
14
|
+
* layer draws — never this tab's own cursor.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
function sessionScoped(key: string, make: () => string): string {
|
|
18
|
+
try {
|
|
19
|
+
const existing = window.sessionStorage.getItem(key);
|
|
20
|
+
if (existing) return existing;
|
|
21
|
+
const value = make();
|
|
22
|
+
window.sessionStorage.setItem(key, value);
|
|
23
|
+
return value;
|
|
24
|
+
} catch {
|
|
25
|
+
return make();
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const humanClientId = sessionScoped(
|
|
30
|
+
'pmx-canvas-human-client',
|
|
31
|
+
() => `tab-${Math.random().toString(36).slice(2, 8)}`,
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
function resolveName(): string {
|
|
35
|
+
try {
|
|
36
|
+
const fromQuery = new URLSearchParams(window.location.search).get('name')?.trim();
|
|
37
|
+
if (fromQuery) {
|
|
38
|
+
window.localStorage.setItem('pmx-canvas-human-name', fromQuery);
|
|
39
|
+
return fromQuery;
|
|
40
|
+
}
|
|
41
|
+
const stored = window.localStorage.getItem('pmx-canvas-human-name')?.trim();
|
|
42
|
+
if (stored) return stored;
|
|
43
|
+
} catch {
|
|
44
|
+
// storage unavailable: fall through to a generated name
|
|
45
|
+
}
|
|
46
|
+
return `guest-${humanClientId.slice(-4)}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const humanName = signal<string>(resolveName());
|
|
50
|
+
export const humans = signal<HumanPresence[]>([]);
|
|
51
|
+
export const otherHumans = computed(() => humans.value.filter((human) => human.clientId !== humanClientId));
|
|
52
|
+
|
|
53
|
+
/** Nodes a human took over mid-edit → that human's name, shown as the yield pill for a moment. */
|
|
54
|
+
export const yieldedNodes = signal<Map<string, string>>(new Map());
|
|
55
|
+
|
|
56
|
+
export function applyHumanSnapshot(snapshot: Partial<HumanPresenceSnapshot> | null | undefined): void {
|
|
57
|
+
if (!snapshot || !Array.isArray(snapshot.humans)) return;
|
|
58
|
+
humans.value = snapshot.humans;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let grabbing: string | null = null;
|
|
62
|
+
let lastCursor: { x: number; y: number } | null = null;
|
|
63
|
+
let lastSentAt = 0;
|
|
64
|
+
let pending: ReturnType<typeof setTimeout> | null = null;
|
|
65
|
+
|
|
66
|
+
function post(body: Record<string, unknown>): void {
|
|
67
|
+
void requestBestEffort('humanPresence', '/api/canvas/human-presence', {
|
|
68
|
+
method: 'POST',
|
|
69
|
+
headers: { 'Content-Type': 'application/json' },
|
|
70
|
+
body: JSON.stringify({ clientId: humanClientId, name: humanName.value, ...body }),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function flushCursor(): void {
|
|
75
|
+
pending = null;
|
|
76
|
+
lastSentAt = Date.now();
|
|
77
|
+
post({ cursor: lastCursor, grabbingNodeId: grabbing });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Report the pointer in world coordinates (throttled); null when it leaves the canvas. */
|
|
81
|
+
export function reportHumanCursor(cursor: { x: number; y: number } | null): void {
|
|
82
|
+
lastCursor = cursor;
|
|
83
|
+
const elapsed = Date.now() - lastSentAt;
|
|
84
|
+
if (elapsed >= HUMAN_CURSOR_THROTTLE_MS) {
|
|
85
|
+
if (pending) clearTimeout(pending);
|
|
86
|
+
flushCursor();
|
|
87
|
+
} else if (!pending) {
|
|
88
|
+
pending = setTimeout(flushCursor, HUMAN_CURSOR_THROTTLE_MS - elapsed);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Hold / release a node (drag, rename): the edit lock agents must respect. */
|
|
93
|
+
export function reportHumanGrab(nodeId: string | null): void {
|
|
94
|
+
grabbing = nodeId;
|
|
95
|
+
post({ cursor: lastCursor, grabbingNodeId: nodeId });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function markYielded(nodeId: string, name: string): void {
|
|
99
|
+
const next = new Map(yieldedNodes.value);
|
|
100
|
+
next.set(nodeId, name);
|
|
101
|
+
yieldedNodes.value = next;
|
|
102
|
+
setTimeout(() => {
|
|
103
|
+
const later = new Map(yieldedNodes.value);
|
|
104
|
+
if (later.get(nodeId) === name) {
|
|
105
|
+
later.delete(nodeId);
|
|
106
|
+
yieldedNodes.value = later;
|
|
107
|
+
}
|
|
108
|
+
}, 6000);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* User wins (item 6): the human grabbed a node the agent is mid-edit on. Every
|
|
113
|
+
* explicit intent targeting it is vetoed through the normal veto path (the
|
|
114
|
+
* agent hears it as steering), a Yield entry lands in the timeline, and the
|
|
115
|
+
* node wears a "took over — agent yielded" pill for a moment.
|
|
116
|
+
*/
|
|
117
|
+
export function takeOverNode(nodeId: string, title: string): void {
|
|
118
|
+
const pendingOnNode = [...intents.value.values()].filter(
|
|
119
|
+
(intent) => intent.nodeId === nodeId && !intent.auto && intent.phase === 'forming',
|
|
120
|
+
);
|
|
121
|
+
if (pendingOnNode.length === 0) return;
|
|
122
|
+
const name = humanName.value;
|
|
123
|
+
markYielded(nodeId, name);
|
|
124
|
+
for (const intent of pendingOnNode) {
|
|
125
|
+
void vetoGhostIntent({
|
|
126
|
+
id: intent.id,
|
|
127
|
+
kind: intent.kind,
|
|
128
|
+
label: intent.label,
|
|
129
|
+
reason: `${name} took over “${title}” mid-edit — yield and requeue the change once it is released`,
|
|
130
|
+
}).then((ok) => {
|
|
131
|
+
if (ok) removeIntent(intent.id);
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
void requestBestEffort('yieldEvent', '/api/canvas/ax/event', {
|
|
135
|
+
method: 'POST',
|
|
136
|
+
headers: { 'Content-Type': 'application/json' },
|
|
137
|
+
body: JSON.stringify({
|
|
138
|
+
kind: 'yield',
|
|
139
|
+
summary: `${name} grabbed “${title}” mid-edit — agent yielded, change requeued`,
|
|
140
|
+
nodeIds: [nodeId],
|
|
141
|
+
source: 'browser',
|
|
142
|
+
}),
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
let heartbeat: ReturnType<typeof setInterval> | null = null;
|
|
147
|
+
|
|
148
|
+
/** Announce this tab and keep it alive; renews a held grab. Call once per connection. */
|
|
149
|
+
export function startHumanPresence(): () => void {
|
|
150
|
+
post({ cursor: lastCursor, grabbingNodeId: grabbing });
|
|
151
|
+
heartbeat = setInterval(() => post({ grabbingNodeId: grabbing }), 3000);
|
|
152
|
+
const leave = () => {
|
|
153
|
+
try {
|
|
154
|
+
navigator.sendBeacon?.(
|
|
155
|
+
'/api/canvas/human-presence',
|
|
156
|
+
new Blob([JSON.stringify({ clientId: humanClientId, left: true })], { type: 'application/json' }),
|
|
157
|
+
);
|
|
158
|
+
} catch {
|
|
159
|
+
// best effort
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
window.addEventListener('pagehide', leave);
|
|
163
|
+
return () => {
|
|
164
|
+
if (heartbeat) clearInterval(heartbeat);
|
|
165
|
+
heartbeat = null;
|
|
166
|
+
window.removeEventListener('pagehide', leave);
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function resetHumanPresence(): void {
|
|
171
|
+
humans.value = [];
|
|
172
|
+
yieldedNodes.value = new Map();
|
|
173
|
+
grabbing = null;
|
|
174
|
+
lastCursor = null;
|
|
175
|
+
}
|