bunnyquery 1.8.2 → 1.8.4

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.
@@ -42,10 +42,20 @@
42
42
  * failed), how many passes are currently loaded, and `mayHaveOlder` when the
43
43
  * file's first pass is not among them.
44
44
  *
45
+ * For the same reason it also reports NOT KNOWING. A run whose start is still
46
+ * being paged in, and a worker-driven run whose queue state has not been asked
47
+ * for yet, are both rows whose state is a moving target — and on a chatbox that
48
+ * was just opened, that is most of them. `resolving` marks those, so the view can
49
+ * say which wait it is waiting on rather than committing to "indexing" or
50
+ * "indexed" on a fraction of the evidence.
51
+ *
45
52
  * Pure and view-agnostic: agent.vue and the BunnyQuery widget both render from
46
53
  * this, so the two stay identical.
47
54
  */
48
55
  import type { ChatMessage, IndexingFileRef } from './host';
56
+ import { isPagedReadFile, isImageVisionFile } from './office';
57
+ import { windowedIndexingEnabled } from './config';
58
+ import { MAX_INDEXING_RESUME_PASSES } from './requests';
49
59
 
50
60
  export type IndexingGroupStatus = 'active' | 'done' | 'error' | 'cancelled';
51
61
 
@@ -53,7 +63,16 @@ export type IndexingGroup = {
53
63
  /** The FILE this row is about: storage path when known (a file can be
54
64
  * re-uploaded under a name that already exists elsewhere), else name. Shared
55
65
  * by every run of that file, and what ChatSession.cancelIndexingGroup and
56
- * _indexKeyOf match on — never use it as a render key. */
66
+ * _indexKeyOf match on — never use it as a render key.
67
+ *
68
+ * It IS the key for persistent view state, above all the expansion state. That
69
+ * used to be keyed by runKey, which is renamed the moment a run's true first
70
+ * pass loads (see below) — so a row the user had opened silently closed itself
71
+ * mid-indexing, every time a pass arrived ahead of the earlier ones. This never
72
+ * changes for the life of a file. The cost is that two runs OF THE SAME FILE
73
+ * (an index and a later re-index) open and close together, which is a fair
74
+ * reading of "show me this file's steps" and is not a state the user can be
75
+ * surprised out of. */
57
76
  key: string;
58
77
  /** Identity of this ROW: one indexing RUN of that file. A file indexed on
59
78
  * Monday and re-indexed on Wednesday is two runs, and collapsing them into
@@ -61,8 +80,13 @@ export type IndexingGroup = {
61
80
  * its passes for Wednesday, and let Monday's failure be overwritten by
62
81
  * Wednesday's success. Named after the run's FIRST loaded pass (see where it
63
82
  * 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. */
83
+ * on either side of it never rename a row already on screen.
84
+ *
85
+ * This is the RENDER key, and only that. It is renamed when the run's true
86
+ * first pass finally loads — routine while a worker-driven chain is running,
87
+ * since a pass adopted from the queue can reach the client before the earlier
88
+ * ones are paged in — and a rename is exactly right for a DOM key (the row did
89
+ * change identity) but wrong for anything the USER set. Key that on `key`. */
66
90
  runKey: string;
67
91
  name: string;
68
92
  path?: string;
@@ -81,8 +105,19 @@ export type IndexingGroup = {
81
105
  * when nothing is cancellable — a finished file, or a live pass whose server
82
106
  * id has not come back yet. */
83
107
  cancellableIds: string[];
84
- /** A cancel request is in flight for one of the passes. */
108
+ /** This row is in the middle of stopping: a cancel request is in flight for one
109
+ * of its passes, or the user stopped the run and a pass is still running. Both
110
+ * mean the same thing to a view — the Stop has been spent, so the button reads
111
+ * "Stopping..." and is not offered again. */
85
112
  cancelling: boolean;
113
+ /** The user stopped this run.
114
+ *
115
+ * NOT the same as `status === 'cancelled'`, and the difference is the whole
116
+ * reason it exists: a stop landing on a pass that is already RUNNING cannot
117
+ * un-run it, so the row stays `active` until that pass settles. `status` then
118
+ * describes the work (something is still running) and this describes the user's
119
+ * decision (no more of it will be started). */
120
+ stopped: boolean;
86
121
  /** Why the last cancel attempt failed (e.g. the pass had already finished). */
87
122
  cancelError?: string;
88
123
  /** The file's first pass is not among the loaded messages, so earlier passes
@@ -98,6 +133,71 @@ export type IndexingGroup = {
98
133
  * must not re-derive it from `members`: which member the row renders at is
99
134
  * this module's decision, and the two silently disagreed once already. */
100
135
  anchorId: string;
136
+ /** `members` minus the turns an EXPANDED row should not show: every CONTINUE
137
+ * request, and the running pass's empty placeholder.
138
+ *
139
+ * A continuation's request bubble says "Indexing (continuing) <file>" and
140
+ * nothing else — it repeats the row's own header once per pass, so a long file
141
+ * read as the same line over and over with the actual findings buried between
142
+ * them. The pass is still represented, by its RESPONSE. The placeholder goes
143
+ * for a different reason: the row now carries one loader of its own for as long
144
+ * as work remains, and two spinners in one open row is noise.
145
+ *
146
+ * Additive. `members` is untouched and is still what every count, status,
147
+ * cancel and anchor decision reads — several of them are only correct on the
148
+ * full list (a `mayHaveOlder` run's members[0] IS a continuation). */
149
+ visibleMembers: { msg: ChatMessage; index: number }[];
150
+ /** Who advances this file's chain, which decides what can confirm it is over:
151
+ * 'single' one pass and done; 'client' this client dispatches each CONTINUE
152
+ * pass and stops on the model's completion marker; 'worker' the server advances
153
+ * the loop off the renderer's page count and the client is only a spectator. */
154
+ driver: 'single' | 'client' | 'worker';
155
+ /** Positively established that no further indexing work will happen for this
156
+ * run. NOT "the file was fully read" — a cap-out, a failure and a stop are all
157
+ * finished, and the row's own status says which.
158
+ *
159
+ * False means "not established", which includes "still running" AND "we have
160
+ * not been able to find out". The view shows a loader for both, deliberately:
161
+ * the alternative default is the failure this exists to prevent, a row that
162
+ * reads "Indexed" between two passes of a file still being read. */
163
+ finished: boolean;
164
+ /** This row cannot honestly claim a state yet, because something it is derived
165
+ * FROM is still being fetched. Both `status` and `finished` are read off the
166
+ * passes that happen to be LOADED, and on a freshly opened chatbox that is a
167
+ * moving target: history pages newest-first, so a long run arrives as a tail
168
+ * of CONTINUE passes while its beginning is still being paged in. The row was
169
+ * picking a side through that window — a spinner reading "Indexing" for a file
170
+ * that finished last week, or a green "Indexed" for one still being read — and
171
+ * both are verdicts drawn from a fraction of the run.
172
+ *
173
+ * Only ever set from status 'done'. A loaded pending pass PROVES the run is
174
+ * live, and an error or a stop is the newest pass's own outcome, which
175
+ * newest-first paging always has in hand — none of those is a guess, and
176
+ * hiding any of them behind a loader would lose something the user needs.
177
+ *
178
+ * For the 'history' reason this means "a fetch is IN FLIGHT", not "the picture
179
+ * is incomplete". Older history is paged in by explicit triggers only (the
180
+ * viewport fill, the user scrolling to the top) and nothing auto-fetches on a
181
+ * row's behalf, so a run whose start is still unloaded once the paging stops
182
+ * has to go back to reporting what it does know — `mayHaveOlder` and the `+` on
183
+ * the pass count carry the rest. A "loading..." that never ends is the same lie
184
+ * pointing the other way.
185
+ *
186
+ * The 'status' reason is weaker on purpose: "the queue has not answered", which
187
+ * a permanently failing query never resolves. That is deliberate, because it is
188
+ * the SAME question as what the row should say when it cannot find out, and
189
+ * every alternative is worse: a grey clock reading "checking" claims less than
190
+ * the yellow spinner reading "Indexing" that it replaced. It also self-heals in
191
+ * practice — the answer is re-sought on every first-page history load and every
192
+ * settling pass — and gating it on an in-flight query instead would mean
193
+ * threading a second liveness flag through a retry ladder with nine exit
194
+ * points, i.e. trading this for a flag that can stick in the other direction. */
195
+ resolving: boolean;
196
+ /** Which wait, so the row can name it instead of just spinning. 'history':
197
+ * older pages are being fetched and this run's first pass is not among the
198
+ * loaded ones. 'status': the queue has not yet said whether this file is still
199
+ * being worked on, which is the only thing that can end a worker-driven run. */
200
+ resolvingReason?: 'history' | 'status';
101
201
  };
102
202
 
103
203
  export type DisplayEntry =
@@ -108,6 +208,30 @@ export type BuildDisplayListOptions = {
108
208
  /** True while older history remains unpaged, which is what makes a group
109
209
  * with no first pass genuinely incomplete rather than merely odd. */
110
210
  hasMoreHistory?: boolean;
211
+ /** An OLDER-history fetch is in flight right now — a single page, or the whole
212
+ * viewport-fill loop (createHistoryFiller's onRunningChange, which spans the
213
+ * pages between which a per-request flag keeps dropping to false).
214
+ *
215
+ * Older specifically. A first-page refresh cannot bring in a run's earlier
216
+ * passes, so counting it here would flip every incomplete row to "still
217
+ * loading" for the length of a poll that could never have answered it. */
218
+ loadingOlderHistory?: boolean;
219
+ /** Files the SERVER still has unresolved indexing work for, keyed exactly like
220
+ * IndexingGroup.key (ChatSession.getLiveIndexState). */
221
+ liveIndexKeys?: { [fileKey: string]: boolean };
222
+ /** Whether `liveIndexKeys` has been answered at least once for this chat. False
223
+ * is "we do not know", and a worker-driven run stays unfinished on it. */
224
+ liveIndexChecked?: boolean;
225
+ /** Server item ids of passes that were on a row when the user STOPPED it
226
+ * (ChatSession.state.stoppedIndexIds). A run holding any of them is a run the
227
+ * user stopped — see the status derivation for why a stop usually leaves no
228
+ * other trace in the messages. Ids rather than a file key on purpose: they name
229
+ * one RUN, so a later re-index of the same file cannot inherit the stop. */
230
+ stoppedIndexIds?: { [serverItemId: string]: boolean };
231
+ /** Whether the WORKER drives the windowed text/grid loop (chatEngineConfig's
232
+ * windowedIndexing). Passed in rather than read from config so this stays a
233
+ * pure function of its inputs and can be exercised for both settings. */
234
+ windowedIndexing?: boolean;
111
235
  };
112
236
 
113
237
  // The indexing label is view-formatted (formatIndexingLabel), so parsing it is
@@ -169,6 +293,24 @@ function isPendingMsg(m: ChatMessage): boolean {
169
293
  return !!(m.isPending || m.isPendingInProcess || m.isPendingQueued || m.isSendingToServer);
170
294
  }
171
295
 
296
+ /**
297
+ * A member an EXPANDED row should not render. See IndexingGroup.visibleMembers.
298
+ *
299
+ * A CANCELLED continuation is an exception and stays: a cancelled item is given no
300
+ * response bubble at all, so hiding its request would erase the only evidence that
301
+ * the pass existed. A user bubble we cannot classify (it joined the run by server
302
+ * id, not by a label we could parse) also stays — showing an unexpected turn is a
303
+ * far smaller error than silently dropping one.
304
+ */
305
+ function isHiddenPass(m: ChatMessage): boolean {
306
+ if (m.role === 'user') {
307
+ if (m.isCancelled) return false;
308
+ var ref = readFileRef(m);
309
+ return !!(ref && ref.continued);
310
+ }
311
+ return !!m.isPending;
312
+ }
313
+
172
314
  /**
173
315
  * Collapse background-indexing turns into per-file groups.
174
316
  *
@@ -180,7 +322,14 @@ export function buildChatDisplayList(
180
322
  opts?: BuildDisplayListOptions,
181
323
  ): DisplayEntry[] {
182
324
  var list = Array.isArray(messages) ? messages : [];
325
+ var liveIndexKeys = (opts && opts.liveIndexKeys) || {};
326
+ var liveIndexChecked = !!(opts && opts.liveIndexChecked);
327
+ var stoppedIndexIds = (opts && opts.stoppedIndexIds) || {};
328
+ var windowedIndexing = opts && opts.windowedIndexing !== undefined
329
+ ? !!opts.windowedIndexing
330
+ : windowedIndexingEnabled();
183
331
  var hasMoreHistory = !!(opts && opts.hasMoreHistory);
332
+ var loadingOlderHistory = !!(opts && opts.loadingOlderHistory);
184
333
 
185
334
  // One entry per RUN (see IndexingGroup.runKey), addressed by an internal id
186
335
  // while the list is being walked; runKey is assigned at the end, once the
@@ -260,11 +409,17 @@ export function buildChatDisplayList(
260
409
  status: 'done',
261
410
  cancellableIds: [],
262
411
  cancelling: false,
412
+ stopped: false,
263
413
  mayHaveOlder: false,
264
414
  // The run's first loaded pass, and never re-stamped: see the file
265
415
  // docstring. `anchorId` is filled in once every member is known.
266
416
  anchorIndex: i,
267
417
  anchorId: '',
418
+ // All five are derived once every member is known, below.
419
+ visibleMembers: [],
420
+ driver: 'single',
421
+ finished: false,
422
+ resolving: false,
268
423
  };
269
424
  order.push(runId);
270
425
  }
@@ -292,6 +447,15 @@ export function buildChatDisplayList(
292
447
  // as a run appears at that end, which silently moves the user's expansion to
293
448
  // a row they never opened.) The one thing that changes it is the run's own
294
449
  // true first pass finally paging in, which happens at most once per run.
450
+ // Whether a run is the NEWEST of its file. Only that one can still be running:
451
+ // `liveIndexKeys` is keyed by FILE, shared by every run of it, so without this a
452
+ // re-index left last week's finished row spinning for the whole of this week's
453
+ // run — one file, two rows, both claiming to be working.
454
+ var newestRunOfKey: { [runId: string]: boolean } = {};
455
+ for (var nk in runsOfKey) {
456
+ var nrs = runsOfKey[nk];
457
+ if (nrs.length) newestRunOfKey[nrs[nrs.length - 1]] = true;
458
+ }
295
459
  for (var rk in runsOfKey) {
296
460
  var runIds = runsOfKey[rk];
297
461
  for (var ri = 0; ri < runIds.length; ri++) {
@@ -323,6 +487,35 @@ export function buildChatDisplayList(
323
487
  for (var mi = lastSettled + 1; mi < grp.members.length; mi++) {
324
488
  if (isPendingMsg(grp.members[mi].msg)) { active = true; break; }
325
489
  }
490
+ // Did the user STOP this run? Asked of the WHOLE run, because a stop routinely
491
+ // leaves nothing behind at the end of it:
492
+ // - only a QUEUED pass can be removed from the queue, and that is the one
493
+ // case that produces a cancelled bubble. The pass that is actually running
494
+ // cannot be un-run — it is flagged cancelled server-side, which is what
495
+ // stops the worker enqueueing the next window, and then finishes its own
496
+ // provider call and writes an ordinary, successful answer;
497
+ // - a PDF (or any worker-driven file) usually has exactly ONE pass live, so
498
+ // that running pass is normally all a stop has to act on;
499
+ // - continuations still sitting in bgTaskQueue are dropped by
500
+ // _applyIndexCancellations before they are ever surfaced, so they leave no
501
+ // bubble either.
502
+ // The messages of a stopped run therefore END in a successful pass, and reading
503
+ // the status off the last member alone reported the stop as a green "Indexed"
504
+ // over a file that was only partly read — with the newest pass's own text still
505
+ // saying "More pages remain" right underneath it.
506
+ //
507
+ // Two witnesses, both scoped to one RUN so a later re-index of the same file
508
+ // can never inherit a stop: a cancelled member (durable — it comes back from
509
+ // history), and the ids ChatSession recorded when the user hit Stop (this
510
+ // page's memory only, which is what covers the run that has no cancelled
511
+ // member at all).
512
+ var stopped = false;
513
+ for (var ki = 0; ki < grp.members.length; ki++) {
514
+ var km = grp.members[ki].msg;
515
+ if (km.isCancelled) { stopped = true; break; }
516
+ if (km._serverItemId && stoppedIndexIds[km._serverItemId]) { stopped = true; break; }
517
+ }
518
+ grp.stopped = stopped;
326
519
  // What a stop button would act on: the REQUEST bubble of every pass that is
327
520
  // still queued or running server-side. The assistant placeholder shares its
328
521
  // pass's server id, so ids are de-duplicated. A pass mid-cancel is left out
@@ -340,7 +533,14 @@ export function buildChatDisplayList(
340
533
  // went on to finish normally kept describing a one-off transient failure
341
534
  // as a permanent property of the file, until a full history refresh
342
535
  // rebuilt the bubbles.
343
- if (cm._cancelError && (active || grp.cancelling)) grp.cancelError = cm._cancelError;
536
+ //
537
+ // And never for a run that IS stopped, which is the case the message gets
538
+ // wrong: the only thing that failed there is removing the pass already in
539
+ // flight (it cannot be un-run — it finishes and answers), while the stop
540
+ // itself holds. The row says "Stopping..." and then "Indexing cancelled",
541
+ // which is the accurate account; "Could not stop this file" beside it is
542
+ // not.
543
+ if (cm._cancelError && !stopped && (active || grp.cancelling)) grp.cancelError = cm._cancelError;
344
544
  if (cm.role !== 'user' || !cm._serverItemId || cm._cancelling || cm.isSendingToServer) continue;
345
545
  if (!(cm.isPendingQueued || cm.isPendingInProcess)) continue;
346
546
  // Same staleness rule: never offer to stop a pass a later one outlived.
@@ -350,12 +550,19 @@ export function buildChatDisplayList(
350
550
  grp.cancellableIds.push(cm._serverItemId);
351
551
  }
352
552
  if (active) {
553
+ // A pass IS still running, and a row that claimed otherwise would hide work
554
+ // the user can still see the effects of. The stop is reported as `cancelling`
555
+ // instead — "Stopping...", Stop spent — and the row settles to 'cancelled'
556
+ // when that last pass lands.
353
557
  grp.status = 'active';
558
+ if (stopped) grp.cancelling = true;
559
+ } else if (stopped) {
560
+ grp.status = 'cancelled';
354
561
  } else {
355
562
  // The newest loaded outcome is the file's state: an early pass may have
356
563
  // errored and a later one succeeded.
357
564
  var last = grp.members[grp.members.length - 1].msg;
358
- grp.status = last.isError ? 'error' : last.isCancelled ? 'cancelled' : 'done';
565
+ grp.status = last.isError ? 'error' : 'done';
359
566
  }
360
567
  // A group whose passes are ALL continuations began before the loaded
361
568
  // window; its earlier passes arrive when older history is paged in.
@@ -373,6 +580,116 @@ export function buildChatDisplayList(
373
580
  var anchor = grp.members[0];
374
581
  grp.anchorIndex = anchor.index;
375
582
  grp.anchorId = anchor.msg._serverItemId || anchor.msg._localId || '';
583
+
584
+ // --- what an expanded row renders, and whether the run is over -------------
585
+ var sawComplete = false;
586
+ for (var vi = 0; vi < grp.members.length; vi++) {
587
+ var vm = grp.members[vi];
588
+ if (vm.msg._indexComplete) sawComplete = true;
589
+ if (!isHiddenPass(vm.msg)) grp.visibleMembers.push(vm);
590
+ }
591
+ // Mirrors ChatSession.maybeResumeIndexing's own routing, which is what makes
592
+ // the answer match who will actually dispatch the next pass.
593
+ grp.driver = !isPagedReadFile(grp.name, grp.mime) ? 'single'
594
+ : isImageVisionFile(grp.name, grp.mime) ? 'worker'
595
+ : (windowedIndexing ? 'worker' : 'client');
596
+ if (grp.status === 'active') {
597
+ grp.finished = false;
598
+ } else if (grp.status === 'cancelled') {
599
+ // The user stopped it. Nothing more will run, by construction.
600
+ grp.finished = true;
601
+ } else if (grp.driver === 'single') {
602
+ // One pass is the whole job: nothing continues a file that is not a paged
603
+ // read, so a settled pass IS the end. Exact, and survives a reload.
604
+ grp.finished = true;
605
+ } else if (grp.driver === 'client') {
606
+ // The three branches this client itself stops dispatching on. Using the
607
+ // same rule means the row cannot disagree with the pipeline: if the client
608
+ // will send no more passes, the run is over whether or not the file was
609
+ // fully read.
610
+ grp.finished = sawComplete || grp.status === 'error' ||
611
+ grp.passCount >= MAX_INDEXING_RESUME_PASSES;
612
+ } else {
613
+ // WORKER-driven, and the interesting case. Nothing in the messages can
614
+ // settle it: the loop is advanced inside the worker off the renderer's page
615
+ // count, the client only ever sees passes appear and settle, and the prompt
616
+ // for these paths deliberately never asks for a completion marker — a model
617
+ // that volunteers one is guessing, which is how an 88-page file once
618
+ // "finished" at page 15. So sawComplete is deliberately NOT consulted here.
619
+ // Either the server said this was the last pass, or the queue has been
620
+ // asked and holds nothing for this file.
621
+ //
622
+ // A run that is NOT the newest of its file is over by construction: a newer
623
+ // run exists, so this one's chain ended when that one began, and the
624
+ // file-keyed queue answer describes the newer run, not this one.
625
+ //
626
+ // There used to be a third disjunct here, a server-stamped "this was the
627
+ // run's last pass" (`_indexFinal`, off an `index_final` field). Nothing ever
628
+ // wrote that field — not the SDK's history mapper, not the worker — so it was
629
+ // permanently false and the two tests below were already carrying the whole
630
+ // decision. It is gone rather than left as a hook, so this reads as what it
631
+ // actually is: for a worker-driven run, ONLY the queue can say it is over.
632
+ grp.finished = !newestRunOfKey[order[oi]] ||
633
+ (liveIndexChecked && !liveIndexKeys[grp.key]);
634
+ }
635
+
636
+ // --- is any of that knowable YET? ------------------------------------------
637
+ // See IndexingGroup.resolving. Both answers above are read off the passes
638
+ // that happen to be loaded, and while a fetch that would change WHICH passes
639
+ // those are is still running, the row states the wait instead of picking a
640
+ // side. Two waits, checked in that order because the first subsumes the
641
+ // second: a run whose start has not arrived cannot be judged by any queue
642
+ // answer either.
643
+ if (grp.status !== 'done') {
644
+ // 'active' is proof the run is live; 'error' and 'cancelled' are the
645
+ // newest pass's own outcome, and newest-first paging always has that pass.
646
+ grp.resolving = false;
647
+ } else if (grp.mayHaveOlder && loadingOlderHistory &&
648
+ !liveIndexKeys[grp.key] && newestRunOfKey[order[oi]]) {
649
+ // The line this draws, and it is the same line the 'status' branch draws:
650
+ // withhold what is UNKNOWN, and what is only INFERRED settled. Never
651
+ // withhold what is PROVEN, in either direction. Two proofs, and an older
652
+ // page can revise neither:
653
+ // liveIndexKeys hit — the server just said this file is on the queue RIGHT
654
+ // NOW. Yellow and spinning, and no amount of older history makes that
655
+ // untrue. (Survives an unanswered `checked` for the reason above: only
656
+ // ABSENCE needs the truncation guard.)
657
+ // not the newest run of this file — a LATER run exists in the loaded
658
+ // messages, so this run's chain ended when that one began. Prepending
659
+ // adds OLDER messages, so it can never add a run after this one.
660
+ // (A third proof used to sit here, a server-stamped last-pass flag, but
661
+ // nothing ever wrote the field it read — see `finished` above.)
662
+ //
663
+ // What is deliberately NOT proof here: `finished` as a whole. Its remaining
664
+ // source is `liveIndexChecked && !liveIndexKeys[key]`, which is an inference
665
+ // from ABSENCE and is exactly what the user asked to stop reading as a green
666
+ // "Indexed" mid-fetch. And a blanket `!grp.finished` would be wrong for a
667
+ // second reason: driver 'single' also yields finished, and THAT verdict an
668
+ // older page really can revise — the first pass is what carries the mime, so
669
+ // a page bringing it in re-classifies the file as paged and hands the
670
+ // question back to the queue.
671
+ grp.resolving = true;
672
+ grp.resolvingReason = 'history';
673
+ } else if (!grp.finished && grp.driver === 'worker' && !liveIndexChecked && !liveIndexKeys[grp.key]) {
674
+ // Worker-driven and the queue has not answered. `finished` is already
675
+ // false here and the row would spin "Indexing" on the strength of nothing
676
+ // — the same false claim, one round trip long, on every chatbox open of a
677
+ // file that finished days ago. A 'client' run in the same shape is NOT
678
+ // resolving: no answer is being waited for, this client is the one that
679
+ // dispatches the next pass, so "Indexing" is a statement about its own
680
+ // intent and is true.
681
+ //
682
+ // A POSITIVE key survives `!liveIndexChecked`, and must. `checked` is set
683
+ // from `!truncated`: a capped page suppresses the whole answer, which is
684
+ // right for ABSENCE (an omitted file would otherwise read as finished) and
685
+ // wrong for PRESENCE, because an item the query did return is proof the
686
+ // file is live. Hiding that behind "checking..." would withhold the one
687
+ // thing here that is actually known.
688
+ grp.resolving = true;
689
+ grp.resolvingReason = 'status';
690
+ } else {
691
+ grp.resolving = false;
692
+ }
376
693
  }
377
694
 
378
695
  var out: DisplayEntry[] = [];
@@ -0,0 +1,124 @@
1
+ /**
2
+ * The chip / preview markup for one classified inline link.
3
+ *
4
+ * `classifyInlineLink` was consolidated into the engine because deciding what a
5
+ * link IS had drifted between the two clients and every link bug had to be found
6
+ * and fixed twice. The EMITTER stayed forked, byte for byte identical in
7
+ * agent.vue and the widget. The image preview is the first behaviour that would
8
+ * have had to be written twice, so the emitter moves here too.
9
+ *
10
+ * Pure string in, pure string out: no DOM, no globals, nothing reactive. That is
11
+ * what lets agent.vue keep memoizing parseMsgParts on the message text alone.
12
+ */
13
+
14
+ /** Neither client sanitizes bubble HTML (no DOMPurify, no marked sanitize), so
15
+ * everything interpolated here is escaped at the point of interpolation. */
16
+ export function escapeInlineHtml(v: string | null | undefined): string {
17
+ return String(v == null ? '' : v).replace(/[&<>"']/g, function (ch) {
18
+ return ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' } as Record<string, string>)[ch];
19
+ });
20
+ }
21
+
22
+ /**
23
+ * Previews per MESSAGE. Each one costs a presign call and an image download the
24
+ * moment it is hydrated, and a reply listing a folder can name dozens. Past this
25
+ * many the link renders as the ordinary text chip.
26
+ */
27
+ export var IMAGE_PREVIEWS_PER_MESSAGE = 8;
28
+
29
+ /**
30
+ * The glyph IS the promise: ↗ says "click this and the file opens". When the
31
+ * client could not get a url for the file, keeping that glyph on a chip it knows
32
+ * is dead is the bug: the click either opens a tab on a 403/404 or, once the
33
+ * href is gone, does nothing at all with no explanation. ✕ says what happened.
34
+ */
35
+ export var INLINE_LINK_GLYPH = '↗';
36
+ export var INLINE_LINK_UNAVAILABLE_GLYPH = '✕';
37
+ export var INLINE_LINK_UNAVAILABLE_SUFFIX = ' (unavailable)';
38
+
39
+ /** Widened so each client's local link-part type is assignable. */
40
+ export interface RenderableInlineLink {
41
+ label: string;
42
+ fullLabel?: string;
43
+ href: string;
44
+ expired: boolean;
45
+ expiredHref?: string;
46
+ remotePath?: string;
47
+ image?: { ext: string; contentType: string };
48
+ }
49
+
50
+ export interface InlineLinkMarkupOptions {
51
+ /** The view's own "a mint is in flight for this href" flag. */
52
+ refreshing?: boolean;
53
+ /** False once the caller has spent its per-message preview budget. */
54
+ allowImagePreview?: boolean;
55
+ /**
56
+ * The view already tried to get a url for this file and failed (see
57
+ * isLinkUnavailable). Renders a dead chip: ✕, greyed, no href.
58
+ */
59
+ unavailable?: boolean;
60
+ }
61
+
62
+ export function renderInlineLinkHtml(link: RenderableInlineLink, opts?: InlineLinkMarkupOptions): string {
63
+ var o = opts || {};
64
+ var unavailable = !!o.unavailable;
65
+ // A dead chip is never also "fetching...": the mint that would have cleared
66
+ // that state is the one that failed.
67
+ var refreshing = !unavailable && !!o.refreshing;
68
+ var full = link.fullLabel || link.label;
69
+ // No preview either. The <img> hides itself when its url fails, but a
70
+ // re-render emits a FRESH element with no state, so leaving the preview in
71
+ // would re-mint and re-fail once per render for the rest of the session.
72
+ var preview = !!link.image && !!link.remotePath && o.allowImagePreview !== false && !unavailable;
73
+
74
+ var cls = ['bq-link-button'];
75
+ if (link.expired) cls.push('is-expired');
76
+ if (refreshing) cls.push('is-refreshing');
77
+ if (unavailable) cls.push('is-unavailable');
78
+ if (preview) cls.push('is-image-preview');
79
+
80
+ var labelText = (unavailable ? INLINE_LINK_UNAVAILABLE_GLYPH : INLINE_LINK_GLYPH) + ' ' + link.label
81
+ + (unavailable ? INLINE_LINK_UNAVAILABLE_SUFFIX : refreshing ? ' (fetching...)' : '');
82
+ var attrs = ['class="' + cls.join(' ') + '"'];
83
+ // NO href when the file is unavailable. That is what disables the click:
84
+ // an anchor with no href does not navigate, is not a tab stop and takes the
85
+ // default cursor, so nothing else has to remember to swallow the event.
86
+ if (unavailable) attrs.push('aria-disabled="true"', 'data-bq-unavailable="1"');
87
+ else attrs.push('href="' + escapeInlineHtml(link.href) + '"', 'target="_blank"', 'rel="noopener noreferrer"');
88
+ attrs.push('title="' + escapeInlineHtml(unavailable ? full + INLINE_LINK_UNAVAILABLE_SUFFIX : full) + '"');
89
+ // `download` is ignored cross-origin and forces a save same-origin. On a
90
+ // preview it states the wrong intent: the user clicked a picture to LOOK at
91
+ // it. Every other chip keeps the attribute exactly where it was.
92
+ if (!preview && !unavailable) attrs.push('download="' + escapeInlineHtml(full) + '"');
93
+ attrs.push('data-bq-link="1"');
94
+ // Deliberately NOT marked expired: `data-bq-expired` is what tells the
95
+ // delegated click handler to mint a fresh url, and this is the chip whose
96
+ // mint just failed.
97
+ if (link.expired && !unavailable) attrs.push('data-bq-expired="1"');
98
+ if (link.expiredHref) attrs.push('data-bq-expired-href="' + escapeInlineHtml(link.expiredHref) + '"');
99
+ if (link.remotePath) attrs.push('data-bq-remote-path="' + escapeInlineHtml(link.remotePath) + '"');
100
+ if (link.fullLabel) attrs.push('data-bq-full-label="' + escapeInlineHtml(link.fullLabel) + '"');
101
+
102
+ if (!preview) return '<a ' + attrs.join(' ') + '>' + escapeInlineHtml(labelText) + '</a>';
103
+
104
+ // NO src attribute. A stored file always classifies as the _expired_.url
105
+ // placeholder until something mints a real url, so a src written here would be
106
+ // a guaranteed broken image on every reload. The element carries the PATH and
107
+ // hydrateImagePreviews fills the src in a DOM pass that runs after render.
108
+ //
109
+ // The caption is not decoration, it is the FALLBACK: if the url dies or the
110
+ // file is gone the <img> hides itself and what is left is exactly the text
111
+ // chip this feature replaced, still clickable.
112
+ return '<a ' + attrs.join(' ') + '>' +
113
+ '<img class="bq-img-preview" alt="' + escapeInlineHtml(full) + '"' +
114
+ ' data-bq-img-path="' + escapeInlineHtml(link.remotePath || '') + '"' +
115
+ ' data-bq-img-type="' + escapeInlineHtml(link.image ? link.image.contentType : '') + '"' +
116
+ ' loading="lazy" decoding="async">' +
117
+ // Minting the url is a network round trip before the image even starts
118
+ // downloading, so the wait is real and needs a state. Inline load, so the
119
+ // dot trail, never the jumping bunny. CSS hides it the moment the <img>
120
+ // gains a src, so no JS removes DOM and nothing re-parses.
121
+ '<span class="bq-loader" data-bq-img-loader="1"></span>' +
122
+ '<span class="bq-img-preview-caption" translate="no">' + escapeInlineHtml(labelText) + '</span>' +
123
+ '</a>';
124
+ }