@ably/ai-transport 0.2.0 → 0.3.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 (166) hide show
  1. package/README.md +10 -19
  2. package/dist/ably-ai-transport.js +1790 -1091
  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 +2 -2
  7. package/dist/core/agent.d.ts +20 -5
  8. package/dist/core/channel-options.d.ts +57 -0
  9. package/dist/core/codec/codec-event.d.ts +9 -0
  10. package/dist/core/codec/decoder.d.ts +4 -1
  11. package/dist/core/codec/define-codec.d.ts +100 -0
  12. package/dist/core/codec/encoder.d.ts +2 -7
  13. package/dist/core/codec/field-bag.d.ts +85 -0
  14. package/dist/core/codec/fields.d.ts +141 -0
  15. package/dist/core/codec/index.d.ts +8 -1
  16. package/dist/core/codec/input-descriptor-decoder.d.ts +19 -0
  17. package/dist/core/codec/input-descriptor-encoder.d.ts +22 -0
  18. package/dist/core/codec/input-descriptors.d.ts +281 -0
  19. package/dist/core/codec/output-descriptor-decoder.d.ts +29 -0
  20. package/dist/core/codec/output-descriptor-encoder.d.ts +31 -0
  21. package/dist/core/codec/output-descriptors.d.ts +237 -0
  22. package/dist/core/codec/types.d.ts +95 -36
  23. package/dist/core/codec/well-known-inputs.d.ts +52 -0
  24. package/dist/core/transport/agent-view.d.ts +296 -0
  25. package/dist/core/transport/decode-fold.d.ts +40 -32
  26. package/dist/core/transport/headers.d.ts +30 -1
  27. package/dist/core/transport/index.d.ts +1 -1
  28. package/dist/core/transport/invocation.d.ts +1 -1
  29. package/dist/core/transport/load-history-pages.d.ts +71 -0
  30. package/dist/core/transport/load-history.d.ts +21 -16
  31. package/dist/core/transport/run-manager.d.ts +9 -11
  32. package/dist/core/transport/session-support.d.ts +55 -0
  33. package/dist/core/transport/tree.d.ts +165 -15
  34. package/dist/core/transport/types/agent.d.ts +120 -98
  35. package/dist/core/transport/types/client.d.ts +45 -12
  36. package/dist/core/transport/types/tree.d.ts +52 -10
  37. package/dist/core/transport/types/view.d.ts +55 -28
  38. package/dist/core/transport/view.d.ts +176 -58
  39. package/dist/core/transport/wire-log.d.ts +102 -0
  40. package/dist/errors.d.ts +10 -4
  41. package/dist/index.d.ts +6 -5
  42. package/dist/react/ably-ai-transport-react.js +784 -415
  43. package/dist/react/ably-ai-transport-react.js.map +1 -1
  44. package/dist/react/ably-ai-transport-react.umd.cjs +1 -1
  45. package/dist/react/ably-ai-transport-react.umd.cjs.map +1 -1
  46. package/dist/react/contexts/client-session-context.d.ts +2 -1
  47. package/dist/react/contexts/client-session-provider.d.ts +3 -0
  48. package/dist/react/index.d.ts +2 -1
  49. package/dist/react/internal/skipped-session.d.ts +8 -0
  50. package/dist/react/use-view.d.ts +3 -3
  51. package/dist/utils.d.ts +22 -54
  52. package/dist/vercel/ably-ai-transport-vercel.js +2297 -2026
  53. package/dist/vercel/ably-ai-transport-vercel.js.map +1 -1
  54. package/dist/vercel/ably-ai-transport-vercel.umd.cjs +1 -1
  55. package/dist/vercel/ably-ai-transport-vercel.umd.cjs.map +1 -1
  56. package/dist/vercel/codec/decode-lifecycle.d.ts +9 -0
  57. package/dist/vercel/codec/events.d.ts +1 -2
  58. package/dist/vercel/codec/fields.d.ts +44 -0
  59. package/dist/vercel/codec/fold-content.d.ts +16 -0
  60. package/dist/vercel/codec/fold-data.d.ts +16 -0
  61. package/dist/vercel/codec/fold-input.d.ts +67 -0
  62. package/dist/vercel/codec/fold-lifecycle.d.ts +16 -0
  63. package/dist/vercel/codec/fold-text.d.ts +16 -0
  64. package/dist/vercel/codec/fold-tool-input.d.ts +17 -0
  65. package/dist/vercel/codec/fold-tool-output.d.ts +16 -0
  66. package/dist/vercel/codec/index.d.ts +5 -30
  67. package/dist/vercel/codec/inputs.d.ts +11 -0
  68. package/dist/vercel/codec/outputs.d.ts +11 -0
  69. package/dist/vercel/codec/reducer-state.d.ts +121 -0
  70. package/dist/vercel/codec/reducer.d.ts +20 -102
  71. package/dist/vercel/codec/tool-transitions.d.ts +0 -6
  72. package/dist/vercel/codec/wire-data.d.ts +34 -0
  73. package/dist/vercel/index.d.ts +1 -0
  74. package/dist/vercel/react/ably-ai-transport-vercel-react.js +2013 -9500
  75. package/dist/vercel/react/ably-ai-transport-vercel-react.js.map +1 -1
  76. package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs +1 -70
  77. package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs.map +1 -1
  78. package/dist/vercel/react/contexts/chat-transport-context.d.ts +2 -1
  79. package/dist/vercel/run-end-reason.d.ts +66 -11
  80. package/dist/vercel/tool-part.d.ts +21 -0
  81. package/dist/vercel/transport/chat-transport.d.ts +0 -2
  82. package/dist/vercel/transport/index.d.ts +1 -1
  83. package/dist/vercel/transport/run-output-stream.d.ts +6 -8
  84. package/dist/version.d.ts +1 -1
  85. package/package.json +2 -2
  86. package/src/constants.ts +2 -2
  87. package/src/core/agent.ts +43 -19
  88. package/src/core/channel-options.ts +89 -0
  89. package/src/core/codec/codec-event.ts +27 -0
  90. package/src/core/codec/decoder.ts +145 -21
  91. package/src/core/codec/define-codec.ts +432 -0
  92. package/src/core/codec/encoder.ts +13 -54
  93. package/src/core/codec/field-bag.ts +142 -0
  94. package/src/core/codec/fields.ts +193 -0
  95. package/src/core/codec/index.ts +43 -0
  96. package/src/core/codec/input-descriptor-decoder.ts +97 -0
  97. package/src/core/codec/input-descriptor-encoder.ts +150 -0
  98. package/src/core/codec/input-descriptors.ts +373 -0
  99. package/src/core/codec/output-descriptor-decoder.ts +139 -0
  100. package/src/core/codec/output-descriptor-encoder.ts +101 -0
  101. package/src/core/codec/output-descriptors.ts +307 -0
  102. package/src/core/codec/types.ts +99 -36
  103. package/src/core/codec/well-known-inputs.ts +96 -0
  104. package/src/core/transport/agent-session.ts +330 -589
  105. package/src/core/transport/agent-view.ts +738 -0
  106. package/src/core/transport/client-session.ts +74 -69
  107. package/src/core/transport/decode-fold.ts +57 -47
  108. package/src/core/transport/headers.ts +57 -4
  109. package/src/core/transport/index.ts +2 -1
  110. package/src/core/transport/invocation.ts +1 -1
  111. package/src/core/transport/load-history-pages.ts +220 -0
  112. package/src/core/transport/load-history.ts +63 -61
  113. package/src/core/transport/pipe-stream.ts +10 -1
  114. package/src/core/transport/run-manager.ts +25 -31
  115. package/src/core/transport/session-support.ts +96 -0
  116. package/src/core/transport/tree.ts +414 -47
  117. package/src/core/transport/types/agent.ts +129 -102
  118. package/src/core/transport/types/client.ts +49 -13
  119. package/src/core/transport/types/tree.ts +61 -12
  120. package/src/core/transport/types/view.ts +57 -28
  121. package/src/core/transport/view.ts +520 -172
  122. package/src/core/transport/wire-log.ts +189 -0
  123. package/src/errors.ts +10 -3
  124. package/src/index.ts +44 -11
  125. package/src/react/contexts/client-session-context.ts +1 -1
  126. package/src/react/contexts/client-session-provider.tsx +38 -2
  127. package/src/react/index.ts +2 -1
  128. package/src/react/internal/skipped-session.ts +62 -0
  129. package/src/react/use-client-session.ts +7 -30
  130. package/src/react/use-view.ts +3 -3
  131. package/src/utils.ts +31 -97
  132. package/src/vercel/codec/decode-lifecycle.ts +70 -0
  133. package/src/vercel/codec/events.ts +1 -3
  134. package/src/vercel/codec/fields.ts +58 -0
  135. package/src/vercel/codec/fold-content.ts +54 -0
  136. package/src/vercel/codec/fold-data.ts +46 -0
  137. package/src/vercel/codec/fold-input.ts +255 -0
  138. package/src/vercel/codec/fold-lifecycle.ts +85 -0
  139. package/src/vercel/codec/fold-text.ts +55 -0
  140. package/src/vercel/codec/fold-tool-input.ts +86 -0
  141. package/src/vercel/codec/fold-tool-output.ts +79 -0
  142. package/src/vercel/codec/index.ts +23 -63
  143. package/src/vercel/codec/inputs.ts +116 -0
  144. package/src/vercel/codec/outputs.ts +207 -0
  145. package/src/vercel/codec/reducer-state.ts +169 -0
  146. package/src/vercel/codec/reducer.ts +52 -838
  147. package/src/vercel/codec/tool-transitions.ts +1 -12
  148. package/src/vercel/codec/wire-data.ts +64 -0
  149. package/src/vercel/index.ts +1 -0
  150. package/src/vercel/react/contexts/chat-transport-context.ts +1 -1
  151. package/src/vercel/react/use-chat-transport.ts +8 -28
  152. package/src/vercel/react/use-message-sync.ts +5 -10
  153. package/src/vercel/run-end-reason.ts +95 -16
  154. package/src/vercel/tool-part.ts +25 -0
  155. package/src/vercel/transport/chat-transport.ts +10 -22
  156. package/src/vercel/transport/index.ts +1 -1
  157. package/src/vercel/transport/run-output-stream.ts +7 -8
  158. package/src/version.ts +1 -1
  159. package/dist/core/transport/branch-chain.d.ts +0 -43
  160. package/dist/core/transport/load-conversation.d.ts +0 -128
  161. package/dist/vercel/codec/decoder.d.ts +0 -9
  162. package/dist/vercel/codec/encoder.d.ts +0 -11
  163. package/src/core/transport/branch-chain.ts +0 -58
  164. package/src/core/transport/load-conversation.ts +0 -355
  165. package/src/vercel/codec/decoder.ts +0 -696
  166. package/src/vercel/codec/encoder.ts +0 -548
@@ -26,7 +26,7 @@ import { EventEmitter } from '../../event-emitter.js';
26
26
  import type { Logger } from '../../logger.js';
27
27
  import { getTransportHeaders } from '../../utils.js';
28
28
  import type { Codec, CodecInputEvent, CodecMessage, CodecOutputEvent } from '../codec/types.js';
29
- import { applyWireMessage } from './decode-fold.js';
29
+ import type { WireApplier } from './decode-fold.js';
30
30
  import { loadHistory } from './load-history.js';
31
31
  import { nodeKey, type TreeInternal } from './tree.js';
32
32
  import type {
@@ -82,13 +82,19 @@ export type SendDelegate<TInput extends CodecInputEvent> = (
82
82
  // ---------------------------------------------------------------------------
83
83
 
84
84
  /** Options for creating a View. */
85
- export interface ViewOptions<TInput extends CodecInputEvent, TOutput extends CodecOutputEvent, TProjection, TMessage> {
85
+ interface ViewOptions<TInput extends CodecInputEvent, TOutput extends CodecOutputEvent, TProjection, TMessage> {
86
86
  /** The tree to project. */
87
87
  tree: TreeInternal<TInput, TOutput, TProjection>;
88
88
  /** The Ably channel to load history from. */
89
89
  channel: Ably.RealtimeChannel;
90
- /** The codec used to project messages, mint regenerate inputs, and decode history. */
90
+ /** The codec used to project messages and mint regenerate inputs. */
91
91
  codec: Codec<TInput, TOutput, TProjection, TMessage>;
92
+ /**
93
+ * The Tree's single decode-and-apply engine, owned by the session and
94
+ * shared with the live decode loop. History replay feeds pages through it
95
+ * so the one decoder instance sees every route into the Tree.
96
+ */
97
+ applier: WireApplier;
92
98
  /** Delegate for executing sends through the session. */
93
99
  sendDelegate: SendDelegate<TInput>;
94
100
  /** Logger for diagnostic output. */
@@ -139,15 +145,48 @@ type RegenSelection =
139
145
  | { kind: 'pending'; carrierCodecMessageId: string };
140
146
 
141
147
  /**
142
- * A resolved branch point: the group `kind` plus the sibling nodes that make
143
- * up the alternatives. `fork-of` is an edit-style branch anchored at the user
144
- * input node; `regen` is a regenerate-style branch anchored at the assistant
145
- * slot. `groupRoot` is the group's key (input group root for fork-of, the
146
- * original reply's group root for regen).
148
+ * One alternative inside a {@link MessageBranchPoint}. The representative is the
149
+ * member's own head message for fork-of and whole-reply regen groups, but the
150
+ * *regenerate target* (a non-head message) for a non-head regen group - so it is
151
+ * tracked explicitly rather than re-derived from the node's head.
147
152
  */
148
- type MessageBranchPoint<TProjection> =
149
- | { kind: 'fork-of'; groupRoot: string; siblings: ConversationNode<TProjection>[] }
150
- | { kind: 'regen'; groupRoot: string; siblings: ConversationNode<TProjection>[] };
153
+ interface BranchMember {
154
+ /**
155
+ * The member node's `nodeKey` (tree.ts): a runId for a reply/regenerator run,
156
+ * a codecMessageId for an input node. Matched by `_resolveSelectedIndex`.
157
+ */
158
+ memberNodeKey: string;
159
+ /** The codec-message-id rendered in this member's branch-arrow slot. */
160
+ representativeCodecMessageId: string;
161
+ }
162
+
163
+ /**
164
+ * A resolved branch point: the group `kind` plus the member alternatives.
165
+ *
166
+ * Terms: "regenerate target" = the message being replaced; "regenerator run" =
167
+ * the run that replaces it; "non-head message" = any message after a run's
168
+ * first (index > 0, includes the tail).
169
+ *
170
+ * The three kinds, by anchor:
171
+ * - `fork-of` — edit-style branch anchored at the user input node; members are
172
+ * the alternate prompts (input-node sibling group).
173
+ * - `regen` — whole-reply regenerate branch anchored at the assistant slot;
174
+ * members are the original reply + its regenerator runs (same-input-node
175
+ * sibling reply runs).
176
+ * - `non-head-regen` — a regenerate that replaced a non-head message inside a
177
+ * multi-message reply run; members are the owner run (the regenerate target in
178
+ * place) plus each regenerator run. Not expressible as a same-parent
179
+ * sibling-run group, so the View resolves and renders it itself (see
180
+ * `_extractMessages`).
181
+ *
182
+ * `groupRoot` is the selection-map key: the input group root for fork-of, the
183
+ * original reply's group root for regen, and the regenerate target's
184
+ * codec-message-id for non-head-regen.
185
+ */
186
+ type MessageBranchPoint =
187
+ | { kind: 'fork-of'; groupRoot: string; members: BranchMember[] }
188
+ | { kind: 'regen'; groupRoot: string; members: BranchMember[] }
189
+ | { kind: 'non-head-regen'; groupRoot: string; members: BranchMember[] };
151
190
 
152
191
  // ---------------------------------------------------------------------------
153
192
  // Send-input normalisation
@@ -162,22 +201,6 @@ type MessageBranchPoint<TProjection> =
162
201
  const _normaliseSend = <TInput extends CodecInputEvent>(input: TInput | TInput[]): TInput[] =>
163
202
  Array.isArray(input) ? input : [input];
164
203
 
165
- // ---------------------------------------------------------------------------
166
- // Fetch tuning
167
- // ---------------------------------------------------------------------------
168
-
169
- /**
170
- * Multiplier applied to the user-supplied Run-unit `loadOlder(limit)`
171
- * when issuing the first `loadHistory` page request. `loadHistory`
172
- * counts complete domain *messages* per page, not Runs; a typical Run
173
- * produces ~2 messages (user + assistant). Asking for `limit * factor`
174
- * messages on the first page reduces extra round-trips when the actual
175
- * messages-per-Run ratio is around the factor. `_loadUntilVisible`
176
- * still loops on the Run count regardless, so this is purely a
177
- * fetch-efficiency hint.
178
- */
179
- const _RUN_TO_MESSAGE_FETCH_FACTOR = 3;
180
-
181
204
  /**
182
205
  * Project a Tree `RunNode` down to the View-facing `RunInfo` shape:
183
206
  * drop the codec projection and the structural fields that callers
@@ -188,8 +211,8 @@ const _RUN_TO_MESSAGE_FETCH_FACTOR = 3;
188
211
  const _toRunInfo = <TProjection>(run: RunNode<TProjection>): RunInfo => ({
189
212
  runId: run.runId,
190
213
  clientId: run.clientId,
191
- status: run.status,
192
214
  invocationId: run.invocationId,
215
+ ...run.state,
193
216
  });
194
217
 
195
218
  // ---------------------------------------------------------------------------
@@ -205,6 +228,7 @@ export class DefaultView<
205
228
  private readonly _tree: TreeInternal<TInput, TOutput, TProjection>;
206
229
  private readonly _channel: Ably.RealtimeChannel;
207
230
  private readonly _codec: Codec<TInput, TOutput, TProjection, TMessage>;
231
+ private readonly _applier: WireApplier;
208
232
  private readonly _sendDelegate: SendDelegate<TInput>;
209
233
  private readonly _logger: Logger;
210
234
  private readonly _emitter: EventEmitter<ViewEventsMap>;
@@ -227,6 +251,17 @@ export class DefaultView<
227
251
  */
228
252
  private readonly _regenSelections = new Map<string, RegenSelection>();
229
253
 
254
+ /**
255
+ * Non-head regenerate selections, keyed by the regenerate target's
256
+ * codec-message-id. Separate from {@link _regenSelections} because a non-head
257
+ * regenerator parents inside the owner run rather than as a same-parent
258
+ * sibling, so it lives outside the Tree's `visibleNodes` selection space and
259
+ * is resolved at extraction (see `_extractMessages`). Value is the selected
260
+ * member's nodeKey (the owner run id, or a regenerator run id); absent groups
261
+ * default to the newest regenerator.
262
+ */
263
+ private readonly _nonHeadRegenSelections = new Map<string, RegenSelection>();
264
+
230
265
  /** Spec: AIT-CT11c — runIds loaded from history but not yet revealed to the UI. */
231
266
  private readonly _withheldRunIds = new Set<string>();
232
267
 
@@ -258,6 +293,17 @@ export class DefaultView<
258
293
  /** Buffer of withheld nodes (input + reply), drained newest-first by successive loadOlder() calls. */
259
294
  private readonly _withheldBuffer: ConversationNode<TProjection>[] = [];
260
295
 
296
+ /**
297
+ * Message-level trim on top of the run-level pagination window. Runs are
298
+ * revealed whole (via `_withheldRunIds`/`_withheldBuffer`), so a `loadOlder`
299
+ * may surface more messages than asked; this is the count of OLDEST messages
300
+ * of the visible node chain to hide from `getMessages()` so a page lands on
301
+ * exactly `limit` messages. The boundary run still appears in `runs()` (it's
302
+ * a revealed node); only its oldest messages are trimmed from the flat list.
303
+ * Live messages append at the newest end and are never trimmed.
304
+ */
305
+ private _hiddenMessageCount = 0;
306
+
261
307
  /** Unsubscribe functions for tree event subscriptions. */
262
308
  private readonly _unsubs: (() => void)[] = [];
263
309
 
@@ -277,6 +323,7 @@ export class DefaultView<
277
323
  this._tree = options.tree;
278
324
  this._channel = options.channel;
279
325
  this._codec = options.codec;
326
+ this._applier = options.applier;
280
327
  this._sendDelegate = options.sendDelegate;
281
328
  this._onClose = options.onClose;
282
329
  this._logger = options.logger.withContext({ component: 'View' });
@@ -330,7 +377,7 @@ export class DefaultView<
330
377
  // boundary already dedup by array reference, so a redundant emit is a
331
378
  // no-op for unchanged hook consumers.
332
379
  this._lastVisibleProjections = this._cachedNodes.map((n) => n.projection);
333
- this._lastVisibleMessagePairs = this._extractMessages(this._cachedNodes);
380
+ this._lastVisibleMessagePairs = this._extractMessages(this._cachedNodes).slice(this._hiddenMessageCount);
334
381
  this._emitter.emit('update');
335
382
  }
336
383
 
@@ -404,84 +451,185 @@ export class DefaultView<
404
451
  }
405
452
 
406
453
  /**
407
- * Extract the flat TMessage[] from a visible node chain.
408
- *
409
- * In the two-node model the Tree's `visibleNodes` has already selected one
410
- * member per sibling group (the chosen edit version, the chosen regenerate
411
- * run), so a regenerate is just a sibling reply run that appears in place of
412
- * the original. Each visible node contributes its own messages in projection
413
- * order; the flat list is their concatenation.
414
- *
415
- * Deferred caveat: a mid-reply regenerate that replaces a non-head message
416
- * inside a multi-message reply run is not expressible as a sibling run in
417
- * this model and is not handled here (see the `regenerate-of-multi-message`
418
- * golden test).
419
- * @param nodes - The visible nodes (inputs + reply runs) in chronological order.
420
- * @returns The flat message list, each message paired with its codec-message-id.
454
+ * The regenerator runs that replaced a non-head message of a reply run. They
455
+ * file under the target's predecessor (not the owner run's input node), so the
456
+ * Tree's `visibleNodes` cannot collapse them into the owner's slot; this
457
+ * surfaces them for the View to resolve and render. Head-message (index 0)
458
+ * regenerates are excluded - those are whole-reply sibling runs the Tree
459
+ * already groups.
460
+ * @param targetCodecMessageId - The regenerate target's (non-head) message id.
461
+ * @param predecessorCodecMessageId - The codec-message-id immediately before it in the owner run.
462
+ * @returns The regenerator runs in startSerial order (oldest first).
463
+ */
464
+ private _nonHeadRegenerators(
465
+ targetCodecMessageId: string,
466
+ predecessorCodecMessageId: string,
467
+ ): RunNode<TProjection>[] {
468
+ return this._tree
469
+ .getReplyRuns(predecessorCodecMessageId)
470
+ .filter((r) => r.regeneratesCodecMessageId === targetCodecMessageId)
471
+ .toSorted((a, b) => (a.startSerial ?? '￿').localeCompare(b.startSerial ?? '￿'));
472
+ }
473
+
474
+ /**
475
+ * Resolve the selected member of a non-head regenerate group anchored at
476
+ * `targetCodecMessageId`. Members are the owner run `O` (memberNodeKey =
477
+ * `ownerRunId`, the regenerate target in place) followed by each regenerator
478
+ * run. Honours an explicit {@link _nonHeadRegenSelections} entry, else
479
+ * defaults to the latest member (newest regenerator), mirroring the
480
+ * whole-reply regenerate default.
481
+ * @param targetCodecMessageId - The regenerate target's message id (the group anchor).
482
+ * @param ownerRunId - The runId of the run that owns the regenerate target.
483
+ * @param regenerators - The regenerator runs (oldest first) from `_nonHeadRegenerators`.
484
+ * @returns The selected member's node key (`ownerRunId` or a regenerator runId).
485
+ */
486
+ private _selectedNonHeadMember(
487
+ targetCodecMessageId: string,
488
+ ownerRunId: string,
489
+ regenerators: RunNode<TProjection>[],
490
+ ): string {
491
+ const sel = this._nonHeadRegenSelections.get(targetCodecMessageId);
492
+ if (sel && sel.kind !== 'pending') {
493
+ const keys = [ownerRunId, ...regenerators.map((r) => r.runId)];
494
+ if (keys.includes(sel.selectedRunId)) return sel.selectedRunId;
495
+ }
496
+ // Default: latest member = newest regenerator (regenerators is oldest-first).
497
+ return regenerators.at(-1)?.runId ?? ownerRunId;
498
+ }
499
+
500
+ /**
501
+ * Flatten visible nodes to messages, collapsing a non-head regenerate into the
502
+ * slot it replaces: while emitting a reply run, at each non-head message that
503
+ * has a selected regenerator, drop that message and the run's tail and emit the
504
+ * selected regenerator instead (recursive for regen-of-regen). Whole-reply
505
+ * regenerates need nothing here - visibleNodes already picks the sibling.
506
+ * @param nodes - Visible nodes (inputs + reply runs), chronological.
507
+ * @returns The flat message list, each paired with its codec-message-id.
421
508
  */
422
509
  private _extractMessages(nodes: ConversationNode<TProjection>[]): CodecMessage<TMessage>[] {
423
510
  const messages: CodecMessage<TMessage>[] = [];
511
+ // Regenerator runs already emitted via substitution at their anchor — skip
512
+ // them when the node walk reaches them directly.
513
+ const consumedRunIds = new Set<string>();
514
+
424
515
  for (const node of nodes) {
425
- for (const m of this._codec.getMessages(node.projection)) {
426
- messages.push(m);
427
- }
516
+ if (node.kind === 'run' && consumedRunIds.has(node.runId)) continue;
517
+ this._emitNodeMessages(node, messages, consumedRunIds);
428
518
  }
429
519
  return messages;
430
520
  }
431
521
 
522
+ /**
523
+ * Emit one visible node's messages into `out`, applying non-head regenerate
524
+ * substitution for a reply run (see `_extractMessages`). Input nodes and runs
525
+ * with no non-head regenerators emit their projection verbatim.
526
+ * @param node - The node to emit.
527
+ * @param out - The accumulating flat message list (mutated in place).
528
+ * @param consumedRunIds - Set of regenerator runIds already emitted via substitution (mutated in place).
529
+ */
530
+ private _emitNodeMessages(
531
+ node: ConversationNode<TProjection>,
532
+ out: CodecMessage<TMessage>[],
533
+ consumedRunIds: Set<string>,
534
+ ): void {
535
+ const own = this._codec.getMessages(node.projection);
536
+ if (node.kind !== 'run') {
537
+ out.push(...own);
538
+ return;
539
+ }
540
+ for (let i = 0; i < own.length; i++) {
541
+ const m = own[i];
542
+ if (!m) continue;
543
+ // Head message (i === 0) regenerates are whole-reply sibling runs, already
544
+ // resolved by visibleNodes — only non-head messages anchor a non-head group.
545
+ const predecessor = i > 0 ? own[i - 1]?.codecMessageId : undefined;
546
+ if (predecessor !== undefined) {
547
+ const regenerators = this._nonHeadRegenerators(m.codecMessageId, predecessor);
548
+ if (regenerators.length > 0) {
549
+ // Every regenerator (and any same-anchor sibling the Tree already
550
+ // collapsed in `visibleNodes`) is an alternative at THIS one slot, so
551
+ // mark them all consumed up front — the node walk must not re-emit the
552
+ // Tree's default-latest sibling once we render a different member here.
553
+ for (const r of regenerators) consumedRunIds.add(r.runId);
554
+ const selectedKey = this._selectedNonHeadMember(m.codecMessageId, node.runId, regenerators);
555
+ if (selectedKey !== node.runId) {
556
+ // A regenerator is selected: drop M and the rest of O, emit the
557
+ // selected regenerator in M's place (recursively for nested regen).
558
+ const chosen = regenerators.find((r) => r.runId === selectedKey);
559
+ if (chosen) {
560
+ this._emitNodeMessages(chosen, out, consumedRunIds);
561
+ return;
562
+ }
563
+ }
564
+ // Original (owner run) selected: fall through and emit M from O.
565
+ }
566
+ }
567
+ out.push(m);
568
+ }
569
+ }
570
+
432
571
  hasOlder(): boolean {
433
- return this._withheldBuffer.length > 0 || this._hasMoreHistory;
572
+ return this._hiddenMessageCount > 0 || this._withheldBuffer.length > 0 || this._hasMoreHistory;
434
573
  }
435
574
 
436
575
  /**
437
- * Reveal up to `limit` older Runs in this view.
576
+ * Reveal `limit` more older codecMessages in this view — fewer only when
577
+ * channel history is exhausted.
438
578
  *
439
- * The pagination unit is the **Run**, not the message. A single Run
440
- * typically materialises into multiple messages (e.g. user + assistant
441
- * pair) so revealing `limit` Runs may add several messages to the flat
442
- * list returned by {@link getMessages}. Channel pages don't align to
443
- * Run boundaries, so {@link _loadUntilVisible} keeps fetching channel
444
- * pages until at least `limit` Runs are buffered (or the channel is
445
- * exhausted).
446
- * @param limit - Maximum number of older Runs to reveal. Defaults to 100.
579
+ * Internally runs are revealed WHOLE (run-granular withholding), counting
580
+ * codecMessages to decide how many runs to bring in, then the flat list
581
+ * returned by {@link getMessages} is trimmed to exactly `limit` more
582
+ * messages. So a run straddling the boundary still appears in {@link runs}
583
+ * (it's a revealed node) while only its newest messages show in
584
+ * `getMessages`. Live messages append at the newest end and are never
585
+ * trimmed.
586
+ * @param limit - Number of older codecMessages to reveal. Defaults to 10.
447
587
  */
448
- async loadOlder(limit = 100): Promise<void> {
588
+ async loadOlder(limit = 10): Promise<void> {
449
589
  if (this._closed || this._loadingOlder) return;
450
590
  this._loadingOlder = true;
451
591
  this._logger.trace('DefaultView.loadOlder();', { limit });
452
592
 
453
593
  try {
454
- // Drain withheld buffer first (older nodes, released newest-first). The
455
- // buffer holds a union of input + reply nodes, so this splices the newest
456
- // `limit` NODES, not `limit` runs. Because an input node travels with the
457
- // reply run it precedes, a drain may surface fewer than `limit` runs.
458
- if (this._withheldBuffer.length > 0) {
459
- const batch = this._withheldBuffer.splice(-limit, limit);
460
- this._releaseWithheld(batch);
461
- return;
462
- }
463
-
464
- // Buffer exhausted - load from channel history.
465
- if (!this._hasMoreHistory && !this._lastHistoryPage) {
466
- await this._loadFirstPage(limit);
594
+ // Phase A: the boundary run is already revealed (a previous loadOlder
595
+ // pulled in a whole run that overshot the message limit); reveal more of
596
+ // its trimmed-off oldest messages without fetching or revealing new runs.
597
+ if (this._hiddenMessageCount >= limit) {
598
+ this._hiddenMessageCount -= limit;
599
+ this._recomputeAndEmit();
467
600
  return;
468
601
  }
469
602
 
470
- if (!this._hasMoreHistory) return;
603
+ // Phase B: reveal whole older runs covering the remaining message budget,
604
+ // then re-trim so exactly `limit` new messages surface. Runs are revealed
605
+ // whole (node granularity); the trim makes the message count exact.
606
+ const need = limit - this._hiddenMessageCount;
607
+ const before = this._extractMessages(this._computeFlatNodes()).length;
608
+ const revealedSoFar = (): number => this._extractMessages(this._computeFlatNodes()).length - before;
471
609
 
472
- if (!this._lastHistoryPage?.hasNext()) {
473
- this._hasMoreHistory = false;
474
- return;
610
+ // Drain the withheld buffer toward `need` (whole older runs, newest-first).
611
+ if (this._withheldBuffer.length > 0) {
612
+ const splitIdx = this._messageTailSplitIndex(this._withheldBuffer, need);
613
+ const batch = this._withheldBuffer.splice(splitIdx);
614
+ this._releaseWithheld(batch);
475
615
  }
476
616
 
477
- const nextPage = await this._lastHistoryPage.next();
478
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- close() may be called during await
479
- if (this._closed || !nextPage) {
480
- if (!nextPage) this._hasMoreHistory = false;
481
- return;
617
+ // If the buffer was empty or fell short of `need` (e.g. it held a
618
+ // zero-message run), fetch channel history for the remainder. The fetch
619
+ // path loops over pages internally until it covers its target or history
620
+ // is exhausted, so a single call here suffices.
621
+ if (revealedSoFar() < need) {
622
+ await this._fetchOlder(need - revealedSoFar());
623
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- close() may be set during the await above
624
+ if (this._closed) return;
482
625
  }
483
626
 
484
- await this._revealFromPage(nextPage, limit);
627
+ const after = this._extractMessages(this._computeFlatNodes()).length;
628
+ // `after - before` whole-run messages were added at the oldest end; show
629
+ // `limit` of them (newest), hiding the overshoot plus what was already
630
+ // trimmed. `<= 0` when history is exhausted before `limit` is reached.
631
+ this._hiddenMessageCount = Math.max(0, this._hiddenMessageCount + (after - before) - limit);
632
+ this._recomputeAndEmit();
485
633
  } catch (error) {
486
634
  this._logger.error('DefaultView.loadOlder(); failed', { error });
487
635
  throw error;
@@ -490,6 +638,64 @@ export class DefaultView<
490
638
  }
491
639
  }
492
640
 
641
+ /**
642
+ * Fetch older channel history covering at least `target` more codecMessages,
643
+ * buffering the older nodes and revealing whole runs. The withheld buffer is
644
+ * assumed already drained by the caller. Loads the first page when no history
645
+ * has been fetched yet, otherwise advances to the next older page; the
646
+ * page-walk inside {@link _revealFromPage} loops until `target` messages are
647
+ * covered or history runs out. No-op (leaving `_hasMoreHistory` false) once
648
+ * channel history is exhausted.
649
+ * @param target - Minimum additional codecMessages this fetch aims to cover.
650
+ */
651
+ private async _fetchOlder(target: number): Promise<void> {
652
+ if (!this._hasMoreHistory && !this._lastHistoryPage) {
653
+ await this._loadFirstPage(target);
654
+ return;
655
+ }
656
+
657
+ if (!this._hasMoreHistory) return;
658
+
659
+ if (!this._lastHistoryPage?.hasNext()) {
660
+ this._hasMoreHistory = false;
661
+ return;
662
+ }
663
+
664
+ const nextPage = await this._lastHistoryPage.next();
665
+ if (this._closed || !nextPage) {
666
+ if (!nextPage) this._hasMoreHistory = false;
667
+ return;
668
+ }
669
+
670
+ await this._revealFromPage(nextPage, target);
671
+ }
672
+
673
+ /**
674
+ * Find the index in `nodes` (chronological, oldest-first) at which the newest
675
+ * whole runs covering at least `target` codecMessages begin. Walks newest-first
676
+ * summing each node's `codec.getMessages(projection)` count; once the running
677
+ * total reaches `target`, the current node (and everything newer) is the
678
+ * revealed batch — so whole runs are revealed and the batch may overshoot
679
+ * `target` (the caller trims). Returns `0` when the nodes hold fewer than
680
+ * `target` messages — reveal everything.
681
+ *
682
+ * Shared by the buffer-drain and history-fetch reveal paths so they agree on
683
+ * "covering `target` messages".
684
+ * @param nodes - Candidate nodes, oldest-first.
685
+ * @param target - Minimum codecMessages the revealed batch must cover.
686
+ * @returns The split index; `nodes[splitIdx..]` is the revealed batch.
687
+ */
688
+ private _messageTailSplitIndex(nodes: ConversationNode<TProjection>[], target: number): number {
689
+ let messages = 0;
690
+ for (let i = nodes.length - 1; i >= 0; i--) {
691
+ const node = nodes[i];
692
+ if (!node) continue;
693
+ messages += this._codec.getMessages(node.projection).length;
694
+ if (messages >= target) return i; // reveal nodes[i..]
695
+ }
696
+ return 0; // fewer than `target` messages — reveal everything
697
+ }
698
+
493
699
  // -------------------------------------------------------------------------
494
700
  // Run lookup
495
701
  // -------------------------------------------------------------------------
@@ -547,12 +753,18 @@ export class DefaultView<
547
753
  branchSelection(codecMessageId: string): BranchSelection<TMessage> {
548
754
  const branch = this._resolveMessageBranchPoint(codecMessageId);
549
755
  if (branch) {
550
- // Each sibling contributes its head message as the branch-arrow slot:
551
- // for an edit fork that is the alternate user prompt; for a regenerate
552
- // group it is the variant's first (anchor-equivalent) message.
553
- const siblings = branch.siblings.flatMap((s) => {
554
- const first = this._codec.getMessages(s.projection).at(0);
555
- return first ? [first.message] : [];
756
+ // Each member contributes its representative message as the branch-arrow
757
+ // slot: for an edit fork that is the alternate user prompt; for a
758
+ // whole-reply regenerate group the variant's first message; for a non-head
759
+ // regenerate group the regenerate target (original) or the regenerator's
760
+ // first message.
761
+ const siblings = branch.members.flatMap((member) => {
762
+ const owner = this._tree.getNodeByCodecMessageId(member.representativeCodecMessageId);
763
+ if (!owner) return [];
764
+ const found = this._codec
765
+ .getMessages(owner.projection)
766
+ .find((m) => m.codecMessageId === member.representativeCodecMessageId);
767
+ return found ? [found.message] : [];
556
768
  });
557
769
 
558
770
  if (siblings.length > 0) {
@@ -595,22 +807,32 @@ export class DefaultView<
595
807
  this._logger.trace('DefaultView.selectSibling();', { codecMessageId, index });
596
808
  const branch = this._resolveMessageBranchPoint(codecMessageId);
597
809
  if (!branch) return;
598
- const clamped = Math.max(0, Math.min(index, branch.siblings.length - 1));
599
- const selected = branch.siblings[clamped];
810
+ const clamped = Math.max(0, Math.min(index, branch.members.length - 1));
811
+ const selected = branch.members[clamped];
600
812
  if (!selected) return; // unreachable: clamped is always in bounds
601
813
  if (branch.kind === 'fork-of') {
602
- this._branchSelections.set(branch.groupRoot, { kind: 'user', selectedKey: nodeKey(selected) });
814
+ this._branchSelections.set(branch.groupRoot, { kind: 'user', selectedKey: selected.memberNodeKey });
603
815
  this._logger.debug('DefaultView.selectSibling(); fork-of', {
604
816
  codecMessageId,
605
817
  index: clamped,
606
- selectedKey: nodeKey(selected),
818
+ selectedKey: selected.memberNodeKey,
819
+ });
820
+ } else if (branch.kind === 'non-head-regen') {
821
+ // Non-head groups live outside the visibleNodes sibling space — store in
822
+ // the dedicated map the message-extraction substitution reads.
823
+ this._nonHeadRegenSelections.set(branch.groupRoot, { kind: 'user', selectedRunId: selected.memberNodeKey });
824
+ this._logger.debug('DefaultView.selectSibling(); non-head-regen', {
825
+ codecMessageId,
826
+ index: clamped,
827
+ selectedRunId: selected.memberNodeKey,
828
+ anchor: branch.groupRoot,
607
829
  });
608
830
  } else {
609
- this._regenSelections.set(branch.groupRoot, { kind: 'user', selectedRunId: nodeKey(selected) });
831
+ this._regenSelections.set(branch.groupRoot, { kind: 'user', selectedRunId: selected.memberNodeKey });
610
832
  this._logger.debug('DefaultView.selectSibling(); regenerate', {
611
833
  codecMessageId,
612
834
  index: clamped,
613
- selectedRunId: nodeKey(selected),
835
+ selectedRunId: selected.memberNodeKey,
614
836
  groupRoot: branch.groupRoot,
615
837
  });
616
838
  }
@@ -624,41 +846,32 @@ export class DefaultView<
624
846
  * @param branch - Resolved branch-point descriptor from `_resolveMessageBranchPoint`.
625
847
  * @returns The selected sibling's index within `branch.siblings`.
626
848
  */
627
- private _resolveSelectedIndex(branch: MessageBranchPoint<TProjection>): number {
849
+ private _resolveSelectedIndex(branch: MessageBranchPoint): number {
628
850
  if (branch.kind === 'fork-of') {
629
851
  const sel = this._branchSelections.get(branch.groupRoot);
630
- if (!sel) return branch.siblings.length - 1;
631
- const idx = branch.siblings.findIndex((n) => nodeKey(n) === sel.selectedKey);
632
- return idx === -1 ? branch.siblings.length - 1 : idx;
852
+ if (!sel) return branch.members.length - 1;
853
+ const idx = branch.members.findIndex((m) => m.memberNodeKey === sel.selectedKey);
854
+ return idx === -1 ? branch.members.length - 1 : idx;
633
855
  }
634
- const sel = this._regenSelections.get(branch.groupRoot);
635
- if (!sel || sel.kind === 'pending') return branch.siblings.length - 1;
636
- const idx = branch.siblings.findIndex((n) => nodeKey(n) === sel.selectedRunId);
637
- return idx === -1 ? branch.siblings.length - 1 : idx;
856
+ const sel =
857
+ branch.kind === 'non-head-regen'
858
+ ? this._nonHeadRegenSelections.get(branch.groupRoot)
859
+ : this._regenSelections.get(branch.groupRoot);
860
+ if (!sel || sel.kind === 'pending') return branch.members.length - 1;
861
+ const idx = branch.members.findIndex((m) => m.memberNodeKey === sel.selectedRunId);
862
+ return idx === -1 ? branch.members.length - 1 : idx;
638
863
  }
639
864
 
640
865
  /**
641
- * Resolve the branch point anchored at `codecMessageId`, if any.
642
- *
643
- * Returns the resolved group `kind` along with the sibling list so the
644
- * caller can update the correct selection map without re-entering the
645
- * runId-based `select()` dispatch (which biases to fork-of first and
646
- * would mis-route a regen-anchor codec-message-id when the owning Run is in
647
- * BOTH groups — e.g. R1 owns both a user prompt that got edited and
648
- * an assistant that got regenerated).
649
- *
650
- * Two anchor cases:
651
- * - **fork-of** — `codecMessageId` is the first message of a Run in a fork-of
652
- * sibling group (edit-style branch point anchored at the user prompt).
653
- * - **regen** — `codecMessageId` is the regen-anchor itself (in the owner Run)
654
- * or content of a regenerator Run (regen-style branch point anchored
655
- * at the assistant slot).
866
+ * Resolve the branch point anchored at `codecMessageId`, if any, returning the
867
+ * group `kind` + members + groupRoot so the caller routes to the correct
868
+ * selection map directly (not via a runId dispatch that would mis-route when
869
+ * the owning Run is in both a fork-of and a regen group).
656
870
  * @param codecMessageId - The codec-message-id to look up.
657
- * @returns The kind + sibling list + group key (runId for fork-of,
658
- * anchor codec-message-id for regen), or undefined when `codecMessageId` is not an
659
- * anchor in either group type.
871
+ * @returns The resolved branch point, or undefined when `codecMessageId`
872
+ * anchors no group.
660
873
  */
661
- private _resolveMessageBranchPoint(codecMessageId: string): MessageBranchPoint<TProjection> | undefined {
874
+ private _resolveMessageBranchPoint(codecMessageId: string): MessageBranchPoint | undefined {
662
875
  const node = this._tree.getNodeByCodecMessageId(codecMessageId);
663
876
  if (!node) return undefined;
664
877
 
@@ -668,26 +881,122 @@ export class DefaultView<
668
881
  if (node.kind === 'input') {
669
882
  const siblings = this._tree.getSiblingNodes(node.codecMessageId);
670
883
  if (siblings.length > 1) {
671
- return { kind: 'fork-of', groupRoot: this._tree.getGroupRoot(node.codecMessageId), siblings };
884
+ return {
885
+ kind: 'fork-of',
886
+ groupRoot: this._tree.getGroupRoot(node.codecMessageId),
887
+ members: this._nodeHeadMembers(siblings),
888
+ };
672
889
  }
673
890
  return undefined;
674
891
  }
675
892
 
893
+ // Non-head regenerate branch point: `codecMessageId` is the rendered slot for
894
+ // a regenerate that replaced a non-head message inside a multi-message reply
895
+ // run. Resolved BEFORE the same-parent `regen` group below: several non-head
896
+ // regenerators of one anchor share a parent (the anchor's predecessor), so
897
+ // the Tree files them as their own sibling group excluding the owner run; the
898
+ // non-head resolver instead gathers the owner plus every regenerator into one
899
+ // anchor-keyed group.
900
+ const ownMessages = this._codec.getMessages(node.projection);
901
+ const nonHead = this._resolveNonHeadBranchPoint(node, ownMessages, codecMessageId);
902
+ if (nonHead) return nonHead;
903
+
676
904
  // Regenerate branch point: `codecMessageId` is owned by a reply run that has
677
905
  // sibling reply runs (the original reply + its regenerators, all parented at
678
906
  // the same input node). Anchor on the head message of the run so arrows
679
907
  // appear once per variant, not on every follow-up message.
680
908
  const siblings = this._tree.getSiblingNodes(node.runId);
681
- if (siblings.length > 1) {
682
- const firstMsg = this._codec.getMessages(node.projection).at(0);
683
- if (firstMsg?.codecMessageId === codecMessageId) {
684
- return { kind: 'regen', groupRoot: this._tree.getGroupRoot(node.runId), siblings };
685
- }
909
+ if (siblings.length > 1 && ownMessages.at(0)?.codecMessageId === codecMessageId) {
910
+ return {
911
+ kind: 'regen',
912
+ groupRoot: this._tree.getGroupRoot(node.runId),
913
+ members: this._nodeHeadMembers(siblings),
914
+ };
686
915
  }
687
916
 
688
917
  return undefined;
689
918
  }
690
919
 
920
+ /**
921
+ * Resolve a non-head regenerate branch point from a reply-run message, if any.
922
+ * `codecMessageId` is either (a) a non-head message `M` of its owner run with
923
+ * regenerators, or (b) a regenerator run's head; both resolve to the same group
924
+ * anchored at `M` (key matching {@link _nonHeadRegenSelections}).
925
+ * @param node - The reply run owning `codecMessageId`.
926
+ * @param ownMessages - That run's projected messages (already extracted).
927
+ * @param codecMessageId - The slot's codec-message-id (an `M`, or a regenerator head).
928
+ * @returns The non-head branch point, or undefined when `codecMessageId` anchors none.
929
+ */
930
+ private _resolveNonHeadBranchPoint(
931
+ node: RunNode<TProjection>,
932
+ ownMessages: CodecMessage<TMessage>[],
933
+ codecMessageId: string,
934
+ ): MessageBranchPoint | undefined {
935
+ // Case (b): `codecMessageId` is a regenerator run's head. Re-anchor on the
936
+ // message it regenerates and resolve from the owner run's perspective.
937
+ const isHead = ownMessages.at(0)?.codecMessageId === codecMessageId;
938
+ if (isHead && node.regeneratesCodecMessageId !== undefined) {
939
+ const anchorId = node.regeneratesCodecMessageId;
940
+ const owner = this._runByCodecMessageId(anchorId);
941
+ if (owner) {
942
+ const ownerMsgs = this._codec.getMessages(owner.projection);
943
+ const idx = ownerMsgs.findIndex((mm) => mm.codecMessageId === anchorId);
944
+ const predecessor = idx > 0 ? ownerMsgs[idx - 1]?.codecMessageId : undefined;
945
+ if (predecessor !== undefined) {
946
+ return this._buildNonHeadGroup(anchorId, owner.runId, predecessor);
947
+ }
948
+ }
949
+ return undefined;
950
+ }
951
+
952
+ // Case (a): `codecMessageId` is a non-head message of its owner run.
953
+ const idx = ownMessages.findIndex((mm) => mm.codecMessageId === codecMessageId);
954
+ const predecessor = idx > 0 ? ownMessages[idx - 1]?.codecMessageId : undefined;
955
+ if (predecessor === undefined) return undefined;
956
+ return this._buildNonHeadGroup(codecMessageId, node.runId, predecessor);
957
+ }
958
+
959
+ /**
960
+ * Build the {@link MessageBranchPoint} for a non-head regenerate group, or
961
+ * undefined when the anchor has no regenerators. The owner member's
962
+ * representative is the anchor message (the regenerate target); each
963
+ * regenerator's is its head message.
964
+ * @param anchorCodecMessageId - The regenerate target's (non-head) message id.
965
+ * @param ownerRunId - The runId owning the regenerate target.
966
+ * @param predecessorCodecMessageId - The codec-message-id immediately before the anchor in the owner run.
967
+ * @returns The non-head branch point, or undefined when there are no regenerators.
968
+ */
969
+ private _buildNonHeadGroup(
970
+ anchorCodecMessageId: string,
971
+ ownerRunId: string,
972
+ predecessorCodecMessageId: string,
973
+ ): MessageBranchPoint | undefined {
974
+ const regenerators = this._nonHeadRegenerators(anchorCodecMessageId, predecessorCodecMessageId);
975
+ if (regenerators.length === 0) return undefined;
976
+ const members: BranchMember[] = [{ memberNodeKey: ownerRunId, representativeCodecMessageId: anchorCodecMessageId }];
977
+ for (const r of regenerators) {
978
+ const head = this._codec.getMessages(r.projection).at(0);
979
+ if (head) members.push({ memberNodeKey: r.runId, representativeCodecMessageId: head.codecMessageId });
980
+ }
981
+ return { kind: 'non-head-regen', groupRoot: anchorCodecMessageId, members };
982
+ }
983
+
984
+ /**
985
+ * Project nodes to {@link BranchMember}s for fork-of / whole-reply regen
986
+ * groups, where each member's branch-arrow representative is its own head
987
+ * message and its memberNodeKey is its node key.
988
+ * @param nodes - The sibling nodes.
989
+ * @returns One member per node that has a head message.
990
+ */
991
+ private _nodeHeadMembers(nodes: ConversationNode<TProjection>[]): BranchMember[] {
992
+ const members: BranchMember[] = [];
993
+ for (const n of nodes) {
994
+ const head = this._codec.getMessages(n.projection).at(0);
995
+ if (head) members.push({ memberNodeKey: nodeKey(n), representativeCodecMessageId: head.codecMessageId });
996
+ }
997
+ return members;
998
+ }
999
+
691
1000
  // -------------------------------------------------------------------------
692
1001
  // Write operations
693
1002
  // -------------------------------------------------------------------------
@@ -752,6 +1061,26 @@ export class DefaultView<
752
1061
  // (`result.inputCodecMessageId`) so we can promote when the new reply run lands.
753
1062
  const anchorRun = this._runByCodecMessageId(anchorCodecMessageId);
754
1063
  if (!anchorRun) return;
1064
+
1065
+ // Non-head regenerate: the anchor is a non-head message of its owner run, so
1066
+ // the new run won't be a same-parent sibling — it parents at the anchor's
1067
+ // predecessor. Defer in the dedicated non-head map (keyed by the anchor
1068
+ // message), not the sibling-group regen map.
1069
+ const anchorMsgs = this._codec.getMessages(anchorRun.projection);
1070
+ if (anchorMsgs.at(0)?.codecMessageId !== anchorCodecMessageId) {
1071
+ this._nonHeadRegenSelections.set(anchorCodecMessageId, {
1072
+ kind: 'pending',
1073
+ carrierCodecMessageId: result.inputCodecMessageId,
1074
+ });
1075
+ this._logger.debug('DefaultView._applyRegenerateAutoSelect(); deferring non-head regenerate selection', {
1076
+ anchorCodecMessageId,
1077
+ carrier: result.inputCodecMessageId,
1078
+ });
1079
+ this._resolvePendingNonHeadRegenSelections();
1080
+ this._recomputeAndEmitIfChanged();
1081
+ return;
1082
+ }
1083
+
755
1084
  const groupRoot = this._tree.getGroupRoot(anchorRun.runId);
756
1085
 
757
1086
  this._regenSelections.set(groupRoot, {
@@ -940,8 +1269,10 @@ export class DefaultView<
940
1269
  this._emitter.off();
941
1270
  this._branchSelections.clear();
942
1271
  this._regenSelections.clear();
1272
+ this._nonHeadRegenSelections.clear();
943
1273
  this._withheldRunIds.clear();
944
1274
  this._withheldBuffer.length = 0;
1275
+ this._hiddenMessageCount = 0;
945
1276
  this._onClose?.();
946
1277
  }
947
1278
 
@@ -949,57 +1280,45 @@ export class DefaultView<
949
1280
  // Private: history loading
950
1281
  // -------------------------------------------------------------------------
951
1282
 
952
- private async _loadFirstPage(limit: number): Promise<void> {
953
- // loadHistory's limit counts complete domain messages per page (not
954
- // Runs); see `_RUN_TO_MESSAGE_FETCH_FACTOR` for the scaling rationale.
955
- const messageLimit = limit * _RUN_TO_MESSAGE_FETCH_FACTOR;
956
- const firstPage = await loadHistory(this._channel, { limit: messageLimit }, this._logger);
1283
+ private async _loadFirstPage(target: number): Promise<void> {
1284
+ // `loadHistory`'s limit and this view's reveal target both count complete
1285
+ // domain messages (codecMessages), so the target passes straight through.
1286
+ const firstPage = await loadHistory(this._channel, { limit: target }, this._logger);
957
1287
  if (this._closed) return;
958
- await this._revealFromPage(firstPage, limit);
1288
+ await this._revealFromPage(firstPage, target);
959
1289
  }
960
1290
 
961
1291
  /**
962
- * Walk channel history from `page` until at least `limit` new Runs are
963
- * observed (or the channel is exhausted), then reveal the newest batch and
964
- * withhold the rest. Snapshots the already-visible nodes up front so only
965
- * newly-observed Runs count toward `limit`. No-op if the view closed during
966
- * the page walk.
1292
+ * Walk channel history from `page` until the newly-observed nodes hold at
1293
+ * least `target` codecMessages (or the channel is exhausted), then reveal the
1294
+ * newest whole runs covering `target` and withhold the rest. Snapshots the
1295
+ * already-visible nodes up front so only newly-observed nodes count toward
1296
+ * `target`. No-op if the view closed during the page walk.
967
1297
  * @param page - The decoded history page to start from.
968
- * @param limit - Max Runs to reveal in this batch.
1298
+ * @param target - Minimum codecMessages to reveal in this batch.
969
1299
  */
970
- private async _revealFromPage(page: HistoryPage, limit: number): Promise<void> {
1300
+ private async _revealFromPage(page: HistoryPage, target: number): Promise<void> {
971
1301
  // Snapshot before loading: every node already in the tree stays visible.
972
1302
  const beforeRunIds = new Set(this._treeVisibleNodes().map((n) => nodeKey(n)));
973
1303
 
974
- const { newVisible, lastPage } = await this._loadUntilVisible(page, limit, beforeRunIds);
1304
+ const { newVisible, lastPage } = await this._loadUntilVisible(page, target, beforeRunIds);
975
1305
  if (this._closed) return;
976
1306
  this._lastHistoryPage = lastPage;
977
1307
  this._hasMoreHistory = lastPage.hasNext();
978
- this._splitReveal(newVisible, limit);
1308
+ this._splitReveal(newVisible, target);
979
1309
  }
980
1310
 
981
1311
  /**
982
- * Reveal the newest `limit` Runs from `newVisible` and withhold the rest
983
- * so subsequent `loadOlder` calls can drain them. Called by
984
- * {@link _revealFromPage} to enforce the Run-unit pagination contract.
985
- * @param newVisible - Newly observed Runs from the history fetch.
986
- * @param limit - Max Runs to reveal in this batch.
1312
+ * Reveal the newest whole runs covering `target` codecMessages from
1313
+ * `newVisible` and withhold the rest so subsequent `loadOlder` calls can
1314
+ * drain them. Reveal granularity is the whole run; the caller trims the flat
1315
+ * message list (via `_hiddenMessageCount`) to make the visible message count
1316
+ * exact. Called by {@link _revealFromPage}.
1317
+ * @param newVisible - Newly observed nodes (inputs + reply runs) from the history fetch, chronological.
1318
+ * @param target - Minimum codecMessages the revealed batch must cover.
987
1319
  */
988
- private _splitReveal(newVisible: ConversationNode<TProjection>[], limit: number): void {
989
- // Reveal granularity is the reply RUN; an input node travels with the reply
990
- // run it precedes. Walk newest-first, counting reply runs toward `limit`,
991
- // and split the union list at the resulting boundary so an input + its reply
992
- // are revealed or withheld together.
993
- let runs = 0;
994
- let splitIdx = newVisible.length; // index of first revealed node
995
- for (let i = newVisible.length - 1; i >= 0; i--) {
996
- const node = newVisible[i];
997
- if (node?.kind === 'run') {
998
- if (runs === limit) break;
999
- runs++;
1000
- }
1001
- splitIdx = i;
1002
- }
1320
+ private _splitReveal(newVisible: ConversationNode<TProjection>[], target: number): void {
1321
+ const splitIdx = this._messageTailSplitIndex(newVisible, target);
1003
1322
  const batch = newVisible.slice(splitIdx);
1004
1323
  const withheld = newVisible.slice(0, splitIdx);
1005
1324
  for (const n of withheld) {
@@ -1010,20 +1329,20 @@ export class DefaultView<
1010
1329
  }
1011
1330
 
1012
1331
  /**
1013
- * Replay a history page's raw messages into the Tree. Dispatches by Ably
1014
- * message name to run-lifecycle vs. regular wire messages, mirroring the
1015
- * live `client-session._handleMessage` decode loop. Uses a fresh decoder
1016
- * since the session's live decoder maintains its own stream-tracker state.
1332
+ * Replay a history page's raw messages into the Tree through the Tree's
1333
+ * single decode-and-apply engine the same applier (and decoder instance)
1334
+ * the client's live loop uses, so history replay can't drift from it. The
1335
+ * shared decoder's version-guarded trackers make the overlap between the
1336
+ * two routes safe: an in-flight stream that spans the attach boundary is
1337
+ * continued rather than re-started, and content the live route already
1338
+ * incorporated decodes to nothing.
1017
1339
  * @param page - The history page returned by `loadHistory`.
1018
1340
  */
1019
1341
  private _processHistoryPage(page: HistoryPage): void {
1020
1342
  this._processingHistory = true;
1021
1343
  try {
1022
- // Reconstruct the tree via the shared decode-fold engine — the same path
1023
- // the client's live loop uses, so history replay can't drift from it.
1024
- const decoder = this._codec.createDecoder();
1025
1344
  for (const rawMsg of page.rawMessages) {
1026
- applyWireMessage(this._tree, decoder, rawMsg);
1345
+ this._applier.apply(rawMsg);
1027
1346
  }
1028
1347
 
1029
1348
  // Emit ably-message in a batch AFTER the whole page is applied, so a
@@ -1047,9 +1366,9 @@ export class DefaultView<
1047
1366
  const newVisibleCount = (): number => {
1048
1367
  let count = 0;
1049
1368
  for (const n of this._treeVisibleNodes()) {
1050
- // Pagination counts reply RUNS toward the target (an input node travels
1051
- // with the reply run it precedes see `_splitReveal`).
1052
- if (n.kind === 'run' && !beforeRunIds.has(nodeKey(n))) count++;
1369
+ // Count newly-visible codecMessages toward the target (whole runs are
1370
+ // revealed; the caller trims to the exact message count).
1371
+ if (!beforeRunIds.has(nodeKey(n))) count += this._codec.getMessages(n.projection).length;
1053
1372
  }
1054
1373
  return count;
1055
1374
  };
@@ -1086,7 +1405,10 @@ export class DefaultView<
1086
1405
  this._lastVisibleNodeKeys = resolved.map((n) => nodeKey(n));
1087
1406
  this._lastVisibleNodeKeySet = new Set(this._lastVisibleNodeKeys);
1088
1407
  this._lastVisibleProjections = resolved.map((n) => n.projection);
1089
- this._lastVisibleMessagePairs = this._extractMessages(resolved);
1408
+ // Run-level reveal, message-level trim: drop the oldest `_hiddenMessageCount`
1409
+ // messages so a `loadOlder` page lands on exactly `limit` messages even
1410
+ // though whole runs were revealed.
1411
+ this._lastVisibleMessagePairs = this._extractMessages(resolved).slice(this._hiddenMessageCount);
1090
1412
  }
1091
1413
 
1092
1414
  private _onTreeUpdate(): void {
@@ -1109,6 +1431,7 @@ export class DefaultView<
1109
1431
  // shifting this view to a branch the user didn't navigate to.
1110
1432
  this._pinBranchSelections();
1111
1433
  this._resolvePendingRegenSelections();
1434
+ this._resolvePendingNonHeadRegenSelections();
1112
1435
 
1113
1436
  this._recomputeAndEmitIfChanged();
1114
1437
  }
@@ -1191,6 +1514,31 @@ export class DefaultView<
1191
1514
  }
1192
1515
  }
1193
1516
 
1517
+ /**
1518
+ * Roll `pending` and `auto` non-head regenerate selections forward to the
1519
+ * newest regenerator of their anchor message. Mirrors
1520
+ * {@link _resolvePendingRegenSelections} for the non-head group, which lives in
1521
+ * a separate selection map (anchored by the regenerate target rather than a
1522
+ * sibling-group root): a `user` selection pins and is left untouched; a
1523
+ * `pending`/`auto` slot adopts the newest regenerator once one lands. The
1524
+ * anchor's predecessor — the key the regenerators file under — is recovered
1525
+ * from the owning run's projection.
1526
+ */
1527
+ private _resolvePendingNonHeadRegenSelections(): void {
1528
+ for (const [anchorId, sel] of this._nonHeadRegenSelections) {
1529
+ if (sel.kind === 'user') continue;
1530
+ const owner = this._runByCodecMessageId(anchorId);
1531
+ if (!owner) continue;
1532
+ const ownerMsgs = this._codec.getMessages(owner.projection);
1533
+ const idx = ownerMsgs.findIndex((m) => m.codecMessageId === anchorId);
1534
+ const predecessor = idx > 0 ? ownerMsgs[idx - 1]?.codecMessageId : undefined;
1535
+ if (predecessor === undefined) continue;
1536
+ const newest = this._nonHeadRegenerators(anchorId, predecessor).at(-1);
1537
+ if (!newest) continue;
1538
+ this._nonHeadRegenSelections.set(anchorId, { kind: 'auto', selectedRunId: newest.runId });
1539
+ }
1540
+ }
1541
+
1194
1542
  private _onTreeAblyMessage(msg: Ably.InboundMessage): void {
1195
1543
  // Re-emit only if the message corresponds to a visible Run
1196
1544
  const headers = getTransportHeaders(msg);