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.
@@ -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
+ }
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Keep older history REACHABLE by paging until the message box actually gains
3
+ * something to scroll to.
4
+ *
5
+ * Older history is paged in by one trigger only: the user scrolling to the top
6
+ * of the message box. That trigger has two ways to die, and collapsed indexing
7
+ * rows cause both:
8
+ *
9
+ * 1. The box never scrolls. A file's every indexing pass (the first plus every
10
+ * CONTINUE pass, request AND response bubble each) folds into ONE row, so a
11
+ * full history page — twenty-plus messages — can render as a single line.
12
+ * Content shorter than the viewport fires no scroll event, so page 2 is
13
+ * never requested and any conversation the user had before that upload is
14
+ * permanently out of reach.
15
+ * 2. The fetched page adds no height. A page that is entirely the same file's
16
+ * earlier passes joins the collapsed row already on screen and renders
17
+ * nothing new. The user, sitting at scrollTop 0, scrolls up again — and
18
+ * because the position never changed, no further scroll event fires.
19
+ *
20
+ * Both are the same shape: fetch, re-measure, and keep going until the user
21
+ * genuinely gained reachable content, history ran out, or the pager stopped
22
+ * advancing. `isSatisfied` is what differs between the two (can the box scroll
23
+ * at all / did it grow), so the loop below takes it as a predicate.
24
+ *
25
+ * DOM-free like the rest of the engine — the caller supplies the measurement and
26
+ * awaits its own render before measuring, so agent.vue and the widget run the
27
+ * identical loop over their own pagers.
28
+ */
29
+
30
+ /** Overflow (px) that counts as "the user can scroll here". Comfortably more
31
+ * than the 60px top threshold that triggers the next page, so a filled box has
32
+ * real room to scroll rather than sitting one pixel from the trigger. */
33
+ export const HISTORY_FILL_SLACK_PX = 64;
34
+
35
+ /** Pages one fill pass will request before giving up. Reached only by a chat
36
+ * whose history really is dozens of pages of one file's indexing passes; the
37
+ * cap exists so a pager that stops advancing can never spin forever. */
38
+ export const MAX_HISTORY_FILL_PAGES = 24;
39
+
40
+ export type FillHistoryViewportOptions = {
41
+ /** The user has reachable content and paging can stop. Called AFTER the
42
+ * caller's own render has settled (nextTick / rAF), since only the caller
43
+ * knows when its view has painted — hence the allowance for a promise. */
44
+ isSatisfied: () => boolean | Promise<boolean>;
45
+ /** All history is loaded — nothing left to page in. */
46
+ isEndOfList: () => boolean;
47
+ /** A history request is already in flight. Waited out, not treated as a stop
48
+ * condition: a background first-page refresh (the queue-detect tick fires one
49
+ * every couple of seconds while a file is indexing) would otherwise swallow
50
+ * the user's scroll-up entirely, and scrolling up again from scrollTop 0
51
+ * produces no second event to retry with. */
52
+ isLoading: () => boolean;
53
+ /** Messages currently loaded. Used to detect a page that added nothing, which
54
+ * means the pager is not advancing and looping would never terminate. */
55
+ messageCount: () => number;
56
+ /** Fetch ONE older page (the caller's own fetchMore path, scroll-restore and
57
+ * all). Return `false` when the request was NOT issued (the caller's own
58
+ * single-flight guard swallowed it) so the loop retries instead of reading
59
+ * the unchanged message count as an exhausted pager. Anything else, including
60
+ * undefined, means it was attempted. */
61
+ fetchOlder: () => Promise<boolean | void | any>;
62
+ /** The chat this fill was started for is gone (project switched, view
63
+ * unmounted, gate token bumped). Checked between pages so a stale fill can
64
+ * never keep paging another chat's history. */
65
+ isStale?: () => boolean;
66
+ maxPages?: number;
67
+ };
68
+
69
+ /** How long to wait out an in-flight history request before giving up on it. */
70
+ const IDLE_WAIT_STEP_MS = 120;
71
+ const IDLE_WAIT_MAX_MS = 15000;
72
+
73
+ /** Resolve once no history request is in flight. False if the wait timed out or
74
+ * the chat went stale, in which case the caller should stop. */
75
+ async function waitForIdle(
76
+ opts: FillHistoryViewportOptions,
77
+ stale: () => boolean,
78
+ ): Promise<boolean> {
79
+ var waited = 0;
80
+ while (opts.isLoading()) {
81
+ if (stale() || waited >= IDLE_WAIT_MAX_MS) return false;
82
+ await new Promise(function (r) { setTimeout(r, IDLE_WAIT_STEP_MS); });
83
+ waited += IDLE_WAIT_STEP_MS;
84
+ }
85
+ return !stale();
86
+ }
87
+
88
+ /**
89
+ * Page older history until `isSatisfied`, until history runs out, or until the
90
+ * pager stops advancing. Never throws: a failed page ends the fill, and the
91
+ * user's own scrolling remains the fallback trigger.
92
+ */
93
+ export async function fillHistoryViewport(opts: FillHistoryViewportOptions): Promise<void> {
94
+ var maxPages = typeof opts.maxPages === 'number' ? opts.maxPages : MAX_HISTORY_FILL_PAGES;
95
+ var stale = function () { return !!(opts.isStale && opts.isStale()); };
96
+ var swallowed = 0;
97
+
98
+ for (var page = 0; page < maxPages; page++) {
99
+ if (stale() || opts.isEndOfList()) return;
100
+ if (!(await waitForIdle(opts, stale))) return;
101
+ var satisfied = false;
102
+ try {
103
+ satisfied = !!(await opts.isSatisfied());
104
+ } catch {
105
+ return; // cannot measure (view torn down mid-fill) — stop.
106
+ }
107
+ if (satisfied || stale()) return;
108
+
109
+ // Re-check immediately before dispatching: measuring above yields for a
110
+ // frame, and a background first-page refresh landing in that gap would make
111
+ // the caller's own single-flight guard swallow this request. A swallowed
112
+ // page adds no messages, which used to read as "the pager is exhausted" and
113
+ // abandoned the fill for good.
114
+ if (!(await waitForIdle(opts, stale))) return;
115
+ var before = opts.messageCount();
116
+ var attempted: boolean | void;
117
+ try {
118
+ attempted = await opts.fetchOlder();
119
+ } catch {
120
+ return; // history is optional; the scroll trigger stays as the fallback.
121
+ }
122
+ if (stale()) return;
123
+ if (attempted === false) {
124
+ // The caller reported it never issued the request. Retry it rather than
125
+ // mistaking it for an exhausted pager, but not forever.
126
+ if (++swallowed > 3) return;
127
+ page--;
128
+ continue;
129
+ }
130
+ // The page came back with nothing new. Either the pager is exhausted (and
131
+ // endOfList simply has not been set) or it is stuck; either way another
132
+ // round would request the same page again.
133
+ if (opts.messageCount() <= before) return;
134
+ }
135
+ }
136
+
137
+ /**
138
+ * One fill loop per view, with predicates COMBINED rather than dropped.
139
+ *
140
+ * Fills come from several places at once — a first page finishing, a window
141
+ * resize, a row being collapsed, and the user's own scroll to the top — and a
142
+ * plain "one at a time, drop the rest" guard picks the wrong winner: a resize
143
+ * fill (satisfied the moment the box can scroll at all) would swallow the user's
144
+ * scroll-up (which needs content specifically ABOVE them), and the scroll-up
145
+ * cannot be retried, because a reader parked at scrollTop 0 produces no further
146
+ * scroll event. Dropping the guard entirely is no better: every frame of a
147
+ * window drag would start its own 24-page loop.
148
+ *
149
+ * So a request that arrives mid-loop ANDs its predicate into the running one:
150
+ * the loop then keeps paging until EVERY caller is satisfied. Predicates that
151
+ * come true are dropped as it goes, so the cost stays flat.
152
+ */
153
+ export function createHistoryFiller(
154
+ base: Omit<FillHistoryViewportOptions, 'isSatisfied'>,
155
+ ): { fill: (isSatisfied: () => boolean | Promise<boolean>) => Promise<void>; isRunning: () => boolean } {
156
+ var pending: Array<() => boolean | Promise<boolean>> = [];
157
+ var running = false;
158
+
159
+ async function allSatisfied(): Promise<boolean> {
160
+ var next: Array<() => boolean | Promise<boolean>> = [];
161
+ for (var i = 0; i < pending.length; i++) {
162
+ if (!(await pending[i]())) next.push(pending[i]);
163
+ }
164
+ pending = next;
165
+ return pending.length === 0;
166
+ }
167
+
168
+ return {
169
+ isRunning: function () { return running; },
170
+ fill: function (isSatisfied) {
171
+ pending.push(isSatisfied);
172
+ if (running) return Promise.resolve();
173
+ running = true;
174
+ var done = function () { running = false; pending = []; };
175
+ return fillHistoryViewport({
176
+ isSatisfied: allSatisfied,
177
+ isEndOfList: base.isEndOfList,
178
+ isLoading: base.isLoading,
179
+ messageCount: base.messageCount,
180
+ fetchOlder: base.fetchOlder,
181
+ isStale: base.isStale,
182
+ maxPages: base.maxPages,
183
+ }).then(done, done);
184
+ },
185
+ };
186
+ }
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;
@@ -99,6 +111,128 @@
99
111
  .bq-cancel-queue-btn:hover:not(.is-disabled) { background: var(--bq-warning-bg); color: var(--bq-warning); }
100
112
  .bq-cancel-queue-btn.is-disabled { opacity: 0.3; cursor: not-allowed; pointer-events: none; }
101
113
 
114
+ /* ---- collapsed background-indexing group ---------------------------------*/
115
+ /* One file's many indexing passes (first pass + every CONTINUE pass, each with
116
+ a request AND a response bubble) render as a single status row instead of
117
+ filling the conversation with the same turn over and over. The row is a full
118
+ width strip rather than a bubble: it is chat CHROME, not something anyone
119
+ said. See engine/indexing_groups.ts for the grouping itself. */
120
+ .bq-index-group {
121
+ display: block;
122
+ margin-bottom: 0.5rem;
123
+ border: 1px solid var(--bq-line);
124
+ background: rgba(127, 127, 127, 0.05);
125
+ }
126
+ .bq-index-group.is-active { border-color: var(--bq-warning-border); background: var(--bq-warning-bg); }
127
+ .bq-index-group.is-error { border-color: var(--bq-danger); background: var(--bq-danger-bg); }
128
+
129
+ .bq-index-head {
130
+ display: flex;
131
+ align-items: center;
132
+ gap: 0.5rem;
133
+ width: 100%;
134
+ padding: 0.45rem 0.6rem;
135
+ border: 0;
136
+ border-radius: 0;
137
+ box-shadow: none;
138
+ background: transparent;
139
+ color: var(--bq-muted);
140
+ font: inherit;
141
+ font-size: 0.78rem;
142
+ line-height: 1.35;
143
+ text-align: left;
144
+ cursor: pointer;
145
+ min-height: 0;
146
+ }
147
+ .bq-index-head:hover { background: rgba(127, 127, 127, 0.08); color: var(--bq-muted); }
148
+ .bq-index-group.is-active .bq-index-head { color: var(--bq-warning); }
149
+ .bq-index-group.is-error .bq-index-head { color: var(--bq-danger); }
150
+
151
+ /* Status glyph. The active one is a circular-arrow SVG the consumer inlines
152
+ (never an icon font: agent.vue's Material Symbols build gates glyphs behind
153
+ an icon_names allowlist, which a shared stylesheet cannot reach). */
154
+ .bq-index-icon {
155
+ flex: 0 0 auto;
156
+ width: 0.95rem;
157
+ height: 0.95rem;
158
+ display: inline-flex;
159
+ align-items: center;
160
+ justify-content: center;
161
+ }
162
+ .bq-index-icon svg { width: 100%; height: 100%; display: block; }
163
+ .bq-index-group.is-active .bq-index-icon svg { animation: bq-index-spin 1.1s linear infinite; }
164
+ @keyframes bq-index-spin { to { transform: rotate(360deg); } }
165
+ @media (prefers-reduced-motion: reduce) {
166
+ .bq-index-group.is-active .bq-index-icon svg { animation-duration: 3s; }
167
+ }
168
+
169
+ .bq-index-label {
170
+ flex: 1 1 auto;
171
+ min-width: 0;
172
+ overflow: hidden;
173
+ text-overflow: ellipsis;
174
+ white-space: nowrap;
175
+ }
176
+ .bq-index-label .bq-link-button { max-width: 100%; font-size: inherit; }
177
+ /* The label runs through the same markdown renderer as a bubble, which wraps it
178
+ in a block <p>. Inline it so the row stays one line high. */
179
+ .bq-index-label .bq-md,
180
+ .bq-index-label .bq-md p { display: inline; margin: 0; }
181
+ /* Passes LOADED, never a server-side total: history pages newest-first, so any
182
+ total derived from what is on screen is a lower bound. */
183
+ .bq-index-count { flex: 0 0 auto; opacity: 0.75; font-size: 0.72rem; }
184
+ /* Stop indexing. Only ever rendered while a file has a queued/running pass, so
185
+ it never competes with the row's normal (collapsed, finished) look. Sized off
186
+ the row's own font so it stays a thin strip, not a chat action button. */
187
+ .bq-index-cancel {
188
+ flex: 0 0 auto;
189
+ padding: 0.1rem 0.4rem;
190
+ min-height: 0;
191
+ border: 1px solid currentColor;
192
+ border-radius: 0;
193
+ background: transparent;
194
+ color: inherit;
195
+ font: inherit;
196
+ font-size: 0.68rem;
197
+ line-height: 1.3;
198
+ letter-spacing: 0.02em;
199
+ cursor: pointer;
200
+ box-shadow: none;
201
+ opacity: 0.8;
202
+ }
203
+ .bq-index-cancel:hover:not(.is-disabled) { background: var(--bq-warning-bg); color: var(--bq-warning); opacity: 1; }
204
+ .bq-index-cancel.is-disabled { opacity: 0.45; cursor: default; pointer-events: none; }
205
+ .bq-index-chevron {
206
+ flex: 0 0 auto;
207
+ font-size: 0.62rem;
208
+ opacity: 0.7;
209
+ transition: transform 0.15s ease;
210
+ }
211
+ .bq-index-group.is-open .bq-index-chevron { transform: rotate(90deg); }
212
+
213
+ /* Expanded: the file's own turns, in order, rendered as ordinary messages
214
+ directly under the row they collapsed into (they are scattered through the
215
+ conversation otherwise). Rendered as SIBLINGS of the row, not children, so
216
+ both consumers keep exactly one copy of the message-bubble markup. */
217
+ .bq-index-group.is-open { margin-bottom: 0.35rem; }
218
+ .bq-message.bq-index-pass {
219
+ margin-bottom: 0.3rem;
220
+ margin-left: 0.7rem;
221
+ padding-left: 0.7rem;
222
+ border-left: 2px solid var(--bq-line);
223
+ }
224
+ .bq-message.bq-index-pass .bq-bubble { font-size: 0.78rem; }
225
+ /* Close the run: the next ordinary message needs its normal breathing room. */
226
+ .bq-message.bq-index-pass + :not(.bq-index-pass) { margin-top: 0.55rem; }
227
+ .bq-index-note {
228
+ padding: 0 0.6rem 0.45rem;
229
+ font-size: 0.7rem;
230
+ font-style: italic;
231
+ color: var(--bq-muted);
232
+ }
233
+ /* A cancel that the server refused (the pass had already finished). */
234
+ .bq-index-note.is-error { color: var(--bq-danger); }
235
+
102
236
  /* ---- in-bubble file / link anchors --------------------------------------*/
103
237
  .bq-file-download,
104
238
  .bq-link-button {