bunnyquery 1.7.0 → 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.7.0",
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": {
@@ -64,6 +64,13 @@ 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;
@@ -110,6 +117,7 @@ export function mapHistoryListToMessages(list: any[], platform: 'claude' | 'open
110
117
  if (indexFile) userMsg._indexFile = indexFile;
111
118
  if (item._isOnBgQueue) userMsg._useBgQueue = true;
112
119
  if (serverItemId !== undefined) userMsg._serverItemId = serverItemId;
120
+ if (userTs !== undefined) userMsg._ts = userTs;
113
121
  mapped.push(userMsg);
114
122
  }
115
123
  if (isCancelledItem) { /* no assistant bubble */ }
@@ -123,6 +131,7 @@ export function mapHistoryListToMessages(list: any[], platform: 'claude' | 'open
123
131
  var em: any = { role: 'assistant', content: getErrorMessage(response), isError: true };
124
132
  if (item._isBgTask) em.isBackgroundTask = true;
125
133
  if (serverItemId !== undefined) em._serverItemId = serverItemId;
134
+ if (replyTs !== undefined) em._ts = replyTs;
126
135
  mapped.push(em);
127
136
  } else if (assistantText) {
128
137
  // Safe db-only sanitize (forAssistant) so a volatile db url the model
@@ -130,6 +139,7 @@ export function mapHistoryListToMessages(list: any[], platform: 'claude' | 'open
130
139
  var okm: any = { role: 'assistant', content: sanitizeAttachmentLinksForHistory(assistantText, opts.serviceId, true) };
131
140
  if (item._isBgTask) okm.isBackgroundTask = true;
132
141
  if (serverItemId !== undefined) okm._serverItemId = serverItemId;
142
+ if (replyTs !== undefined) okm._ts = replyTs;
133
143
  mapped.push(okm);
134
144
  }
135
145
  });
@@ -67,6 +67,12 @@ export interface ChatMessage {
67
67
  _localId?: string;
68
68
  _cancelling?: boolean;
69
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;
70
76
  // History cache key (`serviceId#platform`) this bubble was created under.
71
77
  // Stamped on LOCALLY-created bubbles only (the optimistic user message and
72
78
  // its "Thinking..." placeholder); server-mapped bubbles are identified by
@@ -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,
@@ -7,12 +7,34 @@
7
7
  * repeating forever, and any real question the user asks in between gets buried.
8
8
  *
9
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).
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.
16
38
  *
17
39
  * The group deliberately reports no authoritative pass TOTAL. History is paged
18
40
  * newest-first, so any total computed from loaded messages is a lower bound that
@@ -37,9 +59,10 @@ export type IndexingGroup = {
37
59
  * Monday and re-indexed on Wednesday is two runs, and collapsing them into
38
60
  * one row erased Monday's from Monday's place in the conversation, claimed
39
61
  * 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. */
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. */
43
66
  runKey: string;
44
67
  name: string;
45
68
  path?: string;
@@ -65,8 +88,16 @@ export type IndexingGroup = {
65
88
  /** The file's first pass is not among the loaded messages, so earlier passes
66
89
  * exist in history that has not been paged in yet. */
67
90
  mayHaveOlder: boolean;
68
- /** Position in the source array this collapsed row renders at. */
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). */
69
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;
70
101
  };
71
102
 
72
103
  export type DisplayEntry =
@@ -230,7 +261,10 @@ export function buildChatDisplayList(
230
261
  cancellableIds: [],
231
262
  cancelling: false,
232
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.
233
266
  anchorIndex: i,
267
+ anchorId: '',
234
268
  };
235
269
  order.push(runId);
236
270
  }
@@ -246,7 +280,6 @@ export function buildChatDisplayList(
246
280
  g.passCount++;
247
281
  }
248
282
  g.members.push({ msg: msg, index: i });
249
- g.anchorIndex = i;
250
283
  runOfIndex[i] = runId;
251
284
  if (msg._serverItemId) runByItemId[msg._serverItemId] = runId;
252
285
  if (ref && ref.name) keyByName[ref.name] = g.key;
@@ -334,6 +367,12 @@ export function buildChatDisplayList(
334
367
  if (pref && !pref.continued) { sawFirstPass = true; break; }
335
368
  }
336
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 || '';
337
376
  }
338
377
 
339
378
  var out: DisplayEntry[] = [];
@@ -183,6 +183,33 @@ export function repairUrlWhitespace(href: string): string {
183
183
  return href.trim().replace(/\s/g, '%20');
184
184
  }
185
185
 
186
+ /**
187
+ * A model reproducing a URL sometimes HTML-escapes its `&` query separators as
188
+ * `&` (or the numeric `&` / `&`). Left in the href that escaping
189
+ * survives the client's own escapeAttr -> v-html/innerHTML decode round-trip and
190
+ * reaches the browser LITERALLY, so a presigned S3 URL is navigated with its
191
+ * parameters named `amp;Signature`, `amp;Expires`, `amp;response-content-type`,
192
+ * ... — the real params vanish, the signature can't be located, and S3 rejects
193
+ * it (the "링크가 안되" dead export link). Undo just that entity escaping.
194
+ *
195
+ * This is a no-op on a clean URL: a valid link carries a raw `&` between params
196
+ * and percent-encodes (`%26`) any literal ampersand that is data, so a real URL
197
+ * never contains `&` to begin with. Mirrors repairUrlWhitespace: it repairs
198
+ * model damage, not the URL. The loop collapses a doubly-escaped `&` too.
199
+ */
200
+ export function repairUrlEntities(href: string): string {
201
+ if (!href || href.indexOf('&') === -1) return href;
202
+ var out = href, prev = '';
203
+ while (out !== prev) {
204
+ prev = out;
205
+ out = out
206
+ .replace(/&/gi, '&')
207
+ .replace(/&#0*38;/g, '&')
208
+ .replace(/&#x0*26;/gi, '&');
209
+ }
210
+ return out;
211
+ }
212
+
186
213
  /**
187
214
  * Trim punctuation and unmatched wrappers that cling to a token in prose.
188
215
  * `src::a/b.pdf).` -> `src::a/b.pdf`, while a balanced `file (v2).pdf` is kept.
@@ -280,8 +307,11 @@ export function classifyInlineLink(
280
307
  // source into the storage-path branch, where it became a chip pointing at
281
308
  // this project for someone else's file.
282
309
  if (srcIsUrl && !isDbHost(rawPath) && !readExpiredAttachmentHref(rawPath)) {
310
+ // decode any model-introduced `&` in the URL (tail stays keyed on
311
+ // the raw match length, so it is left untouched)
312
+ var srcUrl = repairUrlEntities(rawPath);
283
313
  return {
284
- part: { type: 'link', label: truncateLabelForDisplay(rawPath), fullLabel: rawPath, href: rawPath, expired: false },
314
+ part: { type: 'link', label: truncateLabelForDisplay(srcUrl), fullLabel: srcUrl, href: srcUrl, expired: false },
285
315
  tail: tail,
286
316
  };
287
317
  }
@@ -337,6 +367,10 @@ export function classifyInlineLink(
337
367
  // [label](url) and bare urls.
338
368
  var originalHref = g3 || g6 || '';
339
369
  if (!originalHref) return null;
370
+ // A model that reproduces the URL may have HTML-escaped its `&` separators;
371
+ // decode them now so every downstream check and the final href see a clean
372
+ // URL (otherwise a presigned link navigates with `&` and 403s).
373
+ originalHref = repairUrlEntities(originalHref);
340
374
  // A bare url swallows the punctuation that ends the sentence it sits in, so
341
375
  // `see https://host/a.pdf.` linked to `a.pdf.` and 404'd. Trim it and hand the
342
376
  // trimmed text back as `tail`, exactly as the src:: branch does.
@@ -26,6 +26,9 @@ export function buildChatSystemPrompt(params: ChatSystemPromptParams): string {
26
26
  You are a dedicated assistant for the project ID: "${formattedServiceId}".
27
27
  Scope: Only answer questions about this project and its data. Do not answer questions about other projects or topics unrelated to this project. When the user refers to "my database", "my data", or "my files", treat those as references to this project's database and file storage.
28
28
  Knowledge lookup: Before saying you don't know or that something isn't in the chat history, ALWAYS query this project's database through the available MCP tools to look for the answer. The user's data is the source of truth - the chat transcript is not. Only respond with "I don't know" or "I couldn't find that" after you have actually searched the project's data and come back empty.
29
+ Complete answers over stored data: The database holds one record per spreadsheet row, and each uploaded file becomes many records that usually share a table. So a question about the data almost always spans many records across several files. For any request that counts, sums, totals, lists every match, compares across records, finds which one, or asks whether something is present or ABSENT (for example "how many", "total spent", "which card", "is there any", "없어?", "하나도 없나?"), you MUST read the COMPLETE matching set before answering. Query with fetch_all set to true, or page through getToolResponsePage until pagination.complete is true, across EVERY relevant table and EVERY relevant file. A single default query returns only the first page (about 50 records). That is a SAMPLE. Never treat it as the whole dataset.
30
+ Never assert absence from a partial read. Do not say "there is no X", "none", "not found", or "아니요, 없습니다" until a complete scan has come back empty. If you have not finished scanning every relevant table and file, keep querying instead of guessing. A confident "no" that later turns out wrong is worse than telling the user you are still checking.
31
+ Embedded values: a search term is often stored inside a larger string. A merchant "GODADDY" appears as "DNH*GODADDY#4070277042", and a card as "4140****2941". Server-side index and tag filters match only exact values or leading prefixes, not substrings, so filtering on such a field silently drops rows. When the value you are looking for may be embedded, do not trust a narrow filter to be complete. Fetch the full set with fetch_all and match the substring yourself.
29
32
  File attachments: When a user message contains an "Attached files:" section with markdown links, those links point to short-lived signed URLs in this project's db storage and will expire.
30
33
  - Image files (.jpg, .jpeg, .png, .gif, .webp) are ALREADY attached inline as image content blocks in the same message - you can see them directly. Do NOT call web_fetch on image URLs; that will fail or return garbage. Just look at the image block and answer.
31
34
  - Most attached files (office documents like .docx/.xlsx/.pptx/.hwp/.hwpx/.ods, and text/data/code files like .csv/.tsv/.json/.xml/.txt/.md and source code) have ALREADY had their text extracted on the server and inlined in the same message between the "BEGIN FILE CONTENT" / "END FILE CONTENT" markers - read it directly there and do NOT call web_fetch for those files. A "[skapi: ...]" note in that block means the file could not be extracted.
@@ -40,6 +40,7 @@ import { isErrorResponseBody, isAuthExpiredError, isNonRetryableRequestError, ge
40
40
  import { buildBoundedChatMessages } from './budget';
41
41
  import { createInlineLinkRegex } from './links';
42
42
  import { mapHistoryListToMessages, extractLastUserTextFromRequest } 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';
@@ -477,7 +478,7 @@ export class ChatSession {
477
478
  var offExisting = this.aiChatHistoryCache[key] || { messages: [], endOfList: false, startKeyHistory: [] };
478
479
  this.aiChatHistoryCache[key] = {
479
480
  messages: offExisting.messages.concat([
480
- { role: 'user', content: composed, _ownerKey: key },
481
+ { role: 'user', content: composed, _ownerKey: key, _ts: wallClockNow() },
481
482
  { role: 'assistant', content: '', isPending: true, isPendingInProcess: true, _ownerKey: key },
482
483
  ]),
483
484
  endOfList: offExisting.endOfList,
@@ -500,7 +501,7 @@ export class ChatSession {
500
501
  platform: aiPlatform, model: aiModel, systemPrompt: systemPrompt, serviceId: id.serviceId,
501
502
  history: resolvedHistory.concat([{ role: 'user', content: llmComposed }]),
502
503
  });
503
- var queuedBubble: ChatMessage = { role: 'user', content: composed, isPendingQueued: true, isSendingToServer: true };
504
+ var queuedBubble: ChatMessage = { role: 'user', content: composed, isPendingQueued: true, isSendingToServer: true, _ts: wallClockNow() };
504
505
  if (key) queuedBubble._ownerKey = key;
505
506
  if (useBgQueue) queuedBubble._useBgQueue = true;
506
507
  this.state.messages.push(queuedBubble);
@@ -542,7 +543,7 @@ export class ChatSession {
542
543
  // view unmount), then rendered from the cache via typewriteLatestReply. A
543
544
  // later resumePendingRequest() re-renders it if the view remounted while the
544
545
  // request was still in flight.
545
- this.state.messages.push({ role: 'user', content: composed, ...(key ? { _ownerKey: key } : {}) });
546
+ this.state.messages.push({ role: 'user', content: composed, _ts: wallClockNow(), ...(key ? { _ownerKey: key } : {}) });
546
547
  this.state.messages.push({ role: 'assistant', content: '', isPending: true, isPendingInProcess: true, ...(key ? { _ownerKey: key } : {}) });
547
548
  this.host.notify(); this.updateHistoryCache(); this.state.sending = true; this.host.scrollToBottom(true);
548
549
 
@@ -598,6 +599,7 @@ export class ChatSession {
598
599
  var existing = this.state.messages[nextIdx];
599
600
  var promoted: ChatMessage = { role: 'user', content: existing.content, isPendingInProcess: true, isBackgroundTask: true };
600
601
  if (existing._indexFile) promoted._indexFile = existing._indexFile;
602
+ if (existing._ts !== undefined) promoted._ts = existing._ts;
601
603
  if (existing._serverItemId !== undefined) promoted._serverItemId = existing._serverItemId;
602
604
  if (existing._ownerKey !== undefined) promoted._ownerKey = existing._ownerKey;
603
605
  this.state.messages[nextIdx] = promoted;
@@ -618,6 +620,7 @@ export class ChatSession {
618
620
  var promoted: ChatMessage = { role: 'user', content: existing.content, isPendingInProcess: true };
619
621
  if (existing.isBackgroundTask) promoted.isBackgroundTask = true;
620
622
  if (existing._indexFile) promoted._indexFile = existing._indexFile;
623
+ if (existing._ts !== undefined) promoted._ts = existing._ts;
621
624
  if (existing._serverItemId !== undefined) promoted._serverItemId = existing._serverItemId;
622
625
  if (existing._ownerKey !== undefined) promoted._ownerKey = existing._ownerKey;
623
626
  if (existing.isSendingToServer) promoted.isSendingToServer = true;
@@ -677,6 +680,7 @@ export class ChatSession {
677
680
  var repl: ChatMessage = { role: 'user', content: exist.content };
678
681
  if (exist._serverItemId !== undefined) repl._serverItemId = exist._serverItemId;
679
682
  if (exist._ownerKey !== undefined) repl._ownerKey = exist._ownerKey;
683
+ if (exist._ts !== undefined) repl._ts = exist._ts;
680
684
  this.state.messages[userIdx] = repl;
681
685
  }
682
686
  var thinkingIdx = userIdx >= 0
@@ -686,6 +690,9 @@ export class ChatSession {
686
690
  }
687
691
 
688
692
  insertAtTarget(msg: ChatMessage, targetIdx: number): void {
693
+ // Error/direct replies land here rather than through the typewriter, so this
694
+ // is where they pick up their display time.
695
+ if (msg && msg.role === 'assistant' && msg._ts === undefined) msg._ts = wallClockNow();
689
696
  if (targetIdx >= 0 && this.state.messages[targetIdx] && this.state.messages[targetIdx].isPending) this.state.messages[targetIdx] = msg;
690
697
  else if (targetIdx >= 0) this.state.messages.splice(targetIdx, 0, msg);
691
698
  else this.state.messages.push(msg);
@@ -1000,6 +1007,12 @@ export class ChatSession {
1000
1007
  private typewriterQueue: Promise<any> = Promise.resolve();
1001
1008
  enqueueTypewrite(idx: number, fullText: string, localId?: string): Promise<any> {
1002
1009
  var self = this;
1010
+ // Stamp the reply bubble's display time as it starts revealing. This is the
1011
+ // single chokepoint every typed reply (immediate, queued, and bg-resolution)
1012
+ // passes through, so it is where a live reply gets its "when it arrived"
1013
+ // timestamp; a history reload later replaces it with the server `updated`.
1014
+ var target = this.state.messages[idx];
1015
+ if (target && target._ts === undefined) target._ts = wallClockNow();
1003
1016
  this.typewriterQueue = this.typewriterQueue.then(function () { return self.typewriteIntoIndex(idx, fullText, localId); });
1004
1017
  return this.typewriterQueue;
1005
1018
  }
@@ -1068,6 +1081,7 @@ export class ChatSession {
1068
1081
  var u = this.state.messages[uIdx];
1069
1082
  var cleaned: ChatMessage = { role: 'user', content: u.content, _serverItemId: itemId };
1070
1083
  if (u.isBackgroundTask) cleaned.isBackgroundTask = true;
1084
+ if (u._ts !== undefined) cleaned._ts = u._ts;
1071
1085
  // Carry the file ref: it is what keeps this pass in its file's collapsed
1072
1086
  // row (the label parser is only a fallback for older cached bubbles).
1073
1087
  if (u._indexFile) cleaned._indexFile = u._indexFile;
@@ -1156,6 +1170,7 @@ export class ChatSession {
1156
1170
  // bg-queued chat cancelling against the right queue) once the pass settles.
1157
1171
  var settledUser: ChatMessage = { role: 'user', content: ex.content, _serverItemId: itemId };
1158
1172
  if (ex.isBackgroundTask) settledUser.isBackgroundTask = true;
1173
+ if (ex._ts !== undefined) settledUser._ts = ex._ts;
1159
1174
  if (ex._indexFile) settledUser._indexFile = ex._indexFile;
1160
1175
  if (ex._useBgQueue) settledUser._useBgQueue = true;
1161
1176
  this.state.messages[userIdx] = settledUser;
@@ -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;