bunnyquery 1.6.2 → 1.7.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bunnyquery",
3
- "version": "1.6.2",
3
+ "version": "1.7.0",
4
4
  "description": "Embeddable BunnyQuery AI chat widget + its framework-agnostic chat engine",
5
5
  "main": "bunnyquery.js",
6
6
  "exports": {
@@ -44,7 +44,7 @@ export type MapHistoryOptions = {
44
44
  clearedAt: number;
45
45
  serviceId: string;
46
46
  /** View-side display formatter for "Indexing:/Reindexing: …" bubbles. */
47
- formatIndexingLabel: (name: string, mime?: string, size?: number | null, storagePath?: string) => string;
47
+ formatIndexingLabel: (name: string, mime?: string, size?: number | null, storagePath?: string, reindex?: boolean, continued?: boolean) => string;
48
48
  };
49
49
 
50
50
  export function mapHistoryListToMessages(list: any[], platform: 'claude' | 'openai', opts: MapHistoryOptions) {
@@ -67,18 +67,35 @@ export function mapHistoryListToMessages(list: any[], platform: 'claude' | 'open
67
67
 
68
68
  if (userText) {
69
69
  var displayContent;
70
+ // Structured file ref for the display layer's per-file collapsing, kept
71
+ // alongside the formatted label so grouping never has to parse it back.
72
+ var indexFile: any = undefined;
70
73
  if (item._isBgTask) {
71
74
  var nameMatch = userText.match(/^- name: (.+)$/m);
72
75
  if (nameMatch) {
73
76
  var mimeMatch = userText.match(/^- mime type: (.+)$/m);
74
77
  var sizeMatch = userText.match(/^- size \(bytes\): (\d+)$/m);
75
78
  var pathMatch = userText.match(/^- storage path: (.+)$/m);
79
+ // A CONTINUE pass ("CONTINUE indexing …") gets the compact
80
+ // continuation label; a first pass ("A new file …") gets the full
81
+ // one. Mirrors agent.vue's mapHistoryListToMessages so a big file's
82
+ // windows read as progress, not the same task repeating.
83
+ var isContinuePass = userText.indexOf('CONTINUE indexing') === 0;
76
84
  displayContent = opts.formatIndexingLabel(
77
85
  nameMatch[1].trim(),
78
86
  mimeMatch ? mimeMatch[1].trim() : '',
79
87
  sizeMatch ? Number(sizeMatch[1]) : null,
80
- pathMatch ? pathMatch[1].trim() : undefined
88
+ pathMatch ? pathMatch[1].trim() : undefined,
89
+ false,
90
+ isContinuePass
81
91
  );
92
+ indexFile = {
93
+ name: nameMatch[1].trim(),
94
+ path: pathMatch ? pathMatch[1].trim() : undefined,
95
+ mime: mimeMatch ? mimeMatch[1].trim() : undefined,
96
+ size: sizeMatch ? Number(sizeMatch[1]) : undefined,
97
+ continued: isContinuePass,
98
+ };
82
99
  } else {
83
100
  displayContent = userText;
84
101
  }
@@ -90,6 +107,7 @@ export function mapHistoryListToMessages(list: any[], platform: 'claude' | 'open
90
107
  if (isQueued) userMsg.isPendingQueued = true;
91
108
  if (isCancelledItem) userMsg.isCancelled = true;
92
109
  if (item._isBgTask) userMsg.isBackgroundTask = true;
110
+ if (indexFile) userMsg._indexFile = indexFile;
93
111
  if (item._isOnBgQueue) userMsg._useBgQueue = true;
94
112
  if (serverItemId !== undefined) userMsg._serverItemId = serverItemId;
95
113
  mapped.push(userMsg);
@@ -30,6 +30,25 @@ export interface PinnedDispatchContext {
30
30
  systemPrompt: string;
31
31
  }
32
32
 
33
+ /**
34
+ * The file a background-indexing bubble belongs to. Stamped on the REQUEST
35
+ * bubble (both the live one and the one rebuilt from history) so the display
36
+ * layer can group a file's many passes without reverse-parsing the view's
37
+ * formatted label. See indexing_groups.buildChatDisplayList.
38
+ */
39
+ export interface IndexingFileRef {
40
+ name: string;
41
+ /** Storage path, when known. Preferred group identity: a file can be
42
+ * re-uploaded under a name that already exists elsewhere. */
43
+ path?: string;
44
+ mime?: string;
45
+ size?: number;
46
+ isReindex?: boolean;
47
+ /** A CONTINUE pass. Its absence across every loaded pass is what tells the
48
+ * display layer that earlier passes are still unpaged. */
49
+ continued?: boolean;
50
+ }
51
+
33
52
  export interface ChatMessage {
34
53
  role: 'user' | 'assistant';
35
54
  content: string; // raw markdown — never HTML (the view parses for display)
@@ -41,6 +60,8 @@ export interface ChatMessage {
41
60
  isCancelled?: boolean;
42
61
  isError?: boolean;
43
62
  isBackgroundTask?: boolean;
63
+ /** Set on background-indexing REQUEST bubbles only (see IndexingFileRef). */
64
+ _indexFile?: IndexingFileRef;
44
65
  _useBgQueue?: boolean;
45
66
  _serverItemId?: string;
46
67
  _localId?: string;
@@ -86,6 +107,12 @@ export interface ChatHost {
86
107
  scrollToBottom(smooth?: boolean): Promise<void> | void;
87
108
  /** Scroll only if the user is pinned to the bottom (does not force-pin). */
88
109
  scrollToBottomIfSticky(smooth?: boolean): Promise<void> | void;
110
+ /** A history page finished loading and rendering. OPTIONAL. The view uses it
111
+ * to page further when the message box came out too short to scroll — the
112
+ * only trigger for loading older history is a scroll to the top, so a box
113
+ * that cannot scroll has no way to reach page 2 (see viewport_fill). Only
114
+ * the view can measure that, which is why the engine merely announces it. */
115
+ onHistoryLoaded?(fetchMore: boolean, token: number): void;
89
116
 
90
117
  // --- skapi surface beyond configureChatEngine() ---
91
118
  cancelRequest(opts: {
@@ -95,7 +122,7 @@ export interface ChatHost {
95
122
 
96
123
  // --- bg-indexing display ---
97
124
  /** Build the "Indexing:/Reindexing: …" label (view-side display formatting). */
98
- formatIndexingLabel(name: string, mime?: string, size?: number | null, storagePath?: string, reindex?: boolean): string;
125
+ formatIndexingLabel(name: string, mime?: string, size?: number | null, storagePath?: string, reindex?: boolean, continued?: boolean): string;
99
126
  /** drainBgTaskQueue is a no-op until the chat view is mounted. */
100
127
  isViewMounted(): boolean;
101
128
 
@@ -54,10 +54,33 @@ export {
54
54
  type MapHistoryOptions,
55
55
  } from './history';
56
56
 
57
+ // Older history is reachable only by scrolling to the top of the message box, so
58
+ // a box too short to scroll strands the user on page 1 — the normal state once a
59
+ // page of history collapses into one indexing row. Shared so both chatboxes page
60
+ // their way out of it identically.
61
+ export {
62
+ fillHistoryViewport,
63
+ createHistoryFiller,
64
+ HISTORY_FILL_SLACK_PX,
65
+ MAX_HISTORY_FILL_PAGES,
66
+ type FillHistoryViewportOptions,
67
+ } from './viewport_fill';
68
+
57
69
  // Tier-2: the stateful chat orchestration (queue/poll/cancel, typewriter,
58
70
  // bg-task drain, resolution). DOM-free; the consumer implements ChatHost.
59
71
  export { ChatSession } from './session';
60
- export type { ChatHost, ChatIdentity, ChatState, ChatMessage, PinnedDispatchContext } from './host';
72
+ export type { ChatHost, ChatIdentity, ChatState, ChatMessage, IndexingFileRef, PinnedDispatchContext } from './host';
73
+
74
+ // Display transform: collapse a file's many background-indexing turns into one
75
+ // row, wherever they sit in the conversation. Shared so both chatboxes match.
76
+ export {
77
+ buildChatDisplayList,
78
+ parseIndexingLabel,
79
+ type DisplayEntry,
80
+ type IndexingGroup,
81
+ type IndexingGroupStatus,
82
+ type BuildDisplayListOptions,
83
+ } from './indexing_groups';
61
84
 
62
85
  export {
63
86
  // constants
@@ -0,0 +1,350 @@
1
+ /**
2
+ * Background file-indexing turns, collapsed into ONE row per file.
3
+ *
4
+ * A single upload can produce many chat turns: the first "Indexing: <file>" pass
5
+ * plus up to MAX_INDEXING_RESUME_PASSES CONTINUE passes, each with its own
6
+ * request AND response bubble. Rendered flat, that reads as the same task
7
+ * repeating forever, and any real question the user asks in between gets buried.
8
+ *
9
+ * buildChatDisplayList turns the flat message array into a DISPLAY list in which
10
+ * every message belonging to one file (however far apart they sit, and whatever
11
+ * else is interleaved between them) is represented by a single group entry,
12
+ * rendered at the position of that file's NEWEST turn. Newest, not oldest, so:
13
+ * - a running index sits at the bottom, where the activity is, and
14
+ * - paging older history in never moves a row that is already on screen
15
+ * (older passes simply join the group they already belong to).
16
+ *
17
+ * The group deliberately reports no authoritative pass TOTAL. History is paged
18
+ * newest-first, so any total computed from loaded messages is a lower bound that
19
+ * a later scroll-up would contradict. It reports STATE (indexing / indexed /
20
+ * failed), how many passes are currently loaded, and `mayHaveOlder` when the
21
+ * file's first pass is not among them.
22
+ *
23
+ * Pure and view-agnostic: agent.vue and the BunnyQuery widget both render from
24
+ * this, so the two stay identical.
25
+ */
26
+ import type { ChatMessage, IndexingFileRef } from './host';
27
+
28
+ export type IndexingGroupStatus = 'active' | 'done' | 'error' | 'cancelled';
29
+
30
+ export type IndexingGroup = {
31
+ /** The FILE this row is about: storage path when known (a file can be
32
+ * re-uploaded under a name that already exists elsewhere), else name. Shared
33
+ * by every run of that file, and what ChatSession.cancelIndexingGroup and
34
+ * _indexKeyOf match on — never use it as a render key. */
35
+ key: string;
36
+ /** Identity of this ROW: one indexing RUN of that file. A file indexed on
37
+ * Monday and re-indexed on Wednesday is two runs, and collapsing them into
38
+ * one row erased Monday's from Monday's place in the conversation, claimed
39
+ * its passes for Wednesday, and let Monday's failure be overwritten by
40
+ * Wednesday's success. Numbered from the NEWEST run backwards (`#0` is the
41
+ * newest) so paging in older history never renames a row already on screen.
42
+ * This is the render key and the expansion key. */
43
+ runKey: string;
44
+ name: string;
45
+ path?: string;
46
+ mime?: string;
47
+ size?: number;
48
+ /** True when any loaded pass was a re-index of an already-stored file. */
49
+ isReindex: boolean;
50
+ /** Every message of this file, in chat order, with its index in the source
51
+ * array (so cancel/typewriter paths keep addressing the real message). */
52
+ members: { msg: ChatMessage; index: number }[];
53
+ /** Indexing passes LOADED (request bubbles), never a server-side total. */
54
+ passCount: number;
55
+ status: IndexingGroupStatus;
56
+ /** Server item ids of the passes that are still queued/running, so the row can
57
+ * offer a stop button (ChatSession.cancelIndexingGroup cancels each). Empty
58
+ * when nothing is cancellable — a finished file, or a live pass whose server
59
+ * id has not come back yet. */
60
+ cancellableIds: string[];
61
+ /** A cancel request is in flight for one of the passes. */
62
+ cancelling: boolean;
63
+ /** Why the last cancel attempt failed (e.g. the pass had already finished). */
64
+ cancelError?: string;
65
+ /** The file's first pass is not among the loaded messages, so earlier passes
66
+ * exist in history that has not been paged in yet. */
67
+ mayHaveOlder: boolean;
68
+ /** Position in the source array this collapsed row renders at. */
69
+ anchorIndex: number;
70
+ };
71
+
72
+ export type DisplayEntry =
73
+ | { kind: 'message'; msg: ChatMessage; index: number }
74
+ | { kind: 'indexing'; group: IndexingGroup; index: number };
75
+
76
+ export type BuildDisplayListOptions = {
77
+ /** True while older history remains unpaged, which is what makes a group
78
+ * with no first pass genuinely incomplete rather than merely odd. */
79
+ hasMoreHistory?: boolean;
80
+ };
81
+
82
+ // The indexing label is view-formatted (formatIndexingLabel), so parsing it is
83
+ // the FALLBACK path only: it exists for bubbles restored from a history cache
84
+ // written before `_indexFile` was stamped. Live and freshly-mapped bubbles carry
85
+ // the structured ref and never reach this.
86
+ //
87
+ // Shapes produced by formatIndexingLabel:
88
+ // "Indexing: [name](path) · mime · 1.2 MB"
89
+ // "Reindexing: name · mime"
90
+ // "Indexing (continuing) [name](path)"
91
+ const INDEXING_LABEL_RE = /^(Re)?[Ii]ndexing(\s*\(continuing\))?\s*:?\s+(.+)$/;
92
+ const LEADING_MD_LINK_RE = /^\[([^\]]+)\]\(([^)]+)\)/;
93
+
94
+ export function parseIndexingLabel(
95
+ content: string,
96
+ ): { name: string; path?: string; continued: boolean; isReindex: boolean } | null {
97
+ if (typeof content !== 'string' || !content) return null;
98
+ var firstLine = content.split('\n')[0].trim();
99
+ var m = firstLine.match(INDEXING_LABEL_RE);
100
+ if (!m) return null;
101
+ // Only the first " · " segment is the file; the rest is mime/size trivia.
102
+ var head = m[3].split(' · ')[0].trim();
103
+ var link = head.match(LEADING_MD_LINK_RE);
104
+ var name = link ? link[1].trim() : head;
105
+ if (!name) return null;
106
+ return {
107
+ name: name,
108
+ path: link ? link[2].trim() : undefined,
109
+ continued: !!m[2],
110
+ isReindex: !!m[1],
111
+ };
112
+ }
113
+
114
+ /** The file a background-indexing bubble belongs to, structured ref preferred. */
115
+ function readFileRef(msg: ChatMessage): (IndexingFileRef & { continued: boolean }) | null {
116
+ var ref = msg && msg._indexFile;
117
+ if (ref && (ref.path || ref.name)) {
118
+ return {
119
+ name: ref.name || ref.path || '',
120
+ path: ref.path,
121
+ mime: ref.mime,
122
+ size: ref.size,
123
+ isReindex: ref.isReindex,
124
+ continued: !!ref.continued,
125
+ };
126
+ }
127
+ var parsed = parseIndexingLabel(msg && msg.content);
128
+ if (!parsed) return null;
129
+ return {
130
+ name: parsed.name,
131
+ path: parsed.path,
132
+ isReindex: parsed.isReindex,
133
+ continued: parsed.continued,
134
+ };
135
+ }
136
+
137
+ function isPendingMsg(m: ChatMessage): boolean {
138
+ return !!(m.isPending || m.isPendingInProcess || m.isPendingQueued || m.isSendingToServer);
139
+ }
140
+
141
+ /**
142
+ * Collapse background-indexing turns into per-file groups.
143
+ *
144
+ * Messages that are not background-indexing pass through untouched, at their
145
+ * original positions and with their original indices.
146
+ */
147
+ export function buildChatDisplayList(
148
+ messages: ChatMessage[],
149
+ opts?: BuildDisplayListOptions,
150
+ ): DisplayEntry[] {
151
+ var list = Array.isArray(messages) ? messages : [];
152
+ var hasMoreHistory = !!(opts && opts.hasMoreHistory);
153
+
154
+ // One entry per RUN (see IndexingGroup.runKey), addressed by an internal id
155
+ // while the list is being walked; runKey is assigned at the end, once the
156
+ // number of runs per file is known.
157
+ var groups: { [runId: string]: IndexingGroup } = {};
158
+ var order: string[] = [];
159
+ var runOfIndex: (string | undefined)[] = new Array(list.length);
160
+ // A response bubble carries no file metadata of its own; it is tied to its
161
+ // request by the server item id, with the immediately preceding request as
162
+ // the fallback for locally-pushed bubbles that have no id yet.
163
+ var runByItemId: { [itemId: string]: string } = {};
164
+ // Row already opened for a file NAME, so a pass that reports no storage path
165
+ // joins it instead of opening a second row for the same file.
166
+ var keyByName: { [name: string]: string } = {};
167
+ // The run currently being accumulated for each file, every run of it, and the
168
+ // file each run belongs to (a run id is opaque — a file key can contain any
169
+ // character, so it must never be parsed back out of one).
170
+ var openRunOfKey: { [key: string]: string } = {};
171
+ var runsOfKey: { [key: string]: string[] } = {};
172
+ var keyOfRun: { [runId: string]: string } = {};
173
+ var runSeq = 0;
174
+
175
+ for (var i = 0; i < list.length; i++) {
176
+ var msg = list[i];
177
+ if (!msg || !msg.isBackgroundTask) continue;
178
+
179
+ var runId: string | undefined;
180
+ var ref = msg.role === 'user' ? readFileRef(msg) : null;
181
+ if (ref) {
182
+ // Path first — a file can be re-uploaded under a name that already
183
+ // exists elsewhere. But a pass that supplied no path (a compact
184
+ // continuation label recovered from an old history cache) is still the
185
+ // same file as the path-bearing passes above it, so let it join them
186
+ // rather than splitting one file across two collapsed rows.
187
+ var key = ref.path || keyByName[ref.name] || ref.name;
188
+ // A FIRST pass ("A new file has just been uploaded") when this file
189
+ // already has a run open starts a new one: it is a re-index, or a
190
+ // re-upload over the same storage path. Continuations join the run.
191
+ if (!ref.continued && openRunOfKey[key]) delete openRunOfKey[key];
192
+ runId = openRunOfKey[key];
193
+ if (!runId) {
194
+ runId = 'run' + (runSeq++);
195
+ openRunOfKey[key] = runId;
196
+ keyOfRun[runId] = key;
197
+ (runsOfKey[key] || (runsOfKey[key] = [])).push(runId);
198
+ }
199
+ } else if (msg._serverItemId && runByItemId[msg._serverItemId]) {
200
+ runId = runByItemId[msg._serverItemId];
201
+ } else if (msg.role !== 'user') {
202
+ // ADJACENT only. Both paths that create a pass emit its request and
203
+ // response bubbles together, so an id-less response belongs to the
204
+ // message right before it. Falling back to the last file seen ANYWHERE
205
+ // above swallowed stray background bubbles — an indexing prompt whose
206
+ // shape we could not parse, a response whose request is not loaded —
207
+ // into whichever file happened to be indexed most recently, however
208
+ // much unrelated conversation sat in between.
209
+ runId = runOfIndex[i - 1];
210
+ }
211
+ // A background bubble we cannot attribute to a file (an unrecognised label,
212
+ // or a response whose request is not loaded) stays an ordinary message
213
+ // rather than being folded into whichever group happens to be nearest.
214
+ if (!runId) continue;
215
+
216
+ var g = groups[runId];
217
+ if (!g) {
218
+ var fileKey = keyOfRun[runId];
219
+ g = groups[runId] = {
220
+ key: fileKey,
221
+ runKey: runId, // provisional; renumbered newest-first below
222
+ name: ref ? ref.name : fileKey,
223
+ path: ref ? ref.path : undefined,
224
+ mime: ref ? ref.mime : undefined,
225
+ size: ref ? ref.size : undefined,
226
+ isReindex: !!(ref && ref.isReindex),
227
+ members: [],
228
+ passCount: 0,
229
+ status: 'done',
230
+ cancellableIds: [],
231
+ cancelling: false,
232
+ mayHaveOlder: false,
233
+ anchorIndex: i,
234
+ };
235
+ order.push(runId);
236
+ }
237
+ if (ref) {
238
+ // Later passes carry the compact continuation label with no mime/size,
239
+ // so keep the richest values any pass supplied.
240
+ if (ref.name) g.name = ref.name;
241
+ if (ref.path) g.path = ref.path;
242
+ if (ref.mime) g.mime = ref.mime;
243
+ if (typeof ref.size === 'number') g.size = ref.size;
244
+ if (ref.isReindex) g.isReindex = true;
245
+ if (!ref.continued) g.mayHaveOlder = false;
246
+ g.passCount++;
247
+ }
248
+ g.members.push({ msg: msg, index: i });
249
+ g.anchorIndex = i;
250
+ runOfIndex[i] = runId;
251
+ if (msg._serverItemId) runByItemId[msg._serverItemId] = runId;
252
+ if (ref && ref.name) keyByName[ref.name] = g.key;
253
+ }
254
+
255
+ // Name each run after its FIRST loaded pass. That id survives everything the
256
+ // list does around it: passes appended to this run, other runs of the same
257
+ // file appearing before OR after it, and an older page prepending. (An
258
+ // ordinal would not — numbering from either end renames existing rows as soon
259
+ // as a run appears at that end, which silently moves the user's expansion to
260
+ // a row they never opened.) The one thing that changes it is the run's own
261
+ // true first pass finally paging in, which happens at most once per run.
262
+ for (var rk in runsOfKey) {
263
+ var runIds = runsOfKey[rk];
264
+ for (var ri = 0; ri < runIds.length; ri++) {
265
+ var grpR = groups[runIds[ri]];
266
+ if (!grpR) continue;
267
+ var first = grpR.members[0];
268
+ var firstId = first && first.msg && (first.msg._serverItemId || first.msg._localId);
269
+ // A run whose passes are all still local (no server id yet) falls back to
270
+ // its position, which is all there is to go on.
271
+ grpR.runKey = rk + '#' + (firstId || 'n' + ri);
272
+ }
273
+ }
274
+
275
+ // Status + completeness, once every member is known.
276
+ for (var oi = 0; oi < order.length; oi++) {
277
+ var grp = groups[order[oi]];
278
+ // A file's passes run strictly one after another, so a pass still marked
279
+ // queued/running with a SETTLED pass after it is stale history, not live
280
+ // work — the server's row for it simply never left "running". Ignoring
281
+ // those matters most for pages reached by scrolling up (or by the viewport
282
+ // fill), which get no poll attached to resolve them: one stale row would
283
+ // otherwise flip a long-finished file back to a spinner with a "Stop"
284
+ // button aimed at a dead item id, permanently.
285
+ var lastSettled = -1;
286
+ for (var si = 0; si < grp.members.length; si++) {
287
+ if (!isPendingMsg(grp.members[si].msg)) lastSettled = si;
288
+ }
289
+ var active = false;
290
+ for (var mi = lastSettled + 1; mi < grp.members.length; mi++) {
291
+ if (isPendingMsg(grp.members[mi].msg)) { active = true; break; }
292
+ }
293
+ // What a stop button would act on: the REQUEST bubble of every pass that is
294
+ // still queued or running server-side. The assistant placeholder shares its
295
+ // pass's server id, so ids are de-duplicated. A pass mid-cancel is left out
296
+ // (its request is already on its way) but keeps the row in a cancelling
297
+ // state so the button does not flicker back to "stop".
298
+ for (var xi = 0; xi < grp.members.length; xi++) {
299
+ if (grp.members[xi].msg._cancelling) { grp.cancelling = true; break; }
300
+ }
301
+ var seenIds: { [id: string]: boolean } = {};
302
+ for (var ci = 0; ci < grp.members.length; ci++) {
303
+ var cm = grp.members[ci].msg;
304
+ // Report a failed Stop only while the stop is still something the user
305
+ // can act on. "Could not remove from queue" (the pass had just started)
306
+ // stays on that bubble, so reporting it from ANY member meant a row that
307
+ // went on to finish normally kept describing a one-off transient failure
308
+ // as a permanent property of the file, until a full history refresh
309
+ // rebuilt the bubbles.
310
+ if (cm._cancelError && (active || grp.cancelling)) grp.cancelError = cm._cancelError;
311
+ if (cm.role !== 'user' || !cm._serverItemId || cm._cancelling || cm.isSendingToServer) continue;
312
+ if (!(cm.isPendingQueued || cm.isPendingInProcess)) continue;
313
+ // Same staleness rule: never offer to stop a pass a later one outlived.
314
+ if (ci < lastSettled) continue;
315
+ if (seenIds[cm._serverItemId]) continue;
316
+ seenIds[cm._serverItemId] = true;
317
+ grp.cancellableIds.push(cm._serverItemId);
318
+ }
319
+ if (active) {
320
+ grp.status = 'active';
321
+ } else {
322
+ // The newest loaded outcome is the file's state: an early pass may have
323
+ // errored and a later one succeeded.
324
+ var last = grp.members[grp.members.length - 1].msg;
325
+ grp.status = last.isError ? 'error' : last.isCancelled ? 'cancelled' : 'done';
326
+ }
327
+ // A group whose passes are ALL continuations began before the loaded
328
+ // window; its earlier passes arrive when older history is paged in.
329
+ var sawFirstPass = false;
330
+ for (var pi = 0; pi < grp.members.length; pi++) {
331
+ var pm = grp.members[pi].msg;
332
+ if (pm.role !== 'user') continue;
333
+ var pref = readFileRef(pm);
334
+ if (pref && !pref.continued) { sawFirstPass = true; break; }
335
+ }
336
+ grp.mayHaveOlder = !sawFirstPass && hasMoreHistory;
337
+ }
338
+
339
+ var out: DisplayEntry[] = [];
340
+ for (var j = 0; j < list.length; j++) {
341
+ var r = runOfIndex[j];
342
+ if (r === undefined) {
343
+ out.push({ kind: 'message', msg: list[j], index: j });
344
+ continue;
345
+ }
346
+ // Every other member of the run is represented by the row at the anchor.
347
+ if (groups[r].anchorIndex === j) out.push({ kind: 'indexing', group: groups[r], index: j });
348
+ }
349
+ return out;
350
+ }