@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.
- package/README.md +10 -19
- package/dist/ably-ai-transport.js +1790 -1091
- package/dist/ably-ai-transport.js.map +1 -1
- package/dist/ably-ai-transport.umd.cjs +1 -1
- package/dist/ably-ai-transport.umd.cjs.map +1 -1
- package/dist/constants.d.ts +2 -2
- package/dist/core/agent.d.ts +20 -5
- package/dist/core/channel-options.d.ts +57 -0
- package/dist/core/codec/codec-event.d.ts +9 -0
- package/dist/core/codec/decoder.d.ts +4 -1
- package/dist/core/codec/define-codec.d.ts +100 -0
- package/dist/core/codec/encoder.d.ts +2 -7
- package/dist/core/codec/field-bag.d.ts +85 -0
- package/dist/core/codec/fields.d.ts +141 -0
- package/dist/core/codec/index.d.ts +8 -1
- package/dist/core/codec/input-descriptor-decoder.d.ts +19 -0
- package/dist/core/codec/input-descriptor-encoder.d.ts +22 -0
- package/dist/core/codec/input-descriptors.d.ts +281 -0
- package/dist/core/codec/output-descriptor-decoder.d.ts +29 -0
- package/dist/core/codec/output-descriptor-encoder.d.ts +31 -0
- package/dist/core/codec/output-descriptors.d.ts +237 -0
- package/dist/core/codec/types.d.ts +95 -36
- package/dist/core/codec/well-known-inputs.d.ts +52 -0
- package/dist/core/transport/agent-view.d.ts +296 -0
- package/dist/core/transport/decode-fold.d.ts +40 -32
- package/dist/core/transport/headers.d.ts +30 -1
- package/dist/core/transport/index.d.ts +1 -1
- package/dist/core/transport/invocation.d.ts +1 -1
- package/dist/core/transport/load-history-pages.d.ts +71 -0
- package/dist/core/transport/load-history.d.ts +21 -16
- package/dist/core/transport/run-manager.d.ts +9 -11
- package/dist/core/transport/session-support.d.ts +55 -0
- package/dist/core/transport/tree.d.ts +165 -15
- package/dist/core/transport/types/agent.d.ts +120 -98
- package/dist/core/transport/types/client.d.ts +45 -12
- package/dist/core/transport/types/tree.d.ts +52 -10
- package/dist/core/transport/types/view.d.ts +55 -28
- package/dist/core/transport/view.d.ts +176 -58
- package/dist/core/transport/wire-log.d.ts +102 -0
- package/dist/errors.d.ts +10 -4
- package/dist/index.d.ts +6 -5
- package/dist/react/ably-ai-transport-react.js +784 -415
- package/dist/react/ably-ai-transport-react.js.map +1 -1
- package/dist/react/ably-ai-transport-react.umd.cjs +1 -1
- package/dist/react/ably-ai-transport-react.umd.cjs.map +1 -1
- package/dist/react/contexts/client-session-context.d.ts +2 -1
- package/dist/react/contexts/client-session-provider.d.ts +3 -0
- package/dist/react/index.d.ts +2 -1
- package/dist/react/internal/skipped-session.d.ts +8 -0
- package/dist/react/use-view.d.ts +3 -3
- package/dist/utils.d.ts +22 -54
- package/dist/vercel/ably-ai-transport-vercel.js +2297 -2026
- package/dist/vercel/ably-ai-transport-vercel.js.map +1 -1
- package/dist/vercel/ably-ai-transport-vercel.umd.cjs +1 -1
- package/dist/vercel/ably-ai-transport-vercel.umd.cjs.map +1 -1
- package/dist/vercel/codec/decode-lifecycle.d.ts +9 -0
- package/dist/vercel/codec/events.d.ts +1 -2
- package/dist/vercel/codec/fields.d.ts +44 -0
- package/dist/vercel/codec/fold-content.d.ts +16 -0
- package/dist/vercel/codec/fold-data.d.ts +16 -0
- package/dist/vercel/codec/fold-input.d.ts +67 -0
- package/dist/vercel/codec/fold-lifecycle.d.ts +16 -0
- package/dist/vercel/codec/fold-text.d.ts +16 -0
- package/dist/vercel/codec/fold-tool-input.d.ts +17 -0
- package/dist/vercel/codec/fold-tool-output.d.ts +16 -0
- package/dist/vercel/codec/index.d.ts +5 -30
- package/dist/vercel/codec/inputs.d.ts +11 -0
- package/dist/vercel/codec/outputs.d.ts +11 -0
- package/dist/vercel/codec/reducer-state.d.ts +121 -0
- package/dist/vercel/codec/reducer.d.ts +20 -102
- package/dist/vercel/codec/tool-transitions.d.ts +0 -6
- package/dist/vercel/codec/wire-data.d.ts +34 -0
- package/dist/vercel/index.d.ts +1 -0
- package/dist/vercel/react/ably-ai-transport-vercel-react.js +2013 -9500
- package/dist/vercel/react/ably-ai-transport-vercel-react.js.map +1 -1
- package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs +1 -70
- package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs.map +1 -1
- package/dist/vercel/react/contexts/chat-transport-context.d.ts +2 -1
- package/dist/vercel/run-end-reason.d.ts +66 -11
- package/dist/vercel/tool-part.d.ts +21 -0
- package/dist/vercel/transport/chat-transport.d.ts +0 -2
- package/dist/vercel/transport/index.d.ts +1 -1
- package/dist/vercel/transport/run-output-stream.d.ts +6 -8
- package/dist/version.d.ts +1 -1
- package/package.json +2 -2
- package/src/constants.ts +2 -2
- package/src/core/agent.ts +43 -19
- package/src/core/channel-options.ts +89 -0
- package/src/core/codec/codec-event.ts +27 -0
- package/src/core/codec/decoder.ts +145 -21
- package/src/core/codec/define-codec.ts +432 -0
- package/src/core/codec/encoder.ts +13 -54
- package/src/core/codec/field-bag.ts +142 -0
- package/src/core/codec/fields.ts +193 -0
- package/src/core/codec/index.ts +43 -0
- package/src/core/codec/input-descriptor-decoder.ts +97 -0
- package/src/core/codec/input-descriptor-encoder.ts +150 -0
- package/src/core/codec/input-descriptors.ts +373 -0
- package/src/core/codec/output-descriptor-decoder.ts +139 -0
- package/src/core/codec/output-descriptor-encoder.ts +101 -0
- package/src/core/codec/output-descriptors.ts +307 -0
- package/src/core/codec/types.ts +99 -36
- package/src/core/codec/well-known-inputs.ts +96 -0
- package/src/core/transport/agent-session.ts +330 -589
- package/src/core/transport/agent-view.ts +738 -0
- package/src/core/transport/client-session.ts +74 -69
- package/src/core/transport/decode-fold.ts +57 -47
- package/src/core/transport/headers.ts +57 -4
- package/src/core/transport/index.ts +2 -1
- package/src/core/transport/invocation.ts +1 -1
- package/src/core/transport/load-history-pages.ts +220 -0
- package/src/core/transport/load-history.ts +63 -61
- package/src/core/transport/pipe-stream.ts +10 -1
- package/src/core/transport/run-manager.ts +25 -31
- package/src/core/transport/session-support.ts +96 -0
- package/src/core/transport/tree.ts +414 -47
- package/src/core/transport/types/agent.ts +129 -102
- package/src/core/transport/types/client.ts +49 -13
- package/src/core/transport/types/tree.ts +61 -12
- package/src/core/transport/types/view.ts +57 -28
- package/src/core/transport/view.ts +520 -172
- package/src/core/transport/wire-log.ts +189 -0
- package/src/errors.ts +10 -3
- package/src/index.ts +44 -11
- package/src/react/contexts/client-session-context.ts +1 -1
- package/src/react/contexts/client-session-provider.tsx +38 -2
- package/src/react/index.ts +2 -1
- package/src/react/internal/skipped-session.ts +62 -0
- package/src/react/use-client-session.ts +7 -30
- package/src/react/use-view.ts +3 -3
- package/src/utils.ts +31 -97
- package/src/vercel/codec/decode-lifecycle.ts +70 -0
- package/src/vercel/codec/events.ts +1 -3
- package/src/vercel/codec/fields.ts +58 -0
- package/src/vercel/codec/fold-content.ts +54 -0
- package/src/vercel/codec/fold-data.ts +46 -0
- package/src/vercel/codec/fold-input.ts +255 -0
- package/src/vercel/codec/fold-lifecycle.ts +85 -0
- package/src/vercel/codec/fold-text.ts +55 -0
- package/src/vercel/codec/fold-tool-input.ts +86 -0
- package/src/vercel/codec/fold-tool-output.ts +79 -0
- package/src/vercel/codec/index.ts +23 -63
- package/src/vercel/codec/inputs.ts +116 -0
- package/src/vercel/codec/outputs.ts +207 -0
- package/src/vercel/codec/reducer-state.ts +169 -0
- package/src/vercel/codec/reducer.ts +52 -838
- package/src/vercel/codec/tool-transitions.ts +1 -12
- package/src/vercel/codec/wire-data.ts +64 -0
- package/src/vercel/index.ts +1 -0
- package/src/vercel/react/contexts/chat-transport-context.ts +1 -1
- package/src/vercel/react/use-chat-transport.ts +8 -28
- package/src/vercel/react/use-message-sync.ts +5 -10
- package/src/vercel/run-end-reason.ts +95 -16
- package/src/vercel/tool-part.ts +25 -0
- package/src/vercel/transport/chat-transport.ts +10 -22
- package/src/vercel/transport/index.ts +1 -1
- package/src/vercel/transport/run-output-stream.ts +7 -8
- package/src/version.ts +1 -1
- package/dist/core/transport/branch-chain.d.ts +0 -43
- package/dist/core/transport/load-conversation.d.ts +0 -128
- package/dist/vercel/codec/decoder.d.ts +0 -9
- package/dist/vercel/codec/encoder.d.ts +0 -11
- package/src/core/transport/branch-chain.ts +0 -58
- package/src/core/transport/load-conversation.ts +0 -355
- package/src/vercel/codec/decoder.ts +0 -696
- 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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
*
|
|
408
|
-
*
|
|
409
|
-
*
|
|
410
|
-
*
|
|
411
|
-
*
|
|
412
|
-
*
|
|
413
|
-
*
|
|
414
|
-
*
|
|
415
|
-
*
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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
|
-
|
|
426
|
-
|
|
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
|
|
576
|
+
* Reveal `limit` more older codecMessages in this view — fewer only when
|
|
577
|
+
* channel history is exhausted.
|
|
438
578
|
*
|
|
439
|
-
*
|
|
440
|
-
*
|
|
441
|
-
*
|
|
442
|
-
*
|
|
443
|
-
*
|
|
444
|
-
*
|
|
445
|
-
*
|
|
446
|
-
* @param limit -
|
|
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 =
|
|
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
|
-
//
|
|
455
|
-
//
|
|
456
|
-
//
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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
|
-
|
|
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
|
-
|
|
473
|
-
|
|
474
|
-
|
|
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
|
-
|
|
478
|
-
//
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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
|
-
|
|
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
|
|
551
|
-
// for an edit fork that is the alternate user prompt; for a
|
|
552
|
-
//
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
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.
|
|
599
|
-
const selected = branch.
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
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.
|
|
631
|
-
const idx = branch.
|
|
632
|
-
return idx === -1 ? branch.
|
|
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 =
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
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
|
-
*
|
|
644
|
-
*
|
|
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
|
|
658
|
-
*
|
|
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
|
|
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 {
|
|
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
|
-
|
|
683
|
-
|
|
684
|
-
|
|
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(
|
|
953
|
-
// loadHistory's limit
|
|
954
|
-
//
|
|
955
|
-
const
|
|
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,
|
|
1288
|
+
await this._revealFromPage(firstPage, target);
|
|
959
1289
|
}
|
|
960
1290
|
|
|
961
1291
|
/**
|
|
962
|
-
* Walk channel history from `page` until
|
|
963
|
-
*
|
|
964
|
-
* withhold the rest. Snapshots the
|
|
965
|
-
*
|
|
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
|
|
1298
|
+
* @param target - Minimum codecMessages to reveal in this batch.
|
|
969
1299
|
*/
|
|
970
|
-
private async _revealFromPage(page: HistoryPage,
|
|
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,
|
|
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,
|
|
1308
|
+
this._splitReveal(newVisible, target);
|
|
979
1309
|
}
|
|
980
1310
|
|
|
981
1311
|
/**
|
|
982
|
-
* Reveal the newest
|
|
983
|
-
* so subsequent `loadOlder` calls can
|
|
984
|
-
*
|
|
985
|
-
*
|
|
986
|
-
*
|
|
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>[],
|
|
989
|
-
|
|
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
|
|
1014
|
-
*
|
|
1015
|
-
*
|
|
1016
|
-
*
|
|
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
|
-
|
|
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
|
-
//
|
|
1051
|
-
//
|
|
1052
|
-
if (
|
|
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
|
-
|
|
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);
|