jamdesk 1.1.28 → 1.1.30

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 (35) hide show
  1. package/README.md +3 -1
  2. package/dist/lib/deps.js +3 -3
  3. package/package.json +3 -3
  4. package/vendored/app/[[...slug]]/page.tsx +36 -109
  5. package/vendored/app/api/assets/[...path]/route.ts +2 -2
  6. package/vendored/app/api/chat/[project]/route.ts +206 -138
  7. package/vendored/app/api/isr-health/route.ts +2 -3
  8. package/vendored/app/layout.tsx +2 -0
  9. package/vendored/components/JdReadySentinel.tsx +25 -0
  10. package/vendored/components/chat/ChatEmptyState.tsx +13 -1
  11. package/vendored/components/chat/ChatPanel.tsx +43 -9
  12. package/vendored/components/navigation/Breadcrumb.tsx +2 -2
  13. package/vendored/components/navigation/Header.tsx +23 -17
  14. package/vendored/components/navigation/LanguageSelector.tsx +7 -4
  15. package/vendored/components/navigation/Sidebar.tsx +28 -37
  16. package/vendored/components/navigation/TabsNav.tsx +1 -1
  17. package/vendored/hooks/useChat.ts +113 -60
  18. package/vendored/hooks/useDelayedNavigationSpinner.ts +94 -0
  19. package/vendored/hooks/useTextStreamPacer.ts +152 -0
  20. package/vendored/lib/chat-prompt.ts +69 -29
  21. package/vendored/lib/chat-tools.ts +111 -0
  22. package/vendored/lib/crisp-bridge.ts +91 -0
  23. package/vendored/lib/docs-types.ts +4 -0
  24. package/vendored/lib/embedding-chunker.ts +85 -11
  25. package/vendored/lib/find-first-nav-page.ts +40 -0
  26. package/vendored/lib/hedge-strip.ts +29 -0
  27. package/vendored/lib/middleware-helpers.ts +2 -1
  28. package/vendored/lib/openapi/lang-spec-path.ts +16 -0
  29. package/vendored/lib/page-isr-helpers.ts +4 -1
  30. package/vendored/lib/public-paths-resolver.ts +3 -42
  31. package/vendored/lib/query-rewriter.ts +91 -0
  32. package/vendored/lib/ui-strings.ts +52 -0
  33. package/vendored/lib/vector-store.ts +5 -3
  34. package/vendored/schema/docs-schema.json +15 -0
  35. package/vendored/workspace-package-lock.json +88 -88
@@ -22,11 +22,15 @@
22
22
  */
23
23
  import { NextRequest } from 'next/server';
24
24
  import { querySimilarChunks } from '@/lib/vector-store';
25
- import { buildSystemPrompt, buildCannedIdentityReply, resolveSiteName } from '@/lib/chat-prompt';
25
+ import { buildSystemPrompt, isCannedIdentityReply, resolveSiteName } from '@/lib/chat-prompt';
26
+ import { stripHedge, isHedgeReply } from '@/lib/hedge-strip';
26
27
  import { getDocsPath, getBaseUrl, trackChatAnalytics } from '@/lib/route-helpers';
27
28
  import { getAnthropicClient } from '@/lib/anthropic-client';
29
+ import { rewriteQueryForSearch } from '@/lib/query-rewriter';
28
30
  import { fetchDocsConfig } from '@/lib/r2-content';
29
31
  import { redis } from '@/lib/redis';
32
+ import { CHAT_TOOLS } from '@/lib/chat-tools';
33
+ import { log } from '@/lib/logger';
30
34
 
31
35
  export const runtime = 'nodejs';
32
36
  export const maxDuration = 30;
@@ -35,115 +39,11 @@ const CHAT_MODEL = 'claude-haiku-4-5-20251001';
35
39
  const RATE_LIMIT = 10;
36
40
  const RATE_WINDOW_SECONDS = 60;
37
41
  const MAX_HISTORY = 10;
42
+ /** Messages shorter than this with prior history are treated as follow-ups.
43
+ * Used both for searchQuery enrichment and to skip the rewriter. */
44
+ const SHORT_FOLLOWUP_LEN = 60;
38
45
  const VALID_ROLES = new Set<string>(['user', 'assistant']);
39
46
 
40
- /**
41
- * Extract citations from Claude's response by matching [Title] references
42
- * against the vector search chunks. Deduplicates by page slug.
43
- * Falls back to top 2 unique pages by score if Claude didn't cite anything.
44
- */
45
- type Citation = { title: string; slug: string; section?: string };
46
- type ScoredChunk = { pageSlug: string; pageTitle: string; sectionHeading: string; score: number };
47
-
48
- function extractCitations(
49
- responseText: string,
50
- chunks: ScoredChunk[],
51
- ): { sources: Citation[]; hadExplicitCitations: boolean } {
52
- // Match [Title] but not markdown links [text](url)
53
- const referencedTitles = new Set(
54
- Array.from(responseText.matchAll(/\[([^\]]+)\](?!\()/g), (m) => m[1]),
55
- );
56
-
57
- const seen = new Set<string>();
58
- const sources: Citation[] = [];
59
-
60
- function addUniqueChunk(c: (typeof chunks)[number]): void {
61
- if (seen.has(c.pageSlug)) return;
62
- seen.add(c.pageSlug);
63
- sources.push({ title: c.pageTitle, slug: c.pageSlug, section: c.sectionHeading });
64
- }
65
-
66
- // Filter chunks to only those Claude referenced, deduplicate by slug
67
- for (const c of chunks) {
68
- if (referencedTitles.has(c.pageTitle)) addUniqueChunk(c);
69
- }
70
-
71
- // Record whether Claude explicitly cited sources before the fallback
72
- const hadExplicitCitations = sources.length > 0;
73
-
74
- // Fallback: if Claude didn't cite anything explicitly, show top 2 unique pages by score
75
- if (sources.length === 0) {
76
- for (const c of chunks) {
77
- addUniqueChunk(c);
78
- if (sources.length >= 2) break;
79
- }
80
- }
81
-
82
- return { sources, hadExplicitCitations };
83
- }
84
-
85
- /**
86
- * Detect option lists in Claude's response that indicate a clarification question.
87
- * Returns extracted option strings, or null if the response is a normal answer.
88
- *
89
- * Heuristic guards to avoid false positives on instructional lists:
90
- * - Response must contain a question mark or colon (clarification Qs ask something)
91
- * - Response must be short (< 500 chars — clarification Qs are brief)
92
- * - Must be 2-3 items (instructional lists tend to be longer)
93
- * - List must be at the END of the response
94
- *
95
- * Supports numbered (1. 2. 3.) and unnumbered (plain lines) formats.
96
- * Strips markdown formatting (bold, backticks) from option text.
97
- */
98
- export function extractClarificationOptions(responseText: string): string[] | null {
99
- // Trim trailing whitespace/newlines so the $ anchor in option-list regexes
100
- // matches reliably — SSE text deltas can include trailing newlines.
101
- const text = responseText.trimEnd();
102
-
103
- // Must indicate a question. A `?` always qualifies. A `:` only qualifies when
104
- // the preamble contains clarification words (to avoid false positives on
105
- // instructional lists like "To enable dark mode:\n\n1. Open Settings").
106
- const hasQuestion = text.includes('?');
107
- const hasClarificationColon = !hasQuestion
108
- && text.includes(':')
109
- && /\b(which|what|clarify|interested|looking for|asking|type of|kind of)\b/i.test(text);
110
- if (!hasQuestion && !hasClarificationColon) return null;
111
-
112
- // Only short responses are likely clarification questions
113
- if (text.length > 500) return null;
114
-
115
- // Strip markdown formatting (bold, italic, backticks) from option labels
116
- function cleanOption(text: string): string {
117
- return text.trim().replace(/[*`]/g, '');
118
- }
119
-
120
- // Primary: numbered list (1. 2. 3.) at the end
121
- const numbered = text.match(
122
- /\n\n1\.\s+(.+)\n2\.\s+(.+)(?:\n3\.\s+(.+))?$/,
123
- );
124
- if (numbered) {
125
- const options = [cleanOption(numbered[1]), cleanOption(numbered[2])];
126
- if (numbered[3]) options.push(cleanOption(numbered[3]));
127
- return options;
128
- }
129
-
130
- // Fallback: unnumbered list (2-3 short lines at the end, after a blank line)
131
- // Each line must be short (< 80 chars) and non-empty to distinguish from paragraphs
132
- const unnumbered = text.match(
133
- /\n\n([^\n]{2,80})\n([^\n]{2,80})(?:\n([^\n]{2,80}))?$/,
134
- );
135
- if (unnumbered) {
136
- // Extra guard: lines must NOT look like sentences (no periods at end)
137
- const lines = [unnumbered[1], unnumbered[2], unnumbered[3]].filter(Boolean) as string[];
138
- const looksLikeOptions = lines.every(l => !l.trim().endsWith('.') && l.trim().length < 80);
139
- if (looksLikeOptions && lines.length >= 2) {
140
- return lines.map(cleanOption);
141
- }
142
- }
143
-
144
- return null;
145
- }
146
-
147
47
  export async function POST(
148
48
  request: NextRequest,
149
49
  context: { params: Promise<{ project: string }> },
@@ -197,11 +97,12 @@ export async function POST(
197
97
  })
198
98
  .map((h) => ({ role: h.role, content: h.content.slice(0, 4000) }));
199
99
 
200
- // Enrich short messages with conversation context for better vector search.
201
- // When user picks a clarification option (e.g., "Post Analytics"), the message
202
- // alone is too short for good retrieval. Combine with the preceding question.
100
+ const isShortFollowUp = message.length < SHORT_FOLLOWUP_LEN && history.length > 0;
101
+
102
+ // When a user picks a clarification option (e.g. "Post Analytics"), the
103
+ // message alone is too short for good retrieval — combine with the prior turn.
203
104
  let searchQuery = message;
204
- if (message.length < 60 && history.length > 0) {
105
+ if (isShortFollowUp) {
205
106
  const prevUserMsg = [...history].reverse().find(
206
107
  h => h.role === 'user' && h.content !== message,
207
108
  );
@@ -221,9 +122,39 @@ export async function POST(
221
122
  fetchDocsConfig(project).catch(() => null),
222
123
  ]);
223
124
 
224
- let chunks: Awaited<ReturnType<typeof querySimilarChunks>>;
125
+ // Fully parallel retrieval:
126
+ // - Original vector query runs immediately.
127
+ // - Rewriter + rewritten-query vector search are chained into ONE promise,
128
+ // then Promise.all'd with the original. Critical path is
129
+ // max(original_query_time, rewriter_time + rewritten_query_time), not
130
+ // max(original, rewriter) + rewritten_query_time.
131
+ // Any failure in the rewrite path resolves to an empty chunk list — the
132
+ // original query still returns results so chat doesn't fail.
133
+ const originalQueryPromise = querySimilarChunks(project, searchQuery, 15);
134
+
135
+ // Skipping the rewriter on short follow-ups avoids 500-2000ms of serial
136
+ // Anthropic latency — `searchQuery` above is already enriched with prior-turn
137
+ // context using the same threshold, so the rewrite has no retrieval benefit.
138
+ const rewrittenPathPromise: Promise<Awaited<ReturnType<typeof querySimilarChunks>>> =
139
+ isShortFollowUp
140
+ ? Promise.resolve([])
141
+ : rewriteQueryForSearch(message, history)
142
+ .catch(() => null)
143
+ .then(rewritten => {
144
+ // Compare against `message` (the rewriter's input), not `searchQuery`
145
+ // (which is enriched with prior-turn context for short follow-ups).
146
+ // The rewriter only sees `message`, so a no-op rewrite equals `message`.
147
+ if (!rewritten || rewritten === message) return [];
148
+ return querySimilarChunks(project, rewritten, 15).catch(() => []);
149
+ });
150
+
151
+ let originalChunks: Awaited<ReturnType<typeof querySimilarChunks>>;
152
+ let rewrittenChunks: Awaited<ReturnType<typeof querySimilarChunks>>;
225
153
  try {
226
- chunks = await querySimilarChunks(project, searchQuery, 8);
154
+ [originalChunks, rewrittenChunks] = await Promise.all([
155
+ originalQueryPromise,
156
+ rewrittenPathPromise,
157
+ ]);
227
158
  } catch {
228
159
  return Response.json(
229
160
  { error: 'AI chat is not available for this project.' },
@@ -231,6 +162,21 @@ export async function POST(
231
162
  );
232
163
  }
233
164
 
165
+ // Merge: original chunks first (they match the user's literal phrasing),
166
+ // then unique chunks from the rewritten query. Dedup by pageSlug +
167
+ // sectionHeading — chunks with the same identity are interchangeable across
168
+ // queries.
169
+ const seenKeys = new Set(originalChunks.map(c => `${c.pageSlug}#${c.sectionHeading}`));
170
+ const chunks = [
171
+ ...originalChunks,
172
+ ...rewrittenChunks.filter(c => {
173
+ const key = `${c.pageSlug}#${c.sectionHeading}`;
174
+ if (seenKeys.has(key)) return false;
175
+ seenKeys.add(key);
176
+ return true;
177
+ }),
178
+ ];
179
+
234
180
  if (chunks.length === 0) {
235
181
  return Response.json(
236
182
  { error: 'No documentation content found for this project.' },
@@ -257,6 +203,8 @@ export async function POST(
257
203
  max_tokens: 2048,
258
204
  temperature: 0.3,
259
205
  system: systemPrompt,
206
+ tools: CHAT_TOOLS,
207
+ tool_choice: { type: 'any' },
260
208
  messages: [
261
209
  ...history.slice(-MAX_HISTORY),
262
210
  { role: 'user', content: message },
@@ -268,43 +216,157 @@ export async function POST(
268
216
  return encoder.encode(`data: ${JSON.stringify(data)}\n\n`);
269
217
  }
270
218
 
219
+ type Citation = { title: string; slug: string; section?: string };
220
+ type AnswerInput = { markdown?: string; cited_page_slugs?: string[] };
221
+ type ClarificationInput = { question?: string; options?: string[] };
222
+
223
+ function topChunksAsCitations(limit = 2, skip?: Set<string>): Citation[] {
224
+ const out: Citation[] = [];
225
+ const seen = new Set<string>(skip);
226
+ for (const c of chunks) {
227
+ if (seen.has(c.pageSlug)) continue;
228
+ seen.add(c.pageSlug);
229
+ out.push({ title: c.pageTitle, slug: c.pageSlug, section: c.sectionHeading });
230
+ if (out.length >= limit) break;
231
+ }
232
+ return out;
233
+ }
234
+
271
235
  const readable = new ReadableStream({
272
236
  async start(controller) {
273
- let fullText = '';
237
+ let toolName: string | null = null;
238
+ let emittedMarkdownLength = 0;
239
+ let emittedQuestionLength = 0;
240
+
241
+ // Incrementally extract a still-open string field from partial tool_use JSON.
242
+ // The SDK's jsonSnapshot only exposes a field once its string literal closes,
243
+ // so a ~1000-char answer lands in one snapshot update and the client sees it
244
+ // in one flash. Instead, accumulate the raw partial_json and close the string
245
+ // + object ourselves to read the in-progress value.
246
+ let accumulatedJson = '';
247
+ function extractPartialString(field: 'markdown' | 'question'): string | null {
248
+ // A trailing odd number of backslashes means we're mid-escape. Appending
249
+ // `"}` would turn `\` into `\"` — a valid escaped quote — producing a
250
+ // bogus literal `"` in the extracted string that gets streamed as text.
251
+ const trailing = accumulatedJson.match(/\\+$/)?.[0].length ?? 0;
252
+ if (trailing % 2 !== 0) return null;
253
+ try {
254
+ const parsed = JSON.parse(accumulatedJson + '"}') as Record<string, unknown>;
255
+ const value = parsed[field];
256
+ return typeof value === 'string' ? value : null;
257
+ } catch {
258
+ return null;
259
+ }
260
+ }
261
+
262
+ stream.on('inputJson', (partialJson, jsonSnapshot) => {
263
+ accumulatedJson += typeof partialJson === 'string' ? partialJson : '';
264
+
265
+ // Gate on toolName — emitting before content_block_start resolves
266
+ // would leak clarification JSON into the answer text channel.
267
+ if (toolName === 'answer') {
268
+ const snap = jsonSnapshot as AnswerInput | null | undefined;
269
+ const md = typeof snap?.markdown === 'string'
270
+ ? snap.markdown
271
+ : extractPartialString('markdown');
272
+ if (md !== null) {
273
+ const trimmed = stripHedge(md);
274
+ if (trimmed.length > emittedMarkdownLength) {
275
+ const newContent = trimmed.slice(emittedMarkdownLength);
276
+ emittedMarkdownLength = trimmed.length;
277
+ controller.enqueue(sendEvent({ type: 'text', content: newContent }));
278
+ }
279
+ }
280
+ } else if (toolName === 'ask_clarification') {
281
+ const snap = jsonSnapshot as ClarificationInput | null | undefined;
282
+ const q = typeof snap?.question === 'string'
283
+ ? snap.question
284
+ : extractPartialString('question');
285
+ if (q !== null && q.length > emittedQuestionLength) {
286
+ const newContent = q.slice(emittedQuestionLength);
287
+ emittedQuestionLength = q.length;
288
+ controller.enqueue(sendEvent({ type: 'text', content: newContent }));
289
+ }
290
+ }
291
+ });
274
292
 
275
293
  try {
294
+ // Watch for content_block_start to learn which tool Claude picked.
276
295
  for await (const event of stream) {
277
- if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') {
278
- fullText += event.delta.text;
279
- controller.enqueue(sendEvent({ type: 'text', content: event.delta.text }));
296
+ if (event.type === 'content_block_start' && event.content_block.type === 'tool_use') {
297
+ toolName = event.content_block.name;
280
298
  }
281
299
  }
282
300
 
283
- // After streaming completes, get token usage
284
301
  const finalMessage = await stream.finalMessage();
285
302
  const inputTokens = finalMessage.usage?.input_tokens ?? 0;
286
303
  const outputTokens = finalMessage.usage?.output_tokens ?? 0;
287
304
 
288
- // Extract clarification options (if Claude asked a disambiguation question)
289
- const options = extractClarificationOptions(fullText);
290
- if (options) {
305
+ // With tool_choice: 'any' there is exactly one tool_use block.
306
+ const toolUse = finalMessage.content.find(c => c.type === 'tool_use');
307
+ if (!toolUse || toolUse.type !== 'tool_use') {
308
+ throw new Error('Expected tool_use block in final message');
309
+ }
310
+
311
+ let sources: Citation[];
312
+ let hadExplicitCitations: boolean;
313
+ const hasClarification = toolUse.name === 'ask_clarification';
314
+
315
+ if (toolUse.name === 'answer') {
316
+ const input = toolUse.input as AnswerInput;
317
+ const slugs = Array.isArray(input.cited_page_slugs) ? input.cited_page_slugs : [];
318
+ const rawMarkdown = typeof input.markdown === 'string' ? input.markdown : '';
319
+ const markdownText = stripHedge(rawMarkdown);
320
+ // Suppress citations on identity + hedge replies — the top-2 fallback
321
+ // would otherwise attach unrelated docs under "I'm the documentation
322
+ // assistant…" or under "I don't have information…".
323
+ const suppressCitations =
324
+ isCannedIdentityReply(markdownText, siteName) || isHedgeReply(rawMarkdown);
325
+
326
+ const seen = new Set<string>();
327
+ const explicitSources: Citation[] = [];
328
+ if (!suppressCitations) {
329
+ for (const slug of slugs) {
330
+ const chunk = chunks.find(c => c.pageSlug === slug);
331
+ if (chunk && !seen.has(chunk.pageSlug)) {
332
+ seen.add(chunk.pageSlug);
333
+ explicitSources.push({
334
+ title: chunk.pageTitle,
335
+ slug: chunk.pageSlug,
336
+ section: chunk.sectionHeading,
337
+ });
338
+ }
339
+ }
340
+ }
341
+ hadExplicitCitations = suppressCitations || explicitSources.length > 0;
342
+ sources = suppressCitations
343
+ ? []
344
+ : explicitSources.length > 0
345
+ ? explicitSources
346
+ : topChunksAsCitations(2, seen);
347
+ } else if (toolUse.name === 'ask_clarification') {
348
+ const input = toolUse.input as ClarificationInput;
349
+ const options = Array.isArray(input.options) ? input.options : [];
350
+ const question = typeof input.question === 'string' ? input.question.trim() : '';
351
+ // Missing question → UI shows buttons with no prompt.
352
+ // <2 options → dead-end UI. Interrupt so the user can retry.
353
+ if (!question) {
354
+ throw new Error('ask_clarification returned no question');
355
+ }
356
+ if (options.length < 2) {
357
+ throw new Error(`ask_clarification returned ${options.length} options; minimum is 2`);
358
+ }
291
359
  controller.enqueue(sendEvent({ type: 'clarification', options }));
360
+ // Surface top chunks so users see where the disambiguation candidates come from.
361
+ sources = topChunksAsCitations();
362
+ hadExplicitCitations = false;
363
+ } else {
364
+ throw new Error(`Unexpected tool name: ${toolUse.name}`);
292
365
  }
293
366
 
294
- // Extract [Title] references from Claude's response (skip markdown links [text](url))
295
- const { sources, hadExplicitCitations } = extractCitations(fullText, chunks);
296
-
297
- // If Claude returned the canned identity reply, suppress citations — otherwise
298
- // the "top 2 by score" fallback in extractCitations would attach unrelated
299
- // docs sources under "I'm the documentation assistant for …".
300
- const isIdentityReply = fullText.trim() === buildCannedIdentityReply(siteName);
301
- controller.enqueue(sendEvent({
302
- type: 'citations',
303
- sources: isIdentityReply ? [] : sources,
304
- }));
367
+ controller.enqueue(sendEvent({ type: 'citations', sources }));
305
368
  controller.enqueue(sendEvent({ type: 'done' }));
306
369
 
307
- // Fire-and-forget analytics — only on success (finalMessage unreliable on error)
308
370
  trackChatAnalytics({
309
371
  projectSlug: project,
310
372
  query: message,
@@ -312,12 +374,17 @@ export async function POST(
312
374
  inputTokens,
313
375
  outputTokens,
314
376
  model: CHAT_MODEL,
315
- hadExplicitCitations: isIdentityReply || hadExplicitCitations,
316
- hasClarification: options !== null,
377
+ hadExplicitCitations,
378
+ hasClarification,
317
379
  durationMs: Date.now() - startTime,
318
380
  userAgent: request.headers.get('user-agent') || undefined,
319
381
  }).catch(() => {});
320
- } catch {
382
+ } catch (err) {
383
+ log('warn', 'chat: stream interrupted', {
384
+ projectSlug: project,
385
+ toolName,
386
+ error: err instanceof Error ? err.message : String(err),
387
+ });
321
388
  controller.enqueue(sendEvent({ type: 'error', message: 'The response was interrupted. Please try again.' }));
322
389
  }
323
390
  controller.close();
@@ -327,8 +394,9 @@ export async function POST(
327
394
  return new Response(readable, {
328
395
  headers: {
329
396
  'Content-Type': 'text/event-stream',
330
- 'Cache-Control': 'no-cache',
397
+ 'Cache-Control': 'no-cache, no-transform',
331
398
  'Connection': 'keep-alive',
399
+ 'X-Accel-Buffering': 'no',
332
400
  },
333
401
  });
334
402
  }
@@ -9,6 +9,7 @@ import { NextResponse } from 'next/server';
9
9
  import { getConfigCacheSize } from '@/lib/r2-content';
10
10
  import { getSnippetCacheSize } from '@/lib/snippet-compiler-isr';
11
11
  import { getOpenApiCacheSize } from '@/lib/openapi-isr';
12
+ import { isIsrMode } from '@/lib/page-isr-helpers';
12
13
 
13
14
  interface HealthCheck {
14
15
  status: 'ok' | 'degraded' | 'unhealthy';
@@ -31,8 +32,6 @@ interface HealthCheck {
31
32
  const startTime = Date.now();
32
33
 
33
34
  export async function GET() {
34
- const isIsrMode = process.env.ISR_MODE === 'true';
35
-
36
35
  // Memory usage
37
36
  const memUsage = process.memoryUsage();
38
37
  const memory = {
@@ -54,7 +53,7 @@ export async function GET() {
54
53
  memory,
55
54
  uptime: Math.round((Date.now() - startTime) / 1000),
56
55
  timestamp: new Date().toISOString(),
57
- isrMode: isIsrMode,
56
+ isrMode: isIsrMode(),
58
57
  };
59
58
 
60
59
  return NextResponse.json(health, {
@@ -7,6 +7,7 @@ import { LayoutWrapper } from '@/components/layout/LayoutWrapper';
7
7
  import { CodeBlockCopyButton } from '@/components/CodeBlockCopyButton';
8
8
  import { HeaderLinkCopy } from '@/components/HeaderLinkCopy';
9
9
  import { FontAwesomeLoader } from '@/components/FontAwesomeLoader';
10
+ import { JdReadySentinel } from '@/components/JdReadySentinel';
10
11
  import { FA_CSS_HREF } from '@/lib/font-awesome';
11
12
  import { getDocsConfig } from '@/lib/docs';
12
13
  import { getDocsConfig as getIsrDocsConfig } from '@/lib/docs-isr';
@@ -580,6 +581,7 @@ export default async function RootLayout({
580
581
  {config.integrations?.ga4?.measurementId && (
581
582
  <ConditionalGA gaId={config.integrations.ga4.measurementId} />
582
583
  )}
584
+ <JdReadySentinel />
583
585
  </body>
584
586
  </html>
585
587
  );
@@ -0,0 +1,25 @@
1
+ 'use client';
2
+
3
+ import { useEffect } from 'react';
4
+
5
+ /**
6
+ * Sets document.body.dataset.jdReady = 'true' after window.load so the
7
+ * pdf-service (and any future browser automation) can wait on a reliable
8
+ * signal that the page is fully rendered and resources have settled.
9
+ */
10
+ export function JdReadySentinel(): null {
11
+ useEffect(() => {
12
+ const mark = () => {
13
+ document.body.dataset.jdReady = 'true';
14
+ };
15
+ if (document.readyState === 'complete') {
16
+ mark();
17
+ } else {
18
+ window.addEventListener('load', mark, { once: true });
19
+ }
20
+ // removeEventListener with an unregistered listener is a no-op, so the
21
+ // cleanup is safe whether or not the branch above registered it.
22
+ return () => window.removeEventListener('load', mark);
23
+ }, []);
24
+ return null;
25
+ }
@@ -5,6 +5,9 @@ import { memo, useMemo, useState, useCallback } from 'react';
5
5
  interface ChatEmptyStateProps {
6
6
  starterQuestions?: string[];
7
7
  onSelectQuestion: (question: string) => void;
8
+ /** Escape-hatch to hand the visitor off to Crisp. Omit to hide the button
9
+ * (e.g. when Crisp isn't loaded). Detection happens in ChatPanel. */
10
+ onTalkToHuman?: () => void;
8
11
  }
9
12
 
10
13
  const STARTER_BASE = 'w-full text-left px-4 py-3 rounded-xl border text-sm transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-accent)]';
@@ -25,7 +28,7 @@ function starterButtonClass(question: string, selectedQuestion: string | null):
25
28
  * Starter questions are auto-generated by Haiku during builds when not
26
29
  * defined in docs.json — see lib/generate-starter-questions.ts.
27
30
  */
28
- export const ChatEmptyState = memo(function ChatEmptyState({ starterQuestions, onSelectQuestion }: ChatEmptyStateProps) {
31
+ export const ChatEmptyState = memo(function ChatEmptyState({ starterQuestions, onSelectQuestion, onTalkToHuman }: ChatEmptyStateProps) {
29
32
  const shortcutKey = useMemo(
30
33
  () => typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.userAgent) ? '⌘' : 'Ctrl+',
31
34
  [],
@@ -74,6 +77,15 @@ export const ChatEmptyState = memo(function ChatEmptyState({ starterQuestions, o
74
77
  ))}
75
78
  </div>
76
79
  )}
80
+ {onTalkToHuman && (
81
+ <button
82
+ type="button"
83
+ onClick={onTalkToHuman}
84
+ className="mt-6 text-xs text-[var(--color-text-secondary)] hover:text-[var(--color-accent)] transition-colors cursor-pointer focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-accent)] rounded-md"
85
+ >
86
+ Need to talk to a human? <span className="font-medium text-[var(--color-accent)]">Start a chat →</span>
87
+ </button>
88
+ )}
77
89
  </div>
78
90
  );
79
91
  });
@@ -1,11 +1,12 @@
1
1
  'use client';
2
2
 
3
- import { useRef, useEffect, useLayoutEffect, useCallback } from 'react';
3
+ import { useRef, useEffect, useLayoutEffect, useCallback, useState } from 'react';
4
4
  import { useChat } from '@/hooks/useChat';
5
5
  import { useBodyScrollLock } from '@/hooks/useBodyScrollLock';
6
6
  import { ChatMessage } from './ChatMessage';
7
7
  import { ChatInput } from './ChatInput';
8
8
  import { ChatEmptyState } from './ChatEmptyState';
9
+ import { crispAvailable, hideCrispLauncher, openCrispChat, showCrispLauncher } from '../../lib/crisp-bridge';
9
10
 
10
11
  const SOMETHING_ELSE_PATTERNS = ['something else', 'none of the above', 'none of these'];
11
12
 
@@ -41,6 +42,15 @@ interface ChatPanelProps {
41
42
  */
42
43
  export function ChatPanel({ isOpen, onClose, starterQuestions, chatEndpoint, mode = 'overlay' }: ChatPanelProps) {
43
44
  const { messages, sendMessage, isLoading, abort, retry, clearChat, error, markClarificationSelected } = useChat(chatEndpoint);
45
+
46
+ const [hasCrisp, setHasCrisp] = useState(false);
47
+ useEffect(() => { setHasCrisp(crispAvailable()); }, []);
48
+
49
+ const handleTalkToHuman = useCallback(() => {
50
+ onClose();
51
+ openCrispChat();
52
+ }, [onClose]);
53
+
44
54
  const messagesContainerRef = useRef<HTMLDivElement>(null);
45
55
  const inputRef = useRef<HTMLDivElement>(null);
46
56
  const overlayPanelRef = useRef<HTMLDivElement>(null);
@@ -50,6 +60,17 @@ export function ChatPanel({ isOpen, onClose, starterQuestions, chatEndpoint, mod
50
60
  // Lock body scroll when mobile overlay is open
51
61
  useBodyScrollLock(!isInline && isOpen);
52
62
 
63
+ // Coordinate visibility with Crisp so the two chats don't overlap in the
64
+ // bottom-right corner. Applies to both modes — Crisp's fixed launcher sits
65
+ // behind the inline chat column's send button on desktop too.
66
+ useEffect(() => {
67
+ if (!isOpen) return;
68
+ hideCrispLauncher();
69
+ return () => {
70
+ showCrispLauncher();
71
+ };
72
+ }, [isOpen]);
73
+
53
74
  // On iOS, the virtual keyboard doesn't resize the layout viewport, so the
54
75
  // panel's max-h-[80dvh] can extend behind the keyboard. When the user taps the
55
76
  // textarea, iOS scrolls the page to reveal it — pushing the fixed panel off-screen.
@@ -171,6 +192,7 @@ export function ChatPanel({ isOpen, onClose, starterQuestions, chatEndpoint, mod
171
192
  <ChatEmptyState
172
193
  starterQuestions={starterQuestions}
173
194
  onSelectQuestion={sendMessage}
195
+ onTalkToHuman={hasCrisp ? handleTalkToHuman : undefined}
174
196
  />
175
197
  )}
176
198
  </div>
@@ -181,12 +203,24 @@ export function ChatPanel({ isOpen, onClose, starterQuestions, chatEndpoint, mod
181
203
  <p className="text-xs text-red-600 dark:text-red-400">
182
204
  {error}
183
205
  </p>
184
- <button
185
- onClick={retry}
186
- className="text-xs font-medium text-red-600 dark:text-red-400 hover:underline flex-shrink-0 cursor-pointer"
187
- >
188
- Try again
189
- </button>
206
+ <div className="flex items-center gap-3 flex-shrink-0">
207
+ {hasCrisp && (
208
+ <button
209
+ type="button"
210
+ onClick={handleTalkToHuman}
211
+ className="text-xs font-medium text-red-600 dark:text-red-400 hover:underline cursor-pointer"
212
+ >
213
+ Talk to a human
214
+ </button>
215
+ )}
216
+ <button
217
+ type="button"
218
+ onClick={retry}
219
+ className="text-xs font-medium text-red-600 dark:text-red-400 hover:underline cursor-pointer"
220
+ >
221
+ Try again
222
+ </button>
223
+ </div>
190
224
  </div>
191
225
  )}
192
226
 
@@ -220,7 +254,7 @@ export function ChatPanel({ isOpen, onClose, starterQuestions, chatEndpoint, mod
220
254
  {/* Mobile backdrop (<lg) */}
221
255
  {isOpen && (
222
256
  <div
223
- className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[54] lg:hidden"
257
+ className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[1000003] lg:hidden"
224
258
  onClick={onClose}
225
259
  aria-hidden="true"
226
260
  />
@@ -236,7 +270,7 @@ export function ChatPanel({ isOpen, onClose, starterQuestions, chatEndpoint, mod
236
270
  data-open={isOpen}
237
271
  data-chat-panel
238
272
  className={`
239
- fixed z-[55] flex flex-col overflow-hidden
273
+ fixed z-[1000004] flex flex-col overflow-hidden
240
274
  transition-all duration-200
241
275
  inset-x-2 top-4 max-h-[80dvh] rounded-2xl shadow-xl border border-[var(--color-border)]
242
276
  lg:inset-x-auto lg:top-0 lg:max-h-none lg:rounded-none lg:shadow-none lg:border-0
@@ -267,7 +267,7 @@ export function Breadcrumb({ slug, config }: BreadcrumbProps) {
267
267
  {/* Home link */}
268
268
  <Link
269
269
  href={homeLink}
270
- prefetch={false}
270
+ prefetch={true}
271
271
  className="text-[var(--color-marker)] hover:text-[var(--color-text-primary)] transition-colors flex items-center"
272
272
  aria-label="Home"
273
273
  >
@@ -284,7 +284,7 @@ export function Breadcrumb({ slug, config }: BreadcrumbProps) {
284
284
  ) : item.path ? (
285
285
  <Link
286
286
  href={`${linkPrefix}/${item.path}`}
287
- prefetch={false}
287
+ prefetch={true}
288
288
  className="text-[var(--color-marker)] hover:text-[var(--color-text-primary)] transition-colors"
289
289
  >
290
290
  {item.label}