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.
Files changed (212) hide show
  1. package/.github/extensions/pmx-canvas/extension.mjs +154 -5
  2. package/.github/extensions/pmx-canvas/steering-delivery.mjs +79 -0
  3. package/CHANGELOG.md +156 -0
  4. package/Readme.md +72 -23
  5. package/dist/canvas/global.css +3071 -796
  6. package/dist/canvas/index.js +122 -104
  7. package/dist/json-render/index.css +1 -1
  8. package/dist/types/cli/agent.d.ts +1 -0
  9. package/dist/types/cli/commands/pump.d.ts +9 -0
  10. package/dist/types/client/App.d.ts +1 -1
  11. package/dist/types/client/canvas/AgentPresenceLayer.d.ts +11 -0
  12. package/dist/types/client/canvas/BarHint.d.ts +22 -0
  13. package/dist/types/client/canvas/CanvasNode.d.ts +5 -0
  14. package/dist/types/client/canvas/CanvasViewport.d.ts +4 -4
  15. package/dist/types/client/canvas/CommandBar.d.ts +10 -0
  16. package/dist/types/client/canvas/ConnectionBanner.d.ts +13 -0
  17. package/dist/types/client/canvas/ContextMenu.d.ts +6 -0
  18. package/dist/types/client/canvas/EdgeLayer.d.ts +2 -1
  19. package/dist/types/client/canvas/EmptyState.d.ts +11 -0
  20. package/dist/types/client/canvas/ExternalWriters.d.ts +5 -0
  21. package/dist/types/client/canvas/HumanPresenceLayer.d.ts +8 -0
  22. package/dist/types/client/canvas/Minimap.d.ts +12 -1
  23. package/dist/types/client/canvas/ScopeFenceLayer.d.ts +8 -0
  24. package/dist/types/client/canvas/SelectionBar.d.ts +6 -0
  25. package/dist/types/client/canvas/SessionPanel.d.ts +1 -0
  26. package/dist/types/client/canvas/SessionReceipt.d.ts +17 -0
  27. package/dist/types/client/canvas/TextPrompt.d.ts +25 -0
  28. package/dist/types/client/canvas/ToolRail.d.ts +21 -0
  29. package/dist/types/client/canvas/TopBar.d.ts +7 -0
  30. package/dist/types/client/canvas/canvas-area.d.ts +39 -0
  31. package/dist/types/client/canvas/create-in-view.d.ts +18 -0
  32. package/dist/types/client/canvas/import-files.d.ts +8 -0
  33. package/dist/types/client/canvas/kind-colors.d.ts +3 -0
  34. package/dist/types/client/canvas/use-focus-trap.d.ts +14 -0
  35. package/dist/types/client/canvas/use-now.d.ts +6 -0
  36. package/dist/types/client/icons.d.ts +11 -4
  37. package/dist/types/client/nodes/ExtAppFrame.d.ts +2 -0
  38. package/dist/types/client/nodes/GroupNode.d.ts +6 -1
  39. package/dist/types/client/nodes/MarkdownNode.d.ts +2 -0
  40. package/dist/types/client/nodes/inline-editor-commands.d.ts +3 -4
  41. package/dist/types/client/state/canvas-store.d.ts +49 -5
  42. package/dist/types/client/state/human-store.d.ts +26 -0
  43. package/dist/types/client/state/iframe-mode.d.ts +1 -0
  44. package/dist/types/client/state/intent-bridge.d.ts +37 -5
  45. package/dist/types/client/state/node-factory.d.ts +0 -1
  46. package/dist/types/client/state/presence-store.d.ts +78 -0
  47. package/dist/types/client/state/session-store.d.ts +176 -0
  48. package/dist/types/client/types.d.ts +2 -1
  49. package/dist/types/client/utils/platform.d.ts +2 -0
  50. package/dist/types/server/agent-presence.d.ts +192 -0
  51. package/dist/types/server/ax-gate-ttl.d.ts +6 -0
  52. package/dist/types/server/ax-interaction.d.ts +4 -10
  53. package/dist/types/server/ax-state-manager.d.ts +21 -2
  54. package/dist/types/server/ax-state.d.ts +49 -3
  55. package/dist/types/server/ax-wait.d.ts +13 -0
  56. package/dist/types/server/canvas-db.d.ts +9 -1
  57. package/dist/types/server/canvas-operations.d.ts +11 -0
  58. package/dist/types/server/canvas-state.d.ts +27 -13
  59. package/dist/types/server/canvas-validation.d.ts +0 -14
  60. package/dist/types/server/html-surface.d.ts +1 -1
  61. package/dist/types/server/human-presence.d.ts +40 -0
  62. package/dist/types/server/index.d.ts +29 -3
  63. package/dist/types/server/intent-registry.d.ts +7 -0
  64. package/dist/types/server/mutation-history.d.ts +15 -2
  65. package/dist/types/server/operations/invoker.d.ts +13 -2
  66. package/dist/types/server/operations/ops/ax-presence.d.ts +2 -0
  67. package/dist/types/server/operations/ops/human-presence.d.ts +3 -0
  68. package/dist/types/server/operations/registry.d.ts +11 -0
  69. package/dist/types/server/operations/types.d.ts +1 -1
  70. package/dist/types/server/scope-fence.d.ts +62 -0
  71. package/dist/types/server/server.d.ts +0 -12
  72. package/dist/types/shared/agent-presence.d.ts +130 -0
  73. package/dist/types/shared/approval-gates.d.ts +19 -0
  74. package/dist/types/shared/auto-arrange.d.ts +0 -1
  75. package/dist/types/shared/ax-kinds.d.ts +11 -0
  76. package/dist/types/shared/human-presence.d.ts +31 -0
  77. package/dist/types/shared/iframe-probe.d.ts +8 -0
  78. package/dist/types/shared/scope-fence.d.ts +21 -0
  79. package/docs/RELEASE.md +20 -0
  80. package/docs/ax-host-adapter-contract.md +30 -14
  81. package/docs/cli.md +37 -1
  82. package/docs/environment.md +3 -0
  83. package/docs/http-api.md +222 -5
  84. package/docs/mcp.md +6 -5
  85. package/docs/node-types.md +3 -1
  86. package/docs/plans/plan-009-tech-debt-backlog.md +13 -0
  87. package/docs/screenshot.png +0 -0
  88. package/docs/screenshots/multi-agent-live.png +0 -0
  89. package/docs/screenshots/workbench-live-dark.png +0 -0
  90. package/docs/screenshots/workbench-live-light.png +0 -0
  91. package/docs/sdk.md +1 -1
  92. package/package.json +4 -4
  93. package/skills/pmx-canvas/SKILL.md +112 -17
  94. package/skills/pmx-canvas/references/codex-app-adapter.md +28 -0
  95. package/skills/pmx-canvas/references/full-reference.md +55 -16
  96. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +41 -24
  97. package/skills/pmx-canvas-orchestration/SKILL.md +56 -2
  98. package/skills/pmx-canvas-testing/SKILL.md +17 -0
  99. package/src/cli/agent.ts +1 -0
  100. package/src/cli/commands/copilot.ts +15 -8
  101. package/src/cli/commands/nodes.ts +1 -19
  102. package/src/cli/commands/pump.ts +263 -0
  103. package/src/cli/index.ts +2 -5
  104. package/src/cli/shared.ts +1 -1
  105. package/src/client/App.tsx +209 -585
  106. package/src/client/canvas/AgentPresenceLayer.tsx +74 -0
  107. package/src/client/canvas/AttentionHistory.tsx +0 -10
  108. package/src/client/canvas/BarHint.tsx +49 -0
  109. package/src/client/canvas/CanvasNode.tsx +488 -136
  110. package/src/client/canvas/CanvasViewport.tsx +190 -94
  111. package/src/client/canvas/CommandBar.tsx +140 -0
  112. package/src/client/canvas/CommandPalette.tsx +133 -58
  113. package/src/client/canvas/ConnectionBanner.tsx +65 -0
  114. package/src/client/canvas/ContextMenu.tsx +88 -27
  115. package/src/client/canvas/ContextPinBar.tsx +2 -2
  116. package/src/client/canvas/EdgeLayer.tsx +121 -38
  117. package/src/client/canvas/EmptyState.tsx +93 -0
  118. package/src/client/canvas/ExpandedNodeOverlay.tsx +64 -118
  119. package/src/client/canvas/ExternalWriters.tsx +308 -0
  120. package/src/client/canvas/FocusFieldLayer.tsx +12 -1
  121. package/src/client/canvas/HumanPresenceLayer.tsx +45 -0
  122. package/src/client/canvas/IntentLayer.tsx +1 -1
  123. package/src/client/canvas/Minimap.tsx +117 -190
  124. package/src/client/canvas/ScopeFenceLayer.tsx +38 -0
  125. package/src/client/canvas/SelectionBar.tsx +175 -23
  126. package/src/client/canvas/SessionPanel.tsx +481 -0
  127. package/src/client/canvas/SessionReceipt.tsx +141 -0
  128. package/src/client/canvas/ShortcutOverlay.tsx +21 -6
  129. package/src/client/canvas/SnapshotPanel.tsx +166 -80
  130. package/src/client/canvas/TextPrompt.tsx +128 -0
  131. package/src/client/canvas/ToolRail.tsx +447 -0
  132. package/src/client/canvas/TopBar.tsx +308 -0
  133. package/src/client/canvas/auto-fit.ts +1 -5
  134. package/src/client/canvas/canvas-area.ts +74 -0
  135. package/src/client/canvas/create-in-view.ts +48 -0
  136. package/src/client/canvas/import-files.ts +64 -0
  137. package/src/client/canvas/kind-colors.ts +22 -0
  138. package/src/client/canvas/snap-guides.ts +1 -1
  139. package/src/client/canvas/use-focus-trap.ts +54 -0
  140. package/src/client/canvas/use-now.ts +17 -0
  141. package/src/client/canvas/use-pan-zoom.ts +13 -2
  142. package/src/client/icons.tsx +57 -18
  143. package/src/client/nodes/ExtAppFrame.tsx +86 -20
  144. package/src/client/nodes/GroupNode.tsx +9 -28
  145. package/src/client/nodes/InlineFormatBar.tsx +4 -3
  146. package/src/client/nodes/InlineMarkdownEditor.tsx +27 -0
  147. package/src/client/nodes/MarkdownNode.tsx +59 -24
  148. package/src/client/nodes/MdFormatBar.tsx +2 -4
  149. package/src/client/nodes/inline-editor-commands.ts +17 -6
  150. package/src/client/nodes/use-surface-frame.ts +3 -1
  151. package/src/client/state/attention-bridge.ts +3 -0
  152. package/src/client/state/canvas-store.ts +285 -90
  153. package/src/client/state/human-store.ts +175 -0
  154. package/src/client/state/iframe-mode.ts +33 -9
  155. package/src/client/state/intent-bridge.ts +75 -13
  156. package/src/client/state/node-factory.ts +2 -8
  157. package/src/client/state/presence-store.ts +168 -0
  158. package/src/client/state/session-store.ts +419 -0
  159. package/src/client/state/sse-bridge.ts +93 -30
  160. package/src/client/theme/global.css +3071 -796
  161. package/src/client/types.ts +3 -1
  162. package/src/client/utils/platform.ts +6 -1
  163. package/src/mcp/canvas-access.ts +2 -2
  164. package/src/server/agent-presence.ts +755 -0
  165. package/src/server/ax-context.ts +45 -0
  166. package/src/server/ax-gate-ttl.ts +61 -0
  167. package/src/server/ax-interaction.ts +29 -1
  168. package/src/server/ax-state-manager.ts +78 -6
  169. package/src/server/ax-state.ts +82 -15
  170. package/src/server/ax-wait.ts +33 -0
  171. package/src/server/canvas-db.ts +103 -33
  172. package/src/server/canvas-operations.ts +58 -14
  173. package/src/server/canvas-state.ts +131 -58
  174. package/src/server/canvas-validation.ts +3 -53
  175. package/src/server/demo-state.json +2 -70
  176. package/src/server/html-surface.ts +11 -2
  177. package/src/server/human-presence.ts +160 -0
  178. package/src/server/index.ts +111 -6
  179. package/src/server/intent-registry.ts +13 -0
  180. package/src/server/mutation-history.ts +47 -2
  181. package/src/server/operations/composites.ts +6 -3
  182. package/src/server/operations/http.ts +10 -1
  183. package/src/server/operations/index.ts +4 -0
  184. package/src/server/operations/invoker.ts +33 -6
  185. package/src/server/operations/ops/ax-presence.ts +62 -0
  186. package/src/server/operations/ops/ax-read.ts +8 -0
  187. package/src/server/operations/ops/ax-state.ts +106 -1
  188. package/src/server/operations/ops/ax-timeline.ts +54 -5
  189. package/src/server/operations/ops/ax-work.ts +46 -0
  190. package/src/server/operations/ops/batch.ts +3 -1
  191. package/src/server/operations/ops/edges.ts +88 -2
  192. package/src/server/operations/ops/groups.ts +4 -3
  193. package/src/server/operations/ops/human-presence.ts +35 -0
  194. package/src/server/operations/ops/json-render.ts +11 -1
  195. package/src/server/operations/ops/nodes.ts +77 -16
  196. package/src/server/operations/ops/query.ts +1 -0
  197. package/src/server/operations/ops/snapshots.ts +7 -4
  198. package/src/server/operations/registry.ts +196 -1
  199. package/src/server/operations/types.ts +1 -1
  200. package/src/server/scope-fence.ts +207 -0
  201. package/src/server/server.ts +177 -79
  202. package/src/server/trace-manager.ts +0 -2
  203. package/src/server/web-artifacts.ts +0 -1
  204. package/src/shared/agent-presence.ts +162 -0
  205. package/src/shared/approval-gates.ts +35 -0
  206. package/src/shared/auto-arrange.ts +121 -13
  207. package/src/shared/ax-kinds.ts +23 -0
  208. package/src/shared/human-presence.ts +30 -0
  209. package/src/shared/iframe-probe.ts +8 -0
  210. package/src/shared/scope-fence.ts +31 -0
  211. package/dist/types/client/canvas/DockedNode.d.ts +0 -4
  212. 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 { fetchAxSurfaceState, reportClientViewportSize } from './intent-bridge';
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: string,
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 }, 'left'));
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
- if (!node.dockPosition) {
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
- if (!node.dockPosition) {
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, 'right');
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 bootVersion = (window as Window & { __PMX_BOOT_SERVER_VERSION?: unknown }).__PMX_BOOT_SERVER_VERSION;
421
- if (typeof bootVersion !== 'string' || !bootVersion || bootVersion === 'unknown') return;
422
- if (serverVersion === bootVersion) return;
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) === serverVersion) return;
425
- window.sessionStorage.setItem(VERSION_RELOAD_KEY, serverVersion);
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
- }, reconnectDelayMs(reconnectAttempts));
1265
+ }, delay);
1203
1266
  };
1204
1267
 
1205
1268
  return () => {