@ably/ai-transport 0.0.1 → 0.1.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.
Files changed (110) hide show
  1. package/README.md +54 -47
  2. package/dist/ably-ai-transport.js +1006 -539
  3. package/dist/ably-ai-transport.js.map +1 -1
  4. package/dist/ably-ai-transport.umd.cjs +1 -1
  5. package/dist/ably-ai-transport.umd.cjs.map +1 -1
  6. package/dist/constants.d.ts +4 -0
  7. package/dist/core/codec/types.d.ts +19 -2
  8. package/dist/core/transport/decode-history.d.ts +8 -6
  9. package/dist/core/transport/headers.d.ts +4 -2
  10. package/dist/core/transport/index.d.ts +4 -1
  11. package/dist/core/transport/pipe-stream.d.ts +3 -2
  12. package/dist/core/transport/stream-router.d.ts +11 -1
  13. package/dist/core/transport/tree.d.ts +171 -0
  14. package/dist/core/transport/turn-manager.d.ts +4 -1
  15. package/dist/core/transport/types.d.ts +270 -119
  16. package/dist/core/transport/view.d.ts +166 -0
  17. package/dist/errors.d.ts +19 -2
  18. package/dist/index.d.ts +3 -1
  19. package/dist/react/ably-ai-transport-react.js +1019 -486
  20. package/dist/react/ably-ai-transport-react.js.map +1 -1
  21. package/dist/react/ably-ai-transport-react.umd.cjs +1 -1
  22. package/dist/react/ably-ai-transport-react.umd.cjs.map +1 -1
  23. package/dist/react/contexts/transport-context.d.ts +31 -0
  24. package/dist/react/contexts/transport-provider.d.ts +49 -0
  25. package/dist/react/create-transport-hooks.d.ts +124 -0
  26. package/dist/react/index.d.ts +14 -8
  27. package/dist/react/use-ably-messages.d.ts +14 -8
  28. package/dist/react/use-active-turns.d.ts +7 -3
  29. package/dist/react/use-client-transport.d.ts +78 -5
  30. package/dist/react/use-create-view.d.ts +22 -0
  31. package/dist/react/use-tree.d.ts +20 -0
  32. package/dist/react/use-view.d.ts +79 -0
  33. package/dist/vercel/ably-ai-transport-vercel.js +1478 -842
  34. package/dist/vercel/ably-ai-transport-vercel.js.map +1 -1
  35. package/dist/vercel/ably-ai-transport-vercel.umd.cjs +1 -1
  36. package/dist/vercel/ably-ai-transport-vercel.umd.cjs.map +1 -1
  37. package/dist/vercel/codec/tool-transitions.d.ts +50 -0
  38. package/dist/vercel/index.d.ts +3 -0
  39. package/dist/vercel/react/ably-ai-transport-vercel-react.js +9099 -852
  40. package/dist/vercel/react/ably-ai-transport-vercel-react.js.map +1 -1
  41. package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs +45 -1
  42. package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs.map +1 -1
  43. package/dist/vercel/react/contexts/chat-transport-context.d.ts +32 -0
  44. package/dist/vercel/react/contexts/chat-transport-provider.d.ts +84 -0
  45. package/dist/vercel/react/index.d.ts +5 -0
  46. package/dist/vercel/react/use-chat-transport.d.ts +61 -20
  47. package/dist/vercel/react/use-message-sync.d.ts +41 -9
  48. package/dist/vercel/react/use-staged-add-tool-approval-response.d.ts +30 -0
  49. package/dist/vercel/tool-approvals.d.ts +124 -0
  50. package/dist/vercel/tool-events.d.ts +26 -0
  51. package/dist/vercel/transport/chat-transport.d.ts +33 -11
  52. package/dist/vercel/transport/index.d.ts +5 -2
  53. package/package.json +23 -17
  54. package/src/constants.ts +6 -0
  55. package/src/core/codec/encoder.ts +10 -1
  56. package/src/core/codec/types.ts +19 -3
  57. package/src/core/transport/client-transport.ts +382 -364
  58. package/src/core/transport/decode-history.ts +229 -81
  59. package/src/core/transport/headers.ts +6 -2
  60. package/src/core/transport/index.ts +13 -5
  61. package/src/core/transport/pipe-stream.ts +8 -5
  62. package/src/core/transport/server-transport.ts +212 -58
  63. package/src/core/transport/stream-router.ts +21 -3
  64. package/src/core/transport/{conversation-tree.ts → tree.ts} +192 -77
  65. package/src/core/transport/turn-manager.ts +28 -10
  66. package/src/core/transport/types.ts +318 -139
  67. package/src/core/transport/view.ts +840 -0
  68. package/src/errors.ts +21 -1
  69. package/src/index.ts +10 -5
  70. package/src/react/contexts/transport-context.ts +37 -0
  71. package/src/react/contexts/transport-provider.tsx +164 -0
  72. package/src/react/create-transport-hooks.ts +144 -0
  73. package/src/react/index.ts +15 -8
  74. package/src/react/use-ably-messages.ts +34 -16
  75. package/src/react/use-active-turns.ts +28 -17
  76. package/src/react/use-client-transport.ts +184 -24
  77. package/src/react/use-create-view.ts +68 -0
  78. package/src/react/use-tree.ts +53 -0
  79. package/src/react/use-view.ts +233 -0
  80. package/src/react/vite.config.ts +4 -1
  81. package/src/vercel/codec/accumulator.ts +64 -79
  82. package/src/vercel/codec/decoder.ts +11 -8
  83. package/src/vercel/codec/encoder.ts +68 -54
  84. package/src/vercel/codec/index.ts +0 -2
  85. package/src/vercel/codec/tool-transitions.ts +122 -0
  86. package/src/vercel/index.ts +17 -0
  87. package/src/vercel/react/contexts/chat-transport-context.ts +40 -0
  88. package/src/vercel/react/contexts/chat-transport-provider.tsx +122 -0
  89. package/src/vercel/react/index.ts +14 -0
  90. package/src/vercel/react/use-chat-transport.ts +164 -42
  91. package/src/vercel/react/use-message-sync.ts +77 -19
  92. package/src/vercel/react/use-staged-add-tool-approval-response.ts +87 -0
  93. package/src/vercel/react/vite.config.ts +4 -2
  94. package/src/vercel/tool-approvals.ts +380 -0
  95. package/src/vercel/tool-events.ts +53 -0
  96. package/src/vercel/transport/chat-transport.ts +225 -79
  97. package/src/vercel/transport/index.ts +14 -3
  98. package/dist/core/transport/conversation-tree.d.ts +0 -9
  99. package/dist/react/use-conversation-tree.d.ts +0 -20
  100. package/dist/react/use-edit.d.ts +0 -7
  101. package/dist/react/use-history.d.ts +0 -19
  102. package/dist/react/use-messages.d.ts +0 -7
  103. package/dist/react/use-regenerate.d.ts +0 -7
  104. package/dist/react/use-send.d.ts +0 -7
  105. package/src/react/use-conversation-tree.ts +0 -71
  106. package/src/react/use-edit.ts +0 -24
  107. package/src/react/use-history.ts +0 -111
  108. package/src/react/use-messages.ts +0 -32
  109. package/src/react/use-regenerate.ts +0 -24
  110. package/src/react/use-send.ts +0 -25
@@ -0,0 +1,840 @@
1
+ /**
2
+ * DefaultView — a paginated, branch-aware projection over the Tree.
3
+ *
4
+ * Wraps a Tree and manages a pagination window that controls which nodes
5
+ * are visible to the UI. New live messages appear immediately; older messages
6
+ * are revealed progressively via `loadOlder()`.
7
+ *
8
+ * Each View owns its own branch selection state and pagination window,
9
+ * allowing multiple independent Views over the same Tree.
10
+ *
11
+ * Events are scoped to the visible window — 'update' only fires when the
12
+ * visible output changes, 'ably-message' only for messages corresponding to
13
+ * visible nodes, and 'turn' only for turns with visible messages.
14
+ */
15
+
16
+ import * as Ably from 'ably';
17
+
18
+ import { EVENT_TURN_END, EVENT_TURN_START, HEADER_MSG_ID, HEADER_TURN_ID } from '../../constants.js';
19
+ import { ErrorCode } from '../../errors.js';
20
+ import { EventEmitter } from '../../event-emitter.js';
21
+ import type { Logger } from '../../logger.js';
22
+ import { getHeaders } from '../../utils.js';
23
+ import type { Codec } from '../codec/types.js';
24
+ import { decodeHistory } from './decode-history.js';
25
+ import type { TreeInternal } from './tree.js';
26
+ import type {
27
+ ActiveTurn,
28
+ EventsNode,
29
+ HistoryPage,
30
+ MessageNode,
31
+ SendOptions,
32
+ TurnLifecycleEvent,
33
+ View,
34
+ } from './types.js';
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Events map
38
+ // ---------------------------------------------------------------------------
39
+
40
+ interface ViewEventsMap {
41
+ update: undefined;
42
+ 'ably-message': Ably.InboundMessage;
43
+ turn: TurnLifecycleEvent;
44
+ }
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Send delegate
48
+ // ---------------------------------------------------------------------------
49
+
50
+ /**
51
+ * Internal delegate function provided by the transport for executing sends.
52
+ * The View pre-computes the visible branch history and passes it directly,
53
+ * so the delegate has no back-reference to the View.
54
+ * When `eventNodes` is provided, the transport includes them in the POST body
55
+ * for the server to publish as cross-turn events.
56
+ */
57
+ export type SendDelegate<TEvent, TMessage> = (
58
+ input: TMessage | TMessage[],
59
+ options: SendOptions | undefined,
60
+ history: MessageNode<TMessage>[],
61
+ eventNodes?: EventsNode<TEvent>[],
62
+ ) => Promise<ActiveTurn<TEvent>>;
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // Options
66
+ // ---------------------------------------------------------------------------
67
+
68
+ /** Options for creating a View. */
69
+ export interface ViewOptions<TEvent, TMessage> {
70
+ /** The tree to project. */
71
+ tree: TreeInternal<TMessage>;
72
+ /** The Ably channel to load history from. */
73
+ channel: Ably.RealtimeChannel;
74
+ /** The codec for decoding history messages. */
75
+ codec: Codec<TEvent, TMessage>;
76
+ /** Delegate for executing sends through the transport. */
77
+ sendDelegate: SendDelegate<TEvent, TMessage>;
78
+ /** Logger for diagnostic output. */
79
+ logger: Logger;
80
+ /** Called when the view is closed, allowing the owner to clean up references. */
81
+ onClose?: () => void;
82
+ }
83
+
84
+ // ---------------------------------------------------------------------------
85
+ // Branch selection
86
+ // ---------------------------------------------------------------------------
87
+
88
+ /**
89
+ * Tagged union representing why a branch was selected.
90
+ * Stored per group root in the View's `_branchSelections` map.
91
+ */
92
+ type BranchSelection =
93
+ /** Explicit navigation via `select()`. */
94
+ | { kind: 'user'; selectedId: string }
95
+ /** This view initiated a fork (edit or regenerate) — auto-selected the result. */
96
+ | { kind: 'auto'; selectedId: string }
97
+ /** An external fork appeared — pinned to the currently-visible sibling to prevent drift. */
98
+ | { kind: 'pinned'; selectedId: string }
99
+ /** This view's `regenerate()` is in flight — select newest when turn's response arrives. */
100
+ | { kind: 'pending'; turnId: string };
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // Implementation
104
+ // ---------------------------------------------------------------------------
105
+
106
+ export class DefaultView<TEvent, TMessage> implements View<TEvent, TMessage> {
107
+ private readonly _tree: TreeInternal<TMessage>;
108
+ private readonly _channel: Ably.RealtimeChannel;
109
+ private readonly _codec: Codec<TEvent, TMessage>;
110
+ private readonly _sendDelegate: SendDelegate<TEvent, TMessage>;
111
+ private readonly _logger: Logger;
112
+ private readonly _emitter: EventEmitter<ViewEventsMap>;
113
+ private readonly _onClose?: () => void;
114
+
115
+ /**
116
+ * View-local branch selections: group root msgId → selection intent.
117
+ * Fork points not present here default to the latest sibling.
118
+ * Replaces the previous numeric-index _selections and _pendingForkSelections
119
+ * with a single tagged-union map that carries the selected msgId (not index)
120
+ * and the reason for the selection.
121
+ */
122
+ private readonly _branchSelections = new Map<string, BranchSelection>();
123
+
124
+ /** Spec: AIT-CT11c — msg-ids loaded from history but not yet revealed to the UI. */
125
+ private readonly _withheldMsgIds = new Set<string>();
126
+
127
+ /** Snapshot of visible msgIds — used to detect structural changes and for selection pinning. */
128
+ private _lastVisibleIds: string[] = [];
129
+
130
+ /** Snapshot of visible message references — used to detect in-place content updates (streaming). */
131
+ private _lastVisibleMessages: TMessage[] = [];
132
+
133
+ /** Cached set of turn IDs present on the visible branch — avoids recomputing flattenNodes() on turn events. */
134
+ private _lastVisibleTurnIds = new Set<string>();
135
+
136
+ /** Whether there are more history pages to fetch from the channel. */
137
+ private _hasMoreHistory = false;
138
+
139
+ /** Internal state for continuing history pagination. */
140
+ private _lastHistoryPage: HistoryPage<TMessage> | undefined;
141
+
142
+ /** Buffer of withheld nodes, drained newest-first by successive loadOlder() calls. */
143
+ private readonly _withheldBuffer: MessageNode<TMessage>[] = [];
144
+
145
+ /** Unsubscribe functions for tree event subscriptions. */
146
+ private readonly _unsubs: (() => void)[] = [];
147
+
148
+ /**
149
+ * Cached result of the last flattenNodes computation. Public `flattenNodes()`
150
+ * returns this in O(1); internal callers use `_computeFlatNodes()` when a
151
+ * fresh tree walk is needed (structural changes, selection changes, history reveal).
152
+ */
153
+ private _cachedNodes: MessageNode<TMessage>[] = [];
154
+
155
+ /** Last seen tree structural version - used to distinguish content-only from structural updates. */
156
+ private _lastStructuralVersion = -1;
157
+
158
+ private _loadingOlder = false;
159
+ private _processingHistory = false;
160
+ private _closed = false;
161
+
162
+ constructor(options: ViewOptions<TEvent, TMessage>) {
163
+ this._tree = options.tree;
164
+ this._channel = options.channel;
165
+ this._codec = options.codec;
166
+ this._sendDelegate = options.sendDelegate;
167
+ this._onClose = options.onClose;
168
+ this._logger = options.logger.withContext({ component: 'View' });
169
+ this._logger.trace('DefaultView();');
170
+ this._emitter = new EventEmitter<ViewEventsMap>(this._logger);
171
+
172
+ // Compute initial cache and snapshot visible state
173
+ this._cachedNodes = this._computeFlatNodes();
174
+ this._lastStructuralVersion = this._tree.structuralVersion;
175
+ this._updateVisibleSnapshot(this._cachedNodes);
176
+
177
+ // Subscribe to tree events and re-emit scoped versions
178
+ this._unsubs.push(
179
+ this._tree.on('update', () => {
180
+ this._onTreeUpdate();
181
+ }),
182
+ this._tree.on('ably-message', (msg) => {
183
+ this._onTreeAblyMessage(msg);
184
+ }),
185
+ this._tree.on('turn', (event) => {
186
+ this._onTreeTurn(event);
187
+ }),
188
+ );
189
+ }
190
+
191
+ // -------------------------------------------------------------------------
192
+ // Public query methods
193
+ // -------------------------------------------------------------------------
194
+
195
+ getMessages(): TMessage[] {
196
+ return this.flattenNodes().map((n) => n.message);
197
+ }
198
+
199
+ // Spec: AIT-CT9, AIT-CT11c
200
+ flattenNodes(): MessageNode<TMessage>[] {
201
+ return this._cachedNodes;
202
+ }
203
+
204
+ /**
205
+ * Walk the tree and compute a fresh visible node list, applying branch
206
+ * selections and withheld-message filtering. Use this instead of the
207
+ * public `flattenNodes()` when the cache may be stale (structural
208
+ * changes, selection changes, history reveal).
209
+ * @returns A fresh array of visible nodes.
210
+ */
211
+ private _computeFlatNodes(): MessageNode<TMessage>[] {
212
+ const nodes = this._tree.flattenNodes(this._resolveSelections());
213
+ if (this._withheldMsgIds.size === 0) return nodes;
214
+ return nodes.filter((n) => !this._withheldMsgIds.has(n.msgId));
215
+ }
216
+
217
+ hasOlder(): boolean {
218
+ return this._withheldBuffer.length > 0 || this._hasMoreHistory;
219
+ }
220
+
221
+ async loadOlder(limit = 100): Promise<void> {
222
+ if (this._closed || this._loadingOlder) return;
223
+ this._loadingOlder = true;
224
+ this._logger.trace('DefaultView.loadOlder();', { limit });
225
+
226
+ try {
227
+ // Drain withheld buffer first (older messages, released newest-first)
228
+ if (this._withheldBuffer.length > 0) {
229
+ const batch = this._withheldBuffer.splice(-limit, limit);
230
+ this._releaseWithheld(batch);
231
+ return;
232
+ }
233
+
234
+ // Buffer exhausted — load from channel history
235
+ if (!this._hasMoreHistory && !this._lastHistoryPage) {
236
+ // First load
237
+ await this._loadFirstPage(limit);
238
+ return;
239
+ }
240
+
241
+ if (!this._hasMoreHistory) return;
242
+
243
+ // Continue from last page
244
+ if (!this._lastHistoryPage?.hasNext()) {
245
+ this._hasMoreHistory = false;
246
+ return;
247
+ }
248
+
249
+ const nextPage = await this._lastHistoryPage.next();
250
+ // Re-check: close() may be called during the await from another call stack
251
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- close() may be called during await
252
+ if (this._closed || !nextPage) {
253
+ if (!nextPage) this._hasMoreHistory = false;
254
+ return;
255
+ }
256
+
257
+ await this._loadAndReveal(nextPage, limit);
258
+ } catch (error) {
259
+ this._logger.error('DefaultView.loadOlder(); failed', { error });
260
+ throw error;
261
+ } finally {
262
+ this._loadingOlder = false;
263
+ }
264
+ }
265
+
266
+ // -------------------------------------------------------------------------
267
+ // Branch navigation
268
+ // -------------------------------------------------------------------------
269
+
270
+ // Spec: AIT-CT13c
271
+ select(msgId: string, index: number): void {
272
+ this._logger.trace('DefaultView.select();', { msgId, index });
273
+ const nodes = this._tree.getSiblingNodes(msgId);
274
+ if (nodes.length <= 1) return;
275
+ const groupRootId = this._tree.getGroupRoot(msgId);
276
+ const clamped = Math.max(0, Math.min(index, nodes.length - 1));
277
+ const selected = nodes[clamped];
278
+ if (!selected) return; // unreachable: clamped is always in bounds
279
+ this._branchSelections.set(groupRootId, { kind: 'user', selectedId: selected.msgId });
280
+ this._logger.debug('DefaultView.select();', { msgId, index: clamped, selectedId: selected.msgId });
281
+ this._cachedNodes = this._computeFlatNodes();
282
+ this._updateVisibleSnapshot(this._cachedNodes);
283
+ this._emitter.emit('update');
284
+ }
285
+
286
+ getSelectedIndex(msgId: string): number {
287
+ this._logger.trace('DefaultView.getSelectedIndex();', { msgId });
288
+ const nodes = this._tree.getSiblingNodes(msgId);
289
+ if (nodes.length <= 1) return 0;
290
+ const groupRootId = this._tree.getGroupRoot(msgId);
291
+ const sel = this._branchSelections.get(groupRootId);
292
+ if (!sel || sel.kind === 'pending') return nodes.length - 1; // default: latest
293
+ const idx = nodes.findIndex((n) => n.msgId === sel.selectedId);
294
+ if (idx === -1) return nodes.length - 1; // fallback if stale
295
+ return idx;
296
+ }
297
+
298
+ getSiblings(msgId: string): TMessage[] {
299
+ return this._tree.getSiblings(msgId);
300
+ }
301
+
302
+ hasSiblings(msgId: string): boolean {
303
+ return this._tree.hasSiblings(msgId);
304
+ }
305
+
306
+ getNode(msgId: string): MessageNode<TMessage> | undefined {
307
+ return this._tree.getNode(msgId);
308
+ }
309
+
310
+ // -------------------------------------------------------------------------
311
+ // Write operations
312
+ // -------------------------------------------------------------------------
313
+
314
+ // Spec: AIT-CT3, AIT-CT4
315
+ async send(input: TMessage | TMessage[], options?: SendOptions): Promise<ActiveTurn<TEvent>> {
316
+ this._logger.trace('DefaultView.send();');
317
+ if (this._closed) {
318
+ throw new Ably.ErrorInfo('unable to send; view is closed', ErrorCode.InvalidArgument, 400);
319
+ }
320
+
321
+ // Pre-compute visible branch history before the delegate call so the
322
+ // transport has no back-reference to the View (one-way dependency).
323
+ const history = this.flattenNodes();
324
+ const result = await this._sendDelegate(input, options, history);
325
+
326
+ // Spec: AIT-CT13e
327
+ // Auto-select the new fork in this view when creating a fork.
328
+ if (options?.forkOf) {
329
+ const groupRoot = this._tree.getGroupRoot(options.forkOf);
330
+
331
+ if (result.optimisticMsgIds.length > 0) {
332
+ // The delegate optimistically inserted user messages (edit path).
333
+ // Auto-select the last optimistic msgId — this is deterministic and
334
+ // avoids the sibling-count race that exists when inferring from tree state.
335
+ const lastMsgId = result.optimisticMsgIds.at(-1);
336
+ if (lastMsgId) {
337
+ this._branchSelections.set(groupRoot, { kind: 'auto', selectedId: lastMsgId });
338
+ this._cachedNodes = this._computeFlatNodes();
339
+ this._updateVisibleSnapshot(this._cachedNodes);
340
+ this._emitter.emit('update');
341
+ }
342
+ } else {
343
+ // No optimistic insert (e.g. regenerate sends no user messages). Defer
344
+ // auto-selection until the server response creates the new sibling.
345
+ // Store the group root (not the raw forkOf) so _pinBranchSelections
346
+ // can match it regardless of which sibling is currently visible.
347
+ this._branchSelections.set(groupRoot, { kind: 'pending', turnId: result.turnId });
348
+ this._logger.debug('DefaultView.send(); deferring fork auto-selection', {
349
+ forkOf: options.forkOf,
350
+ groupRoot,
351
+ turnId: result.turnId,
352
+ });
353
+
354
+ // Bound pending entry lifetime to the turn — clean up on turn-end.
355
+ const turnUnsub = this._tree.on('turn', (evt) => {
356
+ if (evt.type !== EVENT_TURN_END || evt.turnId !== result.turnId) return;
357
+ const sel = this._branchSelections.get(groupRoot);
358
+ if (sel?.kind === 'pending' && sel.turnId === result.turnId) {
359
+ this._branchSelections.delete(groupRoot);
360
+ }
361
+ turnUnsub();
362
+ const idx = this._unsubs.indexOf(turnUnsub);
363
+ if (idx !== -1) this._unsubs.splice(idx, 1);
364
+ });
365
+ this._unsubs.push(turnUnsub);
366
+ }
367
+ }
368
+
369
+ return result;
370
+ }
371
+
372
+ // Spec: AIT-CT5
373
+ async regenerate(messageId: string, options?: SendOptions): Promise<ActiveTurn<TEvent>> {
374
+ this._logger.trace('DefaultView.regenerate();', { messageId });
375
+
376
+ const node = this._tree.getNode(messageId);
377
+ if (!node) {
378
+ throw new Ably.ErrorInfo(
379
+ `unable to regenerate; message not found in tree: ${messageId}`,
380
+ ErrorCode.InvalidArgument,
381
+ 400,
382
+ );
383
+ }
384
+ const parentId = node.parentId;
385
+
386
+ return this.send([], {
387
+ ...options,
388
+ body: {
389
+ history: this._getHistoryBefore(messageId),
390
+ ...options?.body,
391
+ },
392
+ forkOf: messageId,
393
+ parent: parentId,
394
+ });
395
+ }
396
+
397
+ // Spec: AIT-CT6
398
+ async edit(
399
+ messageId: string,
400
+ newMessages: TMessage | TMessage[],
401
+ options?: SendOptions,
402
+ ): Promise<ActiveTurn<TEvent>> {
403
+ this._logger.trace('DefaultView.edit();', { messageId });
404
+
405
+ const node = this._tree.getNode(messageId);
406
+ if (!node) {
407
+ throw new Ably.ErrorInfo(
408
+ `unable to edit; message not found in tree: ${messageId}`,
409
+ ErrorCode.InvalidArgument,
410
+ 400,
411
+ );
412
+ }
413
+ const parentId = node.parentId;
414
+
415
+ return this.send(newMessages, {
416
+ ...options,
417
+ body: {
418
+ history: this._getHistoryBefore(messageId),
419
+ ...options?.body,
420
+ },
421
+ forkOf: messageId,
422
+ parent: parentId,
423
+ });
424
+ }
425
+
426
+ async update(msgId: string, events: TEvent[], options?: SendOptions): Promise<ActiveTurn<TEvent>> {
427
+ if (this._closed) {
428
+ throw new Ably.ErrorInfo('unable to update; view is closed', ErrorCode.InvalidArgument, 400);
429
+ }
430
+ this._logger.trace('DefaultView.update();', { msgId, eventCount: events.length });
431
+ const eventNodes: EventsNode<TEvent>[] = [{ kind: 'event', msgId, events }];
432
+ return this._sendDelegate([], options, this.flattenNodes(), eventNodes);
433
+ }
434
+
435
+ private _getHistoryBefore(messageId: string): MessageNode<TMessage>[] {
436
+ this._logger.trace('DefaultView._getHistoryBefore();', { messageId });
437
+ const all = this.flattenNodes();
438
+ const idx = all.findIndex((n) => n.msgId === messageId);
439
+ if (idx === -1) {
440
+ this._logger.warn('DefaultView._getHistoryBefore(); target not in visible nodes, returning full list', {
441
+ messageId,
442
+ });
443
+ return all;
444
+ }
445
+ return all.slice(0, idx);
446
+ }
447
+
448
+ // -------------------------------------------------------------------------
449
+ // Observation
450
+ // -------------------------------------------------------------------------
451
+
452
+ // Spec: AIT-CT17
453
+ getActiveTurnIds(): Map<string, Set<string>> {
454
+ this._logger.trace('DefaultView.getActiveTurnIds();');
455
+ const allTurns = this._tree.getActiveTurnIds();
456
+ if (this._withheldMsgIds.size === 0) return allTurns;
457
+
458
+ // Filter to turns that have at least one visible message
459
+ const result = new Map<string, Set<string>>();
460
+ for (const [clientId, turnIds] of allTurns) {
461
+ const filtered = new Set<string>();
462
+ for (const turnId of turnIds) {
463
+ if (this._lastVisibleTurnIds.has(turnId)) filtered.add(turnId);
464
+ }
465
+ if (filtered.size > 0) result.set(clientId, filtered);
466
+ }
467
+ return result;
468
+ }
469
+
470
+ // -------------------------------------------------------------------------
471
+ // Event subscription
472
+ // -------------------------------------------------------------------------
473
+
474
+ // Spec: AIT-CT8a, AIT-CT8b, AIT-CT8e
475
+ on(event: 'update', handler: () => void): () => void;
476
+ on(event: 'ably-message', handler: (msg: Ably.InboundMessage) => void): () => void;
477
+ on(event: 'turn', handler: (event: TurnLifecycleEvent) => void): () => void;
478
+ on(
479
+ event: 'update' | 'ably-message' | 'turn',
480
+ handler: (() => void) | ((msg: Ably.InboundMessage) => void) | ((event: TurnLifecycleEvent) => void),
481
+ ): () => void {
482
+ // CAST: overload signatures enforce correct handler types per event name.
483
+ const cb = handler as (arg: ViewEventsMap[keyof ViewEventsMap]) => void;
484
+ this._emitter.on(event, cb);
485
+ return () => {
486
+ this._emitter.off(event, cb);
487
+ };
488
+ }
489
+
490
+ // -------------------------------------------------------------------------
491
+ // Lifecycle
492
+ // -------------------------------------------------------------------------
493
+
494
+ /**
495
+ * Tear down the view — unsubscribe from tree events.
496
+ */
497
+ close(): void {
498
+ this._logger.info('DefaultView.close();');
499
+ this._closed = true;
500
+ this._loadingOlder = false;
501
+ for (const unsub of this._unsubs) unsub();
502
+ this._unsubs.length = 0;
503
+ this._emitter.off();
504
+ this._branchSelections.clear();
505
+ this._withheldMsgIds.clear();
506
+ this._withheldBuffer.length = 0;
507
+ this._onClose?.();
508
+ }
509
+
510
+ // -------------------------------------------------------------------------
511
+ // Private: history loading
512
+ // -------------------------------------------------------------------------
513
+
514
+ private async _loadFirstPage(limit: number): Promise<void> {
515
+ // Snapshot before loading — everything already in the tree stays visible
516
+ const beforeMsgIds = new Set(this._tree.flattenNodes(this._resolveSelections()).map((n) => n.msgId));
517
+
518
+ const firstPage = await decodeHistory(this._channel, this._codec, { limit }, this._logger);
519
+ if (this._closed) return;
520
+ const { newVisible, lastPage } = await this._loadUntilVisible(firstPage, limit, beforeMsgIds);
521
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- close() may be called during await
522
+ if (this._closed) return;
523
+
524
+ this._lastHistoryPage = lastPage;
525
+ this._hasMoreHistory = lastPage.hasNext();
526
+
527
+ // Split into withheld (older, kept hidden) and released (newest, shown now).
528
+ // Only add the actually-withheld messages to the set — adding all then
529
+ // releasing would cause a spurious empty-list update if a tree event fires
530
+ // between the two operations.
531
+ const released = newVisible.slice(-limit);
532
+ const withheld = newVisible.slice(0, -limit);
533
+ for (const n of withheld) {
534
+ this._withheldMsgIds.add(n.msgId);
535
+ }
536
+ this._withheldBuffer.push(...withheld);
537
+ this._releaseWithheld(released);
538
+ }
539
+
540
+ private async _loadAndReveal(page: HistoryPage<TMessage>, limit: number): Promise<void> {
541
+ // Everything currently in the tree is "already known"
542
+ const alreadyKnown = new Set(this._tree.flattenNodes(this._resolveSelections()).map((n) => n.msgId));
543
+
544
+ const { newVisible, lastPage } = await this._loadUntilVisible(page, limit, alreadyKnown);
545
+ if (this._closed) return;
546
+ this._lastHistoryPage = lastPage;
547
+ this._hasMoreHistory = lastPage.hasNext();
548
+
549
+ // Release the newest `limit` items; rest stays in buffer.
550
+ // Only add actually-withheld messages to the set — adding all then
551
+ // releasing would cause a spurious empty-list update if a tree event
552
+ // fires between the two operations.
553
+ const batch = newVisible.slice(-limit);
554
+ const withheld = newVisible.slice(0, -limit);
555
+ for (const n of withheld) {
556
+ this._withheldMsgIds.add(n.msgId);
557
+ }
558
+ this._withheldBuffer.push(...withheld);
559
+ this._releaseWithheld(batch);
560
+ }
561
+
562
+ private _processHistoryPage(page: HistoryPage<TMessage>): void {
563
+ this._processingHistory = true;
564
+ try {
565
+ for (const item of page.items) {
566
+ const msgId = item.headers[HEADER_MSG_ID];
567
+ if (!msgId) continue;
568
+ this._tree.upsert(msgId, item.message, item.headers, item.serial);
569
+ }
570
+
571
+ for (const msg of page.rawMessages) {
572
+ this._tree.emitAblyMessage(msg);
573
+ }
574
+ } finally {
575
+ this._processingHistory = false;
576
+ }
577
+ }
578
+
579
+ private async _loadUntilVisible(
580
+ firstPage: HistoryPage<TMessage>,
581
+ target: number,
582
+ beforeMsgIds: Set<string>,
583
+ ): Promise<{ newVisible: MessageNode<TMessage>[]; lastPage: HistoryPage<TMessage> }> {
584
+ this._processHistoryPage(firstPage);
585
+ let page = firstPage;
586
+
587
+ const newVisibleCount = (): number => {
588
+ let count = 0;
589
+ for (const n of this._tree.flattenNodes(this._resolveSelections())) {
590
+ if (!beforeMsgIds.has(n.msgId)) count++;
591
+ }
592
+ return count;
593
+ };
594
+
595
+ while (newVisibleCount() < target && page.hasNext()) {
596
+ const nextPage = await page.next();
597
+ if (!nextPage || this._closed) break;
598
+ this._processHistoryPage(nextPage);
599
+ page = nextPage;
600
+ }
601
+
602
+ const newVisible = this._tree.flattenNodes(this._resolveSelections()).filter((n) => !beforeMsgIds.has(n.msgId));
603
+ return { newVisible, lastPage: page };
604
+ }
605
+
606
+ // Spec: AIT-CT11a
607
+ private _releaseWithheld(nodes: MessageNode<TMessage>[]): void {
608
+ for (const n of nodes) {
609
+ this._withheldMsgIds.delete(n.msgId);
610
+ }
611
+ if (nodes.length > 0) {
612
+ this._cachedNodes = this._computeFlatNodes();
613
+ this._updateVisibleSnapshot(this._cachedNodes);
614
+ this._emitter.emit('update');
615
+ }
616
+ }
617
+
618
+ // -------------------------------------------------------------------------
619
+ // Private: scoped event forwarding
620
+ // -------------------------------------------------------------------------
621
+
622
+ private _updateVisibleSnapshot(nodes?: MessageNode<TMessage>[]): void {
623
+ const resolved = nodes ?? this.flattenNodes();
624
+ this._lastVisibleIds = resolved.map((n) => n.msgId);
625
+ this._lastVisibleMessages = resolved.map((n) => n.message);
626
+ this._lastVisibleTurnIds = new Set<string>();
627
+ for (const n of resolved) {
628
+ const turnId = n.headers[HEADER_TURN_ID];
629
+ if (turnId) this._lastVisibleTurnIds.add(turnId);
630
+ }
631
+ }
632
+
633
+ private _onTreeUpdate(): void {
634
+ // Suppress update forwarding while processing history pages. During
635
+ // _processHistoryPage, each tree.upsert() fires this handler synchronously
636
+ // — but _withheldMsgIds hasn't been populated yet, so flattenNodes() would
637
+ // return unfiltered history. Without this guard, subscribers briefly see all
638
+ // history messages before the pagination window is applied. The final update
639
+ // is emitted by _releaseWithheld after withholding is set up.
640
+ // Scoped to _processingHistory (not _loadingOlder) so that live streaming
641
+ // updates arriving during the async history fetch are still forwarded.
642
+ if (this._processingHistory) return;
643
+
644
+ const currentVersion = this._tree.structuralVersion;
645
+
646
+ // Content-only fast path: the tree structure hasn't changed (no new
647
+ // nodes, deletions, or serial reorders), so the cached node list is
648
+ // still structurally valid. The tree mutated an existing node's
649
+ // .message in place - check if any visible message reference changed.
650
+ // JS single-threaded: structuralVersion cannot change between the
651
+ // check and the response within this synchronous handler invocation.
652
+ if (currentVersion === this._lastStructuralVersion) {
653
+ const changed = this._cachedNodes.some((node, i) => node.message !== this._lastVisibleMessages[i]);
654
+ if (changed) {
655
+ this._lastVisibleMessages = this._cachedNodes.map((n) => n.message);
656
+ this._cachedNodes = [...this._cachedNodes];
657
+ this._emitter.emit('update');
658
+ }
659
+ return;
660
+ }
661
+
662
+ // Structural update: full re-walk required.
663
+ this._lastStructuralVersion = currentVersion;
664
+
665
+ // Pin selections for previously-visible nodes that now have siblings.
666
+ // This prevents new forks (from other views' edits/regenerates) from
667
+ // shifting this view to a branch the user didn't navigate to.
668
+ this._pinBranchSelections();
669
+ this._resolvePendingSelections();
670
+
671
+ const nodes = this._computeFlatNodes();
672
+ const newIds = nodes.map((n) => n.msgId);
673
+ const newMessages = nodes.map((n) => n.message);
674
+ if (this._visibleChanged(newIds, newMessages)) {
675
+ this._cachedNodes = nodes;
676
+ this._updateVisibleSnapshot(nodes);
677
+ this._emitter.emit('update');
678
+ }
679
+ }
680
+
681
+ /**
682
+ * Build a resolved selections map from `_branchSelections` for passing
683
+ * to `tree.flattenNodes()`. Pending entries (no sibling yet) are omitted,
684
+ * causing the tree to use the default (latest sibling).
685
+ * @returns Resolved map of groupRoot → selectedMsgId.
686
+ */
687
+ private _resolveSelections(): Map<string, string> {
688
+ const resolved = new Map<string, string>();
689
+ for (const [groupRoot, sel] of this._branchSelections) {
690
+ if (sel.kind === 'pending') continue;
691
+ resolved.set(groupRoot, sel.selectedId);
692
+ }
693
+ return resolved;
694
+ }
695
+
696
+ /**
697
+ * For each previously-visible message that now has siblings but no
698
+ * explicit selection, pin the selection to that message's msgId.
699
+ * This preserves the current branch when new forks appear from
700
+ * other views or external sources.
701
+ *
702
+ * Exception: if the fork was initiated by this view (tracked as a
703
+ * `pending` BranchSelection), select the newest sibling instead of
704
+ * pinning the old one. This handles regenerate, where no optimistic
705
+ * insert was possible at send time.
706
+ */
707
+ private _pinBranchSelections(): void {
708
+ for (const msgId of this._lastVisibleIds) {
709
+ if (!this._tree.hasSiblings(msgId)) continue;
710
+ const groupRoot = this._tree.getGroupRoot(msgId);
711
+ const existing = this._branchSelections.get(groupRoot);
712
+
713
+ // Spec: AIT-CT13e
714
+ // Check if this fork was initiated by this view (e.g. regenerate).
715
+ // If so, select the newest sibling — but only if it belongs to the
716
+ // pending turn. Without this check, a sibling from another view's
717
+ // concurrent fork would be incorrectly auto-selected.
718
+ if (existing?.kind === 'pending') {
719
+ const nodes = this._tree.getSiblingNodes(msgId);
720
+ const newest = nodes.at(-1);
721
+ if (newest && newest.msgId !== msgId) {
722
+ const newestTurnId = newest.headers[HEADER_TURN_ID];
723
+ if (newestTurnId === existing.turnId) {
724
+ this._logger.debug('DefaultView._pinBranchSelections(); auto-selecting pending fork', {
725
+ msgId,
726
+ newestId: newest.msgId,
727
+ turnId: existing.turnId,
728
+ });
729
+ this._branchSelections.set(groupRoot, { kind: 'auto', selectedId: newest.msgId });
730
+ }
731
+ }
732
+ continue;
733
+ }
734
+
735
+ // Spec: AIT-CT13f
736
+ // External fork — pin to the currently-visible sibling.
737
+ if (existing) continue; // already have a selection
738
+ this._branchSelections.set(groupRoot, { kind: 'pinned', selectedId: msgId });
739
+ }
740
+ }
741
+
742
+ /**
743
+ * Resolve pending selections that are no longer on the visible branch.
744
+ * `_pinBranchSelections` only checks visible nodes, so if the user navigated
745
+ * away before the server response arrived, the pending entry would linger.
746
+ * This pass checks all pending entries against the tree directly.
747
+ */
748
+ private _resolvePendingSelections(): void {
749
+ for (const [groupRoot, sel] of this._branchSelections) {
750
+ if (sel.kind !== 'pending') continue;
751
+ const nodes = this._tree.getSiblingNodes(groupRoot);
752
+ if (nodes.length <= 1) continue;
753
+ const newest = nodes.at(-1);
754
+ if (!newest || newest.msgId === groupRoot) continue;
755
+ const newestTurnId = newest.headers[HEADER_TURN_ID];
756
+ if (newestTurnId === sel.turnId) {
757
+ this._logger.debug('DefaultView._resolvePendingSelections(); resolving off-branch pending', {
758
+ groupRoot,
759
+ newestId: newest.msgId,
760
+ turnId: sel.turnId,
761
+ });
762
+ this._branchSelections.set(groupRoot, { kind: 'auto', selectedId: newest.msgId });
763
+ }
764
+ }
765
+ }
766
+
767
+ private _onTreeAblyMessage(msg: Ably.InboundMessage): void {
768
+ // Re-emit only if the message corresponds to a visible node
769
+ const headers = getHeaders(msg);
770
+ const msgId = headers[HEADER_MSG_ID];
771
+ if (!msgId) {
772
+ // Non-message events (turn-start, turn-end, cancel) — always forward
773
+ this._emitter.emit('ably-message', msg);
774
+ return;
775
+ }
776
+ // Check that msgId is on the visible branch and not withheld
777
+ if (this._lastVisibleIds.includes(msgId)) {
778
+ this._emitter.emit('ably-message', msg);
779
+ }
780
+ }
781
+
782
+ private _onTreeTurn(event: TurnLifecycleEvent): void {
783
+ // Check if any messages for this turn are already on the visible branch.
784
+ if (this._lastVisibleTurnIds.has(event.turnId)) {
785
+ this._emitter.emit('turn', event);
786
+ return;
787
+ }
788
+
789
+ // For turn-start, use branch metadata to predict visibility before
790
+ // messages arrive. Own turns have optimistic inserts (caught above).
791
+ // Remote turns carry parent/forkOf from the server.
792
+ if (event.type === EVENT_TURN_START && this._isTurnStartVisible(event)) {
793
+ // Track the predicted turnId so the corresponding turn-end is not
794
+ // dropped if it arrives before messages update the snapshot.
795
+ this._lastVisibleTurnIds.add(event.turnId);
796
+ this._emitter.emit('turn', event);
797
+ }
798
+ }
799
+
800
+ /**
801
+ * Predict whether a turn-start's messages will be visible on this view's branch
802
+ * using the parent/forkOf metadata from the event.
803
+ * @param event - The turn-start lifecycle event with optional branch metadata.
804
+ * @returns True if the turn's messages are expected to be visible on this view's branch.
805
+ */
806
+ private _isTurnStartVisible(event: TurnLifecycleEvent & { type: typeof EVENT_TURN_START }): boolean {
807
+ const { parent } = event;
808
+
809
+ // No parent metadata — can't determine branch, forward as default.
810
+ // This covers root turns (parent omitted) and backward compat.
811
+ if (parent === undefined) return true;
812
+
813
+ // Check if the parent is on the visible branch
814
+ return this._lastVisibleIds.includes(parent);
815
+ }
816
+
817
+ private _visibleChanged(newIds: string[], newMessages: TMessage[]): boolean {
818
+ if (newIds.length !== this._lastVisibleIds.length) return true;
819
+ for (const [i, newId] of newIds.entries()) {
820
+ if (newId !== this._lastVisibleIds[i]) return true;
821
+ }
822
+ // Also detect in-place content updates (e.g. streaming) via reference comparison
823
+ for (const [i, msg] of newMessages.entries()) {
824
+ if (msg !== this._lastVisibleMessages[i]) return true;
825
+ }
826
+ return false;
827
+ }
828
+ }
829
+
830
+ // ---------------------------------------------------------------------------
831
+ // Factory
832
+ // ---------------------------------------------------------------------------
833
+
834
+ /**
835
+ * Create a View that projects a paginated window over a Tree.
836
+ * @param options - The tree, channel, codec, and logger to use.
837
+ * @returns A new {@link DefaultView} instance.
838
+ */
839
+ export const createView = <TEvent, TMessage>(options: ViewOptions<TEvent, TMessage>): DefaultView<TEvent, TMessage> =>
840
+ new DefaultView(options);