@bdocs/plugin-ask-ai 0.3.0 → 0.4.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.
@@ -1,8 +1,109 @@
1
- import { useCallback, useEffect, useRef, useState } from "react";
1
+ import { memo, useCallback, useEffect, useId, useRef, useState } from "react";
2
2
  import { useConfig } from "boltdocs/client";
3
- import { Streamdown } from "streamdown";
3
+ import { clsx } from "clsx";
4
+ import { twMerge } from "tailwind-merge";
4
5
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
6
+ import { Streamdown } from "streamdown";
7
+ //#region src/client/abort.ts
8
+ /**
9
+ * Creates a cooperative cancellation controller for a single submission.
10
+ *
11
+ * Prefers the native `AbortController` (real `AbortSignal` for `fetch`).
12
+ * When it is unavailable we degrade gracefully: `abort()` cancels the stream
13
+ * reader and sets a flag that the read loop checks. Never throws.
14
+ */
15
+ function createClientController() {
16
+ let holder = null;
17
+ let aborted = false;
18
+ const attachReader = (reader) => {
19
+ holder = reader;
20
+ if (aborted) try {
21
+ holder?.cancel?.();
22
+ } catch {}
23
+ };
24
+ const abort = () => {
25
+ if (aborted) return;
26
+ aborted = true;
27
+ try {
28
+ holder?.cancel?.();
29
+ } catch {}
30
+ };
31
+ try {
32
+ if (typeof AbortController !== "undefined") {
33
+ const native = new AbortController();
34
+ return {
35
+ get signal() {
36
+ return native.signal;
37
+ },
38
+ get aborted() {
39
+ return aborted;
40
+ },
41
+ attachReader,
42
+ abort() {
43
+ abort();
44
+ try {
45
+ native.abort();
46
+ } catch {}
47
+ }
48
+ };
49
+ }
50
+ } catch {}
51
+ return {
52
+ signal: void 0,
53
+ get aborted() {
54
+ return aborted;
55
+ },
56
+ attachReader,
57
+ abort
58
+ };
59
+ }
60
+ //#endregion
5
61
  //#region src/client/use-ask-ai.ts
62
+ const DEFAULT_ASK_AI_UI_COPY = {
63
+ title: "Ask Assistant",
64
+ placeholder: "Ask about this page…",
65
+ emptyTitle: "How can I help you today?",
66
+ emptyDescription: "Ask anything about the current documentation page. The assistant only answers using the page you are viewing.",
67
+ buttonTooltip: "Ask AI assistant",
68
+ composerHint: "Enter to send · Shift+Enter for a new line"
69
+ };
70
+ function readUiCopy(meta) {
71
+ if (!meta) return DEFAULT_ASK_AI_UI_COPY;
72
+ const str = (key) => {
73
+ const v = meta[key];
74
+ return typeof v === "string" && v.length > 0 ? v : DEFAULT_ASK_AI_UI_COPY[key];
75
+ };
76
+ return {
77
+ title: str("title"),
78
+ placeholder: str("placeholder"),
79
+ emptyTitle: str("emptyTitle"),
80
+ emptyDescription: str("emptyDescription"),
81
+ buttonTooltip: str("buttonTooltip"),
82
+ composerHint: str("composerHint")
83
+ };
84
+ }
85
+ function readClassNames(meta, overrides = {}) {
86
+ const configured = meta?.classNames && typeof meta.classNames === "object" ? meta.classNames : {};
87
+ const result = {};
88
+ for (const key of [
89
+ "root",
90
+ "panel",
91
+ "trigger",
92
+ "header",
93
+ "messages",
94
+ "message",
95
+ "userMessage",
96
+ "assistantMessage",
97
+ "emptyState",
98
+ "composer",
99
+ "input",
100
+ "markdown"
101
+ ]) {
102
+ const value = overrides[key] ?? configured[key];
103
+ if (typeof value === "string" && value) result[key] = value;
104
+ }
105
+ return result;
106
+ }
6
107
  function useAskAi(options = {}) {
7
108
  const [messages, setMessages] = useState([]);
8
109
  const [input, setInput] = useState("");
@@ -11,9 +112,12 @@ function useAskAi(options = {}) {
11
112
  const submitAbortRef = useRef(null);
12
113
  const pendingTextRef = useRef({ value: "" });
13
114
  const pendingRafRef = useRef(null);
115
+ const messageIdRef = useRef(0);
14
116
  const askAiPluginMeta = useConfig()?.plugins?.find((p) => p.name === "boltdocs-plugin-ask-ai")?.metadata;
15
117
  const customEndpoint = options.endpoint || askAiPluginMeta?.endpoint || "/api/ask-ai";
16
118
  const devMode = askAiPluginMeta?.devMode ?? false;
119
+ const ui = readUiCopy(askAiPluginMeta);
120
+ const classNames = readClassNames(askAiPluginMeta, options.classNames);
17
121
  useEffect(() => {
18
122
  const handleOpen = () => setIsOpen(true);
19
123
  const handleClose = () => setIsOpen(false);
@@ -27,6 +131,10 @@ function useAskAi(options = {}) {
27
131
  window.removeEventListener("boltdocs:ask-ai:toggle", handleToggle);
28
132
  };
29
133
  }, []);
134
+ useEffect(() => () => {
135
+ submitAbortRef.current?.abort();
136
+ if (pendingRafRef.current !== null) cancelAnimationFrame(pendingRafRef.current);
137
+ }, []);
30
138
  const startTimeRef = useRef(0);
31
139
  const usageRef = useRef(null);
32
140
  const flushPendingText = useCallback(() => {
@@ -38,9 +146,13 @@ function useAskAi(options = {}) {
38
146
  const chunk = pendingTextRef.current.value;
39
147
  pendingTextRef.current.value = "";
40
148
  setMessages((prev) => {
41
- const next = [...prev];
42
- const last = next[next.length - 1];
43
- if (last && last.role === "assistant") last.content += chunk;
149
+ const next = prev.slice();
150
+ const i = next.length - 1;
151
+ const last = next[i];
152
+ if (last && last.role === "assistant") next[i] = {
153
+ ...last,
154
+ content: last.content + chunk
155
+ };
44
156
  return next;
45
157
  });
46
158
  }, []);
@@ -56,16 +168,16 @@ function useAskAi(options = {}) {
56
168
  setMessages((prev) => {
57
169
  const next = [...prev];
58
170
  const last = next[next.length - 1];
59
- if (last && last.role === "assistant" && last.status !== "done") {
60
- last.status = last.content ? "done" : "error";
61
- if (last.status === "error" && opts.message) last.errorMessage = opts.message;
62
- }
171
+ if (last && last.role === "assistant" && last.status !== "done" && last.status !== "cancelled") next[next.length - 1] = {
172
+ ...last,
173
+ status: opts.kind === "cancel" ? "cancelled" : "error",
174
+ ...opts.kind === "error" && opts.message ? { errorMessage: opts.message } : {}
175
+ };
63
176
  return next;
64
177
  });
65
178
  }, [flushPendingText]);
66
179
  const stopStreaming = useCallback(() => {
67
180
  if (submitAbortRef.current) submitAbortRef.current.abort();
68
- setIsLoading(false);
69
181
  finalizeAssistantTerminal({ kind: "cancel" });
70
182
  }, [finalizeAssistantTerminal]);
71
183
  useEffect(() => {
@@ -89,13 +201,18 @@ function useAskAi(options = {}) {
89
201
  if (!trimmed || isLoading) return;
90
202
  pendingTextRef.current = { value: "" };
91
203
  pendingRafRef.current = null;
204
+ usageRef.current = null;
205
+ startTimeRef.current = Date.now();
206
+ const turnId = ++messageIdRef.current;
92
207
  setMessages((prev) => [
93
208
  ...prev,
94
209
  {
210
+ id: `message-${turnId}-user`,
95
211
  role: "user",
96
212
  content: trimmed
97
213
  },
98
214
  {
215
+ id: `message-${turnId}-assistant`,
99
216
  role: "assistant",
100
217
  content: "",
101
218
  status: "reading"
@@ -103,26 +220,45 @@ function useAskAi(options = {}) {
103
220
  ]);
104
221
  setInput("");
105
222
  setIsLoading(true);
106
- const controller = new AbortController();
223
+ const replaceLastAssistant = (prev, apply) => {
224
+ const next = prev.slice();
225
+ const i = next.length - 1;
226
+ const last = next[i];
227
+ if (last && last.role === "assistant") next[i] = apply(last);
228
+ return next;
229
+ };
230
+ const controller = createClientController();
107
231
  submitAbortRef.current = controller;
108
232
  try {
109
233
  const currentPage = options.currentPage || (typeof window !== "undefined" ? window.location.pathname : "/");
110
- const response = await fetch(customEndpoint, {
234
+ const context = options.getContext?.() ?? options.context;
235
+ const init = {
111
236
  method: "POST",
112
237
  headers: { "Content-Type": "application/json" },
113
238
  body: JSON.stringify({
114
239
  question: trimmed,
115
- currentPage
116
- }),
117
- signal: controller.signal
118
- });
119
- if (!response.ok) throw new Error(`Server returned ${response.status}`);
240
+ currentPage,
241
+ ...context ? { context } : {}
242
+ })
243
+ };
244
+ if (controller.signal) init.signal = controller.signal;
245
+ const response = await fetch(customEndpoint, init);
246
+ if (!response.ok) {
247
+ const errorBody = await response.text();
248
+ let errorMessage = `Server returned ${response.status}`;
249
+ try {
250
+ const parsed = JSON.parse(errorBody);
251
+ if (typeof parsed.error === "string") errorMessage = parsed.error;
252
+ } catch {}
253
+ throw new Error(errorMessage);
254
+ }
120
255
  if (!response.body) throw new Error("No streaming body from endpoint");
121
256
  const reader = response.body.getReader();
257
+ controller.attachReader(reader);
122
258
  const decoder = new TextDecoder();
123
259
  let lineBuffer = "";
124
260
  let firstTextSeen = false;
125
- while (true) {
261
+ while (!controller.aborted) {
126
262
  const { value, done } = await reader.read();
127
263
  if (done) break;
128
264
  lineBuffer += decoder.decode(value, { stream: true });
@@ -139,56 +275,61 @@ function useAskAi(options = {}) {
139
275
  } catch {
140
276
  continue;
141
277
  }
142
- if (parsed.context) setMessages((prev) => {
143
- const next = [...prev];
144
- const last = next[next.length - 1];
145
- if (last && last.role === "assistant") {
146
- last.contextChip = parsed.context;
147
- startTimeRef.current = Date.now();
148
- }
149
- return next;
150
- });
278
+ if (parsed.context) setMessages((prev) => replaceLastAssistant(prev, (last) => {
279
+ startTimeRef.current = Date.now();
280
+ return {
281
+ ...last,
282
+ contextChip: parsed.context
283
+ };
284
+ }));
151
285
  else if (parsed.usage) usageRef.current = parsed.usage;
152
286
  else if (typeof parsed.text === "string") {
153
287
  if (!firstTextSeen) {
154
288
  firstTextSeen = true;
155
- setMessages((prev) => {
156
- const next = [...prev];
157
- const last = next[next.length - 1];
158
- if (last && last.role === "assistant") last.status = "streaming";
159
- return next;
160
- });
289
+ setMessages((prev) => replaceLastAssistant(prev, (last) => ({
290
+ ...last,
291
+ status: "streaming"
292
+ })));
161
293
  }
162
294
  pendingTextRef.current.value += parsed.text;
163
295
  scheduleFlush();
164
- } else if (parsed.error) throw new Error(parsed.error);
296
+ } else if (typeof parsed.error === "string") throw new Error(parsed.error);
165
297
  }
166
298
  }
299
+ if (controller.aborted) {
300
+ finalizeAssistantTerminal({ kind: "cancel" });
301
+ return;
302
+ }
167
303
  flushPendingText();
168
- setMessages((prev) => {
169
- const next = [...prev];
170
- const last = next[next.length - 1];
171
- if (last && last.role === "assistant") {
172
- last.status = last.content ? "done" : "error";
173
- if (last.status === "error" && !last.errorMessage) last.errorMessage = "No response received.";
174
- if (usageRef.current && devMode) last.usage = {
175
- ...usageRef.current,
176
- elapsedMs: startTimeRef.current ? Date.now() - startTimeRef.current : usageRef.current.elapsedMs
177
- };
178
- }
179
- return next;
180
- });
304
+ setMessages((prev) => replaceLastAssistant(prev, (last) => {
305
+ const status = last.content ? "done" : "error";
306
+ const usage = usageRef.current && devMode ? {
307
+ ...usageRef.current,
308
+ elapsedMs: startTimeRef.current ? Date.now() - startTimeRef.current : usageRef.current.elapsedMs
309
+ } : last.usage;
310
+ return {
311
+ ...last,
312
+ status,
313
+ errorMessage: status === "error" && !last.errorMessage ? "No response received." : last.errorMessage,
314
+ usage
315
+ };
316
+ }));
181
317
  } catch (error) {
182
318
  if (error instanceof DOMException && error.name === "AbortError") finalizeAssistantTerminal({
183
319
  kind: "cancel",
184
320
  message: "Request cancelled."
185
321
  });
186
322
  else {
323
+ const message = error instanceof Error ? error.message : "Unknown error";
324
+ const userMessage = message.includes("AI_NOT_CONFIGURED") ? "The AI assistant is not configured on this server." : message.includes("RATE_LIMITED") ? "Too many requests. Please try again shortly." : message.includes("UNAUTHORIZED") ? "This assistant requires authorization." : message.includes("CLIENT_CONTEXT_NOT_ALLOWED") ? "This deployment does not accept client-provided context." : "The AI assistant could not complete this request.";
187
325
  finalizeAssistantTerminal({
188
326
  kind: "error",
189
- message: error instanceof Error ? error.message : "Unknown error"
327
+ message: userMessage
190
328
  });
191
- console.error("[Ask AI] failed:", error);
329
+ setMessages((prev) => replaceLastAssistant(prev, (last) => last.status === "error" ? {
330
+ ...last,
331
+ errorMessage: userMessage
332
+ } : last));
192
333
  }
193
334
  } finally {
194
335
  if (pendingRafRef.current !== null) {
@@ -201,20 +342,225 @@ function useAskAi(options = {}) {
201
342
  }, [
202
343
  customEndpoint,
203
344
  options.currentPage,
345
+ options.context,
346
+ options.getContext,
204
347
  isLoading,
205
348
  scheduleFlush,
206
- finalizeAssistantTerminal
349
+ finalizeAssistantTerminal,
350
+ devMode,
351
+ flushPendingText
207
352
  ]),
208
353
  stopStreaming,
209
354
  clearChat,
210
355
  isOpen,
211
356
  setIsOpen,
212
- devMode
357
+ devMode,
358
+ ui,
359
+ classNames
360
+ };
361
+ }
362
+ //#endregion
363
+ //#region src/client/cn.ts
364
+ function cn(...inputs) {
365
+ return twMerge(clsx(inputs));
366
+ }
367
+ //#endregion
368
+ //#region src/client/components/icons.tsx
369
+ function IconBase({ size = 16, className, strokeWidth = 2, children }) {
370
+ return /* @__PURE__ */ jsx("svg", {
371
+ xmlns: "http://www.w3.org/2000/svg",
372
+ width: size,
373
+ height: size,
374
+ viewBox: "0 0 24 24",
375
+ fill: "none",
376
+ stroke: "currentColor",
377
+ strokeWidth,
378
+ strokeLinecap: "round",
379
+ strokeLinejoin: "round",
380
+ className,
381
+ "aria-hidden": "true",
382
+ children
383
+ });
384
+ }
385
+ function SparkleIcon(props) {
386
+ return /* @__PURE__ */ jsx(IconBase, {
387
+ strokeWidth: 2.5,
388
+ ...props,
389
+ children: /* @__PURE__ */ jsx("path", { d: "m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z" })
390
+ });
391
+ }
392
+ function ChatIcon(props) {
393
+ return /* @__PURE__ */ jsx(IconBase, {
394
+ ...props,
395
+ children: /* @__PURE__ */ jsx("path", { d: "M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" })
396
+ });
397
+ }
398
+ function TrashIcon(props) {
399
+ return /* @__PURE__ */ jsxs(IconBase, {
400
+ ...props,
401
+ children: [
402
+ /* @__PURE__ */ jsx("path", { d: "M3 6h18" }),
403
+ /* @__PURE__ */ jsx("path", { d: "M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" }),
404
+ /* @__PURE__ */ jsx("path", { d: "M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" })
405
+ ]
406
+ });
407
+ }
408
+ function CloseIcon(props) {
409
+ return /* @__PURE__ */ jsxs(IconBase, {
410
+ ...props,
411
+ children: [/* @__PURE__ */ jsx("path", { d: "M18 6 6 18" }), /* @__PURE__ */ jsx("path", { d: "m6 6 12 12" })]
412
+ });
413
+ }
414
+ function SendIcon(props) {
415
+ return /* @__PURE__ */ jsxs(IconBase, {
416
+ strokeWidth: 2.5,
417
+ ...props,
418
+ children: [/* @__PURE__ */ jsx("path", { d: "m22 2-7 20-4-9-9-4Z" }), /* @__PURE__ */ jsx("path", { d: "M22 2 11 13" })]
419
+ });
420
+ }
421
+ function StopIcon({ size = 16, className }) {
422
+ return /* @__PURE__ */ jsx("svg", {
423
+ xmlns: "http://www.w3.org/2000/svg",
424
+ width: size,
425
+ height: size,
426
+ viewBox: "0 0 24 24",
427
+ fill: "currentColor",
428
+ className,
429
+ "aria-hidden": "true",
430
+ children: /* @__PURE__ */ jsx("rect", {
431
+ x: "6",
432
+ y: "6",
433
+ width: "12",
434
+ height: "12",
435
+ rx: "2"
436
+ })
437
+ });
438
+ }
439
+ function FileIcon(props) {
440
+ return /* @__PURE__ */ jsxs(IconBase, {
441
+ ...props,
442
+ children: [/* @__PURE__ */ jsx("path", { d: "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" }), /* @__PURE__ */ jsx("polyline", { points: "14 2 14 8 20 8" })]
443
+ });
444
+ }
445
+ //#endregion
446
+ //#region src/client/components/chat-empty-state.tsx
447
+ function ChatEmptyState({ description, title = "How can I help you today?", variant = "bubble", className }) {
448
+ const compact = variant === "dialog";
449
+ return /* @__PURE__ */ jsxs("div", {
450
+ className: cn("flex flex-1 flex-col items-center justify-center text-center", compact ? "p-2" : "p-6", className),
451
+ children: [
452
+ /* @__PURE__ */ jsx("div", {
453
+ className: `${compact ? "w-10 h-10 rounded-xl" : "w-12 h-12 rounded-full"} bg-primary-500/10 flex items-center justify-center text-primary-500 mb-3`,
454
+ children: /* @__PURE__ */ jsx(SparkleIcon, { size: compact ? 20 : 24 })
455
+ }),
456
+ !compact && /* @__PURE__ */ jsx("h3", {
457
+ className: "text-sm font-semibold text-body mb-1",
458
+ children: title
459
+ }),
460
+ /* @__PURE__ */ jsx("p", {
461
+ className: compact ? "text-xs text-muted leading-relaxed" : "text-xs text-muted max-w-[240px]",
462
+ children: description
463
+ })
464
+ ]
465
+ });
466
+ }
467
+ //#endregion
468
+ //#region src/client/components/chat-header.tsx
469
+ function ChatHeader({ title, variant = "bubble", canClear, onClear, onClose, className, titleId }) {
470
+ const compact = variant === "dialog";
471
+ return /* @__PURE__ */ jsx("header", {
472
+ className: cn("shrink-0 border-b border-subtle bg-gradient-to-r from-primary-500/8 to-transparent px-4 py-3", className),
473
+ children: /* @__PURE__ */ jsxs("div", {
474
+ className: "flex items-center justify-between gap-3",
475
+ children: [/* @__PURE__ */ jsxs("div", {
476
+ className: "flex min-w-0 items-center gap-2.5",
477
+ children: [/* @__PURE__ */ jsx("span", {
478
+ className: "flex size-8 shrink-0 items-center justify-center rounded-xl bg-primary-500/12 text-primary-500",
479
+ children: /* @__PURE__ */ jsx(SparkleIcon, { size: compact ? 15 : 17 })
480
+ }), /* @__PURE__ */ jsx("h2", {
481
+ id: titleId,
482
+ className: cn("truncate font-semibold tracking-tight text-body", compact ? "text-xs" : "text-sm"),
483
+ children: title
484
+ })]
485
+ }), /* @__PURE__ */ jsxs("div", {
486
+ className: "flex items-center gap-1",
487
+ children: [canClear ? /* @__PURE__ */ jsx("button", {
488
+ type: "button",
489
+ onClick: onClear,
490
+ className: "cursor-pointer rounded-lg p-1.5 text-muted transition-colors hover:bg-soft hover:text-danger-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/30",
491
+ title: "Clear chat",
492
+ "aria-label": "Clear chat",
493
+ children: /* @__PURE__ */ jsx(TrashIcon, { size: compact ? 13 : 15 })
494
+ }) : null, /* @__PURE__ */ jsx("button", {
495
+ type: "button",
496
+ onClick: onClose,
497
+ className: "cursor-pointer rounded-lg p-1.5 text-muted transition-colors hover:bg-soft hover:text-body focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/30",
498
+ title: "Close assistant",
499
+ "aria-label": "Close assistant",
500
+ children: /* @__PURE__ */ jsx(CloseIcon, { size: compact ? 13 : 16 })
501
+ })]
502
+ })]
503
+ })
504
+ });
505
+ }
506
+ //#endregion
507
+ //#region src/client/components/chat-input.tsx
508
+ function ChatInput({ input, onInputChange, onSubmit, onStop, isLoading, placeholder = "Ask about this page…", hint = "Enter to send · Shift+Enter for a new line", variant = "bubble", inputRef, className, inputClassName }) {
509
+ const compact = variant === "dialog";
510
+ const hintId = useId();
511
+ const canSubmit = Boolean(input.trim()) && !isLoading;
512
+ const rows = Math.min(Math.max(input.split("\n").length, 1), 6);
513
+ const handleKeyDown = (event) => {
514
+ if (event.key !== "Enter" || event.shiftKey) return;
515
+ event.preventDefault();
516
+ if (canSubmit) onSubmit(input);
213
517
  };
518
+ const handleSubmit = (event) => {
519
+ event.preventDefault();
520
+ if (canSubmit) onSubmit(input);
521
+ };
522
+ const buttonSize = compact ? "size-8" : "size-9";
523
+ return /* @__PURE__ */ jsxs("div", {
524
+ className: cn("flex flex-col gap-1.5", compact ? "shrink-0" : "shrink-0", className),
525
+ children: [/* @__PURE__ */ jsxs("form", {
526
+ onSubmit: handleSubmit,
527
+ className: cn("flex items-end gap-2 rounded-2xl border border-subtle bg-surface/80 p-1.5 shadow-sm transition-colors focus-within:border-primary-500/60 focus-within:ring-2 focus-within:ring-primary-500/10", inputClassName),
528
+ children: [/* @__PURE__ */ jsx("textarea", {
529
+ ref: inputRef,
530
+ rows,
531
+ value: input,
532
+ onChange: (event) => onInputChange(event.target.value),
533
+ onKeyDown: handleKeyDown,
534
+ placeholder,
535
+ disabled: isLoading,
536
+ "aria-label": "Ask the assistant",
537
+ "aria-describedby": hintId,
538
+ className: cn("min-h-8 flex-1 resize-none bg-transparent px-2 py-1.5 text-body leading-relaxed outline-none placeholder:text-muted", compact ? "text-xs" : "text-sm")
539
+ }), isLoading ? /* @__PURE__ */ jsx("button", {
540
+ type: "button",
541
+ onClick: onStop,
542
+ title: "Stop generating",
543
+ "aria-label": "Stop generating",
544
+ className: cn(buttonSize, "flex shrink-0 cursor-pointer select-none items-center justify-center rounded-full bg-danger-500 text-white transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-danger-500/40"),
545
+ children: /* @__PURE__ */ jsx(StopIcon, { size: compact ? 12 : 16 })
546
+ }) : /* @__PURE__ */ jsx("button", {
547
+ type: "submit",
548
+ disabled: !input.trim(),
549
+ title: "Send question",
550
+ "aria-label": "Send question",
551
+ className: cn(buttonSize, "flex shrink-0 cursor-pointer select-none items-center justify-center rounded-full bg-primary-500 text-white transition-all hover:bg-primary-600 disabled:cursor-not-allowed disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40"),
552
+ children: /* @__PURE__ */ jsx(SendIcon, { size: compact ? 12 : 16 })
553
+ })]
554
+ }), /* @__PURE__ */ jsx("p", {
555
+ id: hintId,
556
+ className: cn("select-none px-1 text-muted", compact ? "text-[10px]" : "text-[11px]"),
557
+ children: hint
558
+ })]
559
+ });
214
560
  }
215
561
  //#endregion
216
562
  //#region src/client/render-markdown.tsx
217
- function MarkdownRenderer({ content, className, parseIncompleteMarkdown = true }) {
563
+ function MarkdownRendererImpl({ content, className, parseIncompleteMarkdown = true }) {
218
564
  if (!content) return null;
219
565
  return /* @__PURE__ */ jsx("div", {
220
566
  className,
@@ -224,313 +570,283 @@ function MarkdownRenderer({ content, className, parseIncompleteMarkdown = true }
224
570
  })
225
571
  });
226
572
  }
573
+ /**
574
+ * Memoized: while the message streams the same buffered content can be
575
+ * re-rendered (e.g. a status flip reading → streaming) — this skips the
576
+ * heavy markdown re-parse when nothing changed.
577
+ */
578
+ const MarkdownRenderer = memo(MarkdownRendererImpl);
579
+ //#endregion
580
+ //#region src/client/components/typing-indicator.tsx
581
+ function Dot({ delay }) {
582
+ return /* @__PURE__ */ jsx("span", {
583
+ className: "w-1.5 h-1.5 rounded-full bg-primary-500 animate-bounce",
584
+ style: { animationDelay: delay }
585
+ });
586
+ }
587
+ function TypingIndicator({ label = "Waiting…" }) {
588
+ return /* @__PURE__ */ jsxs("span", {
589
+ className: "inline-flex items-center gap-2 text-xs text-muted",
590
+ children: [/* @__PURE__ */ jsxs("span", {
591
+ className: "flex items-center gap-1",
592
+ children: [
593
+ /* @__PURE__ */ jsx(Dot, { delay: "0ms" }),
594
+ /* @__PURE__ */ jsx(Dot, { delay: "120ms" }),
595
+ /* @__PURE__ */ jsx(Dot, { delay: "240ms" })
596
+ ]
597
+ }), label]
598
+ });
599
+ }
600
+ //#endregion
601
+ //#region src/client/components/chat-message.tsx
602
+ function UsageChip({ usage, compact }) {
603
+ return /* @__PURE__ */ jsxs("div", {
604
+ className: "flex items-center gap-1.5 px-2 py-1 mb-1 text-[11px] text-muted font-mono",
605
+ children: [
606
+ /* @__PURE__ */ jsx("span", {
607
+ className: "px-1 py-0.5 rounded bg-warning-500/15 text-warning-500 text-[10px] font-bold uppercase tracking-wide",
608
+ children: "DEV"
609
+ }),
610
+ /* @__PURE__ */ jsxs("span", {
611
+ title: "Provider / model",
612
+ children: [
613
+ usage.provider,
614
+ "/",
615
+ usage.model
616
+ ]
617
+ }),
618
+ /* @__PURE__ */ jsx("span", {
619
+ className: "text-muted",
620
+ children: "·"
621
+ }),
622
+ /* @__PURE__ */ jsxs("span", {
623
+ title: "Prompt tokens",
624
+ children: [usage.promptTokens, "↑"]
625
+ }),
626
+ /* @__PURE__ */ jsxs("span", {
627
+ title: "Completion tokens",
628
+ children: [usage.completionTokens, "↓"]
629
+ }),
630
+ /* @__PURE__ */ jsx("span", {
631
+ className: "text-muted",
632
+ children: "·"
633
+ }),
634
+ /* @__PURE__ */ jsxs("span", {
635
+ title: "Total tokens",
636
+ className: "text-primary-500 font-semibold",
637
+ children: [usage.totalTokens, " tok"]
638
+ }),
639
+ !compact && /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("span", {
640
+ className: "text-muted",
641
+ children: "·"
642
+ }), /* @__PURE__ */ jsxs("span", {
643
+ title: "Elapsed",
644
+ children: [usage.elapsedMs, "ms"]
645
+ })] })
646
+ ]
647
+ });
648
+ }
649
+ function ContextChip({ chip, compact }) {
650
+ return /* @__PURE__ */ jsx("div", {
651
+ className: "flex items-center gap-1.5 px-2 py-1 mb-1 text-[11px] text-muted",
652
+ children: chip.missing ? /* @__PURE__ */ jsx("span", { children: compact ? "No docs in scope" : "No docs page in scope" }) : /* @__PURE__ */ jsxs(Fragment, { children: [
653
+ /* @__PURE__ */ jsx(FileIcon, { size: 12 }),
654
+ /* @__PURE__ */ jsx("span", { children: "Reading" }),
655
+ /* @__PURE__ */ jsx("code", {
656
+ className: "px-1 py-0.5 rounded bg-surface text-primary-500 text-[10px] font-mono",
657
+ children: chip.page
658
+ }),
659
+ /* @__PURE__ */ jsx("span", {
660
+ className: "text-muted",
661
+ children: "·"
662
+ }),
663
+ /* @__PURE__ */ jsxs("span", { children: [chip.chars, compact ? "c" : " chars"] }),
664
+ !compact && typeof chip.elapsedMs === "number" && /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("span", {
665
+ className: "text-muted",
666
+ children: "·"
667
+ }), /* @__PURE__ */ jsxs("span", { children: [chip.elapsedMs, "ms"] })] })
668
+ ] })
669
+ });
670
+ }
671
+ function ChatMessageImpl({ message: msg, variant = "bubble", devMode, className, userClassName, assistantClassName, markdownClassName }) {
672
+ const compact = variant === "dialog";
673
+ const isUser = msg.role === "user";
674
+ const containerClass = cn("flex flex-col", compact ? "max-w-full" : "max-w-[85%]", isUser ? "ml-auto items-end" : "items-start", className);
675
+ const bubbleClass = cn("rounded-2xl px-3.5 py-2.5 text-left", isUser ? cn("rounded-br-md bg-primary-500 text-white shadow-sm", compact && "max-w-[90%]", userClassName) : msg.status === "error" ? cn("rounded-bl-md border border-danger-500/30 bg-danger-500/10 text-danger-500", assistantClassName) : cn("rounded-bl-md border border-subtle bg-surface text-body shadow-sm", assistantClassName));
676
+ return /* @__PURE__ */ jsxs("div", {
677
+ className: containerClass,
678
+ children: [
679
+ msg.role === "assistant" && msg.usage && devMode && /* @__PURE__ */ jsx(UsageChip, {
680
+ usage: msg.usage,
681
+ compact
682
+ }),
683
+ msg.role === "assistant" && msg.contextChip && /* @__PURE__ */ jsx(ContextChip, {
684
+ chip: msg.contextChip,
685
+ compact
686
+ }),
687
+ /* @__PURE__ */ jsx("div", {
688
+ className: bubbleClass,
689
+ children: isUser ? /* @__PURE__ */ jsx("p", {
690
+ className: `whitespace-pre-wrap ${compact ? "text-xs" : "text-sm"}`,
691
+ children: msg.content
692
+ }) : msg.status === "cancelled" ? msg.content ? /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("p", {
693
+ className: "whitespace-pre-wrap text-sm text-body",
694
+ children: msg.content
695
+ }), /* @__PURE__ */ jsx("p", {
696
+ className: "mt-2 text-[11px] text-muted",
697
+ children: "Generation stopped."
698
+ })] }) : /* @__PURE__ */ jsx("p", {
699
+ className: cn("text-muted", compact ? "text-xs" : "text-sm"),
700
+ children: "Generation stopped."
701
+ }) : msg.status === "error" ? msg.content ? /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("p", {
702
+ className: "whitespace-pre-wrap text-sm text-body",
703
+ children: msg.content
704
+ }), /* @__PURE__ */ jsx("p", {
705
+ className: "mt-2 text-xs text-danger-500",
706
+ children: msg.errorMessage || "Something went wrong."
707
+ })] }) : /* @__PURE__ */ jsxs("p", {
708
+ className: compact ? "text-xs" : "text-sm",
709
+ children: [
710
+ /* @__PURE__ */ jsx("strong", { children: "Error:" }),
711
+ " ",
712
+ msg.errorMessage || "Something went wrong."
713
+ ]
714
+ }) : /* @__PURE__ */ jsx("div", {
715
+ className: "ask-ai-streamdown",
716
+ children: msg.content ? /* @__PURE__ */ jsx(MarkdownRenderer, {
717
+ content: msg.content,
718
+ className: cn("text-sm leading-relaxed", markdownClassName)
719
+ }) : msg.status === "reading" ? /* @__PURE__ */ jsx(TypingIndicator, { label: "Reading page…" }) : /* @__PURE__ */ jsx(TypingIndicator, { label: "Waiting…" })
720
+ })
721
+ })
722
+ ]
723
+ });
724
+ }
725
+ /**
726
+ * Memoized so that during streaming only the message whose content actually
727
+ * changed re-renders. The hook structurally shares the array and replaces the
728
+ * final assistant message with a new object, so all older siblings keep their
729
+ * identity and are skipped by the default shallow comparison.
730
+ */
731
+ const ChatMessage = memo(ChatMessageImpl);
227
732
  //#endregion
228
733
  //#region src/client/components/ask-ai-bubble.tsx
229
- function AskAiBubble() {
230
- const { messages, input, setInput, isLoading, submitQuestion, stopStreaming, clearChat, isOpen, setIsOpen, devMode } = useAskAi();
734
+ function AskAiBubble(props = {}) {
735
+ const { messages, input, setInput, isLoading, submitQuestion, stopStreaming, clearChat, isOpen, setIsOpen, devMode, ui, classNames = {} } = useAskAi(props);
231
736
  const messagesEndRef = useRef(null);
737
+ const inputRef = useRef(null);
738
+ const triggerRef = useRef(null);
739
+ const wasOpenRef = useRef(false);
740
+ const titleId = useId();
741
+ const panelId = useId();
742
+ const lastMessageContent = messages.at(-1)?.content;
232
743
  useEffect(() => {
233
- if (isOpen) messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
234
- }, [messages, isOpen]);
235
- const handleSubmit = (e) => {
236
- e.preventDefault();
237
- if (!input.trim()) return;
238
- submitQuestion(input);
239
- };
744
+ if (isOpen) {
745
+ wasOpenRef.current = true;
746
+ inputRef.current?.focus();
747
+ } else if (wasOpenRef.current) {
748
+ wasOpenRef.current = false;
749
+ triggerRef.current?.focus();
750
+ }
751
+ }, [isOpen]);
752
+ useEffect(() => {
753
+ if (isOpen && lastMessageContent !== void 0) messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
754
+ }, [isOpen, lastMessageContent]);
240
755
  return /* @__PURE__ */ jsxs("div", {
241
- className: "fixed bottom-6 right-6 z-50 flex flex-col items-end",
242
- children: [isOpen && /* @__PURE__ */ jsxs("div", {
243
- className: "mb-4 w-[380px] max-w-[calc(100vw-2rem)] h-[min(520px,calc(100vh-8rem))] bg-main/90 backdrop-blur-md border border-subtle rounded-2xl shadow-xl flex flex-col overflow-hidden animate-in fade-in slide-in-from-bottom-5 duration-200",
756
+ className: cn("pointer-events-none fixed inset-x-0 bottom-0 z-50 flex flex-col items-stretch md:inset-x-auto md:bottom-6 md:right-6 md:items-end", classNames.root),
757
+ children: [isOpen ? /* @__PURE__ */ jsxs("section", {
758
+ id: panelId,
759
+ role: "dialog",
760
+ "aria-modal": "false",
761
+ "aria-labelledby": titleId,
762
+ tabIndex: -1,
763
+ onKeyDown: (event) => {
764
+ if (event.key === "Escape") {
765
+ setIsOpen(false);
766
+ return;
767
+ }
768
+ if (event.key !== "Tab") return;
769
+ const focusable = Array.from(event.currentTarget.querySelectorAll("button:not([disabled]), textarea:not([disabled]), [href], input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex=\"-1\"])"));
770
+ if (focusable.length === 0) return;
771
+ const first = focusable[0];
772
+ const last = focusable[focusable.length - 1];
773
+ if (event.shiftKey && document.activeElement === first) {
774
+ event.preventDefault();
775
+ last.focus();
776
+ } else if (!event.shiftKey && document.activeElement === last) {
777
+ event.preventDefault();
778
+ first.focus();
779
+ }
780
+ },
781
+ className: cn("pointer-events-auto mb-0 flex h-[min(100dvh,680px)] w-full flex-col overflow-hidden rounded-t-3xl border border-subtle bg-main/95 shadow-2xl shadow-black/10 backdrop-blur-xl motion-safe:animate-in motion-safe:fade-in motion-safe:slide-in-from-bottom-4 md:mb-4 md:h-[min(560px,calc(100vh-8rem))] md:w-[400px] md:max-w-[calc(100vw-2rem)] md:rounded-3xl", classNames.panel),
244
782
  children: [
245
- /* @__PURE__ */ jsxs("div", {
246
- className: "px-4 py-3 border-b border-subtle flex items-center justify-between bg-surface/50",
247
- children: [/* @__PURE__ */ jsxs("div", {
248
- className: "flex items-center gap-2",
249
- children: [/* @__PURE__ */ jsx("svg", {
250
- xmlns: "http://www.w3.org/2000/svg",
251
- width: "16",
252
- height: "16",
253
- viewBox: "0 0 24 24",
254
- fill: "none",
255
- stroke: "currentColor",
256
- strokeWidth: "2.5",
257
- strokeLinecap: "round",
258
- strokeLinejoin: "round",
259
- className: "text-primary-500",
260
- children: /* @__PURE__ */ jsx("path", { d: "m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z" })
261
- }), /* @__PURE__ */ jsx("span", {
262
- className: "text-sm font-semibold text-body",
263
- children: "Ask Assistant"
264
- })]
265
- }), /* @__PURE__ */ jsxs("div", {
266
- className: "flex items-center gap-1",
267
- children: [messages.length > 0 && /* @__PURE__ */ jsx("button", {
268
- onClick: clearChat,
269
- className: "p-1 text-muted hover:text-red-500 hover:bg-surface rounded-lg transition-colors cursor-pointer",
270
- title: "Clear chat",
271
- "aria-label": "Clear chat",
272
- children: /* @__PURE__ */ jsxs("svg", {
273
- xmlns: "http://www.w3.org/2000/svg",
274
- width: "14",
275
- height: "14",
276
- viewBox: "0 0 24 24",
277
- fill: "none",
278
- stroke: "currentColor",
279
- strokeWidth: "2",
280
- strokeLinecap: "round",
281
- strokeLinejoin: "round",
282
- children: [
283
- /* @__PURE__ */ jsx("path", { d: "M3 6h18" }),
284
- /* @__PURE__ */ jsx("path", { d: "M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" }),
285
- /* @__PURE__ */ jsx("path", { d: "M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" })
286
- ]
287
- })
288
- }), /* @__PURE__ */ jsx("button", {
289
- onClick: () => setIsOpen(false),
290
- className: "p-1 text-muted hover:text-body hover:bg-surface rounded-lg transition-colors cursor-pointer",
291
- title: "Close assistant",
292
- "aria-label": "Close assistant",
293
- children: /* @__PURE__ */ jsxs("svg", {
294
- xmlns: "http://www.w3.org/2000/svg",
295
- width: "16",
296
- height: "16",
297
- viewBox: "0 0 24 24",
298
- fill: "none",
299
- stroke: "currentColor",
300
- strokeWidth: "2",
301
- strokeLinecap: "round",
302
- strokeLinejoin: "round",
303
- children: [/* @__PURE__ */ jsx("path", { d: "M18 6 6 18" }), /* @__PURE__ */ jsx("path", { d: "m6 6 12 12" })]
304
- })
305
- })]
306
- })]
783
+ /* @__PURE__ */ jsx(ChatHeader, {
784
+ title: ui.title,
785
+ canClear: messages.length > 0,
786
+ onClear: clearChat,
787
+ onClose: () => setIsOpen(false),
788
+ className: classNames.header,
789
+ titleId
307
790
  }),
308
791
  /* @__PURE__ */ jsxs("div", {
309
- className: "flex-1 overflow-y-auto p-4 flex flex-col gap-4",
792
+ role: "log",
793
+ "aria-live": "polite",
794
+ "aria-relevant": "additions text",
795
+ "aria-label": "Assistant messages",
796
+ className: cn("flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto overscroll-contain p-4", classNames.messages),
310
797
  children: [
311
- messages.length === 0 && /* @__PURE__ */ jsxs("div", {
312
- className: "flex-1 flex flex-col items-center justify-center text-center p-6",
313
- children: [
314
- /* @__PURE__ */ jsx("div", {
315
- className: "w-12 h-12 rounded-full bg-primary-500/10 flex items-center justify-center text-primary-500 mb-3",
316
- children: /* @__PURE__ */ jsx("svg", {
317
- xmlns: "http://www.w3.org/2000/svg",
318
- width: "24",
319
- height: "24",
320
- viewBox: "0 0 24 24",
321
- fill: "none",
322
- stroke: "currentColor",
323
- strokeWidth: "2",
324
- strokeLinecap: "round",
325
- strokeLinejoin: "round",
326
- children: /* @__PURE__ */ jsx("path", { d: "m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z" })
327
- })
328
- }),
329
- /* @__PURE__ */ jsx("h3", {
330
- className: "text-sm font-semibold text-body mb-1",
331
- children: "How can I help you today?"
332
- }),
333
- /* @__PURE__ */ jsx("p", {
334
- className: "text-xs text-muted max-w-[240px]",
335
- children: "Ask questions about the current documentation page. The assistant only answers using the page you're viewing."
336
- })
337
- ]
338
- }),
339
- messages.map((msg, i) => /* @__PURE__ */ jsxs("div", {
340
- className: `flex flex-col max-w-[85%] ${msg.role === "user" ? "align-self-end items-end ml-auto" : "align-self-start items-start"}`,
341
- children: [
342
- msg.role === "assistant" && msg.usage && devMode && /* @__PURE__ */ jsxs("div", {
343
- className: "flex items-center gap-1.5 px-2 py-1 mb-1 text-[11px] text-muted font-mono",
344
- children: [
345
- /* @__PURE__ */ jsx("span", {
346
- className: "px-1 py-0.5 rounded bg-amber-500/10 text-amber-600 dark:text-amber-400 text-[10px] font-bold uppercase tracking-wide",
347
- children: "DEV"
348
- }),
349
- /* @__PURE__ */ jsxs("span", {
350
- title: "Provider / model",
351
- children: [
352
- msg.usage.provider,
353
- "/",
354
- msg.usage.model
355
- ]
356
- }),
357
- /* @__PURE__ */ jsx("span", {
358
- className: "text-muted",
359
- children: "·"
360
- }),
361
- /* @__PURE__ */ jsxs("span", {
362
- title: "Prompt tokens",
363
- children: [msg.usage.promptTokens, "↑"]
364
- }),
365
- /* @__PURE__ */ jsxs("span", {
366
- title: "Completion tokens",
367
- children: [msg.usage.completionTokens, "↓"]
368
- }),
369
- /* @__PURE__ */ jsx("span", {
370
- className: "text-muted",
371
- children: "·"
372
- }),
373
- /* @__PURE__ */ jsxs("span", {
374
- title: "Total tokens",
375
- className: "text-primary-500 font-semibold",
376
- children: [msg.usage.totalTokens, " tok"]
377
- }),
378
- /* @__PURE__ */ jsx("span", {
379
- className: "text-muted",
380
- children: "·"
381
- }),
382
- /* @__PURE__ */ jsxs("span", {
383
- title: "Elapsed",
384
- children: [msg.usage.elapsedMs, "ms"]
385
- })
386
- ]
387
- }),
388
- msg.role === "assistant" && msg.contextChip && /* @__PURE__ */ jsx("div", {
389
- className: "flex items-center gap-1.5 px-2 py-1 mb-1 text-[11px] text-muted",
390
- children: msg.contextChip.missing ? /* @__PURE__ */ jsx("span", { children: "No docs page in scope" }) : /* @__PURE__ */ jsxs(Fragment, { children: [
391
- /* @__PURE__ */ jsxs("svg", {
392
- xmlns: "http://www.w3.org/2000/svg",
393
- width: "12",
394
- height: "12",
395
- viewBox: "0 0 24 24",
396
- fill: "none",
397
- stroke: "currentColor",
398
- strokeWidth: "2",
399
- strokeLinecap: "round",
400
- strokeLinejoin: "round",
401
- children: [/* @__PURE__ */ jsx("path", { d: "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" }), /* @__PURE__ */ jsx("polyline", { points: "14 2 14 8 20 8" })]
402
- }),
403
- /* @__PURE__ */ jsx("span", { children: "Reading" }),
404
- /* @__PURE__ */ jsx("code", {
405
- className: "px-1 py-0.5 rounded bg-surface text-primary-500 text-[10px] font-mono",
406
- children: msg.contextChip.page
407
- }),
408
- /* @__PURE__ */ jsx("span", {
409
- className: "text-muted",
410
- children: "·"
411
- }),
412
- /* @__PURE__ */ jsxs("span", { children: [msg.contextChip.chars, " chars"] }),
413
- typeof msg.contextChip.elapsedMs === "number" && /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("span", {
414
- className: "text-muted",
415
- children: "·"
416
- }), /* @__PURE__ */ jsxs("span", { children: [msg.contextChip.elapsedMs, "ms"] })] })
417
- ] })
418
- }),
419
- /* @__PURE__ */ jsx("div", {
420
- className: `px-3 py-2 rounded-xl ${msg.role === "user" ? "bg-primary-500 text-white rounded-br-none" : msg.status === "error" ? "bg-red-500/10 border border-red-500/30 text-red-600 dark:text-red-400 rounded-bl-none" : "bg-surface border border-subtle text-body rounded-bl-none"}`,
421
- children: msg.role === "user" ? /* @__PURE__ */ jsx("p", {
422
- className: "whitespace-pre-wrap text-sm",
423
- children: msg.content
424
- }) : msg.status === "error" ? /* @__PURE__ */ jsxs("p", {
425
- className: "text-sm",
426
- children: [
427
- /* @__PURE__ */ jsx("strong", { children: "Error:" }),
428
- " ",
429
- msg.errorMessage || "Something went wrong."
430
- ]
431
- }) : /* @__PURE__ */ jsx("div", {
432
- className: "ask-ai-streamdown",
433
- children: msg.content ? /* @__PURE__ */ jsx(MarkdownRenderer, { content: msg.content }) : msg.status === "reading" ? /* @__PURE__ */ jsxs("span", {
434
- className: "inline-flex items-center gap-1.5 text-xs text-muted",
435
- children: [/* @__PURE__ */ jsx("span", { className: "w-1.5 h-1.5 rounded-full bg-primary-500 animate-pulse" }), /* @__PURE__ */ jsx("span", { children: "Reading page…" })]
436
- }) : /* @__PURE__ */ jsxs("span", {
437
- className: "inline-flex items-center gap-1.5 text-xs text-muted",
438
- children: [/* @__PURE__ */ jsx("span", { className: "w-1.5 h-1.5 rounded-full bg-primary-500 animate-pulse" }), /* @__PURE__ */ jsx("span", { children: "Waiting…" })]
439
- })
440
- })
441
- })
442
- ]
443
- }, i)),
798
+ messages.length === 0 ? /* @__PURE__ */ jsx(ChatEmptyState, {
799
+ title: ui.emptyTitle,
800
+ description: ui.emptyDescription,
801
+ className: classNames.emptyState
802
+ }) : null,
803
+ messages.map((message, index) => /* @__PURE__ */ jsx(ChatMessage, {
804
+ message,
805
+ devMode,
806
+ className: classNames.message,
807
+ userClassName: classNames.userMessage,
808
+ assistantClassName: classNames.assistantMessage,
809
+ markdownClassName: classNames.markdown
810
+ }, message.id ?? `legacy-message-${index}`)),
444
811
  /* @__PURE__ */ jsx("div", { ref: messagesEndRef })
445
812
  ]
446
813
  }),
447
- /* @__PURE__ */ jsxs("form", {
448
- onSubmit: handleSubmit,
449
- className: "p-3 border-t border-subtle bg-surface/30 flex gap-2",
450
- children: [/* @__PURE__ */ jsx("input", {
451
- type: "text",
452
- value: input,
453
- onChange: (e) => setInput(e.target.value),
454
- placeholder: "Ask about this page…",
455
- className: "flex-1 bg-surface border border-subtle rounded-xl px-3 py-1.5 text-sm outline-none text-body focus-visible:border-primary-500 transition-colors",
456
- disabled: isLoading
457
- }), isLoading ? /* @__PURE__ */ jsx("button", {
458
- type: "button",
459
- onClick: stopStreaming,
460
- className: "px-3 py-1.5 bg-red-500 hover:bg-red-600 text-white text-sm font-semibold rounded-xl flex items-center justify-center transition-colors cursor-pointer select-none",
461
- title: "Stop generating",
462
- "aria-label": "Stop generating",
463
- children: /* @__PURE__ */ jsx("svg", {
464
- xmlns: "http://www.w3.org/2000/svg",
465
- width: "16",
466
- height: "16",
467
- viewBox: "0 0 24 24",
468
- fill: "currentColor",
469
- children: /* @__PURE__ */ jsx("rect", {
470
- x: "6",
471
- y: "6",
472
- width: "12",
473
- height: "12",
474
- rx: "2"
475
- })
476
- })
477
- }) : /* @__PURE__ */ jsx("button", {
478
- type: "submit",
479
- disabled: !input.trim(),
480
- className: "px-3 py-1.5 bg-primary-500 hover:bg-primary-600 disabled:opacity-50 text-white text-sm font-semibold rounded-xl flex items-center justify-center transition-colors cursor-pointer select-none",
481
- "aria-label": "Send question",
482
- children: /* @__PURE__ */ jsxs("svg", {
483
- xmlns: "http://www.w3.org/2000/svg",
484
- width: "16",
485
- height: "16",
486
- viewBox: "0 0 24 24",
487
- fill: "none",
488
- stroke: "currentColor",
489
- strokeWidth: "2.5",
490
- strokeLinecap: "round",
491
- strokeLinejoin: "round",
492
- children: [/* @__PURE__ */ jsx("path", { d: "m22 2-7 20-4-9-9-4Z" }), /* @__PURE__ */ jsx("path", { d: "M22 2 11 13" })]
493
- })
494
- })]
814
+ /* @__PURE__ */ jsx(ChatInput, {
815
+ input,
816
+ onInputChange: setInput,
817
+ onSubmit: submitQuestion,
818
+ onStop: stopStreaming,
819
+ isLoading,
820
+ inputRef,
821
+ placeholder: ui.placeholder,
822
+ hint: ui.composerHint,
823
+ className: cn("px-3 pb-3", classNames.composer),
824
+ inputClassName: classNames.input
495
825
  })
496
826
  ]
497
- }), /* @__PURE__ */ jsx("button", {
498
- onClick: () => setIsOpen((prev) => !prev),
499
- className: "w-12 h-12 bg-primary-500 hover:bg-primary-600 active:scale-95 text-white rounded-full flex items-center justify-center shadow-lg transition-all cursor-pointer select-none",
500
- title: "Ask AI assistant",
501
- "aria-label": "Ask AI assistant",
502
- children: isOpen ? /* @__PURE__ */ jsxs("svg", {
503
- xmlns: "http://www.w3.org/2000/svg",
504
- width: "20",
505
- height: "20",
506
- viewBox: "0 0 24 24",
507
- fill: "none",
508
- stroke: "currentColor",
509
- strokeWidth: "2.5",
510
- strokeLinecap: "round",
511
- strokeLinejoin: "round",
512
- children: [/* @__PURE__ */ jsx("path", { d: "M18 6 6 18" }), /* @__PURE__ */ jsx("path", { d: "m6 6 12 12" })]
513
- }) : /* @__PURE__ */ jsx("svg", {
514
- xmlns: "http://www.w3.org/2000/svg",
515
- width: "20",
516
- height: "20",
517
- viewBox: "0 0 24 24",
518
- fill: "none",
519
- stroke: "currentColor",
520
- strokeWidth: "2",
521
- strokeLinecap: "round",
522
- strokeLinejoin: "round",
523
- children: /* @__PURE__ */ jsx("path", { d: "M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" })
524
- })
827
+ }) : null, /* @__PURE__ */ jsx("button", {
828
+ ref: triggerRef,
829
+ type: "button",
830
+ onClick: () => setIsOpen((open) => !open),
831
+ className: cn("pointer-events-auto ml-auto flex size-14 cursor-pointer select-none items-center justify-center rounded-2xl bg-primary-500 text-white shadow-xl shadow-primary-500/25 transition-all hover:-translate-y-0.5 hover:bg-primary-600 hover:shadow-2xl active:translate-y-0 active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40 focus-visible:ring-offset-2 focus-visible:ring-offset-main motion-reduce:transform-none motion-reduce:transition-none", classNames.trigger),
832
+ title: ui.buttonTooltip,
833
+ "aria-label": ui.buttonTooltip,
834
+ "aria-expanded": isOpen,
835
+ "aria-controls": panelId,
836
+ children: isOpen ? /* @__PURE__ */ jsx(CloseIcon, {
837
+ size: 21,
838
+ strokeWidth: 2.5
839
+ }) : /* @__PURE__ */ jsx(ChatIcon, { size: 21 })
525
840
  })]
526
841
  });
527
842
  }
528
843
  //#endregion
529
844
  //#region src/client/components/ask-ai-dialog.tsx
530
- function AskAiDialog() {
531
- const { messages, input, setInput, isLoading, submitQuestion, stopStreaming, clearChat, isOpen, setIsOpen, devMode } = useAskAi();
845
+ function AskAiDialog(props = {}) {
846
+ const { messages, input, setInput, isLoading, submitQuestion, stopStreaming, clearChat, isOpen, setIsOpen, devMode, ui, classNames = {} } = useAskAi(props);
532
847
  const messagesEndRef = useRef(null);
533
848
  const inputRef = useRef(null);
849
+ const titleId = useId();
534
850
  useEffect(() => {
535
851
  if (isOpen) {
536
852
  window.dispatchEvent(new CustomEvent("boltdocs:ask-ai:open"));
@@ -538,239 +854,63 @@ function AskAiDialog() {
538
854
  inputRef.current?.focus();
539
855
  } else window.dispatchEvent(new CustomEvent("boltdocs:ask-ai:close"));
540
856
  }, [isOpen]);
541
- const handleSubmit = (e) => {
542
- e.preventDefault();
543
- if (!input.trim() || isLoading) return;
544
- submitQuestion(input);
545
- };
546
857
  if (!isOpen) return null;
547
- return /* @__PURE__ */ jsxs("div", {
548
- className: "hidden xl:flex flex-col shrink-0 w-[320px] border-l border-subtle bg-main overflow-hidden",
858
+ return /* @__PURE__ */ jsxs("aside", {
859
+ "aria-labelledby": titleId,
860
+ onKeyDown: (event) => {
861
+ if (event.key === "Escape") setIsOpen(false);
862
+ },
863
+ className: cn("hidden w-[340px] shrink-0 flex-col overflow-hidden border-l border-subtle bg-main xl:flex", classNames.root),
549
864
  children: [
550
- /* @__PURE__ */ jsxs("div", {
551
- className: "px-4 py-3 border-b border-subtle flex items-center justify-between bg-surface/50 shrink-0",
552
- children: [/* @__PURE__ */ jsxs("div", {
553
- className: "flex items-center gap-2",
554
- children: [/* @__PURE__ */ jsx("svg", {
555
- xmlns: "http://www.w3.org/2000/svg",
556
- width: "14",
557
- height: "14",
558
- viewBox: "0 0 24 24",
559
- fill: "none",
560
- stroke: "currentColor",
561
- strokeWidth: "2.5",
562
- strokeLinecap: "round",
563
- strokeLinejoin: "round",
564
- className: "text-primary-500 shrink-0",
565
- children: /* @__PURE__ */ jsx("path", { d: "m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z" })
566
- }), /* @__PURE__ */ jsx("span", {
567
- className: "text-xs font-bold text-body",
568
- children: "AI Assistant"
569
- })]
570
- }), /* @__PURE__ */ jsxs("div", {
571
- className: "flex items-center gap-1",
572
- children: [messages.length > 0 && /* @__PURE__ */ jsx("button", {
573
- onClick: clearChat,
574
- className: "p-1 text-muted hover:text-red-500 hover:bg-surface rounded-lg transition-colors cursor-pointer",
575
- title: "Clear chat",
576
- "aria-label": "Clear chat",
577
- children: /* @__PURE__ */ jsxs("svg", {
578
- xmlns: "http://www.w3.org/2000/svg",
579
- width: "12",
580
- height: "12",
581
- viewBox: "0 0 24 24",
582
- fill: "none",
583
- stroke: "currentColor",
584
- strokeWidth: "2",
585
- strokeLinecap: "round",
586
- strokeLinejoin: "round",
587
- children: [
588
- /* @__PURE__ */ jsx("path", { d: "M3 6h18" }),
589
- /* @__PURE__ */ jsx("path", { d: "M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" }),
590
- /* @__PURE__ */ jsx("path", { d: "M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" })
591
- ]
592
- })
593
- }), /* @__PURE__ */ jsx("button", {
594
- onClick: () => setIsOpen(false),
595
- className: "p-1 text-muted hover:text-body hover:bg-surface rounded-lg transition-colors cursor-pointer",
596
- title: "Close assistant",
597
- "aria-label": "Close assistant",
598
- children: /* @__PURE__ */ jsxs("svg", {
599
- xmlns: "http://www.w3.org/2000/svg",
600
- width: "12",
601
- height: "12",
602
- viewBox: "0 0 24 24",
603
- fill: "none",
604
- stroke: "currentColor",
605
- strokeWidth: "2",
606
- strokeLinecap: "round",
607
- strokeLinejoin: "round",
608
- children: [/* @__PURE__ */ jsx("path", { d: "M18 6 6 18" }), /* @__PURE__ */ jsx("path", { d: "m6 6 12 12" })]
609
- })
610
- })]
611
- })]
865
+ /* @__PURE__ */ jsx(ChatHeader, {
866
+ title: ui.title,
867
+ variant: "dialog",
868
+ canClear: messages.length > 0,
869
+ onClear: clearChat,
870
+ onClose: () => setIsOpen(false),
871
+ className: classNames.header,
872
+ titleId
612
873
  }),
613
874
  /* @__PURE__ */ jsxs("div", {
614
- className: "flex-1 overflow-y-auto p-4 flex flex-col gap-3 min-h-0",
875
+ role: "log",
876
+ "aria-live": "polite",
877
+ "aria-relevant": "additions text",
878
+ "aria-label": "Assistant messages",
879
+ className: cn("flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto p-4", classNames.messages),
615
880
  children: [
616
- messages.length === 0 && /* @__PURE__ */ jsxs("div", {
617
- className: "flex-1 flex flex-col items-center justify-center text-center p-2",
618
- children: [/* @__PURE__ */ jsx("div", {
619
- className: "w-10 h-10 rounded-xl bg-primary-500/10 flex items-center justify-center text-primary-500 mb-3",
620
- children: /* @__PURE__ */ jsx("svg", {
621
- xmlns: "http://www.w3.org/2000/svg",
622
- width: "20",
623
- height: "20",
624
- viewBox: "0 0 24 24",
625
- fill: "none",
626
- stroke: "currentColor",
627
- strokeWidth: "2",
628
- strokeLinecap: "round",
629
- strokeLinejoin: "round",
630
- children: /* @__PURE__ */ jsx("path", { d: "m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z" })
631
- })
632
- }), /* @__PURE__ */ jsx("p", {
633
- className: "text-xs text-muted leading-relaxed",
634
- children: "Ask anything about the current documentation page"
635
- })]
636
- }),
637
- messages.map((msg, i) => /* @__PURE__ */ jsxs("div", {
638
- className: `flex flex-col max-w-full ${msg.role === "user" ? "items-end" : "items-start"}`,
639
- children: [
640
- msg.role === "assistant" && msg.usage && devMode && /* @__PURE__ */ jsxs("div", {
641
- className: "flex items-center gap-1.5 px-2 py-1 mb-1 text-[11px] text-muted font-mono",
642
- children: [
643
- /* @__PURE__ */ jsx("span", {
644
- className: "px-1 py-0.5 rounded bg-amber-500/10 text-amber-600 dark:text-amber-400 text-[10px] font-bold uppercase tracking-wide",
645
- children: "DEV"
646
- }),
647
- /* @__PURE__ */ jsxs("span", { children: [
648
- msg.usage.provider,
649
- "/",
650
- msg.usage.model
651
- ] }),
652
- /* @__PURE__ */ jsx("span", { children: "·" }),
653
- /* @__PURE__ */ jsxs("span", { children: [msg.usage.promptTokens, "↑"] }),
654
- /* @__PURE__ */ jsxs("span", { children: [msg.usage.completionTokens, "↓"] }),
655
- /* @__PURE__ */ jsx("span", { children: "·" }),
656
- /* @__PURE__ */ jsxs("span", {
657
- className: "text-primary-500 font-semibold",
658
- children: [msg.usage.totalTokens, " tok"]
659
- }),
660
- /* @__PURE__ */ jsx("span", { children: "·" }),
661
- /* @__PURE__ */ jsxs("span", { children: [msg.usage.elapsedMs, "ms"] })
662
- ]
663
- }),
664
- msg.role === "assistant" && msg.contextChip && /* @__PURE__ */ jsx("div", {
665
- className: "flex items-center gap-1.5 px-2 py-1 mb-1 text-[11px] text-muted",
666
- children: msg.contextChip.missing ? /* @__PURE__ */ jsx("span", { children: "No docs in scope" }) : /* @__PURE__ */ jsxs(Fragment, { children: [
667
- /* @__PURE__ */ jsxs("svg", {
668
- xmlns: "http://www.w3.org/2000/svg",
669
- width: "12",
670
- height: "12",
671
- viewBox: "0 0 24 24",
672
- fill: "none",
673
- stroke: "currentColor",
674
- strokeWidth: "2",
675
- strokeLinecap: "round",
676
- strokeLinejoin: "round",
677
- children: [/* @__PURE__ */ jsx("path", { d: "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" }), /* @__PURE__ */ jsx("polyline", { points: "14 2 14 8 20 8" })]
678
- }),
679
- /* @__PURE__ */ jsx("span", { children: "Reading" }),
680
- /* @__PURE__ */ jsx("code", {
681
- className: "px-1 py-0.5 rounded bg-surface text-primary-500 text-[10px] font-mono",
682
- children: msg.contextChip.page
683
- }),
684
- /* @__PURE__ */ jsx("span", {
685
- className: "text-muted",
686
- children: "·"
687
- }),
688
- /* @__PURE__ */ jsxs("span", { children: [msg.contextChip.chars, "c"] })
689
- ] })
690
- }),
691
- /* @__PURE__ */ jsx("div", {
692
- className: `px-3 py-2 rounded-xl ${msg.role === "user" ? "bg-primary-500 text-white rounded-br-none max-w-[90%]" : msg.status === "error" ? "bg-red-500/10 border border-red-500/30 text-red-600 dark:text-red-400 rounded-bl-none" : "bg-surface border border-subtle text-body rounded-bl-none"}`,
693
- children: msg.role === "user" ? /* @__PURE__ */ jsx("p", {
694
- className: "whitespace-pre-wrap text-xs",
695
- children: msg.content
696
- }) : msg.status === "error" ? /* @__PURE__ */ jsxs("p", {
697
- className: "text-xs",
698
- children: [
699
- /* @__PURE__ */ jsx("strong", { children: "Error:" }),
700
- " ",
701
- msg.errorMessage || "Something went wrong."
702
- ]
703
- }) : /* @__PURE__ */ jsx("div", {
704
- className: "ask-ai-streamdown",
705
- children: msg.content ? /* @__PURE__ */ jsx(MarkdownRenderer, { content: msg.content }) : msg.status === "reading" ? /* @__PURE__ */ jsxs("span", {
706
- className: "inline-flex items-center gap-1.5 text-xs text-muted",
707
- children: [/* @__PURE__ */ jsx("span", { className: "w-1.5 h-1.5 rounded-full bg-primary-500 animate-pulse" }), /* @__PURE__ */ jsx("span", { children: "Reading page…" })]
708
- }) : /* @__PURE__ */ jsxs("span", {
709
- className: "inline-flex items-center gap-1.5 text-xs text-muted",
710
- children: [/* @__PURE__ */ jsx("span", { className: "w-1.5 h-1.5 rounded-full bg-primary-500 animate-pulse" }), /* @__PURE__ */ jsx("span", { children: "Waiting…" })]
711
- })
712
- })
713
- })
714
- ]
715
- }, i)),
881
+ messages.length === 0 ? /* @__PURE__ */ jsx(ChatEmptyState, {
882
+ variant: "dialog",
883
+ title: ui.emptyTitle,
884
+ description: ui.emptyDescription,
885
+ className: classNames.emptyState
886
+ }) : null,
887
+ messages.map((message, index) => /* @__PURE__ */ jsx(ChatMessage, {
888
+ message,
889
+ variant: "dialog",
890
+ devMode,
891
+ className: classNames.message,
892
+ userClassName: classNames.userMessage,
893
+ assistantClassName: classNames.assistantMessage,
894
+ markdownClassName: classNames.markdown
895
+ }, message.id ?? `legacy-message-${index}`)),
716
896
  /* @__PURE__ */ jsx("div", { ref: messagesEndRef })
717
897
  ]
718
898
  }),
719
- /* @__PURE__ */ jsxs("form", {
720
- onSubmit: handleSubmit,
721
- className: "p-3 border-t border-subtle bg-surface/30 flex gap-2 shrink-0",
722
- children: [/* @__PURE__ */ jsx("input", {
723
- ref: inputRef,
724
- type: "text",
725
- value: input,
726
- onChange: (e) => setInput(e.target.value),
727
- placeholder: "Ask about this page…",
728
- className: "flex-1 bg-surface border border-subtle rounded-lg px-2.5 py-1.5 text-xs outline-none text-body focus-within:border-primary-500 transition-colors min-w-0",
729
- disabled: isLoading
730
- }), isLoading ? /* @__PURE__ */ jsx("button", {
731
- type: "button",
732
- onClick: stopStreaming,
733
- className: "px-2.5 py-1.5 bg-red-500 hover:bg-red-600 text-white text-xs font-semibold rounded-lg flex items-center justify-center transition-colors cursor-pointer shrink-0",
734
- title: "Stop generating",
735
- "aria-label": "Stop generating",
736
- children: /* @__PURE__ */ jsx("svg", {
737
- xmlns: "http://www.w3.org/2000/svg",
738
- width: "12",
739
- height: "12",
740
- viewBox: "0 0 24 24",
741
- fill: "currentColor",
742
- children: /* @__PURE__ */ jsx("rect", {
743
- x: "6",
744
- y: "6",
745
- width: "12",
746
- height: "12",
747
- rx: "2"
748
- })
749
- })
750
- }) : /* @__PURE__ */ jsx("button", {
751
- type: "submit",
752
- disabled: !input.trim(),
753
- className: "px-2.5 py-1.5 bg-primary-500 hover:bg-primary-600 disabled:opacity-50 text-white text-xs font-semibold rounded-lg flex items-center justify-center transition-colors cursor-pointer shrink-0",
754
- "aria-label": "Send question",
755
- children: /* @__PURE__ */ jsxs("svg", {
756
- xmlns: "http://www.w3.org/2000/svg",
757
- width: "12",
758
- height: "12",
759
- viewBox: "0 0 24 24",
760
- fill: "none",
761
- stroke: "currentColor",
762
- strokeWidth: "2.5",
763
- strokeLinecap: "round",
764
- strokeLinejoin: "round",
765
- children: [/* @__PURE__ */ jsx("path", { d: "m22 2-7 20-4-9-9-4Z" }), /* @__PURE__ */ jsx("path", { d: "M22 2 11 13" })]
766
- })
767
- })]
899
+ /* @__PURE__ */ jsx(ChatInput, {
900
+ variant: "dialog",
901
+ input,
902
+ onInputChange: setInput,
903
+ onSubmit: submitQuestion,
904
+ onStop: stopStreaming,
905
+ isLoading,
906
+ inputRef,
907
+ placeholder: ui.placeholder,
908
+ hint: ui.composerHint,
909
+ className: cn("border-t border-subtle p-3", classNames.composer),
910
+ inputClassName: classNames.input
768
911
  })
769
912
  ]
770
913
  });
771
914
  }
772
915
  //#endregion
773
- //#region src/client/index.ts
774
- var client_default = null;
775
- //#endregion
776
- export { AskAiBubble, AskAiDialog, MarkdownRenderer, client_default as default, useAskAi };
916
+ export { AskAiBubble, AskAiDialog, ChatEmptyState, ChatHeader, ChatInput, ChatMessage, MarkdownRenderer, TypingIndicator, useAskAi };