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