bunnyquery 1.7.0 → 1.8.1

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.
@@ -39,7 +39,8 @@ import { windowedIndexingEnabled } from './config';
39
39
  import { isErrorResponseBody, isAuthExpiredError, isNonRetryableRequestError, getErrorMessage } from './errors';
40
40
  import { buildBoundedChatMessages } from './budget';
41
41
  import { createInlineLinkRegex } from './links';
42
- import { mapHistoryListToMessages, extractLastUserTextFromRequest } from './history';
42
+ import { mapHistoryListToMessages, extractLastUserTextFromRequest, isIndexingRequestText, parseIndexingRequestText } from './history';
43
+ import { wallClockNow } from './time';
43
44
  import { parseAttachmentContent } from './attachment_parsers';
44
45
  import type { ChatHost, ChatState, ChatMessage, PinnedDispatchContext } from './host';
45
46
  import type { IndexingGroup } from './indexing_groups';
@@ -48,6 +49,16 @@ function sleep(ms: number): Promise<void> {
48
49
  return new Promise(function (r) { setTimeout(r, ms); });
49
50
  }
50
51
 
52
+ // How many live items one look at the background-indexing queue asks for. The
53
+ // queue is FIFO per user, so a healthy chain has one running plus at most a
54
+ // couple queued; the cap only bounds a pathological backlog.
55
+ const WORKER_PASS_ADOPT_LIMIT = 20;
56
+ // Delays before each look (the first is immediate). More than one because the
57
+ // worker writes the next pass's row a few milliseconds AFTER it resolves the
58
+ // current one, so a look that wins that race sees an empty queue for a chain
59
+ // that is still alive. Fixed and finite: this must never become a poll.
60
+ const WORKER_PASS_ADOPT_ATTEMPTS = [0, 2000, 6000];
61
+
51
62
  // requestAnimationFrame / high-res clock, reached through globalThis so the
52
63
  // engine stays DOM-free at the type level (and degrades gracefully in non-DOM
53
64
  // / test environments where these globals are absent).
@@ -477,7 +488,7 @@ export class ChatSession {
477
488
  var offExisting = this.aiChatHistoryCache[key] || { messages: [], endOfList: false, startKeyHistory: [] };
478
489
  this.aiChatHistoryCache[key] = {
479
490
  messages: offExisting.messages.concat([
480
- { role: 'user', content: composed, _ownerKey: key },
491
+ { role: 'user', content: composed, _ownerKey: key, _ts: wallClockNow() },
481
492
  { role: 'assistant', content: '', isPending: true, isPendingInProcess: true, _ownerKey: key },
482
493
  ]),
483
494
  endOfList: offExisting.endOfList,
@@ -500,7 +511,7 @@ export class ChatSession {
500
511
  platform: aiPlatform, model: aiModel, systemPrompt: systemPrompt, serviceId: id.serviceId,
501
512
  history: resolvedHistory.concat([{ role: 'user', content: llmComposed }]),
502
513
  });
503
- var queuedBubble: ChatMessage = { role: 'user', content: composed, isPendingQueued: true, isSendingToServer: true };
514
+ var queuedBubble: ChatMessage = { role: 'user', content: composed, isPendingQueued: true, isSendingToServer: true, _ts: wallClockNow() };
504
515
  if (key) queuedBubble._ownerKey = key;
505
516
  if (useBgQueue) queuedBubble._useBgQueue = true;
506
517
  this.state.messages.push(queuedBubble);
@@ -542,7 +553,7 @@ export class ChatSession {
542
553
  // view unmount), then rendered from the cache via typewriteLatestReply. A
543
554
  // later resumePendingRequest() re-renders it if the view remounted while the
544
555
  // request was still in flight.
545
- this.state.messages.push({ role: 'user', content: composed, ...(key ? { _ownerKey: key } : {}) });
556
+ this.state.messages.push({ role: 'user', content: composed, _ts: wallClockNow(), ...(key ? { _ownerKey: key } : {}) });
546
557
  this.state.messages.push({ role: 'assistant', content: '', isPending: true, isPendingInProcess: true, ...(key ? { _ownerKey: key } : {}) });
547
558
  this.host.notify(); this.updateHistoryCache(); this.state.sending = true; this.host.scrollToBottom(true);
548
559
 
@@ -598,6 +609,7 @@ export class ChatSession {
598
609
  var existing = this.state.messages[nextIdx];
599
610
  var promoted: ChatMessage = { role: 'user', content: existing.content, isPendingInProcess: true, isBackgroundTask: true };
600
611
  if (existing._indexFile) promoted._indexFile = existing._indexFile;
612
+ if (existing._ts !== undefined) promoted._ts = existing._ts;
601
613
  if (existing._serverItemId !== undefined) promoted._serverItemId = existing._serverItemId;
602
614
  if (existing._ownerKey !== undefined) promoted._ownerKey = existing._ownerKey;
603
615
  this.state.messages[nextIdx] = promoted;
@@ -618,6 +630,7 @@ export class ChatSession {
618
630
  var promoted: ChatMessage = { role: 'user', content: existing.content, isPendingInProcess: true };
619
631
  if (existing.isBackgroundTask) promoted.isBackgroundTask = true;
620
632
  if (existing._indexFile) promoted._indexFile = existing._indexFile;
633
+ if (existing._ts !== undefined) promoted._ts = existing._ts;
621
634
  if (existing._serverItemId !== undefined) promoted._serverItemId = existing._serverItemId;
622
635
  if (existing._ownerKey !== undefined) promoted._ownerKey = existing._ownerKey;
623
636
  if (existing.isSendingToServer) promoted.isSendingToServer = true;
@@ -677,6 +690,7 @@ export class ChatSession {
677
690
  var repl: ChatMessage = { role: 'user', content: exist.content };
678
691
  if (exist._serverItemId !== undefined) repl._serverItemId = exist._serverItemId;
679
692
  if (exist._ownerKey !== undefined) repl._ownerKey = exist._ownerKey;
693
+ if (exist._ts !== undefined) repl._ts = exist._ts;
680
694
  this.state.messages[userIdx] = repl;
681
695
  }
682
696
  var thinkingIdx = userIdx >= 0
@@ -686,6 +700,9 @@ export class ChatSession {
686
700
  }
687
701
 
688
702
  insertAtTarget(msg: ChatMessage, targetIdx: number): void {
703
+ // Error/direct replies land here rather than through the typewriter, so this
704
+ // is where they pick up their display time.
705
+ if (msg && msg.role === 'assistant' && msg._ts === undefined) msg._ts = wallClockNow();
689
706
  if (targetIdx >= 0 && this.state.messages[targetIdx] && this.state.messages[targetIdx].isPending) this.state.messages[targetIdx] = msg;
690
707
  else if (targetIdx >= 0) this.state.messages.splice(targetIdx, 0, msg);
691
708
  else this.state.messages.push(msg);
@@ -1000,6 +1017,12 @@ export class ChatSession {
1000
1017
  private typewriterQueue: Promise<any> = Promise.resolve();
1001
1018
  enqueueTypewrite(idx: number, fullText: string, localId?: string): Promise<any> {
1002
1019
  var self = this;
1020
+ // Stamp the reply bubble's display time as it starts revealing. This is the
1021
+ // single chokepoint every typed reply (immediate, queued, and bg-resolution)
1022
+ // passes through, so it is where a live reply gets its "when it arrived"
1023
+ // timestamp; a history reload later replaces it with the server `updated`.
1024
+ var target = this.state.messages[idx];
1025
+ if (target && target._ts === undefined) target._ts = wallClockNow();
1003
1026
  this.typewriterQueue = this.typewriterQueue.then(function () { return self.typewriteIntoIndex(idx, fullText, localId); });
1004
1027
  return this.typewriterQueue;
1005
1028
  }
@@ -1068,6 +1091,7 @@ export class ChatSession {
1068
1091
  var u = this.state.messages[uIdx];
1069
1092
  var cleaned: ChatMessage = { role: 'user', content: u.content, _serverItemId: itemId };
1070
1093
  if (u.isBackgroundTask) cleaned.isBackgroundTask = true;
1094
+ if (u._ts !== undefined) cleaned._ts = u._ts;
1071
1095
  // Carry the file ref: it is what keeps this pass in its file's collapsed
1072
1096
  // row (the label parser is only a fallback for older cached bubbles).
1073
1097
  if (u._indexFile) cleaned._indexFile = u._indexFile;
@@ -1097,8 +1121,29 @@ export class ChatSession {
1097
1121
 
1098
1122
  // --- background-task resolution + drain -------------------------------
1099
1123
  handleHistoryItemResolution(itemId: string, response: any, platform: string): void {
1124
+ // Which file this turn was indexing, read BEFORE the resolution rewrites the
1125
+ // bubbles. Every settling turn funnels through here — the bg drain's own
1126
+ // poll, the history poll, and agent.vue's forked history poll, which calls
1127
+ // straight into this method — so hooking the chain follow-up here is what
1128
+ // makes it work in both clients without either of them forking a call site.
1129
+ var indexRef = this._indexRefOfItem(itemId);
1100
1130
  this.applyHistoryItemResolution(itemId, response, platform);
1101
1131
  this.promoteNextBgQueuedToRunning();
1132
+ // A worker-driven chain has no client-side record of its next pass, so a
1133
+ // settling pass is the only moment there is to go looking for one.
1134
+ if (indexRef) this._followWorkerIndexingChain(indexRef.name, indexRef.mime);
1135
+ }
1136
+
1137
+ /** The file an already-rendered background pass is about, off its request
1138
+ * bubble. Null for an ordinary turn, which is most of them. */
1139
+ private _indexRefOfItem(itemId: string): { name?: string; mime?: string } | null {
1140
+ if (!itemId) return null;
1141
+ for (var i = 0; i < this.state.messages.length; i++) {
1142
+ var m = this.state.messages[i];
1143
+ if (m._serverItemId !== itemId || m.role !== 'user' || !m.isBackgroundTask) continue;
1144
+ return m._indexFile || null;
1145
+ }
1146
+ return null;
1102
1147
  }
1103
1148
 
1104
1149
  applyHistoryItemResolution(itemId: string, response: any, platform: string): void {
@@ -1156,6 +1201,7 @@ export class ChatSession {
1156
1201
  // bg-queued chat cancelling against the right queue) once the pass settles.
1157
1202
  var settledUser: ChatMessage = { role: 'user', content: ex.content, _serverItemId: itemId };
1158
1203
  if (ex.isBackgroundTask) settledUser.isBackgroundTask = true;
1204
+ if (ex._ts !== undefined) settledUser._ts = ex._ts;
1159
1205
  if (ex._indexFile) settledUser._indexFile = ex._indexFile;
1160
1206
  if (ex._useBgQueue) settledUser._useBgQueue = true;
1161
1207
  this.state.messages[userIdx] = settledUser;
@@ -1246,6 +1292,175 @@ export class ChatSession {
1246
1292
  });
1247
1293
  }
1248
1294
 
1295
+ /**
1296
+ * True when the WORKER, not this client, drives the rest of this file's chain.
1297
+ * The mirror image of the early returns in maybeResumeIndexing: whatever that
1298
+ * refuses to continue is exactly what nothing client-side is tracking.
1299
+ */
1300
+ private _isWorkerDrivenIndexing(filename?: string, mime?: string): boolean {
1301
+ if (!isPagedReadFile(filename, mime)) return false;
1302
+ if (isImageVisionFile(filename, mime)) return true;
1303
+ return windowedIndexingEnabled() && isWindowedReadFile(filename, mime);
1304
+ }
1305
+
1306
+ /**
1307
+ * Pick up indexing passes the WORKER minted, which no client ever dispatched.
1308
+ *
1309
+ * For a PDF (and for text/grid when windowed indexing is on) the worker writes
1310
+ * pass N+1's row itself, inside pass N's invocation, right after saving pass
1311
+ * N's result. That row reaches this client through nothing at all: it is not in
1312
+ * bgTaskQueue (the client never asked for it) and it is not in state.messages
1313
+ * (only a first-page history load maps it in, which happens on mount, project
1314
+ * switch or tab return). So between two worker passes every pass the client
1315
+ * knows about is settled, and the collapsed row renders "Indexed N passes"
1316
+ * with no spinner and no Stop, for a file that is still being read. A user who
1317
+ * believes that then asks questions against a half-indexed file — which is what
1318
+ * this exists to prevent.
1319
+ *
1320
+ * So when a background indexing pass settles, ask the bg queue what is still
1321
+ * unresolved on it and adopt anything unknown as an ordinary BgTaskEntry.
1322
+ * drainBgTaskQueue then treats it exactly like a pass this client dispatched:
1323
+ * same bubble, same `_indexFile` (so it joins the file's collapsed row), same
1324
+ * poll — and that poll settling runs this again, so the chain is followed to
1325
+ * its end. `status`-scoped so the reply carries the live items only, never a
1326
+ * page of finished ones with their bodies.
1327
+ *
1328
+ * Termination: the only trigger is a pass SETTLING, which happens once per
1329
+ * pass. An adopted item that is still running is not re-adopted (its id is
1330
+ * already polled), and when the queue holds nothing unknown the chain stops on
1331
+ * its own. Nothing here is periodic — a timer that re-reads history is the
1332
+ * shape that previously looped fetchHistoryPage after an already-DONE index.
1333
+ */
1334
+ private _adoptingWorkerPasses = false;
1335
+ private _adoptWorkerIndexingPasses(attempt: number): void {
1336
+ var self = this;
1337
+ // One in flight at a time. The query is queue-WIDE, so a second file's pass
1338
+ // settling in the same moment is covered by the query already running.
1339
+ if (this._adoptingWorkerPasses) return;
1340
+ var id = this.host.getIdentity();
1341
+ var platform = id.platform;
1342
+ if (!id.serviceId || (platform !== 'claude' && platform !== 'openai')) return;
1343
+ if (this.isPollingPaused() || !this.host.isViewMounted()) return;
1344
+ var svcId = id.serviceId, owner = id.owner;
1345
+ var queue = (id.userId || id.serviceId) + BG_INDEXING_QUEUE_SUFFIX;
1346
+ var ask = function (status: 'pending' | 'running') {
1347
+ return Promise.resolve(getChatHistory(
1348
+ { service: svcId, owner: owner, platform: platform as 'claude' | 'openai', queue: queue, status: status },
1349
+ { limit: WORKER_PASS_ADOPT_LIMIT },
1350
+ )).catch(function () { return null; });
1351
+ };
1352
+ this._adoptingWorkerPasses = true;
1353
+ Promise.all([ask('running'), ask('pending')]).then(function (results: any[]) {
1354
+ self._adoptingWorkerPasses = false;
1355
+ // The chat may have changed under the query; adopting into another
1356
+ // project's session is the cross-project bubble leak all over again.
1357
+ var now = self.host.getIdentity();
1358
+ if (now.serviceId !== svcId || now.platform !== platform) return;
1359
+ if (!self.host.isViewMounted()) return;
1360
+ var adoptedIds: string[] = [];
1361
+ for (var ri = 0; ri < results.length; ri++) {
1362
+ var list = results[ri] && Array.isArray(results[ri].list) ? results[ri].list : [];
1363
+ for (var i = 0; i < list.length; i++) {
1364
+ if (self._adoptWorkerIndexingItem(list[i], svcId, platform as 'claude' | 'openai')) adoptedIds.push(list[i].id);
1365
+ }
1366
+ }
1367
+ if (adoptedIds.length) {
1368
+ self.drainBgTaskQueue();
1369
+ // Only an item the drain KEPT means the chain is still going. One it
1370
+ // threw straight back out is not progress and must not end the ladder:
1371
+ // the status index is eventually consistent, so a look taken the
1372
+ // instant a pass settles can still report that pass as running, and a
1373
+ // pass belonging to a file the user stopped is dropped on sight. Both
1374
+ // used to read as "found the next pass", which ended the search for a
1375
+ // pass that had not been written yet.
1376
+ if (self._isTrackingAny(adoptedIds)) return;
1377
+ }
1378
+ // Nothing yet. The worker writes pass N+1 a few milliseconds AFTER it
1379
+ // flips pass N to resolved, so a poll that lands in that gap sees an
1380
+ // empty queue for a chain that is very much alive — and with no pass
1381
+ // left to settle, nothing would ever ask again. Look once or twice more
1382
+ // before believing the file is finished.
1383
+ if (attempt + 1 >= WORKER_PASS_ADOPT_ATTEMPTS.length) return;
1384
+ setTimeout(function () {
1385
+ var later = self.host.getIdentity();
1386
+ if (later.serviceId !== svcId || later.platform !== platform) return;
1387
+ if (self.isPollingPaused() || !self.host.isViewMounted()) return;
1388
+ self._adoptWorkerIndexingPasses(attempt + 1);
1389
+ }, WORKER_PASS_ADOPT_ATTEMPTS[attempt + 1]);
1390
+ }, function () { self._adoptingWorkerPasses = false; });
1391
+ }
1392
+
1393
+ /** Any of these ids still queued or still polled, i.e. surviving work. */
1394
+ private _isTrackingAny(ids: string[]): boolean {
1395
+ for (var i = 0; i < ids.length; i++) {
1396
+ if (this.historyItemPolls.has(ids[i])) return true;
1397
+ for (var q = 0; q < this.bgTaskQueue.length; q++) {
1398
+ if (this.bgTaskQueue[q].id === ids[i]) return true;
1399
+ }
1400
+ }
1401
+ return false;
1402
+ }
1403
+
1404
+ /** One live bg-queue item -> a BgTaskEntry, if it is an indexing pass this
1405
+ * client is not already tracking. Returns whether it was adopted. */
1406
+ private _adoptWorkerIndexingItem(item: any, svcId: string, platform: 'claude' | 'openai'): boolean {
1407
+ if (!item || typeof item.id !== 'string' || !item.id) return false;
1408
+ if (item.status !== 'pending' && item.status !== 'running') return false;
1409
+ if (typeof item.poll !== 'function') return false;
1410
+ // Already known: a pass this client dispatched, or one adopted earlier that
1411
+ // has not settled yet. Adopting twice would stack a second poll on one item.
1412
+ if (this.historyItemPolls.has(item.id)) return false;
1413
+ for (var q = 0; q < this.bgTaskQueue.length; q++) {
1414
+ if (this.bgTaskQueue[q].id === item.id) return false;
1415
+ }
1416
+ // Already SETTLED here. The status index is eventually consistent, so the
1417
+ // pass that just finished can still come back as running; re-adopting it
1418
+ // would re-poll a dead item and, worse, read as the chain continuing.
1419
+ for (var m = 0; m < this.state.messages.length; m++) {
1420
+ var msg = this.state.messages[m];
1421
+ if (msg._serverItemId !== item.id) continue;
1422
+ if (!(msg.isPending || msg.isPendingInProcess || msg.isPendingQueued)) return false;
1423
+ }
1424
+ // The bg queue also carries ordinary chats routed onto it (_isOnBgQueue), and
1425
+ // both platforms share one queue name, so check the shape as well: a claude
1426
+ // request body has `messages`, an openai one has `input`. Adopting the other
1427
+ // platform's item would poll it against this platform's url.
1428
+ var body = item.request_body;
1429
+ if (!body || typeof body !== 'object') return false;
1430
+ if (platform === 'claude' ? !Array.isArray((body as any).messages) : !Array.isArray((body as any).input)) return false;
1431
+ var userText = extractLastUserTextFromRequest(body);
1432
+ if (!isIndexingRequestText(userText)) return false;
1433
+ var ref = parseIndexingRequestText(userText);
1434
+ if (!ref || !ref.name) return false;
1435
+ // Only chains the worker drives. A client-driven file is already tracked by
1436
+ // the entry that dispatched it, and adopting a copy of it would hand
1437
+ // maybeResumeIndexing an entry whose resumePass no longer counts that
1438
+ // chain's passes — which is the cap that stops it running forever.
1439
+ if (!this._isWorkerDrivenIndexing(ref.name, ref.mime)) return false;
1440
+ this.bgTaskQueue.push({
1441
+ serviceId: svcId,
1442
+ platform: platform,
1443
+ id: item.id,
1444
+ filename: ref.name,
1445
+ storagePath: ref.path,
1446
+ mime: ref.mime,
1447
+ size: ref.size,
1448
+ status: item.status === 'running' ? 'running' : 'pending',
1449
+ poll: item.poll,
1450
+ // Drives the "(continuing)" label and marks this as a continuation for
1451
+ // _applyIndexCancellations, which must not read a CONTINUE pass as the
1452
+ // fresh first pass that lifts a stop.
1453
+ resumePass: ref.continued ? 1 : 0,
1454
+ });
1455
+ return true;
1456
+ }
1457
+
1458
+ /** Follow the chain on from a background indexing pass that just settled. */
1459
+ private _followWorkerIndexingChain(filename?: string, mime?: string): void {
1460
+ if (!this._isWorkerDrivenIndexing(filename, mime)) return;
1461
+ this._adoptWorkerIndexingPasses(0);
1462
+ }
1463
+
1249
1464
  /** Best-effort server-side cancel of a bg-queue item that has no bubble (so
1250
1465
  * cancelQueuedMessage, which drives one, has nothing to act on). */
1251
1466
  private _cancelServerItem(serverId: string): void {
@@ -1470,8 +1685,7 @@ export class ChatSession {
1470
1685
  var chatList = history && Array.isArray(history.list) ? history.list : [];
1471
1686
  chatList.forEach(function (item: any) {
1472
1687
  if (isBgIndexingQueue(item.queue_name)) {
1473
- var userText = extractLastUserTextFromRequest(item.request_body);
1474
- if (typeof userText === 'string' && (userText.indexOf('A new file has just been uploaded') === 0 || userText.indexOf('CONTINUE indexing') === 0)) item._isBgTask = true;
1688
+ if (isIndexingRequestText(extractLastUserTextFromRequest(item.request_body))) item._isBgTask = true;
1475
1689
  else item._isOnBgQueue = true;
1476
1690
  }
1477
1691
  });
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Chat timestamp formatting, shared so agent.vue and the widget render an
3
+ * identical "small text under the bubble". Pure and locale-aware: it formats a
4
+ * given epoch-ms value, it never reads the current time, so it stays testable and
5
+ * DOM-free.
6
+ */
7
+
8
+ /** Wall-clock epoch ms. Separate from the engine's monotonic nowMs() (which is
9
+ * performance.now() when available and therefore NOT epoch): a displayed
10
+ * timestamp must be wall time. */
11
+ export function wallClockNow(): number {
12
+ return Date.now();
13
+ }
14
+
15
+ /**
16
+ * "Jul 24, 2026, 3:42:07 PM" (locale-formatted). Empty string for a missing or
17
+ * non-finite value, so a caller can gate rendering on the result being truthy and
18
+ * a pending bubble (no timestamp yet) simply shows nothing.
19
+ */
20
+ export function formatChatTimestamp(ms?: number): string {
21
+ if (typeof ms !== 'number' || !isFinite(ms) || ms <= 0) return '';
22
+ try {
23
+ return new Date(ms).toLocaleString(undefined, {
24
+ year: 'numeric',
25
+ month: 'short',
26
+ day: 'numeric',
27
+ hour: 'numeric',
28
+ minute: '2-digit',
29
+ second: '2-digit',
30
+ });
31
+ } catch (e) {
32
+ return '';
33
+ }
34
+ }
package/styles/chat.css CHANGED
@@ -76,6 +76,18 @@
76
76
  font-style: italic;
77
77
  clear: both;
78
78
  }
79
+ /* Timestamp under a settled bubble (created for a user turn, response time for an
80
+ assistant turn). Muted and small so it never competes with the message. */
81
+ .bq-msg-time {
82
+ display: block;
83
+ margin-top: 0.25rem;
84
+ font-size: 0.62rem;
85
+ line-height: 1;
86
+ color: var(--bq-muted);
87
+ opacity: 0.7;
88
+ clear: both;
89
+ }
90
+ .bq-message.is-user .bq-msg-time { text-align: right; }
79
91
  .bq-cancel-queue-btn {
80
92
  float: right;
81
93
  margin: -0.25rem -0.25rem 0.2rem 0.4rem;