jamdesk 1.1.126 → 1.1.127

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jamdesk",
3
- "version": "1.1.126",
3
+ "version": "1.1.127",
4
4
  "description": "CLI for Jamdesk — build, preview, and deploy documentation sites from MDX. Dev server with hot reload, 50+ components, OpenAPI support, AI search, and Mintlify migration",
5
5
  "keywords": [
6
6
  "jamdesk",
@@ -1,5 +1,6 @@
1
1
  import { ImageResponse } from '@vercel/og';
2
2
  import { NextRequest } from 'next/server';
3
+ import { sanitizeLogoUrl } from '@/lib/sanitize-url';
3
4
 
4
5
  export const runtime = 'edge';
5
6
 
@@ -20,6 +21,27 @@ function truncateText(text: string, maxLength: number): string {
20
21
  return text.substring(0, maxLength) + '...';
21
22
  }
22
23
 
24
+ /**
25
+ * Fetch an image and convert it to a base64 data URI for Satori compatibility
26
+ * (Satori doesn't support webp and can fail on remote URLs). 3s timeout; '' on failure.
27
+ */
28
+ async function fetchDataUri(url: string): Promise<string> {
29
+ const controller = new AbortController();
30
+ const timeout = setTimeout(() => controller.abort(), 3000);
31
+ try {
32
+ // redirect: 'manual' so a sanitized public host can't 302 to an internal one (SSRF).
33
+ const res = await fetch(url, { signal: controller.signal, redirect: 'manual' });
34
+ if (!res.ok) return '';
35
+ const contentType = res.headers.get('content-type') || 'image/png';
36
+ const buf = await res.arrayBuffer();
37
+ return `data:${contentType};base64,${Buffer.from(buf).toString('base64')}`;
38
+ } catch {
39
+ return ''; // Skip image on fetch failure
40
+ } finally {
41
+ clearTimeout(timeout);
42
+ }
43
+ }
44
+
23
45
  /**
24
46
  * OG Image Generator
25
47
  *
@@ -31,6 +53,7 @@ function truncateText(text: string, maxLength: number): string {
31
53
  * - section: Section/group name (optional, e.g., "Get Started")
32
54
  * - siteName: Site name (optional, defaults to "Documentation")
33
55
  * - logo: URL to the project's logo (optional)
56
+ * - bg: URL to a custom background image rendered behind the text (optional)
34
57
  * - theme: accepted but ignored (always uses cream/warm style)
35
58
  *
36
59
  * Example:
@@ -43,27 +66,14 @@ export async function GET(request: NextRequest) {
43
66
  const description = searchParams.get('description') || '';
44
67
  const section = searchParams.get('section') || '';
45
68
  const siteName = searchParams.get('siteName') || 'Documentation';
46
- const logoUrl = searchParams.get('logo') || '';
69
+ // SSRF guard: HTTPS-only, blocks private/loopback/metadata IPs (incl. encoded forms).
70
+ const logoUrl = sanitizeLogoUrl(searchParams.get('logo') || '');
71
+ const bgUrl = sanitizeLogoUrl(searchParams.get('bg') || '');
47
72
 
48
- // Fetch logo and convert to base64 data URI for Satori compatibility
49
- // (Satori doesn't support webp and can fail on remote URLs)
50
- let logo = '';
51
- if (logoUrl) {
52
- const controller = new AbortController();
53
- const timeout = setTimeout(() => controller.abort(), 3000);
54
- try {
55
- const res = await fetch(logoUrl, { signal: controller.signal });
56
- if (res.ok) {
57
- const contentType = res.headers.get('content-type') || 'image/png';
58
- const buf = await res.arrayBuffer();
59
- logo = `data:${contentType};base64,${Buffer.from(buf).toString('base64')}`;
60
- }
61
- } catch {
62
- // Skip logo on fetch failure
63
- } finally {
64
- clearTimeout(timeout);
65
- }
66
- }
73
+ const [logo, bg] = await Promise.all([
74
+ logoUrl ? fetchDataUri(logoUrl) : Promise.resolve(''),
75
+ bgUrl ? fetchDataUri(bgUrl) : Promise.resolve(''),
76
+ ]);
67
77
 
68
78
  const titleFontSize = title.length > LONG_TITLE_THRESHOLD ? '52px' : '64px';
69
79
 
@@ -71,119 +81,148 @@ export async function GET(request: NextRequest) {
71
81
  (
72
82
  <div
73
83
  style={{
84
+ position: 'relative',
74
85
  height: '100%',
75
86
  width: '100%',
76
87
  display: 'flex',
77
- flexDirection: 'column',
78
- padding: '60px',
79
88
  background: BG_GRADIENT,
80
89
  fontFamily: FONT_STACK,
81
90
  }}
82
91
  >
83
- {/* Logo and site name */}
92
+ {/* Custom background image, full-bleed behind the text */}
93
+ {bg && (
94
+ <img
95
+ src={bg}
96
+ alt=""
97
+ width={1200}
98
+ height={630}
99
+ style={{
100
+ position: 'absolute',
101
+ top: 0,
102
+ left: 0,
103
+ width: '1200px',
104
+ height: '630px',
105
+ objectFit: 'cover',
106
+ }}
107
+ />
108
+ )}
109
+ {/* Content (painted on top of the background) */}
84
110
  <div
85
111
  style={{
86
112
  display: 'flex',
87
113
  flexDirection: 'column',
88
- alignItems: 'flex-start',
89
- marginBottom: '40px',
114
+ width: '100%',
115
+ height: '100%',
116
+ padding: '60px',
90
117
  }}
91
118
  >
92
- {logo && (
93
- <img
94
- src={logo}
95
- alt=""
96
- width={48}
97
- height={48}
98
- style={{
99
- marginBottom: '16px',
100
- borderRadius: '8px',
101
- }}
102
- />
103
- )}
104
- <span
105
- style={{
106
- fontSize: '24px',
107
- fontWeight: 600,
108
- color: MUTED_COLOR,
109
- textTransform: 'uppercase',
110
- letterSpacing: '0.05em',
111
- }}
112
- >
113
- {siteName}
114
- </span>
115
- </div>
116
-
117
- {/* Section badge */}
118
- {section && (
119
+ {/* Logo and site name */}
119
120
  <div
120
121
  style={{
121
122
  display: 'flex',
122
- marginBottom: '20px',
123
+ flexDirection: 'column',
124
+ alignItems: 'flex-start',
125
+ marginBottom: '40px',
123
126
  }}
124
127
  >
128
+ {logo && (
129
+ <img
130
+ src={logo}
131
+ alt=""
132
+ height={48}
133
+ style={{
134
+ height: '48px',
135
+ maxWidth: '360px',
136
+ objectFit: 'contain',
137
+ marginBottom: '16px',
138
+ borderRadius: '8px',
139
+ }}
140
+ />
141
+ )}
125
142
  <span
126
143
  style={{
127
- fontSize: '18px',
128
- fontWeight: 500,
129
- color: ACCENT_COLOR,
130
- padding: '8px 16px',
131
- background: BADGE_BG,
132
- borderRadius: '20px',
144
+ fontSize: '24px',
145
+ fontWeight: 600,
146
+ color: MUTED_COLOR,
147
+ textTransform: 'uppercase',
148
+ letterSpacing: '0.05em',
133
149
  }}
134
150
  >
135
- {section}
151
+ {siteName}
136
152
  </span>
137
153
  </div>
138
- )}
139
154
 
140
- {/* Title and description */}
141
- <div
142
- style={{
143
- display: 'flex',
144
- flex: 1,
145
- flexDirection: 'column',
146
- justifyContent: 'center',
147
- }}
148
- >
149
- <h1
155
+ {/* Section badge */}
156
+ {section && (
157
+ <div
158
+ style={{
159
+ display: 'flex',
160
+ marginBottom: '20px',
161
+ }}
162
+ >
163
+ <span
164
+ style={{
165
+ fontSize: '18px',
166
+ fontWeight: 500,
167
+ color: ACCENT_COLOR,
168
+ padding: '8px 16px',
169
+ background: BADGE_BG,
170
+ borderRadius: '20px',
171
+ }}
172
+ >
173
+ {section}
174
+ </span>
175
+ </div>
176
+ )}
177
+
178
+ {/* Title and description */}
179
+ <div
150
180
  style={{
151
- fontSize: titleFontSize,
152
- fontWeight: 700,
153
- color: TEXT_COLOR,
154
- lineHeight: 1.2,
155
- margin: 0,
156
- maxWidth: '900px',
181
+ display: 'flex',
182
+ flex: 1,
183
+ flexDirection: 'column',
184
+ justifyContent: 'center',
157
185
  }}
158
186
  >
159
- {title}
160
- </h1>
161
-
162
- {description && (
163
- <p
187
+ <h1
164
188
  style={{
165
- fontSize: '28px',
166
- color: MUTED_COLOR,
167
- marginTop: '24px',
168
- lineHeight: 1.4,
169
- maxWidth: '800px',
189
+ fontSize: titleFontSize,
190
+ fontWeight: 700,
191
+ color: TEXT_COLOR,
192
+ lineHeight: 1.2,
193
+ margin: 0,
194
+ maxWidth: '900px',
170
195
  }}
171
196
  >
172
- {truncateText(description, MAX_DESCRIPTION_LENGTH)}
173
- </p>
174
- )}
175
- </div>
197
+ {title}
198
+ </h1>
176
199
 
177
- {/* Bottom accent bar */}
178
- <div
179
- style={{
180
- display: 'flex',
181
- height: '6px',
182
- background: ACCENT_GRADIENT,
183
- borderRadius: '3px',
184
- marginTop: '40px',
185
- }}
186
- />
200
+ {description && (
201
+ <p
202
+ style={{
203
+ fontSize: '28px',
204
+ color: MUTED_COLOR,
205
+ marginTop: '24px',
206
+ lineHeight: 1.4,
207
+ maxWidth: '800px',
208
+ }}
209
+ >
210
+ {truncateText(description, MAX_DESCRIPTION_LENGTH)}
211
+ </p>
212
+ )}
213
+ </div>
214
+
215
+ {/* Bottom accent bar */}
216
+ <div
217
+ style={{
218
+ display: 'flex',
219
+ height: '6px',
220
+ background: ACCENT_GRADIENT,
221
+ borderRadius: '3px',
222
+ marginTop: '40px',
223
+ }}
224
+ />
225
+ </div>
187
226
  </div>
188
227
  ),
189
228
  {
@@ -4,6 +4,7 @@ import { memo } from 'react';
4
4
  import ReactMarkdown from 'react-markdown';
5
5
  import remarkGfm from 'remark-gfm';
6
6
  import { ChatCodeBlock } from './ChatCodeBlock';
7
+ import { CopyButton } from './CopyButton';
7
8
  import { useLinkPrefix } from '@/lib/link-prefix-context';
8
9
  import type { ChatMessage as ChatMessageType } from '@/hooks/useChat';
9
10
 
@@ -126,6 +127,13 @@ export const ChatMessage = memo(function ChatMessage({ message, onSelectOption }
126
127
  )}
127
128
  </div>
128
129
 
130
+ {/* Copy answer — only on a finalized, non-empty assistant message */}
131
+ {!message.isStreaming && message.content && (
132
+ <div className="mt-1 -ml-2">
133
+ <CopyButton getText={() => message.content} ariaLabel="Copy answer" />
134
+ </div>
135
+ )}
136
+
129
137
  {/* Clarification options — tappable buttons for disambiguation */}
130
138
  {hasClarificationOptions && (
131
139
  <div className="mt-3">
@@ -3,9 +3,13 @@
3
3
  import { useRef, useEffect, useLayoutEffect, useCallback, useState } from 'react';
4
4
  import { useChat } from '@/hooks/useChat';
5
5
  import { useBodyScrollLock } from '@/hooks/useBodyScrollLock';
6
+ import { useChatPanelOptional } from '@/hooks/useChatPanel';
6
7
  import { ChatMessage } from './ChatMessage';
7
8
  import { ChatInput } from './ChatInput';
8
9
  import { ChatEmptyState } from './ChatEmptyState';
10
+ import { CopyButton } from './CopyButton';
11
+ import { formatTranscript } from '@/lib/chat-transcript';
12
+ import { isNearBottom } from '@/lib/chat-scroll';
9
13
  import { crispAvailable, hideCrispLauncher, openCrispChat, showCrispLauncher } from '../../lib/crisp-bridge';
10
14
 
11
15
  const SOMETHING_ELSE_PATTERNS = ['something else', 'none of the above', 'none of these'];
@@ -43,6 +47,33 @@ interface ChatPanelProps {
43
47
  export function ChatPanel({ isOpen, onClose, starterQuestions, chatEndpoint, mode = 'overlay' }: ChatPanelProps) {
44
48
  const { messages, sendMessage, isLoading, abort, retry, clearChat, error, markClarificationSelected } = useChat(chatEndpoint);
45
49
 
50
+ // Search (and any external trigger) hands a question to chat via the
51
+ // ChatPanelContext. We append it as the next message — no clearChat — so an
52
+ // in-progress conversation is preserved. useChatPanelOptional (not the
53
+ // throwing useChatPanel) keeps ChatPanel renderable in unit tests / outside
54
+ // a provider.
55
+ const chatPanel = useChatPanelOptional();
56
+ const pendingQuestion = chatPanel?.pendingQuestion ?? null;
57
+ const consumePendingQuestion = chatPanel?.consumePendingQuestion;
58
+ const consumingRef = useRef(false);
59
+
60
+ useEffect(() => {
61
+ if (!pendingQuestion) {
62
+ consumingRef.current = false;
63
+ return;
64
+ }
65
+ // R1: sendMessage() is a no-op while a stream is in flight (useChat guards
66
+ // on isLoadingRef). Do NOT consume the question then — it would vanish.
67
+ // Hold it; isLoading is a dependency, so when the stream finishes the
68
+ // effect re-runs and delivers. consumingRef prevents an effect double-fire
69
+ // from sending twice within one delivery; it resets when pendingQuestion
70
+ // clears.
71
+ if (isLoading || consumingRef.current) return;
72
+ consumingRef.current = true;
73
+ sendMessage(pendingQuestion);
74
+ consumePendingQuestion?.();
75
+ }, [pendingQuestion, isLoading, sendMessage, consumePendingQuestion]);
76
+
46
77
  const [hasCrisp, setHasCrisp] = useState(false);
47
78
  useEffect(() => { setHasCrisp(crispAvailable()); }, []);
48
79
 
@@ -51,6 +82,15 @@ export function ChatPanel({ isOpen, onClose, starterQuestions, chatEndpoint, mod
51
82
  openCrispChat();
52
83
  }, [onClose]);
53
84
 
85
+ // "New chat" must also drop any not-yet-delivered handoff question. Otherwise
86
+ // a question queued mid-stream (held by the consume effect while isLoading)
87
+ // would fire into the freshly cleared conversation once clearChat()'s abort()
88
+ // flips isLoading false and the effect re-runs.
89
+ const handleNewChat = useCallback(() => {
90
+ clearChat();
91
+ consumePendingQuestion?.();
92
+ }, [clearChat, consumePendingQuestion]);
93
+
54
94
  const messagesContainerRef = useRef<HTMLDivElement>(null);
55
95
  const inputRef = useRef<HTMLDivElement>(null);
56
96
  const overlayPanelRef = useRef<HTMLDivElement>(null);
@@ -109,14 +149,29 @@ export function ChatPanel({ isOpen, onClose, starterQuestions, chatEndpoint, mod
109
149
  };
110
150
  }, [isOpen, isInline]);
111
151
 
112
- // Auto-scroll to bottom when new messages arrive. Uses useLayoutEffect so
113
- // scrollHeight is accurate (DOM is updated) and there's no visible 1-frame lag.
114
- // Scrolls the container directly instead of scrollIntoView to avoid scrolling
115
- // ancestor elements (which pushes the panel off-screen on mobile).
152
+ // Auto-scroll only when the user is already at the bottom. If they've
153
+ // scrolled up to re-read mid-stream, leave their position alone. We track
154
+ // "pinned" in a ref (not state) so updating it on every scroll event doesn't
155
+ // re-render. Direct container scroll (no scrollIntoView that scrolls every
156
+ // ancestor; documented anti-pattern in CLAUDE.md).
157
+ const isPinnedToBottomRef = useRef(true);
158
+
159
+ const handleMessagesScroll = useCallback(() => {
160
+ const container = messagesContainerRef.current;
161
+ if (!container) return;
162
+ isPinnedToBottomRef.current = isNearBottom(
163
+ container.scrollTop,
164
+ container.scrollHeight,
165
+ container.clientHeight,
166
+ );
167
+ }, []);
168
+
116
169
  useLayoutEffect(() => {
117
170
  const container = messagesContainerRef.current;
118
171
  if (!container) return;
119
- container.scrollTop = container.scrollHeight;
172
+ if (isPinnedToBottomRef.current) {
173
+ container.scrollTop = container.scrollHeight;
174
+ }
120
175
  }, [messages]);
121
176
 
122
177
  // Escape key handling is in useChatPanel (capture phase, with search-modal awareness)
@@ -152,9 +207,17 @@ export function ChatPanel({ isOpen, onClose, starterQuestions, chatEndpoint, mod
152
207
  </span>
153
208
  </div>
154
209
  <div className="flex items-center gap-1">
210
+ {/* R8: only when no message is mid-stream, so the transcript captures
211
+ the post-rewrite (absolute-URL) link form, not slug placeholders. */}
212
+ {hasMessages && !messages.some((m) => m.isStreaming) && (
213
+ <CopyButton
214
+ getText={() => formatTranscript(messages)}
215
+ ariaLabel="Copy transcript"
216
+ />
217
+ )}
155
218
  {hasMessages && (
156
219
  <button
157
- onClick={clearChat}
220
+ onClick={handleNewChat}
158
221
  className="px-2 py-1 text-xs text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] transition-colors rounded-md hover:bg-[var(--color-bg-secondary)] cursor-pointer focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-accent)]"
159
222
  >
160
223
  New chat
@@ -175,6 +238,7 @@ export function ChatPanel({ isOpen, onClose, starterQuestions, chatEndpoint, mod
175
238
  ref={messagesContainerRef}
176
239
  className="flex-1 overflow-y-auto min-h-0"
177
240
  style={{ overscrollBehavior: 'contain' }}
241
+ onScroll={handleMessagesScroll}
178
242
  aria-live="polite"
179
243
  aria-label="Chat messages"
180
244
  >
@@ -0,0 +1,57 @@
1
+ // builder/build-service/components/chat/CopyButton.tsx
2
+ 'use client';
3
+
4
+ import { memo, useCallback, useEffect, useRef, useState } from 'react';
5
+
6
+ interface CopyButtonProps {
7
+ /** Lazily produce the text to copy (so we don't recompute on every render). */
8
+ getText: () => string;
9
+ /** Accessible name + tooltip (e.g. "Copy answer", "Copy transcript"). */
10
+ ariaLabel: string;
11
+ /** Optional visible label rendered next to the icon (used by the transcript button). */
12
+ idleLabel?: string;
13
+ className?: string;
14
+ }
15
+
16
+ /**
17
+ * Clipboard button with a transient "copied" checkmark. Mirrors the copy
18
+ * pattern in ChatCodeBlock.tsx but reusable: powers both the per-answer copy
19
+ * and the "Copy transcript" header action. Fails silently when the Clipboard
20
+ * API is unavailable (non-HTTPS) or rejects (permission denied).
21
+ */
22
+ export const CopyButton = memo(function CopyButton({ getText, ariaLabel, idleLabel, className }: CopyButtonProps) {
23
+ const [copied, setCopied] = useState(false);
24
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
25
+
26
+ useEffect(() => () => {
27
+ if (timerRef.current) clearTimeout(timerRef.current);
28
+ }, []);
29
+
30
+ const handleCopy = useCallback(() => {
31
+ const text = getText();
32
+ if (!text || !navigator.clipboard?.writeText) return;
33
+ navigator.clipboard.writeText(text).then(() => {
34
+ setCopied(true);
35
+ if (timerRef.current) clearTimeout(timerRef.current);
36
+ timerRef.current = setTimeout(() => setCopied(false), 2000);
37
+ }).catch(() => {
38
+ // Clipboard write rejected (permissions) — leave UI unchanged.
39
+ });
40
+ }, [getText]);
41
+
42
+ return (
43
+ <button
44
+ type="button"
45
+ onClick={handleCopy}
46
+ aria-label={ariaLabel}
47
+ title={copied ? 'Copied!' : ariaLabel}
48
+ className={
49
+ className ??
50
+ 'inline-flex items-center gap-1.5 px-2 py-1 text-xs text-[var(--color-text-muted)] hover:text-[var(--color-text-primary)] transition-colors rounded-md hover:bg-[var(--color-bg-secondary)] cursor-pointer focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-accent)]'
51
+ }
52
+ >
53
+ <i className={`fa-solid ${copied ? 'fa-check text-green-500' : 'fa-copy'}`} aria-hidden="true" />
54
+ {idleLabel && <span>{copied ? 'Copied' : idleLabel}</span>}
55
+ </button>
56
+ );
57
+ });
@@ -10,6 +10,8 @@ import { trackSearch } from '@/lib/analytics-client';
10
10
  import { useLinkPrefix } from '@/lib/link-prefix-context';
11
11
  import { useProjectSlug } from '@/lib/project-slug-context';
12
12
  import type { SearchResult, SearchGroup } from '@/lib/search-client';
13
+ import { useChatPanelOptional } from '@/hooks/useChatPanel';
14
+ import { modifierKeyLabel } from '@/lib/platform-keys';
13
15
 
14
16
  interface PopularPage {
15
17
  title: string;
@@ -136,6 +138,11 @@ function countVisibleResults(rows: SearchRow[]): number {
136
138
  export function SearchModal({ isOpen, onClose, popularPages, onNavigate }: SearchModalProps) {
137
139
  const linkPrefix = useLinkPrefix();
138
140
  const projectSlug = useProjectSlug();
141
+ const chatPanel = useChatPanelOptional();
142
+ // Ask AI is offered only when the docs chrome (ChatPanelProvider) is present
143
+ // AND chat is enabled. The 404 page renders SearchModal without the provider,
144
+ // so chatPanel is null there and the affordance is hidden.
145
+ const canAsk = !!chatPanel && chatPanel.chatEnabled;
139
146
  const pathname = usePathname() ?? '';
140
147
  const [query, setQuery] = useState('');
141
148
  const [groups, setGroups] = useState<SearchGroup[]>([]);
@@ -354,11 +361,38 @@ export function SearchModal({ isOpen, onClose, popularPages, onNavigate }: Searc
354
361
  });
355
362
  }, []);
356
363
 
364
+ const handleAskAi = useCallback(() => {
365
+ // Cap at the chat endpoint's 2000-char limit (route.ts rejects longer with
366
+ // a 400) so a long paste degrades to a truncated question, not a dead end.
367
+ const trimmed = query.trim().slice(0, 2000);
368
+ if (!trimmed || !chatPanel) return;
369
+ addRecentSearch(projectSlug, trimmed);
370
+ onClose();
371
+ chatPanel.askChat(trimmed); // opens chat + queues the question (append)
372
+ // Clear the consumed query (mirrors handleResultClick). SearchModal renders
373
+ // null when closed but stays mounted, so query state would otherwise persist
374
+ // and the next search session concatenates onto the handed-off text.
375
+ setQuery('');
376
+ setGroups([]);
377
+ }, [query, chatPanel, projectSlug, onClose]);
378
+
357
379
  // Keyboard navigation
358
380
  useEffect(() => {
359
381
  if (!isOpen) return;
360
382
 
361
383
  const handleKeyDown = (e: KeyboardEvent) => {
384
+ // Cmd/Ctrl + Enter → hand the query to AI chat. Checked first and
385
+ // returned so it never falls through to the plain-Enter "open top
386
+ // result" path below.
387
+ if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
388
+ if (canAsk && query.trim()) {
389
+ e.preventDefault();
390
+ e.stopPropagation();
391
+ handleAskAi();
392
+ }
393
+ return;
394
+ }
395
+
362
396
  // When we have search results, navigate through them
363
397
  if (rows.length > 0) {
364
398
  if (e.key === 'ArrowDown') {
@@ -419,7 +453,7 @@ export function SearchModal({ isOpen, onClose, popularPages, onNavigate }: Searc
419
453
 
420
454
  document.addEventListener('keydown', handleKeyDown);
421
455
  return () => document.removeEventListener('keydown', handleKeyDown);
422
- }, [isOpen, query, recentSearches, rows, selectedIndex, effectivePopularPages, popularIndex, router, onClose, onNavigate, projectSlug, handleResultClick, handleExpanderClick, isSearching, linkPrefix]);
456
+ }, [isOpen, query, recentSearches, rows, selectedIndex, effectivePopularPages, popularIndex, router, onClose, onNavigate, projectSlug, handleResultClick, handleExpanderClick, isSearching, linkPrefix, canAsk, handleAskAi]);
423
457
 
424
458
  const handleClearRecentSearches = () => {
425
459
  clearRecentSearches(projectSlug);
@@ -684,6 +718,21 @@ export function SearchModal({ isOpen, onClose, popularPages, onNavigate }: Searc
684
718
  <span className="ml-1">Close</span>
685
719
  </div>
686
720
  </div>
721
+ {canAsk && query.trim() && (
722
+ <button
723
+ type="button"
724
+ onClick={handleAskAi}
725
+ className="inline-flex items-center gap-1.5 px-2 py-1 rounded-md text-[11px] font-medium text-[var(--color-accent)] hover:bg-[var(--color-bg-tertiary)] transition-colors cursor-pointer focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-accent)]"
726
+ aria-label={`Ask AI about ${query.trim()}`}
727
+ >
728
+ <i className="fa-solid fa-sparkles text-[10px]" aria-hidden="true" />
729
+ <span>Ask AI</span>
730
+ {/* One keycap for the whole chord (⌘/Ctrl + ↵). Mouse-only:
731
+ hidden on touch (coarse pointer), where there's no shortcut
732
+ to press and the label tap suffices. */}
733
+ <kbd className="px-1 py-0.5 bg-[var(--color-bg-primary)] border border-[var(--color-border)] rounded text-[9px] font-medium pointer-coarse:hidden">{`${modifierKeyLabel()} ↵`}</kbd>
734
+ </button>
735
+ )}
687
736
  </div>
688
737
  </div>
689
738
  </div>
@@ -16,6 +16,12 @@ interface ChatPanelContextValue {
16
16
  chatEnabled: boolean;
17
17
  chatEndpoint: string;
18
18
  starterQuestions?: string[];
19
+ /** Question queued by an external trigger (e.g. search "Ask AI"). Consumed once by ChatPanel. */
20
+ pendingQuestion: string | null;
21
+ /** Open the chat panel and queue `question` to be sent as the next message (append). */
22
+ askChat: (question: string) => void;
23
+ /** Clear the queued question after ChatPanel has sent it. */
24
+ consumePendingQuestion: () => void;
19
25
  }
20
26
 
21
27
  const ChatPanelContext = createContext<ChatPanelContextValue | null>(null);
@@ -34,6 +40,16 @@ function clampWidth(width: number): number {
34
40
  export function ChatPanelProvider({ children, chatEnabled, chatEndpoint, starterQuestions }: ChatPanelProviderProps) {
35
41
  const [isChatOpen, setIsChatOpen] = useState(false);
36
42
  const [chatWidth, setChatWidthRaw] = useState(DEFAULT_WIDTH);
43
+ const [pendingQuestion, setPendingQuestion] = useState<string | null>(null);
44
+
45
+ const askChat = useCallback((question: string) => {
46
+ setPendingQuestion(question);
47
+ setIsChatOpen(true);
48
+ }, []);
49
+
50
+ const consumePendingQuestion = useCallback(() => {
51
+ setPendingQuestion(null);
52
+ }, []);
37
53
 
38
54
  // Load persisted width from localStorage
39
55
  useEffect(() => {
@@ -86,7 +102,7 @@ export function ChatPanelProvider({ children, chatEnabled, chatEndpoint, starter
86
102
  }, [chatEnabled, isChatOpen, isDevMode]);
87
103
 
88
104
  return (
89
- <ChatPanelContext.Provider value={{ isChatOpen, setIsChatOpen, chatWidth, setChatWidth, chatEnabled, chatEndpoint, starterQuestions }}>
105
+ <ChatPanelContext.Provider value={{ isChatOpen, setIsChatOpen, chatWidth, setChatWidth, chatEnabled, chatEndpoint, starterQuestions, pendingQuestion, askChat, consumePendingQuestion }}>
90
106
  {children}
91
107
  </ChatPanelContext.Provider>
92
108
  );
@@ -100,7 +116,17 @@ export function useChatPanel(): ChatPanelContextValue {
100
116
  return context;
101
117
  }
102
118
 
103
- export { MIN_WIDTH, MAX_WIDTH, DEFAULT_WIDTH };
119
+ /**
120
+ * Non-throwing context accessor. Returns null when there is no
121
+ * ChatPanelProvider above (e.g. the 404 page renders SearchModal without
122
+ * the docs chrome). Callers that may render outside the provider MUST use
123
+ * this instead of useChatPanel().
124
+ */
125
+ export function useChatPanelOptional(): ChatPanelContextValue | null {
126
+ return useContext(ChatPanelContext);
127
+ }
128
+
129
+ export { MIN_WIDTH, MAX_WIDTH, DEFAULT_WIDTH, ChatPanelContext };
104
130
 
105
131
  /**
106
132
  * Returns a chat-open handler that shows DevOnlyNotice in dev mode