@dogsbay/plugin-ask 0.2.0-beta.87 → 0.2.0-beta.89

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/src/client.ts CHANGED
@@ -1,22 +1,30 @@
1
1
  /**
2
2
  * Client runtime for `@dogsbay/plugin-ask`.
3
3
  *
4
- * Runs for side effects on every page. Two render targets share one code path:
5
- *
4
+ * Renders a multi-turn "Ask AI" conversation in two targets that share one
5
+ * code path:
6
6
  * - **Docked panel** — a server-rendered shell (`RightRailAsk.astro`,
7
- * `[data-dba-panel="rail"]`) that DocsLayout places in its `right-rail`
8
- * slot. Visible on desktop; hidden < lg because the rail is `hidden lg:block`.
9
- * - **Floating modal** — a body-level button + `<dialog>` built here. Used when
10
- * there's no rail (floating placement, wide/API pages) and as the mobile
11
- * entry when a docked panel exists.
7
+ * `[data-dba-panel="rail"]`) placed in DocsLayout's `right-rail` slot.
8
+ * - **Floating modal** — a body-level button + `<dialog>` built here; the
9
+ * fallback on rail-less layouts and the mobile entry when a panel exists.
12
10
  *
13
- * Astro view transitions swap the document, so we mount on `astro:page-load`
14
- * and tear the floating widget down on `astro:before-swap` (the docked shell is
15
- * part of the swapped document, so it's re-wired each navigation).
11
+ * Each turn appends to a `.dba-thread`; with `conversational` on (default) the
12
+ * history is sent as context so follow-ups are understood. Astro view
13
+ * transitions swap the document, so we mount on `astro:page-load` and tear the
14
+ * floating widget down on `astro:before-swap` (the docked shell is re-wired).
16
15
  */
17
16
  // @ts-expect-error: virtual module resolved by the Dogsbay Vite alias at build.
18
17
  import config from "virtual:dogsbay-plugin-config/@dogsbay/plugin-ask";
19
- import { buildAskRequest, extractText, parseSources, type Source } from "./protocol.js";
18
+ import {
19
+ buildAskRequest,
20
+ buildChatRequest,
21
+ trimHistory,
22
+ extractText,
23
+ parseSources,
24
+ shouldRetryTurnstile,
25
+ type ChatMessage,
26
+ type Source,
27
+ } from "./protocol.js";
20
28
  import { renderMarkdown, escapeHtml } from "./md.js";
21
29
  import type { AskMode, AskPlacement, AskPosition } from "./schema.js";
22
30
 
@@ -29,6 +37,9 @@ interface RuntimeConfig {
29
37
  placeholder: string;
30
38
  position: AskPosition;
31
39
  placement: AskPlacement;
40
+ conversational: boolean;
41
+ maxTurns: number;
42
+ persistConversation: boolean;
32
43
  linkBase: string;
33
44
  turnstileSitekey: string;
34
45
  }
@@ -63,83 +74,262 @@ function sourcesHtml(sources: Source[]): string {
63
74
  const items = sources
64
75
  .map((s) =>
65
76
  s.href
66
- ? `<li><a href="${escapeHtml(s.href)}" target="_blank" rel="noopener noreferrer">${escapeHtml(s.label)}</a></li>`
77
+ ? `<li><a href="${escapeHtml(s.href)}">${escapeHtml(s.label)}</a></li>`
67
78
  : `<li>${escapeHtml(s.label)}</li>`,
68
79
  )
69
80
  .join("");
70
81
  return `<div class="dba-sources"><div class="dba-sources-title">Sources</div><ul>${items}</ul></div>`;
71
82
  }
72
83
 
73
- interface AskEls {
84
+ // ─── Conversation persistence (sessionStorage; tab-scoped) ───────────────────
85
+ // Persisting the reader's own conversation lets it survive a page refresh and
86
+ // same-window navigation (e.g. clicking a source link). Tab-scoped, cleared on
87
+ // tab close, never sent anywhere. Gated by config + a per-tab reader opt-out.
88
+
89
+ const STORE = "dba-ask";
90
+ const OPT_OUT = "0";
91
+ const convoKey = () => `${STORE}:convo:${cfg.site}`;
92
+ const optOutKey = () => `${STORE}:save:${cfg.site}`;
93
+
94
+ function isPersistMode(): boolean {
95
+ return cfg.conversational && cfg.mode !== "search" && cfg.persistConversation;
96
+ }
97
+ function persistOptedOut(): boolean {
98
+ try { return sessionStorage.getItem(optOutKey()) === OPT_OUT; } catch { return false; }
99
+ }
100
+ function persistEnabled(): boolean {
101
+ return isPersistMode() && !persistOptedOut();
102
+ }
103
+ function loadStoredHistory(): ChatMessage[] {
104
+ if (!persistEnabled()) return [];
105
+ try {
106
+ const parsed = JSON.parse(sessionStorage.getItem(convoKey()) || "null");
107
+ if (!Array.isArray(parsed)) return [];
108
+ return parsed.filter(
109
+ (m): m is ChatMessage =>
110
+ m && (m.role === "user" || m.role === "assistant") && typeof m.content === "string",
111
+ );
112
+ } catch { return []; }
113
+ }
114
+ function saveStoredHistory(history: ChatMessage[]): void {
115
+ if (!persistEnabled()) return;
116
+ try { sessionStorage.setItem(convoKey(), JSON.stringify(history)); } catch { /* storage off */ }
117
+ }
118
+ function clearStoredHistory(): void {
119
+ try { sessionStorage.removeItem(convoKey()); } catch { /* ignore */ }
120
+ }
121
+ function setPersistOptOut(optedOut: boolean): void {
122
+ try {
123
+ if (optedOut) { sessionStorage.setItem(optOutKey(), OPT_OUT); clearStoredHistory(); }
124
+ else sessionStorage.removeItem(optOutKey());
125
+ } catch { /* ignore */ }
126
+ }
127
+
128
+ /** POST a prebuilt request body with the current Turnstile token (if any). */
129
+ function postBody(body: object): Promise<Response> {
130
+ return fetch(cfg.endpoint, {
131
+ method: "POST",
132
+ headers: { "Content-Type": "application/json" },
133
+ body: JSON.stringify({ ...body, ...(turnstileToken ? { turnstileToken } : {}) }),
134
+ });
135
+ }
136
+
137
+ /** Resolve once a Turnstile token is available, or after `timeoutMs`. */
138
+ function waitForTurnstileToken(timeoutMs: number): Promise<string | null> {
139
+ if (turnstileToken) return Promise.resolve(turnstileToken);
140
+ return new Promise((resolve) => {
141
+ const start = Date.now();
142
+ const tick = () => {
143
+ if (turnstileToken) return resolve(turnstileToken);
144
+ if (Date.now() - start >= timeoutMs) return resolve(null);
145
+ setTimeout(tick, 150);
146
+ };
147
+ tick();
148
+ });
149
+ }
150
+
151
+ /** Nearest scrollable ancestor (the rail aside, or the dialog's thread). */
152
+ function scrollParent(el: HTMLElement): HTMLElement | null {
153
+ let p = el.parentElement;
154
+ while (p) {
155
+ const oy = getComputedStyle(p).overflowY;
156
+ if ((oy === "auto" || oy === "scroll") && p.scrollHeight > p.clientHeight) return p;
157
+ p = p.parentElement;
158
+ }
159
+ return null;
160
+ }
161
+
162
+ /** Stream a proxy response into an answer element; returns the answer Markdown. */
163
+ async function streamInto(res: Response, answer: HTMLDivElement): Promise<string> {
164
+ answer.textContent = "";
165
+ answer.dataset.state = "streaming";
166
+
167
+ const sc = scrollParent(answer);
168
+ // Keep the newest content in view as it streams — but only if the reader is
169
+ // already at the bottom, so we don't yank them up while they re-read earlier.
170
+ const atBottom = () => !sc || sc.scrollTop + sc.clientHeight >= sc.scrollHeight - 80;
171
+ const render = (html: string) => {
172
+ const stick = atBottom();
173
+ answer.innerHTML = html;
174
+ if (stick && sc) sc.scrollTop = sc.scrollHeight;
175
+ };
176
+
177
+ const reader = res.body!.getReader();
178
+ const decoder = new TextDecoder();
179
+ let buf = ""; // line buffer (don't parse a frame split across reads)
180
+ let rawAll = ""; // whole stream, for the trailing `event: chunks` sources
181
+ let answerMd = ""; // accumulated answer markdown
182
+ for (;;) {
183
+ const { value, done } = await reader.read();
184
+ if (done) break;
185
+ const text = decoder.decode(value, { stream: true });
186
+ rawAll += text;
187
+ buf += text;
188
+ const nl = buf.lastIndexOf("\n");
189
+ if (nl >= 0) {
190
+ answerMd += extractText(buf.slice(0, nl + 1));
191
+ buf = buf.slice(nl + 1);
192
+ render(renderMarkdown(answerMd));
193
+ }
194
+ }
195
+ if (buf) answerMd += extractText(buf);
196
+ const sources = parseSources(rawAll, cfg.linkBase);
197
+ render(renderMarkdown(answerMd) + (sources.length ? sourcesHtml(sources) : ""));
198
+ answer.dataset.state = "done";
199
+ return answerMd;
200
+ }
201
+
202
+ function appendUserTurn(thread: HTMLElement, text: string): void {
203
+ const turn = document.createElement("div");
204
+ turn.className = "dba-turn dba-turn-user";
205
+ turn.textContent = text; // textContent — no injection from the question
206
+ thread.appendChild(turn);
207
+ }
208
+
209
+ function appendAssistantTurn(thread: HTMLElement): HTMLDivElement {
210
+ const turn = document.createElement("div");
211
+ turn.className = "dba-turn dba-turn-assistant";
212
+ const answer = document.createElement("div");
213
+ answer.className = "dba-answer";
214
+ answer.setAttribute("aria-live", "polite");
215
+ answer.dataset.state = "loading";
216
+ answer.textContent = "…";
217
+ turn.appendChild(answer);
218
+ thread.appendChild(turn);
219
+ return answer;
220
+ }
221
+
222
+ interface ConvoEls {
74
223
  form: HTMLFormElement;
75
224
  input: HTMLInputElement;
76
- answer: HTMLDivElement;
77
225
  submit: HTMLButtonElement;
226
+ thread: HTMLElement;
227
+ newChat?: HTMLButtonElement | null;
228
+ remember?: HTMLInputElement | null;
229
+ }
230
+
231
+ /** Re-render a stored conversation into the thread (no streaming). */
232
+ function restoreThread(thread: HTMLElement, history: ChatMessage[]): void {
233
+ for (const m of history) {
234
+ if (m.role === "user") {
235
+ appendUserTurn(thread, m.content);
236
+ } else {
237
+ const a = appendAssistantTurn(thread);
238
+ a.dataset.state = "done";
239
+ a.innerHTML = renderMarkdown(m.content);
240
+ }
241
+ }
78
242
  }
79
243
 
80
244
  /**
81
- * The one ask flow: POST the question, stream the answer, render Markdown +
82
- * a Sources list. Shared by the docked panel and the floating dialog.
245
+ * Wire one conversation surface (docked panel or floating dialog). Each keeps
246
+ * its own history closure. Shares the stream + Turnstile-retry logic.
83
247
  */
84
- async function runAsk({ input, answer, submit }: AskEls): Promise<void> {
85
- const query = input.value.trim();
86
- if (!query) return;
248
+ function wireConversation(els: ConvoEls): void {
249
+ // Restore a persisted conversation (refresh / same-window navigation).
250
+ const history: ChatMessage[] = loadStoredHistory();
251
+ if (history.length) restoreThread(els.thread, history);
87
252
 
88
- submit.disabled = true;
89
- answer.dataset.state = "loading";
90
- answer.textContent = "…";
91
-
92
- try {
93
- const res = await fetch(cfg.endpoint, {
94
- method: "POST",
95
- headers: { "Content-Type": "application/json" },
96
- body: JSON.stringify({
97
- ...buildAskRequest(cfg.site, query, cfg.mode),
98
- ...(turnstileToken ? { turnstileToken } : {}),
99
- }),
253
+ // Reader opt-out toggle: reflect the stored preference, and on change
254
+ // start/stop persisting (clearing storage on opt-out).
255
+ if (els.remember) {
256
+ els.remember.checked = !persistOptedOut();
257
+ els.remember.addEventListener("change", () => {
258
+ setPersistOptOut(!els.remember!.checked);
259
+ if (els.remember!.checked) saveStoredHistory(history);
100
260
  });
101
- if (!res.ok || !res.body) {
102
- throw new Error(`Request failed (${res.status})`);
103
- }
104
- answer.textContent = "";
105
- answer.dataset.state = "streaming";
106
- const reader = res.body.getReader();
107
- const decoder = new TextDecoder();
108
- let buf = ""; // line buffer (don't parse a frame split across reads)
109
- let rawAll = ""; // whole stream, for the trailing `event: chunks` sources
110
- let answerMd = ""; // accumulated answer markdown
111
- for (;;) {
112
- const { value, done } = await reader.read();
113
- if (done) break;
114
- const text = decoder.decode(value, { stream: true });
115
- rawAll += text;
116
- buf += text;
117
- const nl = buf.lastIndexOf("\n");
118
- if (nl >= 0) {
119
- answerMd += extractText(buf.slice(0, nl + 1));
120
- buf = buf.slice(nl + 1);
121
- answer.innerHTML = renderMarkdown(answerMd);
122
- }
123
- }
124
- if (buf) answerMd += extractText(buf);
125
- const sources = parseSources(rawAll, cfg.linkBase);
126
- answer.innerHTML = renderMarkdown(answerMd) + (sources.length ? sourcesHtml(sources) : "");
127
- answer.dataset.state = "done";
128
- } catch (err) {
129
- answer.dataset.state = "error";
130
- answer.textContent = `Sorry — something went wrong. ${(err as Error).message}`;
131
- } finally {
132
- submit.disabled = false;
133
- // Turnstile tokens are single-use — refresh for the next question.
134
- turnstileApi()?.reset?.();
135
- turnstileToken = null;
136
261
  }
137
- }
138
262
 
139
- function wireForm(els: AskEls): void {
140
- els.form.addEventListener("submit", (e) => {
263
+ const reset = () => {
264
+ els.thread.innerHTML = "";
265
+ history.length = 0;
266
+ clearStoredHistory();
267
+ };
268
+ els.newChat?.addEventListener("click", () => {
269
+ reset();
270
+ els.input.focus();
271
+ });
272
+
273
+ els.form.addEventListener("submit", async (e) => {
141
274
  e.preventDefault();
142
- void runAsk(els);
275
+ const query = els.input.value.trim();
276
+ if (!query) return;
277
+ els.input.value = "";
278
+
279
+ // Single-shot modes show one exchange at a time.
280
+ const conversational = cfg.conversational && cfg.mode !== "search";
281
+ if (!conversational) els.thread.innerHTML = "";
282
+
283
+ appendUserTurn(els.thread, query);
284
+ const answer = appendAssistantTurn(els.thread);
285
+ answer.scrollIntoView?.({ block: "nearest" });
286
+ els.submit.disabled = true;
287
+
288
+ // Build the request body. Conversational answer mode sends trimmed history;
289
+ // search and non-conversational answer stay single-shot.
290
+ let body: object;
291
+ if (cfg.mode === "search") {
292
+ body = buildAskRequest(cfg.site, query, "search");
293
+ } else if (conversational) {
294
+ history.push({ role: "user", content: query });
295
+ body = buildChatRequest(cfg.site, trimHistory(history, cfg.maxTurns));
296
+ } else {
297
+ body = buildAskRequest(cfg.site, query, "answer");
298
+ }
299
+
300
+ try {
301
+ let res = await postBody(body);
302
+ // Turnstile race: lazy-loaded on first interaction, so a fast submit can
303
+ // beat the token and the proxy 403s. Wait once for a fresh token + retry.
304
+ if (shouldRetryTurnstile(res.status, !!cfg.turnstileSitekey)) {
305
+ loadTurnstile();
306
+ if (turnstileToken) {
307
+ turnstileApi()?.reset?.();
308
+ turnstileToken = null;
309
+ }
310
+ const token = await waitForTurnstileToken(4000);
311
+ if (token) res = await postBody(body);
312
+ }
313
+ if (!res.ok || !res.body) {
314
+ throw new Error(`Request failed (${res.status})`);
315
+ }
316
+ const answerMd = await streamInto(res, answer);
317
+ if (conversational) {
318
+ history.push({ role: "assistant", content: answerMd });
319
+ saveStoredHistory(history);
320
+ }
321
+ } catch (err) {
322
+ answer.dataset.state = "error";
323
+ answer.textContent = `Sorry — something went wrong. ${(err as Error).message}`;
324
+ // Drop the unanswered user turn from history so the next send isn't
325
+ // skewed by a question that never got a reply.
326
+ if (conversational && history[history.length - 1]?.role === "user") history.pop();
327
+ } finally {
328
+ els.submit.disabled = false;
329
+ // Turnstile tokens are single-use — refresh for the next question.
330
+ turnstileApi()?.reset?.();
331
+ turnstileToken = null;
332
+ }
143
333
  });
144
334
  }
145
335
 
@@ -147,8 +337,7 @@ const ROOT_ID = "dba-root";
147
337
 
148
338
  /**
149
339
  * Wire the server-rendered docked panel if present. Returns whether a docked
150
- * shell exists on the page (so the caller knows to mark the floating button as
151
- * a mobile-only entry). Idempotent via a `data-dba-wired` marker.
340
+ * shell exists (so the caller knows to mark the floating button mobile-only).
152
341
  */
153
342
  function wireDockedPanel(): boolean {
154
343
  const panel = document.querySelector<HTMLElement>('[data-dba-panel="rail"]');
@@ -158,14 +347,21 @@ function wireDockedPanel(): boolean {
158
347
 
159
348
  const form = panel.querySelector<HTMLFormElement>("[data-dba-form]");
160
349
  const input = panel.querySelector<HTMLInputElement>(".dba-input");
161
- const answer = panel.querySelector<HTMLDivElement>(".dba-answer");
162
350
  const submit = panel.querySelector<HTMLButtonElement>(".dba-submit");
163
- if (!form || !input || !answer || !submit) return true;
351
+ const thread = panel.querySelector<HTMLElement>("[data-dba-thread]");
352
+ if (!form || !input || !submit || !thread) return true;
164
353
 
165
354
  // Lazy-load Turnstile on first interaction so a page that merely shows the
166
355
  // docked panel still sets no third-party cookies until the reader engages.
167
356
  input.addEventListener("focus", loadTurnstile, { once: true });
168
- wireForm({ form, input, answer, submit });
357
+ wireConversation({
358
+ form,
359
+ input,
360
+ submit,
361
+ thread,
362
+ newChat: panel.querySelector<HTMLButtonElement>("[data-dba-newchat]"),
363
+ remember: panel.querySelector<HTMLInputElement>("[data-dba-remember]"),
364
+ });
169
365
  return true;
170
366
  }
171
367
 
@@ -193,18 +389,28 @@ function mountFloating(hasDocked: boolean): void {
193
389
  const tsMarkup = cfg.turnstileSitekey
194
390
  ? `<div class="cf-turnstile dba-turnstile" data-sitekey="${cfg.turnstileSitekey}" data-appearance="interaction-only" data-callback="__dbaTsCb" data-expired-callback="__dbaTsExpired" data-error-callback="__dbaTsExpired"></div>`
195
391
  : "";
392
+ const newChatMarkup = cfg.conversational
393
+ ? `<button type="button" class="dba-newchat" data-dba-newchat>New chat</button>`
394
+ : "";
395
+ const rememberMarkup = cfg.conversational && cfg.persistConversation
396
+ ? `<label class="dba-remember"><input type="checkbox" data-dba-remember checked /> Remember conversation</label>`
397
+ : "";
196
398
  dialog.innerHTML = `
197
399
  <form class="dba-form" method="dialog" data-dba-form>
198
400
  <header class="dba-header">
199
401
  <h2 class="dba-title"></h2>
200
- <button type="button" class="dba-close" aria-label="Close">×</button>
402
+ <div class="dba-header-actions">
403
+ ${newChatMarkup}
404
+ <button type="button" class="dba-close" aria-label="Close">×</button>
405
+ </div>
201
406
  </header>
407
+ <div class="dba-thread" data-dba-thread></div>
202
408
  <div class="dba-inputrow">
203
409
  <input class="dba-input" type="text" autocomplete="off" enterkeyhint="search" />
204
410
  <button type="submit" class="dba-submit">Ask</button>
205
411
  </div>
206
412
  ${tsMarkup}
207
- <div class="dba-answer" aria-live="polite" tabindex="-1"></div>
413
+ ${rememberMarkup}
208
414
  </form>
209
415
  `;
210
416
 
@@ -212,9 +418,9 @@ function mountFloating(hasDocked: boolean): void {
212
418
  dialog.querySelector<HTMLHeadingElement>(".dba-title")!.textContent = cfg.title;
213
419
  const input = dialog.querySelector<HTMLInputElement>(".dba-input")!;
214
420
  input.placeholder = cfg.placeholder;
215
- const answer = dialog.querySelector<HTMLDivElement>(".dba-answer")!;
216
421
  const form = dialog.querySelector<HTMLFormElement>(".dba-form")!;
217
422
  const submit = dialog.querySelector<HTMLButtonElement>(".dba-submit")!;
423
+ const thread = dialog.querySelector<HTMLElement>("[data-dba-thread]")!;
218
424
 
219
425
  button.addEventListener("click", () => {
220
426
  // Lazy-load Turnstile only when the user engages, so normal page loads set
@@ -225,7 +431,14 @@ function mountFloating(hasDocked: boolean): void {
225
431
  });
226
432
  dialog.querySelector<HTMLButtonElement>(".dba-close")!.addEventListener("click", () => dialog.close());
227
433
 
228
- wireForm({ form, input, answer, submit });
434
+ wireConversation({
435
+ form,
436
+ input,
437
+ submit,
438
+ thread,
439
+ newChat: dialog.querySelector<HTMLButtonElement>("[data-dba-newchat]"),
440
+ remember: dialog.querySelector<HTMLInputElement>("[data-dba-remember]"),
441
+ });
229
442
 
230
443
  root.append(button, dialog);
231
444
  document.body.appendChild(root);
@@ -233,16 +446,12 @@ function mountFloating(hasDocked: boolean): void {
233
446
 
234
447
  function mount(): void {
235
448
  if (typeof document === "undefined") return;
236
- // Wire the docked panel first; its presence decides whether the floating
237
- // button is a full widget (no rail) or just the mobile entry (rail present).
238
449
  const hasDocked = wireDockedPanel();
239
450
  mountFloating(hasDocked);
240
451
  }
241
452
 
242
453
  function unmount(): void {
243
454
  if (typeof document === "undefined") return;
244
- // Only the floating widget is ours to remove; the docked shell is part of the
245
- // document Astro swaps, and is re-wired by mount() on the next page-load.
246
455
  document.getElementById(ROOT_ID)?.remove();
247
456
  }
248
457
 
package/src/styles.css CHANGED
@@ -28,8 +28,54 @@
28
28
  /* The docked form fills the rail column. */
29
29
  .dba-panel .dba-form { padding: 0; gap: 0.5rem; }
30
30
  .dba-panel .dba-title { font-size: 0.95rem; }
31
- /* The rail aside scrolls, so let the answer grow rather than scroll internally. */
31
+ /* The rail aside scrolls, so let answers grow rather than scroll internally. */
32
32
  .dba-panel .dba-answer { max-height: none; }
33
+ /* Keep the input visible while the rail scrolls the conversation. */
34
+ .dba-panel .dba-inputrow {
35
+ position: sticky;
36
+ bottom: 0;
37
+ background: var(--dba-surface);
38
+ padding-block: 0.4rem;
39
+ }
40
+
41
+ /* Conversation thread (docked panel + floating dialog). */
42
+ .dba-thread { display: flex; flex-direction: column; gap: 0.75rem; }
43
+ .dba-thread:empty { display: none; }
44
+ .dba-turn-user {
45
+ align-self: flex-end;
46
+ max-width: 92%;
47
+ padding: 0.4rem 0.7rem;
48
+ white-space: pre-wrap;
49
+ background: color-mix(in srgb, var(--dba-accent) 14%, transparent);
50
+ border-radius: var(--dba-radius);
51
+ }
52
+ .dba-turn-assistant { align-self: stretch; }
53
+ .dba-header-actions { display: flex; align-items: center; gap: 0.4rem; }
54
+ .dba-newchat {
55
+ font: inherit;
56
+ font-size: 0.78rem;
57
+ padding: 0.2rem 0.5rem;
58
+ cursor: pointer;
59
+ color: var(--dba-text);
60
+ opacity: 0.7;
61
+ background: none;
62
+ border: 1px solid var(--dba-border);
63
+ border-radius: var(--dba-radius);
64
+ }
65
+ .dba-newchat:hover { opacity: 1; }
66
+ /* In the modal, bound the thread so it scrolls and the input stays visible. */
67
+ .dba-dialog .dba-thread { max-height: 55vh; overflow-y: auto; }
68
+
69
+ /* Reader opt-out for conversation persistence. */
70
+ .dba-remember {
71
+ display: flex;
72
+ align-items: center;
73
+ gap: 0.4rem;
74
+ font-size: 0.78rem;
75
+ opacity: 0.75;
76
+ cursor: pointer;
77
+ }
78
+ .dba-remember input { cursor: pointer; }
33
79
 
34
80
  .dba-trigger {
35
81
  position: fixed;