@veluai/velu 0.2.13 → 0.2.15

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.
Files changed (109) hide show
  1. package/README.md +80 -80
  2. package/dist/cli.js +37 -37
  3. package/package.json +64 -64
  4. package/runtime/velu-ui/base.css +320 -320
  5. package/runtime/velu-ui/components/Accordion.jsx +64 -64
  6. package/runtime/velu-ui/components/ApiClient.jsx +207 -207
  7. package/runtime/velu-ui/components/ApiField.jsx +87 -87
  8. package/runtime/velu-ui/components/ApiPath.jsx +63 -63
  9. package/runtime/velu-ui/components/ApiReferencePage.jsx +384 -384
  10. package/runtime/velu-ui/components/ApiSamples.jsx +36 -36
  11. package/runtime/velu-ui/components/ApiSidebar.jsx +122 -122
  12. package/runtime/velu-ui/components/AskBar.jsx +71 -71
  13. package/runtime/velu-ui/components/Callout.jsx +114 -114
  14. package/runtime/velu-ui/components/Card.jsx +131 -131
  15. package/runtime/velu-ui/components/Chatbot.jsx +885 -885
  16. package/runtime/velu-ui/components/CodeBlock.jsx +375 -375
  17. package/runtime/velu-ui/components/Columns.jsx +56 -56
  18. package/runtime/velu-ui/components/ContextMenu.jsx +298 -273
  19. package/runtime/velu-ui/components/ErrorCard.jsx +138 -138
  20. package/runtime/velu-ui/components/Field.jsx +81 -81
  21. package/runtime/velu-ui/components/Image.jsx +163 -163
  22. package/runtime/velu-ui/components/Logo.jsx +31 -31
  23. package/runtime/velu-ui/components/MethodBadge.jsx +31 -31
  24. package/runtime/velu-ui/components/NavSelect.jsx +108 -108
  25. package/runtime/velu-ui/components/NotFound.jsx +63 -63
  26. package/runtime/velu-ui/components/PageFeedback.jsx +219 -219
  27. package/runtime/velu-ui/components/PageFooter.jsx +145 -145
  28. package/runtime/velu-ui/components/PageHeader.jsx +422 -422
  29. package/runtime/velu-ui/components/PageNav.jsx +77 -77
  30. package/runtime/velu-ui/components/PoweredBy.jsx +51 -51
  31. package/runtime/velu-ui/components/Prompt.jsx +115 -115
  32. package/runtime/velu-ui/components/Search.jsx +460 -460
  33. package/runtime/velu-ui/components/Sidebar.jsx +254 -254
  34. package/runtime/velu-ui/components/SocialLinks.jsx +90 -90
  35. package/runtime/velu-ui/components/Steps.jsx +65 -65
  36. package/runtime/velu-ui/components/ThemeToggle.jsx +48 -48
  37. package/runtime/velu-ui/components/Toc.jsx +537 -537
  38. package/runtime/velu-ui/components/TocBar.jsx +195 -195
  39. package/runtime/velu-ui/components/Tree.jsx +87 -87
  40. package/runtime/velu-ui/components/TryItBar.jsx +102 -102
  41. package/runtime/velu-ui/components/accordion.css +92 -92
  42. package/runtime/velu-ui/components/api-page.css +208 -208
  43. package/runtime/velu-ui/components/api.css +635 -635
  44. package/runtime/velu-ui/components/ask-bar.css +94 -94
  45. package/runtime/velu-ui/components/card.css +105 -105
  46. package/runtime/velu-ui/components/chatbot.css +622 -622
  47. package/runtime/velu-ui/components/code-block.css +263 -263
  48. package/runtime/velu-ui/components/context-menu.css +173 -173
  49. package/runtime/velu-ui/components/docs-layout.css +822 -822
  50. package/runtime/velu-ui/components/field.css +82 -82
  51. package/runtime/velu-ui/components/image.css +237 -237
  52. package/runtime/velu-ui/components/nav-select.css +157 -157
  53. package/runtime/velu-ui/components/not-found.css +94 -94
  54. package/runtime/velu-ui/components/page-feedback.css +241 -241
  55. package/runtime/velu-ui/components/page-footer.css +130 -130
  56. package/runtime/velu-ui/components/page-header.css +558 -558
  57. package/runtime/velu-ui/components/page-nav.css +50 -50
  58. package/runtime/velu-ui/components/powered-by.css +92 -92
  59. package/runtime/velu-ui/components/prompt.css +99 -99
  60. package/runtime/velu-ui/components/search.css +307 -307
  61. package/runtime/velu-ui/components/sidebar.css +205 -205
  62. package/runtime/velu-ui/components/steps.css +77 -77
  63. package/runtime/velu-ui/components/theme-toggle.css +102 -102
  64. package/runtime/velu-ui/components/toc-bar.css +234 -234
  65. package/runtime/velu-ui/components/tree.css +49 -49
  66. package/runtime/velu-ui/index.js +54 -54
  67. package/runtime/velu-ui/lib/api-send.js +92 -92
  68. package/runtime/velu-ui/lib/brand-icons.jsx +103 -103
  69. package/runtime/velu-ui/lib/component-schemas.js +100 -100
  70. package/runtime/velu-ui/lib/copyText.js +64 -64
  71. package/runtime/velu-ui/lib/docs-assistant.js +250 -250
  72. package/runtime/velu-ui/lib/lang-icons.jsx +147 -147
  73. package/runtime/velu-ui/lib/pagefind.js +113 -113
  74. package/runtime/velu-ui/lib/prism-langs.js +957 -957
  75. package/runtime/velu-ui/lib/prism-loader.js +74 -74
  76. package/runtime/velu-ui/lib/resolveIcon.jsx +29 -29
  77. package/runtime/velu-ui/lib/scrollIntoNearestView.js +66 -66
  78. package/runtime/velu-ui/mdx-components.jsx +105 -105
  79. package/runtime/velu-ui/primitives/Cluster.jsx +49 -49
  80. package/runtime/velu-ui/primitives/Stack.jsx +63 -63
  81. package/runtime/velu-ui/primitives/Switcher.jsx +57 -57
  82. package/runtime/velu-ui/primitives/stack.css +3 -3
  83. package/runtime/velu-ui/primitives/switcher.css +25 -25
  84. package/runtime/velu-ui/styles.css +46 -46
  85. package/runtime/velu-ui/tokens.css +4 -4
  86. package/schema/velu.schema.json +423 -423
  87. package/src/lib/extract-mdx-error.js +170 -170
  88. package/src/lib/issues.js +159 -159
  89. package/src/lib/known-components.js +34 -34
  90. package/src/navigation.js +443 -443
  91. package/src/runtime/App.jsx +1669 -1668
  92. package/src/runtime/ErrorBoundary.jsx +54 -54
  93. package/src/runtime/client-entry.jsx +22 -22
  94. package/src/runtime/server-entry.jsx +16 -16
  95. package/src/template.html +48 -48
  96. package/templates/starter/ai-tools/claude-code.mdx +26 -26
  97. package/templates/starter/ai-tools/cursor.mdx +17 -17
  98. package/templates/starter/api-reference/introduction.mdx +43 -43
  99. package/templates/starter/development.mdx +19 -19
  100. package/templates/starter/essentials/code.mdx +29 -29
  101. package/templates/starter/essentials/images.mdx +29 -29
  102. package/templates/starter/essentials/markdown.mdx +25 -25
  103. package/templates/starter/essentials/navigation.mdx +39 -39
  104. package/templates/starter/essentials/settings.mdx +30 -30
  105. package/templates/starter/favicon.svg +6 -6
  106. package/templates/starter/index.mdx +31 -31
  107. package/templates/starter/openapi.json +160 -160
  108. package/templates/starter/quickstart.mdx +31 -31
  109. package/templates/starter/velu.json +41 -41
@@ -1,885 +1,885 @@
1
- import React, { useState, useRef, useEffect, useCallback } from 'react';
2
- import resolveIcon from '../lib/resolveIcon.jsx';
3
- import Stack from '../primitives/Stack.jsx';
4
- import Cluster from '../primitives/Cluster.jsx';
5
-
6
- /**
7
- * Chatbot — the Velu "Ask AI" panel. A right-side slide-out: header +
8
- * scrolling message body + composer. Ported from the Claude-Design
9
- * "Velu Chatbot" handoff (rich variant), retokenized onto velu-ui's
10
- * scale so it themes for free via [data-theme].
11
- *
12
- * <Chatbot
13
- * open={chatOpen}
14
- * seedQuestion={question} // first message — sent on open
15
- * onClose={() => setChatOpen(false)}
16
- * ask={askDocs} // OPTIONAL — real backend (see below)
17
- * onFeedback={(id, v) => …} // OPTIONAL — 👍/👎 for a message
18
- * />
19
- *
20
- * Slide: the panel is fixed to the inline-end edge; `open` toggles a
21
- * `translateX` between fully off-screen and 0. Width is the
22
- * `--vchat-width` variable (default 24rem ≈ the design's 384px).
23
- *
24
- * Backend seam (mirrors Search.jsx's injectable `search` prop):
25
- * ask(prompt, { conversationId, signal }) → AsyncIterable<AskEvent>
26
- * AskEvent = {
27
- * delta?: string, // text chunk to append (streamed)
28
- * citations?: Source[], // [{ num, title, path, url }] — replaces sources
29
- * messageId?: string, // assistant message id (for feedback)
30
- * conversationId?: string, // threaded across turns
31
- * }
32
- * When `ask` is omitted the panel falls back to a canned, fake-streamed
33
- * demo answer (so dev preview / offline still shows the experience).
34
- */
35
-
36
- /* ── Brand mark — the Velu double-hook logo ─────────────────────────── */
37
- // `size` is the glyph height; width derives from the 32:24 viewBox.
38
- function VeluMark({ size = 'var(--icon-size-lg)' }) {
39
- return (
40
- <svg
41
- className="velu-chatbot__mark"
42
- viewBox="0 0 32 24"
43
- fill="currentColor"
44
- aria-hidden="true"
45
- style={{ width: `calc(${size} * (32 / 24))`, height: size }}
46
- >
47
- <path d="M 29.656 14.153 C 29.574 14.562 29.351 14.929 29.025 15.193 C 28.699 15.457 28.291 15.601 27.869 15.601 L 21.37 15.601 C 20.534 15.601 19.805 16.163 19.603 16.964 L 18.167 22.637 C 18.068 23.026 17.841 23.372 17.521 23.619 C 17.2 23.866 16.805 24 16.399 24 L 10.438 24 C 10.161 24 9.888 23.938 9.639 23.818 C 9.39 23.698 9.172 23.524 9.001 23.308 C 8.831 23.092 8.712 22.841 8.655 22.574 C 8.598 22.306 8.603 22.029 8.67 21.764 L 10.189 15.764 C 10.287 15.374 10.515 15.029 10.835 14.782 C 11.155 14.535 11.55 14.401 11.956 14.4 L 18.393 14.4 C 19.262 14.4 20.01 13.795 20.18 12.953 L 22.388 2.048 C 22.471 1.639 22.694 1.272 23.019 1.008 C 23.345 0.744 23.754 0.6 24.175 0.6 L 30.177 0.6 C 30.447 0.6 30.713 0.659 30.957 0.773 C 31.201 0.886 31.416 1.052 31.587 1.258 C 31.758 1.464 31.881 1.705 31.946 1.964 C 32.011 2.222 32.017 2.492 31.964 2.753 L 29.656 14.153 Z M 9.611 13.554 C 9.528 13.962 9.305 14.329 8.979 14.593 C 8.653 14.857 8.245 15.001 7.824 15.001 L 1.823 15.001 C 1.553 15.001 1.287 14.942 1.043 14.828 C 0.799 14.714 0.584 14.548 0.413 14.342 C 0.242 14.136 0.119 13.895 0.054 13.637 C -0.011 13.378 -0.017 13.109 0.036 12.848 L 2.344 1.447 C 2.426 1.039 2.649 0.672 2.975 0.408 C 3.301 0.144 3.709 0 4.131 0 L 10.132 0 C 10.402 0 10.668 0.059 10.912 0.173 C 11.155 0.287 11.371 0.453 11.541 0.659 C 11.712 0.865 11.835 1.106 11.9 1.364 C 11.965 1.623 11.972 1.892 11.919 2.153 L 9.611 13.553 L 9.611 13.554 Z" />
48
- </svg>
49
- );
50
- }
51
-
52
- /* ── "Thinking" loader — 3x3 grid of squares, opacity wave sweeps the
53
- anti-diagonal so it ripples top-left → bottom-right. ─────────────── */
54
- function BlocksWave() {
55
- const cells = [
56
- [1, 1, 0], [8.33, 1, 1], [15.66, 1, 2],
57
- [1, 8.33, 1], [8.33, 8.33, 2], [15.66, 8.33, 3],
58
- [1, 15.66, 2], [8.33, 15.66, 3], [15.66, 15.66, 4],
59
- ];
60
- return (
61
- <svg className="velu-chatbot__wave" viewBox="0 0 24 24" aria-hidden="true">
62
- {cells.map(([x, y, cell], i) => (
63
- <rect key={i} x={x} y={y} width="7.33" height="7.33" data-cell={cell} />
64
- ))}
65
- </svg>
66
- );
67
- }
68
-
69
- /* ── Demo data ──────────────────────────────────────────────────────── */
70
- const SUGGESTIONS = [
71
- { label: 'How do I set up GitHub sync?', icon: 'github' },
72
- { label: 'Customize the look of my docs site', icon: 'book' },
73
- { label: 'Configure AI search ranking', icon: 'sparkles' },
74
- { label: 'Quickstart in under five minutes', icon: 'rocket' },
75
- ];
76
-
77
- const HISTORY = [
78
- { id: 'h1', title: 'Setting up GitHub sync', when: 'Today, 10:24' },
79
- { id: 'h2', title: 'Custom theme tokens & dark mode', when: 'Today, 09:11' },
80
- { id: 'h3', title: 'Configure AI search ranking', when: 'Yesterday' },
81
- { id: 'h4', title: 'Embed a Mermaid diagram in MDX', when: 'Yesterday' },
82
- { id: 'h5', title: 'Why are my callouts missing icons?', when: 'Apr 28' },
83
- { id: 'h6', title: 'Quickstart — minimum site config', when: 'Apr 27' },
84
- ];
85
-
86
- const SOURCES = [
87
- { num: 1, title: 'GitHub & GitLab Sync', path: 'docs/integrations/github-sync.md' },
88
- { num: 2, title: 'Site Settings — Repositories', path: 'docs/your-docs-site/site-settings.md' },
89
- { num: 3, title: 'AI-native Documentation', path: 'docs/get-started/ai-native.md' },
90
- ];
91
-
92
- /* Canned answer — segments interleave text, inline `code`, and citation
93
- markers. The same answer is returned for any prompt (demo). */
94
- function buildAnswer() {
95
- return {
96
- segments: [
97
- { t: 'Velu syncs documentation directly with GitHub and GitLab — every push to a connected repository updates the live docs site within seconds.' },
98
- { cite: 1 },
99
- { t: ' To set it up:\n\n1. Open ' },
100
- { code: 'Site Settings → Repositories' },
101
- { t: ' and click ' },
102
- { code: 'Connect repository' },
103
- { t: '.\n2. Authorize the Velu app on the org that owns the repo.\n3. Pick a branch (default ' },
104
- { code: 'main' },
105
- { t: ') and a docs root path.' },
106
- { cite: 2 },
107
- { t: '\n\nVelu watches changes to ' },
108
- { code: '.md' },
109
- { t: ' and ' },
110
- { code: '.mdx' },
111
- { t: ' files and re-indexes the site automatically.' },
112
- { cite: 3 },
113
- ],
114
- sources: SOURCES,
115
- followups: [
116
- 'How do I preview pull-request changes?',
117
- 'Can I sync from a monorepo subdirectory?',
118
- 'What about private repositories?',
119
- ],
120
- };
121
- }
122
-
123
- /* Real-backend path: turn the accumulated answer TEXT into the same token
124
- shape `renderStream` consumes. The assistant streams plain text with inline
125
- `code` spans and bracketed citation markers ([1], [2]); split those out so
126
- they render as <code> and citation chips, exactly like the canned path. */
127
- function textToTokens(text) {
128
- const tokens = [];
129
- const re = /(`[^`]*`|\[\d+\])/g;
130
- let last = 0;
131
- let m;
132
- while ((m = re.exec(text)) !== null) {
133
- if (m.index > last) tokens.push({ kind: 't', v: text.slice(last, m.index) });
134
- const tok = m[0];
135
- if (tok[0] === '`') tokens.push({ kind: 'code', v: tok.slice(1, -1) });
136
- else tokens.push({ kind: 'cite', n: Number(tok.slice(1, -1)) });
137
- last = re.lastIndex;
138
- }
139
- if (last < text.length) tokens.push({ kind: 't', v: text.slice(last) });
140
- return tokens;
141
- }
142
-
143
- /* Plain-text of an answer (for the Copy action). */
144
- function tokensToPlain(tokens) {
145
- return tokens
146
- .map((tk) => (tk.kind === 'cite' ? `[${tk.n}]` : tk.v))
147
- .join('')
148
- .trim();
149
- }
150
-
151
- /* Flatten an answer into a stream of small tokens for incremental render. */
152
- function tokenizeAnswer(answer) {
153
- const tokens = [];
154
- for (const seg of answer.segments) {
155
- if (seg.cite != null) { tokens.push({ kind: 'cite', n: seg.cite }); continue; }
156
- if (seg.code != null) { tokens.push({ kind: 'code', v: seg.code }); continue; }
157
- const parts = seg.t.match(/\S+\s*|\s+/g) || [seg.t];
158
- for (const p of parts) tokens.push({ kind: 't', v: p });
159
- }
160
- return tokens;
161
- }
162
-
163
- /* A citation's in-site target. Real backend sources carry a `url` (and `path`
164
- is a route like "/essentials/code"); the demo's `path` is a fake file path,
165
- so those stay inert. */
166
- function sourceHref(s) {
167
- if (s?.url) return s.url;
168
- if (s?.path && s.path.startsWith('/')) return s.path;
169
- return null;
170
- }
171
-
172
- /* A readable label for a source: the section heading if present, else a title
173
- derived from the route (e.g. "/essentials/settings" → "Settings"). */
174
- function citeTitle(s) {
175
- if (s?.title) return s.title;
176
- const path = (s?.path || '').replace(/[#?].*$/, '');
177
- const seg = path.split('/').filter(Boolean).pop();
178
- if (!seg) return 'Documentation';
179
- return seg.replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
180
- }
181
-
182
- /* Source click: SPA-navigate same-origin targets via onNavigate (keeps the chat
183
- panel open + scrolls to the heading); otherwise let the <a href> navigate. */
184
- function onSourceClick(e, s, onNavigate) {
185
- const href = sourceHref(s);
186
- if (!href) { e.preventDefault(); return; }
187
- if (!onNavigate) return; // no SPA hook → let the browser follow the href
188
- try {
189
- const u = new URL(href, window.location.origin);
190
- if (u.origin === window.location.origin) {
191
- e.preventDefault();
192
- onNavigate(u.pathname + u.search + u.hash);
193
- }
194
- } catch { /* malformed — fall back to href */ }
195
- }
196
-
197
- /* Render partial tokens as paragraphs (split on \n\n, single \n → <br>). */
198
- function renderStream(tokens, sources, onNavigate) {
199
- const paragraphs = [[]];
200
- for (const tk of tokens) {
201
- if (tk.kind === 't' && tk.v.includes('\n\n')) {
202
- const split = tk.v.split('\n\n');
203
- paragraphs[paragraphs.length - 1].push({ kind: 't', v: split[0] });
204
- for (let i = 1; i < split.length; i++) {
205
- paragraphs.push([]);
206
- if (split[i]) paragraphs[paragraphs.length - 1].push({ kind: 't', v: split[i] });
207
- }
208
- } else {
209
- paragraphs[paragraphs.length - 1].push(tk);
210
- }
211
- }
212
- return paragraphs.map((para, pi) => (
213
- <p key={pi}>
214
- {para.map((tk, i) => {
215
- if (tk.kind === 'cite') {
216
- const src = sources?.find((s) => s.num === tk.n);
217
- return (
218
- <a
219
- key={i}
220
- className="velu-chatbot__cite"
221
- href={sourceHref(src) || '#'}
222
- onClick={(e) => onSourceClick(e, src, onNavigate)}
223
- title={citeTitle(src)}
224
- >
225
- {tk.n}
226
- </a>
227
- );
228
- }
229
- if (tk.kind === 'code') return <code key={i}>{tk.v}</code>;
230
- const lines = tk.v.split('\n');
231
- return lines.map((line, li) => (
232
- <React.Fragment key={`${i}-${li}`}>
233
- {li > 0 && <br />}
234
- {line}
235
- </React.Fragment>
236
- ));
237
- })}
238
- </p>
239
- ));
240
- }
241
-
242
- /* ── Header ─────────────────────────────────────────────────────────── */
243
- function ChatHeader({ onClose, onNew, onHistory, historyOpen }) {
244
- return (
245
- <Cluster
246
- space="var(--s-2)"
247
- justify="space-between"
248
- align="center"
249
- className="velu-chatbot__header"
250
- >
251
- <Cluster space="var(--s-2)" align="center">
252
- <VeluMark />
253
- <span className="velu-chatbot__brand">
254
- <span className="velu-chatbot__brand-name">Velu</span>
255
- <span className="velu-chatbot__brand-sep">/</span>
256
- <span className="velu-chatbot__brand-title">Ask AI</span>
257
- </span>
258
- </Cluster>
259
- <Cluster space="var(--s-5)" align="center">
260
- <button
261
- type="button"
262
- className={`velu-chatbot__iconbtn${historyOpen ? ' is-active' : ''}`}
263
- aria-label="History"
264
- onClick={onHistory}
265
- >
266
- {resolveIcon('history', { size: '1em' })}
267
- </button>
268
- <button
269
- type="button"
270
- className="velu-chatbot__iconbtn"
271
- aria-label="New chat"
272
- onClick={onNew}
273
- >
274
- {resolveIcon('plus', { size: '1em' })}
275
- </button>
276
- <button
277
- type="button"
278
- className="velu-chatbot__iconbtn"
279
- aria-label="Close"
280
- onClick={onClose}
281
- >
282
- {resolveIcon('x', { size: '1em' })}
283
- </button>
284
- </Cluster>
285
- </Cluster>
286
- );
287
- }
288
-
289
- /* ── History overlay ────────────────────────────────────────────────── */
290
-
291
- /* "Today, 10:24" / "Yesterday" / "Apr 28" for a conversation timestamp. */
292
- function whenLabel(iso) {
293
- if (!iso) return '';
294
- const d = new Date(iso);
295
- if (Number.isNaN(d.getTime())) return '';
296
- const startOfDay = (x) => new Date(x.getFullYear(), x.getMonth(), x.getDate());
297
- const days = Math.round((startOfDay(new Date()) - startOfDay(d)) / 86400000);
298
- if (days <= 0) {
299
- return `Today, ${d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}`;
300
- }
301
- if (days === 1) return 'Yesterday';
302
- return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
303
- }
304
-
305
- function HistoryPanel({ items, loading, onPick, onClose }) {
306
- return (
307
- <div className="velu-chatbot__history">
308
- <Cluster
309
- space="var(--s-2)"
310
- justify="space-between"
311
- align="center"
312
- className="velu-chatbot__history-head"
313
- >
314
- <span className="velu-chatbot__eyebrow">Recent chats</span>
315
- <button
316
- type="button"
317
- className="velu-chatbot__iconbtn"
318
- aria-label="Close history"
319
- onClick={onClose}
320
- >
321
- {resolveIcon('x', { size: '1em' })}
322
- </button>
323
- </Cluster>
324
- <Stack as="div" space="var(--s-5)" className="velu-chatbot__history-list">
325
- {loading ? (
326
- <span className="velu-chatbot__history-empty">Loading…</span>
327
- ) : items.length === 0 ? (
328
- <span className="velu-chatbot__history-empty">No conversations yet.</span>
329
- ) : (
330
- items.map((h) => (
331
- <button
332
- key={h.id}
333
- type="button"
334
- className="velu-chatbot__history-item"
335
- onClick={() => onPick(h)}
336
- >
337
- <span className="velu-chatbot__history-item-title">{h.title}</span>
338
- <span className="velu-chatbot__history-item-when">{h.when}</span>
339
- </button>
340
- ))
341
- )}
342
- </Stack>
343
- </div>
344
- );
345
- }
346
-
347
- /* ── Welcome / empty state ──────────────────────────────────────────── */
348
- function Welcome({ onPick, suggestions }) {
349
- // Site-specific starter questions (velu.json assistant.suggestions, or
350
- // publish-time generated); the canned demo list is the dev-preview fallback.
351
- const items = suggestions?.length
352
- ? suggestions.map((label) => ({ label, icon: 'sparkles' }))
353
- : SUGGESTIONS;
354
- return (
355
- <Stack space="var(--s0)" className="velu-chatbot__welcome">
356
- <p className="velu-chatbot__welcome-hi">Ask anything about the docs.</p>
357
- <span className="velu-chatbot__eyebrow">Suggested</span>
358
- <Stack space="var(--s-4)">
359
- {items.map((s, i) => (
360
- <button
361
- key={i}
362
- type="button"
363
- className="velu-chatbot__suggest"
364
- onClick={() => onPick(s.label)}
365
- >
366
- <Cluster space="var(--s-2)" align="center">
367
- {resolveIcon(s.icon, { size: '1em' })}
368
- <span>{s.label}</span>
369
- </Cluster>
370
- {resolveIcon('arrow-right', { size: '1em' })}
371
- </button>
372
- ))}
373
- </Stack>
374
- </Stack>
375
- );
376
- }
377
-
378
- /* ── Messages ───────────────────────────────────────────────────────── */
379
- function UserMsg({ text }) {
380
- return (
381
- <div className="velu-chatbot__msg velu-chatbot__msg--user">
382
- <div className="velu-chatbot__bubble">{text}</div>
383
- </div>
384
- );
385
- }
386
-
387
- function AiMsg({
388
- tokens,
389
- sources,
390
- followups,
391
- streaming,
392
- onFollowup,
393
- messageId,
394
- onFeedback,
395
- onNavigate,
396
- }) {
397
- const thinking = tokens.length === 0;
398
- const [vote, setVote] = useState(null); // 'up' | 'down' | null
399
- const [copied, setCopied] = useState(false);
400
-
401
- const onAction = (ic) => {
402
- if (ic === 'copy') {
403
- try {
404
- navigator.clipboard?.writeText(tokensToPlain(tokens));
405
- setCopied(true);
406
- setTimeout(() => setCopied(false), 1400);
407
- } catch {
408
- /* clipboard blocked — no-op */
409
- }
410
- return;
411
- }
412
- if (ic === 'thumbs-up' || ic === 'thumbs-down') {
413
- const v = ic === 'thumbs-up' ? 'up' : 'down';
414
- const next = vote === v ? null : v; // toggle off if re-clicked
415
- setVote(next);
416
- onFeedback?.(messageId, next); // null = retract
417
- }
418
- };
419
- return (
420
- <div className="velu-chatbot__msg velu-chatbot__msg--ai">
421
- <Cluster
422
- space="var(--s-3)"
423
- align="center"
424
- className={`velu-chatbot__role${thinking ? ' is-thinking' : ''}`}
425
- >
426
- {thinking ? <BlocksWave /> : resolveIcon('sparkles', { size: '1em' })}
427
- <span>{thinking ? 'Velu is thinking' : 'Velu'}</span>
428
- </Cluster>
429
-
430
- {!thinking && (
431
- <div className="velu-chatbot__answer">
432
- {renderStream(tokens, sources, onNavigate)}
433
- {streaming && <span className="velu-chatbot__caret" />}
434
-
435
- {!streaming && sources?.length > 0 && (
436
- <Stack space="var(--s-4)" className="velu-chatbot__sources">
437
- <span className="velu-chatbot__eyebrow">Sources</span>
438
- {sources.map((s) => (
439
- <a
440
- key={s.num}
441
- className="velu-chatbot__source"
442
- href={sourceHref(s) || '#'}
443
- onClick={(e) => onSourceClick(e, s, onNavigate)}
444
- >
445
- <span className="velu-chatbot__source-num">{s.num}</span>
446
- <span className="velu-chatbot__source-meta">
447
- <span className="velu-chatbot__source-title">{citeTitle(s)}</span>
448
- <span className="velu-chatbot__source-path">{s.path}</span>
449
- </span>
450
- </a>
451
- ))}
452
- </Stack>
453
- )}
454
-
455
- {!streaming && followups?.length > 0 && (
456
- <Stack space="var(--s-4)" className="velu-chatbot__followups">
457
- <span className="velu-chatbot__eyebrow">Follow up</span>
458
- {followups.map((f, i) => (
459
- <button
460
- key={i}
461
- type="button"
462
- className="velu-chatbot__followup"
463
- onClick={() => onFollowup(f)}
464
- >
465
- <span>{f}</span>
466
- {resolveIcon('arrow-right', { size: '1em' })}
467
- </button>
468
- ))}
469
- </Stack>
470
- )}
471
- </div>
472
- )}
473
-
474
- {!streaming && !thinking && (
475
- <Cluster space="0" className="velu-chatbot__msg-actions">
476
- {['copy', 'thumbs-up', 'thumbs-down'].map((ic) => {
477
- const active =
478
- (ic === 'thumbs-up' && vote === 'up') ||
479
- (ic === 'thumbs-down' && vote === 'down');
480
- const label = ic === 'copy' && copied ? 'check' : ic;
481
- return (
482
- <button
483
- key={ic}
484
- type="button"
485
- className={`velu-chatbot__msg-action${active ? ' is-active' : ''}`}
486
- aria-label={ic}
487
- aria-pressed={ic === 'copy' ? undefined : active}
488
- onClick={() => onAction(ic)}
489
- >
490
- {resolveIcon(label, { size: '1em' })}
491
- </button>
492
- );
493
- })}
494
- </Cluster>
495
- )}
496
- </div>
497
- );
498
- }
499
-
500
- /* ── Composer ───────────────────────────────────────────────────────── */
501
- function Composer({ value, onChange, onSubmit, disabled }) {
502
- const taRef = useRef(null);
503
- useEffect(() => {
504
- const ta = taRef.current;
505
- if (!ta) return;
506
- ta.style.height = 'auto';
507
- ta.style.height = `${ta.scrollHeight}px`;
508
- }, [value]);
509
-
510
- const active = value.trim().length > 0 && !disabled;
511
- const onKeyDown = (e) => {
512
- if (e.key === 'Enter' && !e.shiftKey) {
513
- e.preventDefault();
514
- if (active) onSubmit();
515
- }
516
- };
517
-
518
- return (
519
- <div className="velu-chatbot__composer">
520
- <div className="velu-chatbot__input-wrap">
521
- <textarea
522
- ref={taRef}
523
- className="velu-chatbot__input"
524
- placeholder="Ask anything about the docs…"
525
- rows={1}
526
- value={value}
527
- onChange={(e) => onChange(e.target.value)}
528
- onKeyDown={onKeyDown}
529
- />
530
- <button
531
- type="button"
532
- className={`velu-chatbot__send${active ? ' is-active' : ''}`}
533
- onClick={onSubmit}
534
- disabled={!active}
535
- aria-label="Send"
536
- >
537
- {resolveIcon('arrow-up', { size: '1em' })}
538
- </button>
539
- </div>
540
- <div className="velu-chatbot__foot">
541
- <kbd>Enter</kbd> to send · <kbd>⇧ Enter</kbd> for newline
542
- </div>
543
- </div>
544
- );
545
- }
546
-
547
- /* ── Panel ──────────────────────────────────────────────────────────── */
548
- export default function Chatbot({
549
- open = false,
550
- seedQuestion,
551
- onClose,
552
- ask,
553
- onFeedback,
554
- onNavigate,
555
- listHistory,
556
- loadConversation,
557
- suggestions,
558
- className = '',
559
- ...rest
560
- }) {
561
- const [messages, setMessages] = useState([]);
562
- const [input, setInput] = useState('');
563
- const [historyOpen, setHistoryOpen] = useState(false);
564
- const [history, setHistory] = useState([]);
565
- const [historyLoading, setHistoryLoading] = useState(false);
566
- const bodyRef = useRef(null);
567
- const streamingRef = useRef(false);
568
- const lastSeedRef = useRef(undefined);
569
- // Real-backend session: conversation id threads turns; the controller lets
570
- // a new-chat / close / unmount abort an in-flight stream.
571
- const convoRef = useRef(undefined);
572
- const abortRef = useRef(null);
573
-
574
- useEffect(() => {
575
- const el = bodyRef.current;
576
- if (el) el.scrollTop = el.scrollHeight;
577
- }, [messages]);
578
-
579
- // Merge a patch into the last (assistant) message.
580
- const patchLast = useCallback((patch) => {
581
- setMessages((prev) => {
582
- if (!prev.length) return prev;
583
- const out = prev.slice();
584
- out[out.length - 1] = { ...out[out.length - 1], ...patch };
585
- return out;
586
- });
587
- }, []);
588
-
589
- // Stop whatever is in flight — a canned setTimeout chain ({ timer }) or a
590
- // real fetch stream (an AbortController). Safe to call any time.
591
- const cancelInFlight = useCallback(() => {
592
- const a = abortRef.current;
593
- if (a) {
594
- if (a.timer) clearTimeout(a.timer);
595
- if (typeof a.abort === 'function') a.abort();
596
- }
597
- abortRef.current = null;
598
- streamingRef.current = false;
599
- }, []);
600
-
601
- // Real history: fetch the visitor's conversations when the panel opens.
602
- // Without a backend (dev preview) the canned demo entries show instead.
603
- useEffect(() => {
604
- if (!historyOpen) return;
605
- if (!listHistory) {
606
- setHistory(HISTORY.map((h) => ({ ...h, demo: true })));
607
- return;
608
- }
609
- let stale = false;
610
- setHistoryLoading(true);
611
- listHistory()
612
- .then((items) => {
613
- if (stale) return;
614
- setHistory(items.map((c) => ({
615
- id: c.conversationId,
616
- title: c.title,
617
- when: whenLabel(c.updatedAt),
618
- conversation: c,
619
- })));
620
- })
621
- .catch(() => { if (!stale) setHistory([]); })
622
- .finally(() => { if (!stale) setHistoryLoading(false); });
623
- return () => { stale = true; };
624
- }, [historyOpen, listHistory]);
625
-
626
- // Resume a past conversation: load its turns into the panel and thread the
627
- // conversation id so follow-up asks continue it.
628
- const openConversation = useCallback(
629
- async (item) => {
630
- setHistoryOpen(false);
631
- if (!item.conversation || !loadConversation) return; // demo entry
632
- cancelInFlight();
633
- try {
634
- const { conversationId, messages: msgs } = await loadConversation(item.conversation);
635
- convoRef.current = conversationId;
636
- setMessages(msgs.map((m) =>
637
- m.role === 'user'
638
- ? { role: 'user', text: m.content }
639
- : {
640
- role: 'ai',
641
- tokens: textToTokens(m.content),
642
- sources: m.citations || [],
643
- followups: [],
644
- streaming: false,
645
- messageId: m.messageId,
646
- },
647
- ));
648
- } catch {
649
- /* keep the current chat if the load fails */
650
- }
651
- },
652
- [loadConversation, cancelInFlight],
653
- );
654
-
655
- const send = useCallback(
656
- async (text) => {
657
- const prompt = String(text ?? '').trim();
658
- if (!prompt || streamingRef.current) return;
659
- setInput('');
660
-
661
- // ── No backend wired → canned, fake-streamed demo answer ──────────
662
- if (!ask) {
663
- const answer = buildAnswer();
664
- const fullTokens = tokenizeAnswer(answer);
665
- setMessages((prev) => [
666
- ...prev,
667
- { role: 'user', text: prompt },
668
- {
669
- role: 'ai',
670
- tokens: [],
671
- sources: answer.sources,
672
- followups: answer.followups,
673
- streaming: true,
674
- },
675
- ]);
676
- streamingRef.current = true;
677
- let i = 0;
678
- const step = () => {
679
- i = Math.min(fullTokens.length, i + 1);
680
- patchLast({
681
- tokens: fullTokens.slice(0, i),
682
- streaming: i < fullTokens.length,
683
- });
684
- if (i < fullTokens.length) {
685
- abortRef.current = { timer: setTimeout(step, 28) };
686
- } else {
687
- streamingRef.current = false;
688
- abortRef.current = null;
689
- }
690
- };
691
- abortRef.current = { timer: setTimeout(step, 1400) }; // "thinking" pause
692
- return;
693
- }
694
-
695
- // ── Real backend → stream deltas + citations from `ask` ───────────
696
- setMessages((prev) => [
697
- ...prev,
698
- { role: 'user', text: prompt },
699
- { role: 'ai', tokens: [], sources: [], followups: [], streaming: true },
700
- ]);
701
- streamingRef.current = true;
702
- const controller =
703
- typeof AbortController !== 'undefined' ? new AbortController() : null;
704
- abortRef.current = controller;
705
-
706
- let acc = '';
707
- let sources = [];
708
- let messageId;
709
- try {
710
- for await (const ev of ask(prompt, {
711
- conversationId: convoRef.current,
712
- signal: controller?.signal,
713
- })) {
714
- if (ev.conversationId) convoRef.current = ev.conversationId;
715
- if (ev.messageId) messageId = ev.messageId;
716
- if (ev.delta) acc += ev.delta;
717
- if (ev.citations) {
718
- sources = ev.citations.map((c, idx) => ({
719
- num: c.num ?? idx + 1,
720
- title: c.title,
721
- path: c.path ?? c.route_path,
722
- url: c.url,
723
- }));
724
- }
725
- patchLast({ tokens: textToTokens(acc), sources, streaming: true, messageId });
726
- }
727
- patchLast({ tokens: textToTokens(acc), sources, streaming: false, messageId });
728
- } catch (err) {
729
- if (controller?.signal?.aborted) {
730
- patchLast({ streaming: false });
731
- } else {
732
- patchLast({
733
- tokens: textToTokens(
734
- acc || 'Sorry — I couldn’t reach the assistant. Please try again.'
735
- ),
736
- streaming: false,
737
- error: true,
738
- });
739
- }
740
- } finally {
741
- streamingRef.current = false;
742
- abortRef.current = null;
743
- }
744
- },
745
- [ask, patchLast]
746
- );
747
-
748
- // Abort any in-flight stream when the panel unmounts.
749
- useEffect(() => () => cancelInFlight(), [cancelInFlight]);
750
-
751
- // A new seedQuestion (from the page's AskBar) sends the first message.
752
- useEffect(() => {
753
- if (open && seedQuestion && seedQuestion !== lastSeedRef.current) {
754
- lastSeedRef.current = seedQuestion;
755
- send(seedQuestion);
756
- }
757
- }, [open, seedQuestion, send]);
758
-
759
- const newChat = () => {
760
- cancelInFlight();
761
- convoRef.current = undefined; // fresh conversation thread
762
- setMessages([]);
763
- setInput('');
764
- setHistoryOpen(false);
765
- };
766
-
767
- // Drag-to-dismiss (mobile bottom-sheet only). Pointer events are
768
- // attached to the drag-handle element below; the handle's pointer
769
- // capture keeps the events flowing even if the finger slides off
770
- // the handle into the body. While dragging we apply an inline
771
- // `transform: translateY(...)` to the panel, overriding the CSS-
772
- // controlled transform. On release, if the drag distance exceeded
773
- // the threshold we call onClose() and the panel slides the rest of
774
- // the way down via the CSS transition; otherwise we drop the
775
- // inline transform and the panel snaps back via the same
776
- // transition.
777
- const dragRef = useRef({ active: false, startY: 0, currentY: 0 });
778
- const [dragOffset, setDragOffset] = useState(0);
779
- // Drag-to-dismiss is active wherever the chatbot is rendered as a
780
- // bottom sheet — same threshold as the @container query in
781
- // chatbot.css (< 1024px covers both narrow / tablet and mobile).
782
- const isBottomSheet = () =>
783
- typeof window !== 'undefined' &&
784
- window.matchMedia('(max-width: 1024px)').matches;
785
- const onDragStart = (e) => {
786
- if (!isBottomSheet()) return;
787
- dragRef.current = { active: true, startY: e.clientY, currentY: 0 };
788
- e.currentTarget.setPointerCapture?.(e.pointerId);
789
- };
790
- const onDragMove = (e) => {
791
- if (!dragRef.current.active) return;
792
- const delta = Math.max(0, e.clientY - dragRef.current.startY);
793
- dragRef.current.currentY = delta;
794
- setDragOffset(delta);
795
- };
796
- const onDragEnd = () => {
797
- if (!dragRef.current.active) return;
798
- dragRef.current.active = false;
799
- const shouldClose = dragRef.current.currentY > 80;
800
- setDragOffset(0);
801
- if (shouldClose) onClose?.();
802
- };
803
- // Reset any residual drag offset whenever the panel closes from
804
- // outside (scrim click, X button, etc.) so a re-open starts clean.
805
- useEffect(() => {
806
- if (!open) setDragOffset(0);
807
- }, [open]);
808
-
809
- const cls = `velu-chatbot${open ? ' velu-chatbot--open' : ''} ${className}`.trim();
810
- const dragStyle =
811
- dragOffset > 0
812
- ? { transform: `translateY(${dragOffset}px)`, transition: 'none' }
813
- : undefined;
814
-
815
- return (
816
- <aside
817
- className={cls}
818
- aria-hidden={!open}
819
- aria-label="Ask AI"
820
- style={dragStyle}
821
- {...rest}
822
- >
823
- {/* Drag handle — mobile only (CSS in chatbot.css). The pill
824
- visual + the touch-capturing strip across the top of the
825
- sheet. Pointer events here drive the drag-to-dismiss
826
- gesture above. */}
827
- <div
828
- className="velu-chatbot__drag-handle"
829
- onPointerDown={onDragStart}
830
- onPointerMove={onDragMove}
831
- onPointerUp={onDragEnd}
832
- onPointerCancel={onDragEnd}
833
- aria-hidden="true"
834
- />
835
- <ChatHeader
836
- onClose={onClose}
837
- onNew={newChat}
838
- onHistory={() => setHistoryOpen((o) => !o)}
839
- historyOpen={historyOpen}
840
- />
841
-
842
- {historyOpen && (
843
- <HistoryPanel
844
- items={history}
845
- loading={historyLoading}
846
- onClose={() => setHistoryOpen(false)}
847
- onPick={openConversation}
848
- />
849
- )}
850
-
851
- <div className="velu-chatbot__body" ref={bodyRef}>
852
- {messages.length === 0 ? (
853
- <Welcome onPick={(t) => send(t)} suggestions={suggestions} />
854
- ) : (
855
- <Stack space="var(--s1)">
856
- {messages.map((m, i) =>
857
- m.role === 'user' ? (
858
- <UserMsg key={i} text={m.text} />
859
- ) : (
860
- <AiMsg
861
- key={i}
862
- tokens={m.tokens}
863
- sources={m.sources}
864
- followups={m.followups}
865
- streaming={m.streaming}
866
- messageId={m.messageId}
867
- onFeedback={onFeedback}
868
- onNavigate={onNavigate}
869
- onFollowup={(f) => send(f)}
870
- />
871
- ),
872
- )}
873
- </Stack>
874
- )}
875
- </div>
876
-
877
- <Composer
878
- value={input}
879
- onChange={setInput}
880
- onSubmit={() => send(input)}
881
- disabled={streamingRef.current}
882
- />
883
- </aside>
884
- );
885
- }
1
+ import React, { useState, useRef, useEffect, useCallback } from 'react';
2
+ import resolveIcon from '../lib/resolveIcon.jsx';
3
+ import Stack from '../primitives/Stack.jsx';
4
+ import Cluster from '../primitives/Cluster.jsx';
5
+
6
+ /**
7
+ * Chatbot — the Velu "Ask AI" panel. A right-side slide-out: header +
8
+ * scrolling message body + composer. Ported from the Claude-Design
9
+ * "Velu Chatbot" handoff (rich variant), retokenized onto velu-ui's
10
+ * scale so it themes for free via [data-theme].
11
+ *
12
+ * <Chatbot
13
+ * open={chatOpen}
14
+ * seedQuestion={question} // first message — sent on open
15
+ * onClose={() => setChatOpen(false)}
16
+ * ask={askDocs} // OPTIONAL — real backend (see below)
17
+ * onFeedback={(id, v) => …} // OPTIONAL — 👍/👎 for a message
18
+ * />
19
+ *
20
+ * Slide: the panel is fixed to the inline-end edge; `open` toggles a
21
+ * `translateX` between fully off-screen and 0. Width is the
22
+ * `--vchat-width` variable (default 24rem ≈ the design's 384px).
23
+ *
24
+ * Backend seam (mirrors Search.jsx's injectable `search` prop):
25
+ * ask(prompt, { conversationId, signal }) → AsyncIterable<AskEvent>
26
+ * AskEvent = {
27
+ * delta?: string, // text chunk to append (streamed)
28
+ * citations?: Source[], // [{ num, title, path, url }] — replaces sources
29
+ * messageId?: string, // assistant message id (for feedback)
30
+ * conversationId?: string, // threaded across turns
31
+ * }
32
+ * When `ask` is omitted the panel falls back to a canned, fake-streamed
33
+ * demo answer (so dev preview / offline still shows the experience).
34
+ */
35
+
36
+ /* ── Brand mark — the Velu double-hook logo ─────────────────────────── */
37
+ // `size` is the glyph height; width derives from the 32:24 viewBox.
38
+ function VeluMark({ size = 'var(--icon-size-lg)' }) {
39
+ return (
40
+ <svg
41
+ className="velu-chatbot__mark"
42
+ viewBox="0 0 32 24"
43
+ fill="currentColor"
44
+ aria-hidden="true"
45
+ style={{ width: `calc(${size} * (32 / 24))`, height: size }}
46
+ >
47
+ <path d="M 29.656 14.153 C 29.574 14.562 29.351 14.929 29.025 15.193 C 28.699 15.457 28.291 15.601 27.869 15.601 L 21.37 15.601 C 20.534 15.601 19.805 16.163 19.603 16.964 L 18.167 22.637 C 18.068 23.026 17.841 23.372 17.521 23.619 C 17.2 23.866 16.805 24 16.399 24 L 10.438 24 C 10.161 24 9.888 23.938 9.639 23.818 C 9.39 23.698 9.172 23.524 9.001 23.308 C 8.831 23.092 8.712 22.841 8.655 22.574 C 8.598 22.306 8.603 22.029 8.67 21.764 L 10.189 15.764 C 10.287 15.374 10.515 15.029 10.835 14.782 C 11.155 14.535 11.55 14.401 11.956 14.4 L 18.393 14.4 C 19.262 14.4 20.01 13.795 20.18 12.953 L 22.388 2.048 C 22.471 1.639 22.694 1.272 23.019 1.008 C 23.345 0.744 23.754 0.6 24.175 0.6 L 30.177 0.6 C 30.447 0.6 30.713 0.659 30.957 0.773 C 31.201 0.886 31.416 1.052 31.587 1.258 C 31.758 1.464 31.881 1.705 31.946 1.964 C 32.011 2.222 32.017 2.492 31.964 2.753 L 29.656 14.153 Z M 9.611 13.554 C 9.528 13.962 9.305 14.329 8.979 14.593 C 8.653 14.857 8.245 15.001 7.824 15.001 L 1.823 15.001 C 1.553 15.001 1.287 14.942 1.043 14.828 C 0.799 14.714 0.584 14.548 0.413 14.342 C 0.242 14.136 0.119 13.895 0.054 13.637 C -0.011 13.378 -0.017 13.109 0.036 12.848 L 2.344 1.447 C 2.426 1.039 2.649 0.672 2.975 0.408 C 3.301 0.144 3.709 0 4.131 0 L 10.132 0 C 10.402 0 10.668 0.059 10.912 0.173 C 11.155 0.287 11.371 0.453 11.541 0.659 C 11.712 0.865 11.835 1.106 11.9 1.364 C 11.965 1.623 11.972 1.892 11.919 2.153 L 9.611 13.553 L 9.611 13.554 Z" />
48
+ </svg>
49
+ );
50
+ }
51
+
52
+ /* ── "Thinking" loader — 3x3 grid of squares, opacity wave sweeps the
53
+ anti-diagonal so it ripples top-left → bottom-right. ─────────────── */
54
+ function BlocksWave() {
55
+ const cells = [
56
+ [1, 1, 0], [8.33, 1, 1], [15.66, 1, 2],
57
+ [1, 8.33, 1], [8.33, 8.33, 2], [15.66, 8.33, 3],
58
+ [1, 15.66, 2], [8.33, 15.66, 3], [15.66, 15.66, 4],
59
+ ];
60
+ return (
61
+ <svg className="velu-chatbot__wave" viewBox="0 0 24 24" aria-hidden="true">
62
+ {cells.map(([x, y, cell], i) => (
63
+ <rect key={i} x={x} y={y} width="7.33" height="7.33" data-cell={cell} />
64
+ ))}
65
+ </svg>
66
+ );
67
+ }
68
+
69
+ /* ── Demo data ──────────────────────────────────────────────────────── */
70
+ const SUGGESTIONS = [
71
+ { label: 'How do I set up GitHub sync?', icon: 'github' },
72
+ { label: 'Customize the look of my docs site', icon: 'book' },
73
+ { label: 'Configure AI search ranking', icon: 'sparkles' },
74
+ { label: 'Quickstart in under five minutes', icon: 'rocket' },
75
+ ];
76
+
77
+ const HISTORY = [
78
+ { id: 'h1', title: 'Setting up GitHub sync', when: 'Today, 10:24' },
79
+ { id: 'h2', title: 'Custom theme tokens & dark mode', when: 'Today, 09:11' },
80
+ { id: 'h3', title: 'Configure AI search ranking', when: 'Yesterday' },
81
+ { id: 'h4', title: 'Embed a Mermaid diagram in MDX', when: 'Yesterday' },
82
+ { id: 'h5', title: 'Why are my callouts missing icons?', when: 'Apr 28' },
83
+ { id: 'h6', title: 'Quickstart — minimum site config', when: 'Apr 27' },
84
+ ];
85
+
86
+ const SOURCES = [
87
+ { num: 1, title: 'GitHub & GitLab Sync', path: 'docs/integrations/github-sync.md' },
88
+ { num: 2, title: 'Site Settings — Repositories', path: 'docs/your-docs-site/site-settings.md' },
89
+ { num: 3, title: 'AI-native Documentation', path: 'docs/get-started/ai-native.md' },
90
+ ];
91
+
92
+ /* Canned answer — segments interleave text, inline `code`, and citation
93
+ markers. The same answer is returned for any prompt (demo). */
94
+ function buildAnswer() {
95
+ return {
96
+ segments: [
97
+ { t: 'Velu syncs documentation directly with GitHub and GitLab — every push to a connected repository updates the live docs site within seconds.' },
98
+ { cite: 1 },
99
+ { t: ' To set it up:\n\n1. Open ' },
100
+ { code: 'Site Settings → Repositories' },
101
+ { t: ' and click ' },
102
+ { code: 'Connect repository' },
103
+ { t: '.\n2. Authorize the Velu app on the org that owns the repo.\n3. Pick a branch (default ' },
104
+ { code: 'main' },
105
+ { t: ') and a docs root path.' },
106
+ { cite: 2 },
107
+ { t: '\n\nVelu watches changes to ' },
108
+ { code: '.md' },
109
+ { t: ' and ' },
110
+ { code: '.mdx' },
111
+ { t: ' files and re-indexes the site automatically.' },
112
+ { cite: 3 },
113
+ ],
114
+ sources: SOURCES,
115
+ followups: [
116
+ 'How do I preview pull-request changes?',
117
+ 'Can I sync from a monorepo subdirectory?',
118
+ 'What about private repositories?',
119
+ ],
120
+ };
121
+ }
122
+
123
+ /* Real-backend path: turn the accumulated answer TEXT into the same token
124
+ shape `renderStream` consumes. The assistant streams plain text with inline
125
+ `code` spans and bracketed citation markers ([1], [2]); split those out so
126
+ they render as <code> and citation chips, exactly like the canned path. */
127
+ function textToTokens(text) {
128
+ const tokens = [];
129
+ const re = /(`[^`]*`|\[\d+\])/g;
130
+ let last = 0;
131
+ let m;
132
+ while ((m = re.exec(text)) !== null) {
133
+ if (m.index > last) tokens.push({ kind: 't', v: text.slice(last, m.index) });
134
+ const tok = m[0];
135
+ if (tok[0] === '`') tokens.push({ kind: 'code', v: tok.slice(1, -1) });
136
+ else tokens.push({ kind: 'cite', n: Number(tok.slice(1, -1)) });
137
+ last = re.lastIndex;
138
+ }
139
+ if (last < text.length) tokens.push({ kind: 't', v: text.slice(last) });
140
+ return tokens;
141
+ }
142
+
143
+ /* Plain-text of an answer (for the Copy action). */
144
+ function tokensToPlain(tokens) {
145
+ return tokens
146
+ .map((tk) => (tk.kind === 'cite' ? `[${tk.n}]` : tk.v))
147
+ .join('')
148
+ .trim();
149
+ }
150
+
151
+ /* Flatten an answer into a stream of small tokens for incremental render. */
152
+ function tokenizeAnswer(answer) {
153
+ const tokens = [];
154
+ for (const seg of answer.segments) {
155
+ if (seg.cite != null) { tokens.push({ kind: 'cite', n: seg.cite }); continue; }
156
+ if (seg.code != null) { tokens.push({ kind: 'code', v: seg.code }); continue; }
157
+ const parts = seg.t.match(/\S+\s*|\s+/g) || [seg.t];
158
+ for (const p of parts) tokens.push({ kind: 't', v: p });
159
+ }
160
+ return tokens;
161
+ }
162
+
163
+ /* A citation's in-site target. Real backend sources carry a `url` (and `path`
164
+ is a route like "/essentials/code"); the demo's `path` is a fake file path,
165
+ so those stay inert. */
166
+ function sourceHref(s) {
167
+ if (s?.url) return s.url;
168
+ if (s?.path && s.path.startsWith('/')) return s.path;
169
+ return null;
170
+ }
171
+
172
+ /* A readable label for a source: the section heading if present, else a title
173
+ derived from the route (e.g. "/essentials/settings" → "Settings"). */
174
+ function citeTitle(s) {
175
+ if (s?.title) return s.title;
176
+ const path = (s?.path || '').replace(/[#?].*$/, '');
177
+ const seg = path.split('/').filter(Boolean).pop();
178
+ if (!seg) return 'Documentation';
179
+ return seg.replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
180
+ }
181
+
182
+ /* Source click: SPA-navigate same-origin targets via onNavigate (keeps the chat
183
+ panel open + scrolls to the heading); otherwise let the <a href> navigate. */
184
+ function onSourceClick(e, s, onNavigate) {
185
+ const href = sourceHref(s);
186
+ if (!href) { e.preventDefault(); return; }
187
+ if (!onNavigate) return; // no SPA hook → let the browser follow the href
188
+ try {
189
+ const u = new URL(href, window.location.origin);
190
+ if (u.origin === window.location.origin) {
191
+ e.preventDefault();
192
+ onNavigate(u.pathname + u.search + u.hash);
193
+ }
194
+ } catch { /* malformed — fall back to href */ }
195
+ }
196
+
197
+ /* Render partial tokens as paragraphs (split on \n\n, single \n → <br>). */
198
+ function renderStream(tokens, sources, onNavigate) {
199
+ const paragraphs = [[]];
200
+ for (const tk of tokens) {
201
+ if (tk.kind === 't' && tk.v.includes('\n\n')) {
202
+ const split = tk.v.split('\n\n');
203
+ paragraphs[paragraphs.length - 1].push({ kind: 't', v: split[0] });
204
+ for (let i = 1; i < split.length; i++) {
205
+ paragraphs.push([]);
206
+ if (split[i]) paragraphs[paragraphs.length - 1].push({ kind: 't', v: split[i] });
207
+ }
208
+ } else {
209
+ paragraphs[paragraphs.length - 1].push(tk);
210
+ }
211
+ }
212
+ return paragraphs.map((para, pi) => (
213
+ <p key={pi}>
214
+ {para.map((tk, i) => {
215
+ if (tk.kind === 'cite') {
216
+ const src = sources?.find((s) => s.num === tk.n);
217
+ return (
218
+ <a
219
+ key={i}
220
+ className="velu-chatbot__cite"
221
+ href={sourceHref(src) || '#'}
222
+ onClick={(e) => onSourceClick(e, src, onNavigate)}
223
+ title={citeTitle(src)}
224
+ >
225
+ {tk.n}
226
+ </a>
227
+ );
228
+ }
229
+ if (tk.kind === 'code') return <code key={i}>{tk.v}</code>;
230
+ const lines = tk.v.split('\n');
231
+ return lines.map((line, li) => (
232
+ <React.Fragment key={`${i}-${li}`}>
233
+ {li > 0 && <br />}
234
+ {line}
235
+ </React.Fragment>
236
+ ));
237
+ })}
238
+ </p>
239
+ ));
240
+ }
241
+
242
+ /* ── Header ─────────────────────────────────────────────────────────── */
243
+ function ChatHeader({ onClose, onNew, onHistory, historyOpen }) {
244
+ return (
245
+ <Cluster
246
+ space="var(--s-2)"
247
+ justify="space-between"
248
+ align="center"
249
+ className="velu-chatbot__header"
250
+ >
251
+ <Cluster space="var(--s-2)" align="center">
252
+ <VeluMark />
253
+ <span className="velu-chatbot__brand">
254
+ <span className="velu-chatbot__brand-name">Velu</span>
255
+ <span className="velu-chatbot__brand-sep">/</span>
256
+ <span className="velu-chatbot__brand-title">Ask AI</span>
257
+ </span>
258
+ </Cluster>
259
+ <Cluster space="var(--s-5)" align="center">
260
+ <button
261
+ type="button"
262
+ className={`velu-chatbot__iconbtn${historyOpen ? ' is-active' : ''}`}
263
+ aria-label="History"
264
+ onClick={onHistory}
265
+ >
266
+ {resolveIcon('history', { size: '1em' })}
267
+ </button>
268
+ <button
269
+ type="button"
270
+ className="velu-chatbot__iconbtn"
271
+ aria-label="New chat"
272
+ onClick={onNew}
273
+ >
274
+ {resolveIcon('plus', { size: '1em' })}
275
+ </button>
276
+ <button
277
+ type="button"
278
+ className="velu-chatbot__iconbtn"
279
+ aria-label="Close"
280
+ onClick={onClose}
281
+ >
282
+ {resolveIcon('x', { size: '1em' })}
283
+ </button>
284
+ </Cluster>
285
+ </Cluster>
286
+ );
287
+ }
288
+
289
+ /* ── History overlay ────────────────────────────────────────────────── */
290
+
291
+ /* "Today, 10:24" / "Yesterday" / "Apr 28" for a conversation timestamp. */
292
+ function whenLabel(iso) {
293
+ if (!iso) return '';
294
+ const d = new Date(iso);
295
+ if (Number.isNaN(d.getTime())) return '';
296
+ const startOfDay = (x) => new Date(x.getFullYear(), x.getMonth(), x.getDate());
297
+ const days = Math.round((startOfDay(new Date()) - startOfDay(d)) / 86400000);
298
+ if (days <= 0) {
299
+ return `Today, ${d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}`;
300
+ }
301
+ if (days === 1) return 'Yesterday';
302
+ return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
303
+ }
304
+
305
+ function HistoryPanel({ items, loading, onPick, onClose }) {
306
+ return (
307
+ <div className="velu-chatbot__history">
308
+ <Cluster
309
+ space="var(--s-2)"
310
+ justify="space-between"
311
+ align="center"
312
+ className="velu-chatbot__history-head"
313
+ >
314
+ <span className="velu-chatbot__eyebrow">Recent chats</span>
315
+ <button
316
+ type="button"
317
+ className="velu-chatbot__iconbtn"
318
+ aria-label="Close history"
319
+ onClick={onClose}
320
+ >
321
+ {resolveIcon('x', { size: '1em' })}
322
+ </button>
323
+ </Cluster>
324
+ <Stack as="div" space="var(--s-5)" className="velu-chatbot__history-list">
325
+ {loading ? (
326
+ <span className="velu-chatbot__history-empty">Loading…</span>
327
+ ) : items.length === 0 ? (
328
+ <span className="velu-chatbot__history-empty">No conversations yet.</span>
329
+ ) : (
330
+ items.map((h) => (
331
+ <button
332
+ key={h.id}
333
+ type="button"
334
+ className="velu-chatbot__history-item"
335
+ onClick={() => onPick(h)}
336
+ >
337
+ <span className="velu-chatbot__history-item-title">{h.title}</span>
338
+ <span className="velu-chatbot__history-item-when">{h.when}</span>
339
+ </button>
340
+ ))
341
+ )}
342
+ </Stack>
343
+ </div>
344
+ );
345
+ }
346
+
347
+ /* ── Welcome / empty state ──────────────────────────────────────────── */
348
+ function Welcome({ onPick, suggestions }) {
349
+ // Site-specific starter questions (velu.json assistant.suggestions, or
350
+ // publish-time generated); the canned demo list is the dev-preview fallback.
351
+ const items = suggestions?.length
352
+ ? suggestions.map((label) => ({ label, icon: 'sparkles' }))
353
+ : SUGGESTIONS;
354
+ return (
355
+ <Stack space="var(--s0)" className="velu-chatbot__welcome">
356
+ <p className="velu-chatbot__welcome-hi">Ask anything about the docs.</p>
357
+ <span className="velu-chatbot__eyebrow">Suggested</span>
358
+ <Stack space="var(--s-4)">
359
+ {items.map((s, i) => (
360
+ <button
361
+ key={i}
362
+ type="button"
363
+ className="velu-chatbot__suggest"
364
+ onClick={() => onPick(s.label)}
365
+ >
366
+ <Cluster space="var(--s-2)" align="center">
367
+ {resolveIcon(s.icon, { size: '1em' })}
368
+ <span>{s.label}</span>
369
+ </Cluster>
370
+ {resolveIcon('arrow-right', { size: '1em' })}
371
+ </button>
372
+ ))}
373
+ </Stack>
374
+ </Stack>
375
+ );
376
+ }
377
+
378
+ /* ── Messages ───────────────────────────────────────────────────────── */
379
+ function UserMsg({ text }) {
380
+ return (
381
+ <div className="velu-chatbot__msg velu-chatbot__msg--user">
382
+ <div className="velu-chatbot__bubble">{text}</div>
383
+ </div>
384
+ );
385
+ }
386
+
387
+ function AiMsg({
388
+ tokens,
389
+ sources,
390
+ followups,
391
+ streaming,
392
+ onFollowup,
393
+ messageId,
394
+ onFeedback,
395
+ onNavigate,
396
+ }) {
397
+ const thinking = tokens.length === 0;
398
+ const [vote, setVote] = useState(null); // 'up' | 'down' | null
399
+ const [copied, setCopied] = useState(false);
400
+
401
+ const onAction = (ic) => {
402
+ if (ic === 'copy') {
403
+ try {
404
+ navigator.clipboard?.writeText(tokensToPlain(tokens));
405
+ setCopied(true);
406
+ setTimeout(() => setCopied(false), 1400);
407
+ } catch {
408
+ /* clipboard blocked — no-op */
409
+ }
410
+ return;
411
+ }
412
+ if (ic === 'thumbs-up' || ic === 'thumbs-down') {
413
+ const v = ic === 'thumbs-up' ? 'up' : 'down';
414
+ const next = vote === v ? null : v; // toggle off if re-clicked
415
+ setVote(next);
416
+ onFeedback?.(messageId, next); // null = retract
417
+ }
418
+ };
419
+ return (
420
+ <div className="velu-chatbot__msg velu-chatbot__msg--ai">
421
+ <Cluster
422
+ space="var(--s-3)"
423
+ align="center"
424
+ className={`velu-chatbot__role${thinking ? ' is-thinking' : ''}`}
425
+ >
426
+ {thinking ? <BlocksWave /> : resolveIcon('sparkles', { size: '1em' })}
427
+ <span>{thinking ? 'Velu is thinking' : 'Velu'}</span>
428
+ </Cluster>
429
+
430
+ {!thinking && (
431
+ <div className="velu-chatbot__answer">
432
+ {renderStream(tokens, sources, onNavigate)}
433
+ {streaming && <span className="velu-chatbot__caret" />}
434
+
435
+ {!streaming && sources?.length > 0 && (
436
+ <Stack space="var(--s-4)" className="velu-chatbot__sources">
437
+ <span className="velu-chatbot__eyebrow">Sources</span>
438
+ {sources.map((s) => (
439
+ <a
440
+ key={s.num}
441
+ className="velu-chatbot__source"
442
+ href={sourceHref(s) || '#'}
443
+ onClick={(e) => onSourceClick(e, s, onNavigate)}
444
+ >
445
+ <span className="velu-chatbot__source-num">{s.num}</span>
446
+ <span className="velu-chatbot__source-meta">
447
+ <span className="velu-chatbot__source-title">{citeTitle(s)}</span>
448
+ <span className="velu-chatbot__source-path">{s.path}</span>
449
+ </span>
450
+ </a>
451
+ ))}
452
+ </Stack>
453
+ )}
454
+
455
+ {!streaming && followups?.length > 0 && (
456
+ <Stack space="var(--s-4)" className="velu-chatbot__followups">
457
+ <span className="velu-chatbot__eyebrow">Follow up</span>
458
+ {followups.map((f, i) => (
459
+ <button
460
+ key={i}
461
+ type="button"
462
+ className="velu-chatbot__followup"
463
+ onClick={() => onFollowup(f)}
464
+ >
465
+ <span>{f}</span>
466
+ {resolveIcon('arrow-right', { size: '1em' })}
467
+ </button>
468
+ ))}
469
+ </Stack>
470
+ )}
471
+ </div>
472
+ )}
473
+
474
+ {!streaming && !thinking && (
475
+ <Cluster space="0" className="velu-chatbot__msg-actions">
476
+ {['copy', 'thumbs-up', 'thumbs-down'].map((ic) => {
477
+ const active =
478
+ (ic === 'thumbs-up' && vote === 'up') ||
479
+ (ic === 'thumbs-down' && vote === 'down');
480
+ const label = ic === 'copy' && copied ? 'check' : ic;
481
+ return (
482
+ <button
483
+ key={ic}
484
+ type="button"
485
+ className={`velu-chatbot__msg-action${active ? ' is-active' : ''}`}
486
+ aria-label={ic}
487
+ aria-pressed={ic === 'copy' ? undefined : active}
488
+ onClick={() => onAction(ic)}
489
+ >
490
+ {resolveIcon(label, { size: '1em' })}
491
+ </button>
492
+ );
493
+ })}
494
+ </Cluster>
495
+ )}
496
+ </div>
497
+ );
498
+ }
499
+
500
+ /* ── Composer ───────────────────────────────────────────────────────── */
501
+ function Composer({ value, onChange, onSubmit, disabled }) {
502
+ const taRef = useRef(null);
503
+ useEffect(() => {
504
+ const ta = taRef.current;
505
+ if (!ta) return;
506
+ ta.style.height = 'auto';
507
+ ta.style.height = `${ta.scrollHeight}px`;
508
+ }, [value]);
509
+
510
+ const active = value.trim().length > 0 && !disabled;
511
+ const onKeyDown = (e) => {
512
+ if (e.key === 'Enter' && !e.shiftKey) {
513
+ e.preventDefault();
514
+ if (active) onSubmit();
515
+ }
516
+ };
517
+
518
+ return (
519
+ <div className="velu-chatbot__composer">
520
+ <div className="velu-chatbot__input-wrap">
521
+ <textarea
522
+ ref={taRef}
523
+ className="velu-chatbot__input"
524
+ placeholder="Ask anything about the docs…"
525
+ rows={1}
526
+ value={value}
527
+ onChange={(e) => onChange(e.target.value)}
528
+ onKeyDown={onKeyDown}
529
+ />
530
+ <button
531
+ type="button"
532
+ className={`velu-chatbot__send${active ? ' is-active' : ''}`}
533
+ onClick={onSubmit}
534
+ disabled={!active}
535
+ aria-label="Send"
536
+ >
537
+ {resolveIcon('arrow-up', { size: '1em' })}
538
+ </button>
539
+ </div>
540
+ <div className="velu-chatbot__foot">
541
+ <kbd>Enter</kbd> to send · <kbd>⇧ Enter</kbd> for newline
542
+ </div>
543
+ </div>
544
+ );
545
+ }
546
+
547
+ /* ── Panel ──────────────────────────────────────────────────────────── */
548
+ export default function Chatbot({
549
+ open = false,
550
+ seedQuestion,
551
+ onClose,
552
+ ask,
553
+ onFeedback,
554
+ onNavigate,
555
+ listHistory,
556
+ loadConversation,
557
+ suggestions,
558
+ className = '',
559
+ ...rest
560
+ }) {
561
+ const [messages, setMessages] = useState([]);
562
+ const [input, setInput] = useState('');
563
+ const [historyOpen, setHistoryOpen] = useState(false);
564
+ const [history, setHistory] = useState([]);
565
+ const [historyLoading, setHistoryLoading] = useState(false);
566
+ const bodyRef = useRef(null);
567
+ const streamingRef = useRef(false);
568
+ const lastSeedRef = useRef(undefined);
569
+ // Real-backend session: conversation id threads turns; the controller lets
570
+ // a new-chat / close / unmount abort an in-flight stream.
571
+ const convoRef = useRef(undefined);
572
+ const abortRef = useRef(null);
573
+
574
+ useEffect(() => {
575
+ const el = bodyRef.current;
576
+ if (el) el.scrollTop = el.scrollHeight;
577
+ }, [messages]);
578
+
579
+ // Merge a patch into the last (assistant) message.
580
+ const patchLast = useCallback((patch) => {
581
+ setMessages((prev) => {
582
+ if (!prev.length) return prev;
583
+ const out = prev.slice();
584
+ out[out.length - 1] = { ...out[out.length - 1], ...patch };
585
+ return out;
586
+ });
587
+ }, []);
588
+
589
+ // Stop whatever is in flight — a canned setTimeout chain ({ timer }) or a
590
+ // real fetch stream (an AbortController). Safe to call any time.
591
+ const cancelInFlight = useCallback(() => {
592
+ const a = abortRef.current;
593
+ if (a) {
594
+ if (a.timer) clearTimeout(a.timer);
595
+ if (typeof a.abort === 'function') a.abort();
596
+ }
597
+ abortRef.current = null;
598
+ streamingRef.current = false;
599
+ }, []);
600
+
601
+ // Real history: fetch the visitor's conversations when the panel opens.
602
+ // Without a backend (dev preview) the canned demo entries show instead.
603
+ useEffect(() => {
604
+ if (!historyOpen) return;
605
+ if (!listHistory) {
606
+ setHistory(HISTORY.map((h) => ({ ...h, demo: true })));
607
+ return;
608
+ }
609
+ let stale = false;
610
+ setHistoryLoading(true);
611
+ listHistory()
612
+ .then((items) => {
613
+ if (stale) return;
614
+ setHistory(items.map((c) => ({
615
+ id: c.conversationId,
616
+ title: c.title,
617
+ when: whenLabel(c.updatedAt),
618
+ conversation: c,
619
+ })));
620
+ })
621
+ .catch(() => { if (!stale) setHistory([]); })
622
+ .finally(() => { if (!stale) setHistoryLoading(false); });
623
+ return () => { stale = true; };
624
+ }, [historyOpen, listHistory]);
625
+
626
+ // Resume a past conversation: load its turns into the panel and thread the
627
+ // conversation id so follow-up asks continue it.
628
+ const openConversation = useCallback(
629
+ async (item) => {
630
+ setHistoryOpen(false);
631
+ if (!item.conversation || !loadConversation) return; // demo entry
632
+ cancelInFlight();
633
+ try {
634
+ const { conversationId, messages: msgs } = await loadConversation(item.conversation);
635
+ convoRef.current = conversationId;
636
+ setMessages(msgs.map((m) =>
637
+ m.role === 'user'
638
+ ? { role: 'user', text: m.content }
639
+ : {
640
+ role: 'ai',
641
+ tokens: textToTokens(m.content),
642
+ sources: m.citations || [],
643
+ followups: [],
644
+ streaming: false,
645
+ messageId: m.messageId,
646
+ },
647
+ ));
648
+ } catch {
649
+ /* keep the current chat if the load fails */
650
+ }
651
+ },
652
+ [loadConversation, cancelInFlight],
653
+ );
654
+
655
+ const send = useCallback(
656
+ async (text) => {
657
+ const prompt = String(text ?? '').trim();
658
+ if (!prompt || streamingRef.current) return;
659
+ setInput('');
660
+
661
+ // ── No backend wired → canned, fake-streamed demo answer ──────────
662
+ if (!ask) {
663
+ const answer = buildAnswer();
664
+ const fullTokens = tokenizeAnswer(answer);
665
+ setMessages((prev) => [
666
+ ...prev,
667
+ { role: 'user', text: prompt },
668
+ {
669
+ role: 'ai',
670
+ tokens: [],
671
+ sources: answer.sources,
672
+ followups: answer.followups,
673
+ streaming: true,
674
+ },
675
+ ]);
676
+ streamingRef.current = true;
677
+ let i = 0;
678
+ const step = () => {
679
+ i = Math.min(fullTokens.length, i + 1);
680
+ patchLast({
681
+ tokens: fullTokens.slice(0, i),
682
+ streaming: i < fullTokens.length,
683
+ });
684
+ if (i < fullTokens.length) {
685
+ abortRef.current = { timer: setTimeout(step, 28) };
686
+ } else {
687
+ streamingRef.current = false;
688
+ abortRef.current = null;
689
+ }
690
+ };
691
+ abortRef.current = { timer: setTimeout(step, 1400) }; // "thinking" pause
692
+ return;
693
+ }
694
+
695
+ // ── Real backend → stream deltas + citations from `ask` ───────────
696
+ setMessages((prev) => [
697
+ ...prev,
698
+ { role: 'user', text: prompt },
699
+ { role: 'ai', tokens: [], sources: [], followups: [], streaming: true },
700
+ ]);
701
+ streamingRef.current = true;
702
+ const controller =
703
+ typeof AbortController !== 'undefined' ? new AbortController() : null;
704
+ abortRef.current = controller;
705
+
706
+ let acc = '';
707
+ let sources = [];
708
+ let messageId;
709
+ try {
710
+ for await (const ev of ask(prompt, {
711
+ conversationId: convoRef.current,
712
+ signal: controller?.signal,
713
+ })) {
714
+ if (ev.conversationId) convoRef.current = ev.conversationId;
715
+ if (ev.messageId) messageId = ev.messageId;
716
+ if (ev.delta) acc += ev.delta;
717
+ if (ev.citations) {
718
+ sources = ev.citations.map((c, idx) => ({
719
+ num: c.num ?? idx + 1,
720
+ title: c.title,
721
+ path: c.path ?? c.route_path,
722
+ url: c.url,
723
+ }));
724
+ }
725
+ patchLast({ tokens: textToTokens(acc), sources, streaming: true, messageId });
726
+ }
727
+ patchLast({ tokens: textToTokens(acc), sources, streaming: false, messageId });
728
+ } catch (err) {
729
+ if (controller?.signal?.aborted) {
730
+ patchLast({ streaming: false });
731
+ } else {
732
+ patchLast({
733
+ tokens: textToTokens(
734
+ acc || 'Sorry — I couldn’t reach the assistant. Please try again.'
735
+ ),
736
+ streaming: false,
737
+ error: true,
738
+ });
739
+ }
740
+ } finally {
741
+ streamingRef.current = false;
742
+ abortRef.current = null;
743
+ }
744
+ },
745
+ [ask, patchLast]
746
+ );
747
+
748
+ // Abort any in-flight stream when the panel unmounts.
749
+ useEffect(() => () => cancelInFlight(), [cancelInFlight]);
750
+
751
+ // A new seedQuestion (from the page's AskBar) sends the first message.
752
+ useEffect(() => {
753
+ if (open && seedQuestion && seedQuestion !== lastSeedRef.current) {
754
+ lastSeedRef.current = seedQuestion;
755
+ send(seedQuestion);
756
+ }
757
+ }, [open, seedQuestion, send]);
758
+
759
+ const newChat = () => {
760
+ cancelInFlight();
761
+ convoRef.current = undefined; // fresh conversation thread
762
+ setMessages([]);
763
+ setInput('');
764
+ setHistoryOpen(false);
765
+ };
766
+
767
+ // Drag-to-dismiss (mobile bottom-sheet only). Pointer events are
768
+ // attached to the drag-handle element below; the handle's pointer
769
+ // capture keeps the events flowing even if the finger slides off
770
+ // the handle into the body. While dragging we apply an inline
771
+ // `transform: translateY(...)` to the panel, overriding the CSS-
772
+ // controlled transform. On release, if the drag distance exceeded
773
+ // the threshold we call onClose() and the panel slides the rest of
774
+ // the way down via the CSS transition; otherwise we drop the
775
+ // inline transform and the panel snaps back via the same
776
+ // transition.
777
+ const dragRef = useRef({ active: false, startY: 0, currentY: 0 });
778
+ const [dragOffset, setDragOffset] = useState(0);
779
+ // Drag-to-dismiss is active wherever the chatbot is rendered as a
780
+ // bottom sheet — same threshold as the @container query in
781
+ // chatbot.css (< 1024px covers both narrow / tablet and mobile).
782
+ const isBottomSheet = () =>
783
+ typeof window !== 'undefined' &&
784
+ window.matchMedia('(max-width: 1024px)').matches;
785
+ const onDragStart = (e) => {
786
+ if (!isBottomSheet()) return;
787
+ dragRef.current = { active: true, startY: e.clientY, currentY: 0 };
788
+ e.currentTarget.setPointerCapture?.(e.pointerId);
789
+ };
790
+ const onDragMove = (e) => {
791
+ if (!dragRef.current.active) return;
792
+ const delta = Math.max(0, e.clientY - dragRef.current.startY);
793
+ dragRef.current.currentY = delta;
794
+ setDragOffset(delta);
795
+ };
796
+ const onDragEnd = () => {
797
+ if (!dragRef.current.active) return;
798
+ dragRef.current.active = false;
799
+ const shouldClose = dragRef.current.currentY > 80;
800
+ setDragOffset(0);
801
+ if (shouldClose) onClose?.();
802
+ };
803
+ // Reset any residual drag offset whenever the panel closes from
804
+ // outside (scrim click, X button, etc.) so a re-open starts clean.
805
+ useEffect(() => {
806
+ if (!open) setDragOffset(0);
807
+ }, [open]);
808
+
809
+ const cls = `velu-chatbot${open ? ' velu-chatbot--open' : ''} ${className}`.trim();
810
+ const dragStyle =
811
+ dragOffset > 0
812
+ ? { transform: `translateY(${dragOffset}px)`, transition: 'none' }
813
+ : undefined;
814
+
815
+ return (
816
+ <aside
817
+ className={cls}
818
+ aria-hidden={!open}
819
+ aria-label="Ask AI"
820
+ style={dragStyle}
821
+ {...rest}
822
+ >
823
+ {/* Drag handle — mobile only (CSS in chatbot.css). The pill
824
+ visual + the touch-capturing strip across the top of the
825
+ sheet. Pointer events here drive the drag-to-dismiss
826
+ gesture above. */}
827
+ <div
828
+ className="velu-chatbot__drag-handle"
829
+ onPointerDown={onDragStart}
830
+ onPointerMove={onDragMove}
831
+ onPointerUp={onDragEnd}
832
+ onPointerCancel={onDragEnd}
833
+ aria-hidden="true"
834
+ />
835
+ <ChatHeader
836
+ onClose={onClose}
837
+ onNew={newChat}
838
+ onHistory={() => setHistoryOpen((o) => !o)}
839
+ historyOpen={historyOpen}
840
+ />
841
+
842
+ {historyOpen && (
843
+ <HistoryPanel
844
+ items={history}
845
+ loading={historyLoading}
846
+ onClose={() => setHistoryOpen(false)}
847
+ onPick={openConversation}
848
+ />
849
+ )}
850
+
851
+ <div className="velu-chatbot__body" ref={bodyRef}>
852
+ {messages.length === 0 ? (
853
+ <Welcome onPick={(t) => send(t)} suggestions={suggestions} />
854
+ ) : (
855
+ <Stack space="var(--s1)">
856
+ {messages.map((m, i) =>
857
+ m.role === 'user' ? (
858
+ <UserMsg key={i} text={m.text} />
859
+ ) : (
860
+ <AiMsg
861
+ key={i}
862
+ tokens={m.tokens}
863
+ sources={m.sources}
864
+ followups={m.followups}
865
+ streaming={m.streaming}
866
+ messageId={m.messageId}
867
+ onFeedback={onFeedback}
868
+ onNavigate={onNavigate}
869
+ onFollowup={(f) => send(f)}
870
+ />
871
+ ),
872
+ )}
873
+ </Stack>
874
+ )}
875
+ </div>
876
+
877
+ <Composer
878
+ value={input}
879
+ onChange={setInput}
880
+ onSubmit={() => send(input)}
881
+ disabled={streamingRef.current}
882
+ />
883
+ </aside>
884
+ );
885
+ }