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 +1 -1
- package/vendored/app/api/og/route.tsx +140 -101
- package/vendored/components/chat/ChatMessage.tsx +8 -0
- package/vendored/components/chat/ChatPanel.tsx +70 -6
- package/vendored/components/chat/CopyButton.tsx +57 -0
- package/vendored/components/search/SearchModal.tsx +50 -1
- package/vendored/hooks/useChatPanel.tsx +28 -2
- package/vendored/lib/chat-scroll.ts +17 -0
- package/vendored/lib/chat-transcript.ts +15 -0
- package/vendored/lib/git-utils.ts +27 -1
- package/vendored/lib/isr-build-executor.ts +269 -7
- package/vendored/lib/platform-keys.ts +17 -0
- package/vendored/lib/preprocess-mdx.ts +19 -0
- package/vendored/lib/r2-cleanup.ts +239 -1
- package/vendored/lib/r2-manifest.ts +1 -0
- package/vendored/lib/render-doc-page.tsx +9 -7
- package/vendored/lib/sanitize-url.ts +125 -0
- package/vendored/lib/seo.ts +29 -19
- package/vendored/lib/snippet-loader-isr.ts +1 -13
- package/vendored/shared/status-reporter.ts +1 -1
- package/vendored/workspace-package-lock.json +25 -15
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jamdesk",
|
|
3
|
-
"version": "1.1.
|
|
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
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
{/*
|
|
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
|
-
|
|
89
|
-
|
|
114
|
+
width: '100%',
|
|
115
|
+
height: '100%',
|
|
116
|
+
padding: '60px',
|
|
90
117
|
}}
|
|
91
118
|
>
|
|
92
|
-
{
|
|
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
|
-
|
|
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: '
|
|
128
|
-
fontWeight:
|
|
129
|
-
color:
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
{
|
|
151
|
+
{siteName}
|
|
136
152
|
</span>
|
|
137
153
|
</div>
|
|
138
|
-
)}
|
|
139
154
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
margin: 0,
|
|
156
|
-
maxWidth: '900px',
|
|
181
|
+
display: 'flex',
|
|
182
|
+
flex: 1,
|
|
183
|
+
flexDirection: 'column',
|
|
184
|
+
justifyContent: 'center',
|
|
157
185
|
}}
|
|
158
186
|
>
|
|
159
|
-
|
|
160
|
-
</h1>
|
|
161
|
-
|
|
162
|
-
{description && (
|
|
163
|
-
<p
|
|
187
|
+
<h1
|
|
164
188
|
style={{
|
|
165
|
-
fontSize:
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
lineHeight: 1.
|
|
169
|
-
|
|
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
|
-
{
|
|
173
|
-
</
|
|
174
|
-
)}
|
|
175
|
-
</div>
|
|
197
|
+
{title}
|
|
198
|
+
</h1>
|
|
176
199
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
//
|
|
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
|
-
|
|
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={
|
|
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
|
-
|
|
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
|