pmx-canvas 0.4.8 → 0.5.1
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 +3071 -796
- package/dist/canvas/index.js +122 -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 +39 -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 +2 -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 +9 -1
- package/dist/types/server/canvas-operations.d.ts +11 -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 +20 -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 +4 -4
- package/skills/pmx-canvas/SKILL.md +112 -17
- 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 +263 -0
- package/src/cli/index.ts +2 -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 +74 -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 +86 -20
- 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 +285 -90
- 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 +3071 -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 +58 -14
- 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 +111 -6
- 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 +77 -16
- package/src/server/operations/ops/query.ts +1 -0
- package/src/server/operations/ops/snapshots.ts +7 -4
- 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
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
import { computed, signal } from '@preact/signals';
|
|
2
|
+
import type { AxApprovalStatus, AxEventKind, AxWorkItemStatus } from '../../shared/ax-kinds.js';
|
|
3
|
+
import { HUMAN_STARTED_SESSION_LABEL } from '../../shared/agent-presence.js';
|
|
4
|
+
import { axSurfaceState } from './canvas-store';
|
|
5
|
+
import { requestBestEffort, requestJson, requestOk } from './intent-bridge';
|
|
6
|
+
import { agentActivity } from './presence-store';
|
|
7
|
+
|
|
8
|
+
// Structural views of the AX wire shapes (the client never imports server
|
|
9
|
+
// modules; the status/kind unions come from shared/). Only the fields the
|
|
10
|
+
// panel renders.
|
|
11
|
+
export type WorkItemStatus = AxWorkItemStatus;
|
|
12
|
+
export interface WorkItemView {
|
|
13
|
+
id: string;
|
|
14
|
+
title: string;
|
|
15
|
+
status: WorkItemStatus;
|
|
16
|
+
detail: string | null;
|
|
17
|
+
nodeIds: string[];
|
|
18
|
+
updatedAt: string;
|
|
19
|
+
}
|
|
20
|
+
export interface ApprovalGateView {
|
|
21
|
+
id: string;
|
|
22
|
+
title: string;
|
|
23
|
+
detail: string | null;
|
|
24
|
+
status: AxApprovalStatus;
|
|
25
|
+
nodeIds: string[];
|
|
26
|
+
createdAt: string;
|
|
27
|
+
/** Unattended-approval TTL: when a pending gate auto-holds. */
|
|
28
|
+
expiresAt: string | null;
|
|
29
|
+
}
|
|
30
|
+
export interface AxEventView {
|
|
31
|
+
id: string;
|
|
32
|
+
kind: AxEventKind;
|
|
33
|
+
summary: string;
|
|
34
|
+
detail: string | null;
|
|
35
|
+
createdAt: string;
|
|
36
|
+
/** Who recorded it — host/source label and per-agent identity. */
|
|
37
|
+
source?: string | null;
|
|
38
|
+
agentId?: string | null;
|
|
39
|
+
}
|
|
40
|
+
export interface AxEvidenceView {
|
|
41
|
+
id: string;
|
|
42
|
+
title: string;
|
|
43
|
+
body: string | null;
|
|
44
|
+
createdAt: string;
|
|
45
|
+
source?: string | null;
|
|
46
|
+
agentId?: string | null;
|
|
47
|
+
}
|
|
48
|
+
export interface AxSteeringView {
|
|
49
|
+
id: string;
|
|
50
|
+
message: string;
|
|
51
|
+
createdAt: string;
|
|
52
|
+
/** Consumer label this steer was addressed to; null/absent = broadcast. */
|
|
53
|
+
target?: string | null;
|
|
54
|
+
/** Who sent it — an agent label, or "browser" for the human's composer. */
|
|
55
|
+
source?: string | null;
|
|
56
|
+
/** Per-agent identity within the host — preferred over `source` for the row's sender. */
|
|
57
|
+
agentId?: string | null;
|
|
58
|
+
/** False until the target (or any consumer, for broadcasts) claims + marks it. */
|
|
59
|
+
delivered?: boolean;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Session panel data (rail-chrome-v2 phase 4). Nothing here is a second source
|
|
64
|
+
* of truth: work items and gates come from the AX surface snapshot the SSE
|
|
65
|
+
* bridge already refreshes on `ax-state-changed`; the timeline is the bounded
|
|
66
|
+
* AX timeline read, refreshed on `ax-event-created` while a session is
|
|
67
|
+
* attached. Gate decisions go through the existing resolve route.
|
|
68
|
+
*/
|
|
69
|
+
|
|
70
|
+
export interface ScopeFenceView {
|
|
71
|
+
nodeIds: string[];
|
|
72
|
+
padding: number;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface SurfaceView {
|
|
76
|
+
workItems?: WorkItemView[];
|
|
77
|
+
approvalGates?: ApprovalGateView[];
|
|
78
|
+
policy?: { scope?: ScopeFenceView | null };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function surface(): SurfaceView {
|
|
82
|
+
const value = axSurfaceState.value;
|
|
83
|
+
return value && typeof value === 'object' ? (value as SurfaceView) : {};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export const sessionWorkItems = computed<WorkItemView[]>(() => surface().workItems ?? []);
|
|
87
|
+
export const sessionGates = computed<ApprovalGateView[]>(() => surface().approvalGates ?? []);
|
|
88
|
+
export const pendingGates = computed(() => sessionGates.value.filter((gate) => gate.status === 'pending'));
|
|
89
|
+
/** The scope fence the human granted the session (null = unscoped). */
|
|
90
|
+
export const scopeFence = computed<ScopeFenceView | null>(() => {
|
|
91
|
+
const scope = surface().policy?.scope;
|
|
92
|
+
return scope && Array.isArray(scope.nodeIds) && scope.nodeIds.length > 0
|
|
93
|
+
? { nodeIds: scope.nodeIds, padding: typeof scope.padding === 'number' ? scope.padding : 40 }
|
|
94
|
+
: null;
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
/** Grant or clear the fence: writes outside it are refused server-side; reads stay open. */
|
|
98
|
+
export async function setScopeFence(nodeIds: string[] | null): Promise<boolean> {
|
|
99
|
+
const result = await requestOk('setScopeFence', '/api/canvas/ax/policy', {
|
|
100
|
+
method: 'POST',
|
|
101
|
+
headers: { 'Content-Type': 'application/json' },
|
|
102
|
+
body: JSON.stringify({ scope: nodeIds && nodeIds.length > 0 ? { nodeIds } : null, source: 'browser' }),
|
|
103
|
+
});
|
|
104
|
+
return result.ok;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Auto-held gates: the policy said no on the human's behalf; they can be reopened. */
|
|
108
|
+
export const heldGates = computed(() => sessionGates.value.filter((gate) => gate.status === 'held'));
|
|
109
|
+
|
|
110
|
+
export interface AxTimelineView {
|
|
111
|
+
events: AxEventView[];
|
|
112
|
+
evidence: AxEvidenceView[];
|
|
113
|
+
steering: AxSteeringView[];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export const axTimeline = signal<AxTimelineView>({ events: [], evidence: [], steering: [] });
|
|
117
|
+
|
|
118
|
+
export type TimelineEntryKind = AxEventKind | 'evidence' | 'steer' | 'update';
|
|
119
|
+
|
|
120
|
+
/** Timeline filter chips: a handful of human categories over the many kinds. */
|
|
121
|
+
export type TimelineFilter = 'all' | 'update' | 'steer' | 'assistant' | 'event' | 'evidence';
|
|
122
|
+
export const timelineFilter = signal<TimelineFilter>('all');
|
|
123
|
+
|
|
124
|
+
/** Which chip an entry belongs to: board writes / steering-shaped rows / evidence / the rest. */
|
|
125
|
+
export function timelineCategory(kind: TimelineEntryKind): Exclude<TimelineFilter, 'all'> {
|
|
126
|
+
if (kind === 'update') return 'update';
|
|
127
|
+
if (kind === 'steer' || kind === 'steering' || kind === 'yield') return 'steer';
|
|
128
|
+
if (kind === 'assistant-message') return 'assistant';
|
|
129
|
+
if (kind === 'evidence') return 'evidence';
|
|
130
|
+
return 'event';
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface TimelineEntry {
|
|
134
|
+
id: string;
|
|
135
|
+
kind: TimelineEntryKind;
|
|
136
|
+
label: string;
|
|
137
|
+
body: string;
|
|
138
|
+
createdAt: string;
|
|
139
|
+
/** Writer key for the row (agentId, else non-transport source; 'browser' = the human).
|
|
140
|
+
* With several assistants on one board, a row without this is unanswerable:
|
|
141
|
+
* "Assistant · 23:11" from WHOM? Null when only a bare transport is known. */
|
|
142
|
+
who?: string | null;
|
|
143
|
+
/** Item 10: this agent edit is the top of the shared undo stack — "↩ undo this edit". */
|
|
144
|
+
undoable?: boolean;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Transport enums say nothing about WHICH agent spoke — never show them as a writer. */
|
|
148
|
+
const TRANSPORT_WRITERS = new Set(['api', 'mcp', 'sdk', 'cli']);
|
|
149
|
+
|
|
150
|
+
/** The row's writer key: per-agent identity first, else a non-transport source. */
|
|
151
|
+
export function writerKeyFor(agentId?: string | null, source?: string | null): string | null {
|
|
152
|
+
if (agentId?.trim()) return agentId.trim();
|
|
153
|
+
const raw = source?.trim();
|
|
154
|
+
if (raw && !TRANSPORT_WRITERS.has(raw)) return raw;
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Ops that change the board (the ones an undo can revert). */
|
|
159
|
+
const LAYOUT_OPS =
|
|
160
|
+
/^(node|edge|group|annotation|jsonrender|graph|render)\.|^arrange$|^canvas\.clear$|^snapshot\.restore$/;
|
|
161
|
+
|
|
162
|
+
/** The entry Ctrl+Z would undo next, from GET /api/canvas/history. */
|
|
163
|
+
export const historyTop = signal<{ id: string; actor: 'human' | 'agent'; description: string } | null>(null);
|
|
164
|
+
/** Agent edits undone from the panel this page-life — rendered "undone · steering sent". */
|
|
165
|
+
export const undoneActivityIds = signal<Set<string>>(new Set());
|
|
166
|
+
|
|
167
|
+
const EVENT_LABELS: Record<AxEventKind, string> = {
|
|
168
|
+
prompt: 'Prompt',
|
|
169
|
+
'assistant-message': 'Assistant',
|
|
170
|
+
'tool-start': 'Tool run',
|
|
171
|
+
'tool-result': 'Tool result',
|
|
172
|
+
failure: 'Failure',
|
|
173
|
+
approval: 'Approval',
|
|
174
|
+
steering: 'Steering',
|
|
175
|
+
command: 'Command',
|
|
176
|
+
note: 'Note',
|
|
177
|
+
policy: 'Policy',
|
|
178
|
+
yield: 'Yield',
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* One reverse-chronological feed out of the three timeline tables plus the
|
|
183
|
+
* agent's board writes (the presence activity feed), so the panel shows what
|
|
184
|
+
* the agent DID between its tool runs and gates. The newest agent write gets
|
|
185
|
+
* the undo affordance when it is also the top of the shared undo stack.
|
|
186
|
+
*/
|
|
187
|
+
export function mergeTimeline(
|
|
188
|
+
timeline: AxTimelineView,
|
|
189
|
+
limit = 40,
|
|
190
|
+
writes: Array<{ id: string; at: string; op: string; summary: string; sessionId?: string; label?: string }> = [],
|
|
191
|
+
top: { actor: 'human' | 'agent' } | null = null,
|
|
192
|
+
filter: TimelineFilter = 'all',
|
|
193
|
+
): TimelineEntry[] {
|
|
194
|
+
const newestWrite = writes.find((write) => LAYOUT_OPS.test(write.op));
|
|
195
|
+
const entries: TimelineEntry[] = [
|
|
196
|
+
...writes.map((write) => ({
|
|
197
|
+
id: `update-${write.id}`,
|
|
198
|
+
kind: 'update' as const,
|
|
199
|
+
label: 'Update',
|
|
200
|
+
body: write.summary,
|
|
201
|
+
createdAt: write.at,
|
|
202
|
+
// Activity entries are already attributed server-side — the sessionId is
|
|
203
|
+
// the identity key (hue-stable across chrome), the label its display name.
|
|
204
|
+
who: write.sessionId ?? null,
|
|
205
|
+
...(top?.actor === 'agent' && newestWrite?.id === write.id ? { undoable: true } : {}),
|
|
206
|
+
})),
|
|
207
|
+
...timeline.events.map((event) => ({
|
|
208
|
+
id: `event-${event.id}`,
|
|
209
|
+
kind: event.kind,
|
|
210
|
+
label: EVENT_LABELS[event.kind] ?? event.kind,
|
|
211
|
+
body: event.detail ? `${event.summary} — ${event.detail}` : event.summary,
|
|
212
|
+
createdAt: event.createdAt,
|
|
213
|
+
who: writerKeyFor(event.agentId, event.source),
|
|
214
|
+
})),
|
|
215
|
+
...timeline.evidence.map((item) => ({
|
|
216
|
+
id: `evidence-${item.id}`,
|
|
217
|
+
kind: 'evidence' as const,
|
|
218
|
+
label: 'Evidence',
|
|
219
|
+
body: item.body ? `${item.title} — ${item.body}` : item.title,
|
|
220
|
+
createdAt: item.createdAt,
|
|
221
|
+
who: writerKeyFor(item.agentId, item.source),
|
|
222
|
+
})),
|
|
223
|
+
...timeline.steering.map((steer) => {
|
|
224
|
+
// Sender shown for agent-sent steering ("claude-code → copilot · …") so
|
|
225
|
+
// inter-agent coordination is legible; the human's own rows stay clean.
|
|
226
|
+
// Prefer the per-agent identity: transport enums ("api", "mcp") tell the
|
|
227
|
+
// human nothing about WHICH agent spoke.
|
|
228
|
+
const from = steer.agentId ?? (steer.source && steer.source !== 'browser' ? steer.source : null);
|
|
229
|
+
const to = steer.target ? `→ ${steer.target} · ` : from ? '→ all · ' : '';
|
|
230
|
+
// Steering is pull-based: an addressed steer sits queued until the
|
|
231
|
+
// target agent next runs against the canvas. Say so — a silent queued
|
|
232
|
+
// row reads as "nothing happened".
|
|
233
|
+
const waiting = steer.target && steer.delivered === false ? ` — waiting for ${steer.target} to pick it up` : '';
|
|
234
|
+
return {
|
|
235
|
+
id: `steer-${steer.id}`,
|
|
236
|
+
kind: 'steer' as const,
|
|
237
|
+
label: 'Steer',
|
|
238
|
+
body: `${from ? `${from} ` : ''}${to}${steer.message}${waiting}`,
|
|
239
|
+
createdAt: steer.createdAt,
|
|
240
|
+
who: writerKeyFor(steer.agentId, steer.source),
|
|
241
|
+
};
|
|
242
|
+
}),
|
|
243
|
+
];
|
|
244
|
+
// Filter BEFORE the cap so "Steer" shows up to `limit` steers, not just the
|
|
245
|
+
// ones that survived a cap over the mixed feed.
|
|
246
|
+
const kept = filter === 'all' ? entries : entries.filter((entry) => timelineCategory(entry.kind) === filter);
|
|
247
|
+
kept.sort((a, b) => (a.createdAt < b.createdAt ? 1 : a.createdAt > b.createdAt ? -1 : 0));
|
|
248
|
+
return kept.slice(0, limit);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export const timelineEntries = computed(() =>
|
|
252
|
+
mergeTimeline(axTimeline.value, 40, agentActivity.value, historyTop.value, timelineFilter.value),
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
export async function refreshTimeline(): Promise<void> {
|
|
256
|
+
const [data, history] = await Promise.all([
|
|
257
|
+
requestJson<Partial<AxTimelineView> | null>('refreshTimeline', '/api/canvas/ax/timeline?limit=40', null),
|
|
258
|
+
requestJson<{ top?: { id: string; actor: 'human' | 'agent'; description: string } | null } | null>(
|
|
259
|
+
'refreshHistoryTop',
|
|
260
|
+
'/api/canvas/history',
|
|
261
|
+
null,
|
|
262
|
+
),
|
|
263
|
+
]);
|
|
264
|
+
if (history) historyTop.value = history.top ?? null;
|
|
265
|
+
if (!data) return;
|
|
266
|
+
axTimeline.value = {
|
|
267
|
+
events: Array.isArray(data.events) ? data.events : [],
|
|
268
|
+
evidence: Array.isArray(data.evidence) ? data.evidence : [],
|
|
269
|
+
steering: Array.isArray(data.steering) ? data.steering : [],
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Undo the agent's latest edit through the ONE shared undo stack (the same
|
|
275
|
+
* POST /api/canvas/undo Ctrl+Z uses), then tell the agent: steering feedback
|
|
276
|
+
* goes out through the same path a veto takes.
|
|
277
|
+
*/
|
|
278
|
+
export async function undoAgentEdit(entry: TimelineEntry): Promise<boolean> {
|
|
279
|
+
const result = await requestOk('undoAgentEdit', '/api/canvas/undo', { method: 'POST' });
|
|
280
|
+
if (!result.ok) return false;
|
|
281
|
+
undoneActivityIds.value = new Set([...undoneActivityIds.value, entry.id]);
|
|
282
|
+
await requestBestEffort('undoAgentEditSteering', '/api/canvas/ax/steer', {
|
|
283
|
+
method: 'POST',
|
|
284
|
+
headers: { 'Content-Type': 'application/json' },
|
|
285
|
+
body: JSON.stringify({
|
|
286
|
+
message: `Undid your edit: ${entry.body} — it was reverted on the board.`,
|
|
287
|
+
source: 'browser',
|
|
288
|
+
}),
|
|
289
|
+
});
|
|
290
|
+
void refreshTimeline();
|
|
291
|
+
return true;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Ctrl+Z / Ctrl+Shift+Z: whichever op is top of the shared stack, agent or human. */
|
|
295
|
+
export async function undoFromKeyboard(redo = false): Promise<boolean> {
|
|
296
|
+
const result = await requestOk(redo ? 'redo' : 'undo', redo ? '/api/canvas/redo' : '/api/canvas/undo', {
|
|
297
|
+
method: 'POST',
|
|
298
|
+
});
|
|
299
|
+
if (result.ok) void refreshTimeline();
|
|
300
|
+
return result.ok;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Resolve a gate through the existing AX path. A rejection also posts steering
|
|
305
|
+
* feedback so the agent learns WHY its next turn — the same `vetoGhostSteering`
|
|
306
|
+
* contract ghost vetoes use.
|
|
307
|
+
*/
|
|
308
|
+
export async function resolveGate(gate: ApprovalGateView, decision: 'approved' | 'rejected'): Promise<boolean> {
|
|
309
|
+
const result = await requestOk('resolveGate', `/api/canvas/ax/approval/${encodeURIComponent(gate.id)}/resolve`, {
|
|
310
|
+
method: 'POST',
|
|
311
|
+
headers: { 'Content-Type': 'application/json' },
|
|
312
|
+
body: JSON.stringify({ decision, source: 'browser' }),
|
|
313
|
+
});
|
|
314
|
+
if (!result.ok) return false;
|
|
315
|
+
if (decision === 'rejected') {
|
|
316
|
+
await requestBestEffort('rejectGateSteering', '/api/canvas/ax/steer', {
|
|
317
|
+
method: 'POST',
|
|
318
|
+
headers: { 'Content-Type': 'application/json' },
|
|
319
|
+
body: JSON.stringify({ message: `Rejected gate "${gate.title}" — do not proceed with it.`, source: 'browser' }),
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
return true;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Reopen an auto-held gate so it can be answered (fresh TTL). */
|
|
326
|
+
export async function reopenGate(gate: ApprovalGateView): Promise<boolean> {
|
|
327
|
+
const result = await requestOk('reopenGate', `/api/canvas/ax/approval/${encodeURIComponent(gate.id)}/reopen`, {
|
|
328
|
+
method: 'POST',
|
|
329
|
+
headers: { 'Content-Type': 'application/json' },
|
|
330
|
+
body: JSON.stringify({ source: 'browser' }),
|
|
331
|
+
});
|
|
332
|
+
return result.ok;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Post a steering message the agent reads on its next turn. */
|
|
336
|
+
export async function sendSteering(message: string, target?: string | null): Promise<boolean> {
|
|
337
|
+
const result = await requestOk('sendSteering', '/api/canvas/ax/steer', {
|
|
338
|
+
method: 'POST',
|
|
339
|
+
headers: { 'Content-Type': 'application/json' },
|
|
340
|
+
// `target` addresses one connected agent (its consumer label); omitted = broadcast.
|
|
341
|
+
body: JSON.stringify({ message, source: 'browser', ...(target ? { target } : {}) }),
|
|
342
|
+
});
|
|
343
|
+
return result.ok;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// ── Session lifecycle (rail-chrome-v2 phase 5) ────────────────
|
|
347
|
+
|
|
348
|
+
/** Attach a human-started session; the agent's writes are attributed to it. */
|
|
349
|
+
export async function startSession(): Promise<boolean> {
|
|
350
|
+
const result = await requestOk('startSession', '/api/canvas/ax/presence', {
|
|
351
|
+
method: 'POST',
|
|
352
|
+
headers: { 'Content-Type': 'application/json' },
|
|
353
|
+
body: JSON.stringify({ source: 'browser', label: HUMAN_STARTED_SESSION_LABEL, attached: true, phase: 'idle' }),
|
|
354
|
+
});
|
|
355
|
+
return result.ok;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** End the attached session (whoever attached it) — the server answers with a receipt. */
|
|
359
|
+
export async function endSession(session: { source: string; agentId: string | null }): Promise<boolean> {
|
|
360
|
+
const result = await requestOk('endSession', '/api/canvas/ax/presence', {
|
|
361
|
+
method: 'POST',
|
|
362
|
+
headers: { 'Content-Type': 'application/json' },
|
|
363
|
+
body: JSON.stringify({ source: session.source, agentId: session.agentId, attached: false, endedBy: 'human' }),
|
|
364
|
+
});
|
|
365
|
+
return result.ok;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export interface SessionReceipt {
|
|
369
|
+
label: string;
|
|
370
|
+
endedAt: string;
|
|
371
|
+
/** Why it ended — the receipt should answer this, not leave the human asking. */
|
|
372
|
+
endedBy?: 'human' | 'agent' | 'idle-timeout';
|
|
373
|
+
/** The session changed nothing on the board (its pre-session snapshot was dropped). */
|
|
374
|
+
unchanged?: boolean;
|
|
375
|
+
/** Cancelled (withdrawn items), rejected (human's explicit no on a gate), and
|
|
376
|
+
* held (TTL expired unanswered) are three different outcomes — never one
|
|
377
|
+
* "vetoed" pile. */
|
|
378
|
+
counts: { items: number; done: number; cancelled: number; rejected: number; held: number };
|
|
379
|
+
snapshot: { id: string; name: string } | null;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** The last ended session's receipt (design item 2); client-side, cleared on dismiss. */
|
|
383
|
+
export const sessionReceipt = signal<SessionReceipt | null>(null);
|
|
384
|
+
|
|
385
|
+
export function applySessionReceipt(data: Record<string, unknown>): void {
|
|
386
|
+
const counts = data.counts as Partial<SessionReceipt['counts']> | undefined;
|
|
387
|
+
const snapshot = data.snapshot as SessionReceipt['snapshot'] | undefined;
|
|
388
|
+
if (typeof data.label !== 'string' || typeof data.endedAt !== 'string') return;
|
|
389
|
+
sessionReceipt.value = {
|
|
390
|
+
label: data.label,
|
|
391
|
+
endedAt: data.endedAt,
|
|
392
|
+
endedBy:
|
|
393
|
+
data.endedBy === 'human' || data.endedBy === 'agent' || data.endedBy === 'idle-timeout'
|
|
394
|
+
? data.endedBy
|
|
395
|
+
: undefined,
|
|
396
|
+
unchanged: data.unchanged === true,
|
|
397
|
+
counts: {
|
|
398
|
+
items: Number(counts?.items ?? 0) || 0,
|
|
399
|
+
done: Number(counts?.done ?? 0) || 0,
|
|
400
|
+
cancelled: Number(counts?.cancelled ?? 0) || 0,
|
|
401
|
+
rejected: Number(counts?.rejected ?? 0) || 0,
|
|
402
|
+
held: Number(counts?.held ?? 0) || 0,
|
|
403
|
+
},
|
|
404
|
+
snapshot:
|
|
405
|
+
snapshot && typeof snapshot.id === 'string' ? { id: snapshot.id, name: String(snapshot.name ?? '') } : null,
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
export function dismissSessionReceipt(): void {
|
|
410
|
+
sessionReceipt.value = null;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
export function resetSessionStore(): void {
|
|
414
|
+
historyTop.value = null;
|
|
415
|
+
undoneActivityIds.value = new Set();
|
|
416
|
+
sessionReceipt.value = null;
|
|
417
|
+
axTimeline.value = { events: [], evidence: [], steering: [] };
|
|
418
|
+
timelineFilter.value = 'all';
|
|
419
|
+
}
|
|
@@ -24,14 +24,27 @@ import {
|
|
|
24
24
|
traceEnabled,
|
|
25
25
|
updateNodeData,
|
|
26
26
|
workbenchConnectionEpoch,
|
|
27
|
+
reconnectAttempt,
|
|
28
|
+
reconnectDelay,
|
|
27
29
|
} from './canvas-store';
|
|
28
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
fetchAgentPresence,
|
|
32
|
+
fetchAxSurfaceState,
|
|
33
|
+
reportClientViewportSize,
|
|
34
|
+
requestJson,
|
|
35
|
+
tabAgentKey,
|
|
36
|
+
} from './intent-bridge';
|
|
37
|
+
import { agentActivity, applyPresenceSnapshot, sessionActive } from './presence-store';
|
|
38
|
+
import { applyHumanSnapshot, startHumanPresence } from './human-store';
|
|
39
|
+
import type { HumanPresenceSnapshot } from '../../shared/human-presence.js';
|
|
40
|
+
import { applySessionReceipt, refreshTimeline } from './session-store';
|
|
29
41
|
import { initSessionThemeOverride, themeOverrideActive } from './theme-override';
|
|
30
42
|
import { DEFAULT_POSITIONS, makeNodeState } from './node-factory';
|
|
31
43
|
import { invalidateTokenCache } from '../theme/tokens';
|
|
32
44
|
import { resetAttentionBridge, syncAttentionFromSse } from './attention-bridge';
|
|
33
45
|
import { dissolveIntent, resetIntents, settleIntent, upsertIntent } from './intent-store';
|
|
34
46
|
import type { PmxAxIntent } from '../../shared/ax-intent.js';
|
|
47
|
+
import type { AgentPresenceSnapshot } from '../../shared/agent-presence.js';
|
|
35
48
|
import { isCanvasTheme } from '../../shared/themes.js';
|
|
36
49
|
|
|
37
50
|
let eventSource: EventSource | null = null;
|
|
@@ -103,19 +116,13 @@ function applyLayoutOverrides(node: CanvasNodeState): CanvasNodeState {
|
|
|
103
116
|
size: overrides.size ?? node.size,
|
|
104
117
|
collapsed: overrides.collapsed ?? node.collapsed,
|
|
105
118
|
pinned: overrides.pinned ?? node.pinned,
|
|
106
|
-
dockPosition: overrides.dockPosition !== undefined ? overrides.dockPosition : node.dockPosition,
|
|
107
119
|
};
|
|
108
120
|
}
|
|
109
121
|
|
|
110
122
|
// Default geometry lives in the shared node factory (node-factory.ts); this
|
|
111
123
|
// wrapper just layers the connection-local saved-layout overrides on top.
|
|
112
|
-
function makeNode(
|
|
113
|
-
id
|
|
114
|
-
type: CanvasNodeState['type'],
|
|
115
|
-
data: Record<string, unknown>,
|
|
116
|
-
dockPosition: 'left' | 'right' | null = null,
|
|
117
|
-
): CanvasNodeState {
|
|
118
|
-
return applyLayoutOverrides(makeNodeState(id, type, data, { dockPosition }));
|
|
124
|
+
function makeNode(id: string, type: CanvasNodeState['type'], data: Record<string, unknown>): CanvasNodeState {
|
|
125
|
+
return applyLayoutOverrides(makeNodeState(id, type, data));
|
|
119
126
|
}
|
|
120
127
|
|
|
121
128
|
function getMarkdownPlacement(): { x: number; y: number } {
|
|
@@ -126,7 +133,7 @@ function getMarkdownPlacement(): { x: number; y: number } {
|
|
|
126
133
|
function ensureStatusNode(): void {
|
|
127
134
|
const id = 'status-main';
|
|
128
135
|
if (!nodes.value.has(id)) {
|
|
129
|
-
addNode(makeNode(id, 'status', { phase: 'idle', message: '', elapsed: 0 }
|
|
136
|
+
addNode(makeNode(id, 'status', { phase: 'idle', message: '', elapsed: 0 }));
|
|
130
137
|
}
|
|
131
138
|
}
|
|
132
139
|
|
|
@@ -141,9 +148,7 @@ function ensureMarkdownNode(path: string, title: string): void {
|
|
|
141
148
|
const node = makeNode(id, 'markdown', { path, title, content: '', rendered: '' });
|
|
142
149
|
node.position = placement;
|
|
143
150
|
addNode(node);
|
|
144
|
-
|
|
145
|
-
focusNode(id);
|
|
146
|
-
}
|
|
151
|
+
focusNode(id);
|
|
147
152
|
}
|
|
148
153
|
}
|
|
149
154
|
|
|
@@ -227,9 +232,7 @@ function ensureExtAppNode(data: Record<string, unknown>): void {
|
|
|
227
232
|
),
|
|
228
233
|
);
|
|
229
234
|
addNode(node);
|
|
230
|
-
|
|
231
|
-
focusNode(id, { recordHistory: false });
|
|
232
|
-
}
|
|
235
|
+
focusNode(id, { recordHistory: false });
|
|
233
236
|
}
|
|
234
237
|
|
|
235
238
|
function findExtAppNodeId(toolCallId: string): string | null {
|
|
@@ -277,7 +280,7 @@ function ensureLedgerNode(summary: Record<string, unknown>): void {
|
|
|
277
280
|
if (existing) {
|
|
278
281
|
updateNodeData(id, summary);
|
|
279
282
|
} else {
|
|
280
|
-
const node = makeNode(id, 'ledger', summary
|
|
283
|
+
const node = makeNode(id, 'ledger', summary);
|
|
281
284
|
node.collapsed = true;
|
|
282
285
|
addNode(node);
|
|
283
286
|
}
|
|
@@ -347,7 +350,6 @@ function parseCanvasNode(raw: Record<string, unknown>): CanvasNodeState | null {
|
|
|
347
350
|
const size = parseCanvasSize(raw.size);
|
|
348
351
|
if (!position || !size) return null;
|
|
349
352
|
|
|
350
|
-
const dockPosition = raw.dockPosition === 'left' || raw.dockPosition === 'right' ? raw.dockPosition : null;
|
|
351
353
|
const data = raw.data && typeof raw.data === 'object' ? Object.fromEntries(Object.entries(raw.data)) : {};
|
|
352
354
|
|
|
353
355
|
return {
|
|
@@ -358,7 +360,6 @@ function parseCanvasNode(raw: Record<string, unknown>): CanvasNodeState | null {
|
|
|
358
360
|
zIndex: typeof raw.zIndex === 'number' ? raw.zIndex : 1,
|
|
359
361
|
collapsed: raw.collapsed === true,
|
|
360
362
|
pinned: raw.pinned === true,
|
|
361
|
-
dockPosition,
|
|
362
363
|
data,
|
|
363
364
|
};
|
|
364
365
|
}
|
|
@@ -414,30 +415,80 @@ function parseCanvasAnnotation(raw: Record<string, unknown>): CanvasAnnotation |
|
|
|
414
415
|
* pick up the new bundle. Guarded per version so a broken stamp can't loop.
|
|
415
416
|
*/
|
|
416
417
|
const VERSION_RELOAD_KEY = 'pmx-canvas-version-reload';
|
|
417
|
-
function reloadIfServerUpgraded(serverVersion: unknown): void {
|
|
418
|
-
if (typeof serverVersion !== 'string' || !serverVersion || serverVersion === 'unknown') return;
|
|
418
|
+
function reloadIfServerUpgraded(serverVersion: unknown, bundleStamp: unknown): void {
|
|
419
419
|
if (typeof window === 'undefined') return;
|
|
420
|
-
const
|
|
421
|
-
|
|
422
|
-
|
|
420
|
+
const boot = window as Window & { __PMX_BOOT_SERVER_VERSION?: unknown; __PMX_BOOT_BUNDLE_STAMP?: unknown };
|
|
421
|
+
const changed = (live: unknown, booted: unknown): boolean =>
|
|
422
|
+
typeof live === 'string' &&
|
|
423
|
+
live !== '' &&
|
|
424
|
+
live !== 'unknown' &&
|
|
425
|
+
typeof booted === 'string' &&
|
|
426
|
+
booted !== '' &&
|
|
427
|
+
booted !== 'unknown' &&
|
|
428
|
+
live !== booted;
|
|
429
|
+
// Version catches a package upgrade; the bundle stamp catches a dev
|
|
430
|
+
// rebuild+restart, which never bumps the version — without it a long-lived
|
|
431
|
+
// tab keeps stale JS through every roll and "reproduces" fixed bugs.
|
|
432
|
+
if (!changed(serverVersion, boot.__PMX_BOOT_SERVER_VERSION) && !changed(bundleStamp, boot.__PMX_BOOT_BUNDLE_STAMP)) {
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
const marker = `${String(serverVersion)}::${String(bundleStamp)}`;
|
|
423
436
|
try {
|
|
424
|
-
if (window.sessionStorage.getItem(VERSION_RELOAD_KEY) ===
|
|
425
|
-
window.sessionStorage.setItem(VERSION_RELOAD_KEY,
|
|
437
|
+
if (window.sessionStorage.getItem(VERSION_RELOAD_KEY) === marker) return;
|
|
438
|
+
window.sessionStorage.setItem(VERSION_RELOAD_KEY, marker);
|
|
426
439
|
} catch {
|
|
427
440
|
return; // No sessionStorage loop guard available — never risk a reload loop.
|
|
428
441
|
}
|
|
429
442
|
window.location.reload();
|
|
430
443
|
}
|
|
431
444
|
|
|
445
|
+
function handleAgentPresence(data: Record<string, unknown>): void {
|
|
446
|
+
const previousTopWrite = agentActivity.value[0]?.id;
|
|
447
|
+
applyPresenceSnapshot(data as Partial<AgentPresenceSnapshot>);
|
|
448
|
+
// A new agent write moved the shared undo stack: refresh the panel's view
|
|
449
|
+
// of it (history top + timeline) — cursor-only frames cost nothing.
|
|
450
|
+
if (sessionActive.value && agentActivity.value[0]?.id !== previousTopWrite) void refreshTimeline();
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function handleAgentSessionEnded(data: Record<string, unknown>): void {
|
|
454
|
+
applySessionReceipt(data);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
let stopHumanPresence: (() => void) | null = null;
|
|
458
|
+
|
|
459
|
+
function handleHumanPresence(data: Record<string, unknown>): void {
|
|
460
|
+
applyHumanSnapshot(data as Partial<HumanPresenceSnapshot>);
|
|
461
|
+
}
|
|
462
|
+
|
|
432
463
|
function handleConnected(data: Record<string, unknown>): void {
|
|
433
464
|
sessionId.value = (data.sessionId as string) || '';
|
|
434
465
|
connectionStatus.value = 'connected';
|
|
435
466
|
// Reconnect marker for holders of server-minted URLs (Finding S).
|
|
436
467
|
workbenchConnectionEpoch.value += 1;
|
|
437
|
-
reloadIfServerUpgraded(data.version);
|
|
468
|
+
reloadIfServerUpgraded(data.version, data.bundleStamp);
|
|
438
469
|
// Tell the server how big this window actually is, so an agent `fit` sizes
|
|
439
470
|
// the board to the human's window (0.4.6 orb feedback #2).
|
|
440
471
|
void reportClientViewportSize();
|
|
472
|
+
// Agent presence: read the snapshot on (re)connect; `agent-presence` frames
|
|
473
|
+
// keep it live from here on.
|
|
474
|
+
void fetchAgentPresence().then(applyPresenceSnapshot);
|
|
475
|
+
// Human collaborators (phase 8): announce this tab and read who else is here.
|
|
476
|
+
// An `?agent=<key>` tab is an AGENT's view — it must not heartbeat a human
|
|
477
|
+
// guest cursor (its writes already book as the agent).
|
|
478
|
+
stopHumanPresence?.();
|
|
479
|
+
if (!tabAgentKey) stopHumanPresence = startHumanPresence();
|
|
480
|
+
void requestJson<Partial<HumanPresenceSnapshot> | null>(
|
|
481
|
+
'fetchHumanPresence',
|
|
482
|
+
'/api/canvas/human-presence',
|
|
483
|
+
null,
|
|
484
|
+
).then(applyHumanSnapshot);
|
|
485
|
+
// The AX surface snapshot (work items, gates) likewise — it used to arrive
|
|
486
|
+
// only on the first ax-state-changed, so a fresh load showed an empty
|
|
487
|
+
// session panel while persisted work items existed.
|
|
488
|
+
void fetchAxSurfaceState().then((state) => {
|
|
489
|
+
axSurfaceState.value = state;
|
|
490
|
+
if (sessionActive.value) void refreshTimeline();
|
|
491
|
+
});
|
|
441
492
|
// A ?theme= session override (host-default theming) wins over the
|
|
442
493
|
// server-global theme for THIS client only.
|
|
443
494
|
if (typeof data.theme === 'string' && !themeOverrideActive()) {
|
|
@@ -954,6 +1005,8 @@ function handleAxStateChanged(): void {
|
|
|
954
1005
|
void fetchAxSurfaceState().then((state) => {
|
|
955
1006
|
axSurfaceState.value = state;
|
|
956
1007
|
});
|
|
1008
|
+
// The session panel's timeline — only worth fetching while it is mounted.
|
|
1009
|
+
if (sessionActive.value) void refreshTimeline();
|
|
957
1010
|
}, 150);
|
|
958
1011
|
}
|
|
959
1012
|
|
|
@@ -1021,6 +1074,9 @@ export const EVENT_HANDLERS: Record<string, (data: Record<string, unknown>) => v
|
|
|
1021
1074
|
'ax-event-created': handleAxStateChanged,
|
|
1022
1075
|
'ax-intent': handleAxIntent,
|
|
1023
1076
|
'ax-intent-clear': handleAxIntentClear,
|
|
1077
|
+
'agent-presence': handleAgentPresence,
|
|
1078
|
+
'human-presence': handleHumanPresence,
|
|
1079
|
+
'agent-session-ended': handleAgentSessionEnded,
|
|
1024
1080
|
};
|
|
1025
1081
|
|
|
1026
1082
|
/** Dispatch one event (from either transport) through the shared handler map. */
|
|
@@ -1091,7 +1147,6 @@ function startPollingTransport(): () => void {
|
|
|
1091
1147
|
// reconnect performs so in-flight stream routes and transient state
|
|
1092
1148
|
// from the previous window can't leak (H5 orphan class).
|
|
1093
1149
|
savedLayout = restoreLayout();
|
|
1094
|
-
ensureStatusNode();
|
|
1095
1150
|
hasInitialServerLayout.value = false;
|
|
1096
1151
|
resetAttentionBridge();
|
|
1097
1152
|
resetIntents();
|
|
@@ -1100,12 +1155,16 @@ function startPollingTransport(): () => void {
|
|
|
1100
1155
|
for (const entry of body.events) dispatchWorkbenchEvent(entry.event, entry.payload);
|
|
1101
1156
|
dispatchedOnce = true;
|
|
1102
1157
|
pollSeq = body.seq;
|
|
1158
|
+
reconnectAttempt.value = 0;
|
|
1159
|
+
reconnectDelay.value = 0;
|
|
1103
1160
|
connectionStatus.value = 'connected';
|
|
1104
1161
|
pollTimer = setTimeout(() => void pollOnce(), POLL_INTERVAL_MS);
|
|
1105
1162
|
} catch (err) {
|
|
1106
1163
|
if (generation !== pollGeneration) return;
|
|
1107
1164
|
console.warn('[sse-bridge] poll transport error:', err);
|
|
1108
1165
|
connectionStatus.value = 'disconnected';
|
|
1166
|
+
reconnectAttempt.value += 1;
|
|
1167
|
+
reconnectDelay.value = POLL_ERROR_INTERVAL_MS;
|
|
1109
1168
|
pollTimer = setTimeout(() => void pollOnce(), POLL_ERROR_INTERVAL_MS);
|
|
1110
1169
|
}
|
|
1111
1170
|
};
|
|
@@ -1127,7 +1186,6 @@ export function connectSSE(): () => void {
|
|
|
1127
1186
|
// frame so an embedding host panel never flashes the server-global theme.
|
|
1128
1187
|
initSessionThemeOverride(applyCanvasTheme);
|
|
1129
1188
|
savedLayout = restoreLayout();
|
|
1130
|
-
ensureStatusNode();
|
|
1131
1189
|
hasInitialServerLayout.value = false;
|
|
1132
1190
|
resetAttentionBridge();
|
|
1133
1191
|
resetIntents();
|
|
@@ -1187,6 +1245,8 @@ export function connectSSE(): () => void {
|
|
|
1187
1245
|
source.onopen = () => {
|
|
1188
1246
|
if (eventSource !== source) return;
|
|
1189
1247
|
reconnectAttempts = 0;
|
|
1248
|
+
reconnectAttempt.value = 0;
|
|
1249
|
+
reconnectDelay.value = 0;
|
|
1190
1250
|
connectionStatus.value = 'connected';
|
|
1191
1251
|
};
|
|
1192
1252
|
|
|
@@ -1196,10 +1256,13 @@ export function connectSSE(): () => void {
|
|
|
1196
1256
|
source.close();
|
|
1197
1257
|
eventSource = null;
|
|
1198
1258
|
reconnectAttempts += 1;
|
|
1259
|
+
const delay = reconnectDelayMs(reconnectAttempts);
|
|
1260
|
+
reconnectAttempt.value = reconnectAttempts;
|
|
1261
|
+
reconnectDelay.value = delay;
|
|
1199
1262
|
reconnectTimer = setTimeout(() => {
|
|
1200
1263
|
reconnectTimer = null;
|
|
1201
1264
|
connectSSE();
|
|
1202
|
-
},
|
|
1265
|
+
}, delay);
|
|
1203
1266
|
};
|
|
1204
1267
|
|
|
1205
1268
|
return () => {
|