jamdesk 1.0.13 → 1.0.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 (132) hide show
  1. package/README.md +89 -4
  2. package/dist/__tests__/unit/auth.test.d.ts +2 -0
  3. package/dist/__tests__/unit/auth.test.d.ts.map +1 -0
  4. package/dist/__tests__/unit/auth.test.js +200 -0
  5. package/dist/__tests__/unit/auth.test.js.map +1 -0
  6. package/dist/__tests__/unit/config.test.d.ts +2 -0
  7. package/dist/__tests__/unit/config.test.d.ts.map +1 -0
  8. package/dist/__tests__/unit/config.test.js +76 -0
  9. package/dist/__tests__/unit/config.test.js.map +1 -0
  10. package/dist/__tests__/unit/deploy.test.d.ts +2 -0
  11. package/dist/__tests__/unit/deploy.test.d.ts.map +1 -0
  12. package/dist/__tests__/unit/deploy.test.js +273 -0
  13. package/dist/__tests__/unit/deploy.test.js.map +1 -0
  14. package/dist/__tests__/unit/deps-sync.test.js +3 -1
  15. package/dist/__tests__/unit/deps-sync.test.js.map +1 -1
  16. package/dist/__tests__/unit/dev-loading-server.test.d.ts +2 -0
  17. package/dist/__tests__/unit/dev-loading-server.test.d.ts.map +1 -0
  18. package/dist/__tests__/unit/dev-loading-server.test.js +141 -0
  19. package/dist/__tests__/unit/dev-loading-server.test.js.map +1 -0
  20. package/dist/__tests__/unit/docs-json-writer.test.d.ts +2 -0
  21. package/dist/__tests__/unit/docs-json-writer.test.d.ts.map +1 -0
  22. package/dist/__tests__/unit/docs-json-writer.test.js +71 -0
  23. package/dist/__tests__/unit/docs-json-writer.test.js.map +1 -0
  24. package/dist/__tests__/unit/loading-page.test.d.ts +2 -0
  25. package/dist/__tests__/unit/loading-page.test.d.ts.map +1 -0
  26. package/dist/__tests__/unit/loading-page.test.js +73 -0
  27. package/dist/__tests__/unit/loading-page.test.js.map +1 -0
  28. package/dist/__tests__/unit/login.test.d.ts +2 -0
  29. package/dist/__tests__/unit/login.test.d.ts.map +1 -0
  30. package/dist/__tests__/unit/login.test.js +125 -0
  31. package/dist/__tests__/unit/login.test.js.map +1 -0
  32. package/dist/__tests__/unit/logout.test.d.ts +2 -0
  33. package/dist/__tests__/unit/logout.test.d.ts.map +1 -0
  34. package/dist/__tests__/unit/logout.test.js +39 -0
  35. package/dist/__tests__/unit/logout.test.js.map +1 -0
  36. package/dist/__tests__/unit/tarball.test.d.ts +2 -0
  37. package/dist/__tests__/unit/tarball.test.d.ts.map +1 -0
  38. package/dist/__tests__/unit/tarball.test.js +126 -0
  39. package/dist/__tests__/unit/tarball.test.js.map +1 -0
  40. package/dist/__tests__/unit/whoami.test.d.ts +2 -0
  41. package/dist/__tests__/unit/whoami.test.d.ts.map +1 -0
  42. package/dist/__tests__/unit/whoami.test.js +47 -0
  43. package/dist/__tests__/unit/whoami.test.js.map +1 -0
  44. package/dist/commands/deploy.d.ts +13 -0
  45. package/dist/commands/deploy.d.ts.map +1 -0
  46. package/dist/commands/deploy.js +265 -0
  47. package/dist/commands/deploy.js.map +1 -0
  48. package/dist/commands/dev.d.ts.map +1 -1
  49. package/dist/commands/dev.js +48 -25
  50. package/dist/commands/dev.js.map +1 -1
  51. package/dist/commands/login.d.ts +8 -0
  52. package/dist/commands/login.d.ts.map +1 -0
  53. package/dist/commands/login.js +136 -0
  54. package/dist/commands/login.js.map +1 -0
  55. package/dist/commands/logout.d.ts +5 -0
  56. package/dist/commands/logout.d.ts.map +1 -0
  57. package/dist/commands/logout.js +17 -0
  58. package/dist/commands/logout.js.map +1 -0
  59. package/dist/commands/whoami.d.ts +5 -0
  60. package/dist/commands/whoami.d.ts.map +1 -0
  61. package/dist/commands/whoami.js +24 -0
  62. package/dist/commands/whoami.js.map +1 -0
  63. package/dist/index.js +50 -7
  64. package/dist/index.js.map +1 -1
  65. package/dist/lib/auth.d.ts +33 -0
  66. package/dist/lib/auth.d.ts.map +1 -0
  67. package/dist/lib/auth.js +106 -0
  68. package/dist/lib/auth.js.map +1 -0
  69. package/dist/lib/config.d.ts +10 -0
  70. package/dist/lib/config.d.ts.map +1 -1
  71. package/dist/lib/config.js +7 -1
  72. package/dist/lib/config.js.map +1 -1
  73. package/dist/lib/dev-loading-server.d.ts +22 -0
  74. package/dist/lib/dev-loading-server.d.ts.map +1 -0
  75. package/dist/lib/dev-loading-server.js +117 -0
  76. package/dist/lib/dev-loading-server.js.map +1 -0
  77. package/dist/lib/docs-config.d.ts +1 -0
  78. package/dist/lib/docs-config.d.ts.map +1 -1
  79. package/dist/lib/docs-config.js.map +1 -1
  80. package/dist/lib/docs-json-writer.d.ts +2 -0
  81. package/dist/lib/docs-json-writer.d.ts.map +1 -0
  82. package/dist/lib/docs-json-writer.js +35 -0
  83. package/dist/lib/docs-json-writer.js.map +1 -0
  84. package/dist/lib/loading-page.d.ts +11 -0
  85. package/dist/lib/loading-page.d.ts.map +1 -0
  86. package/dist/lib/loading-page.js +222 -0
  87. package/dist/lib/loading-page.js.map +1 -0
  88. package/dist/lib/output.d.ts +13 -5
  89. package/dist/lib/output.d.ts.map +1 -1
  90. package/dist/lib/output.js +22 -5
  91. package/dist/lib/output.js.map +1 -1
  92. package/dist/lib/tarball.d.ts +28 -0
  93. package/dist/lib/tarball.d.ts.map +1 -0
  94. package/dist/lib/tarball.js +117 -0
  95. package/dist/lib/tarball.js.map +1 -0
  96. package/package.json +5 -2
  97. package/vendored/app/[[...slug]]/page.tsx +6 -20
  98. package/vendored/app/api/chat/[project]/route.ts +323 -0
  99. package/vendored/app/api/mcp/[project]/route.ts +2 -63
  100. package/vendored/components/chat/ChatCodeBlock.tsx +63 -0
  101. package/vendored/components/chat/ChatEmptyState.tsx +79 -0
  102. package/vendored/components/chat/ChatFAB.tsx +36 -0
  103. package/vendored/components/chat/ChatInput.tsx +106 -0
  104. package/vendored/components/chat/ChatMessage.tsx +176 -0
  105. package/vendored/components/chat/ChatPanel.tsx +206 -0
  106. package/vendored/components/chat/ChatResizeHandle.tsx +108 -0
  107. package/vendored/components/chat/LazyChatPanel.tsx +19 -0
  108. package/vendored/components/layout/LayoutWrapper.tsx +134 -44
  109. package/vendored/components/layout/PageColumns.tsx +40 -0
  110. package/vendored/components/navigation/Header.tsx +74 -29
  111. package/vendored/components/navigation/Sidebar.tsx +17 -2
  112. package/vendored/hooks/useChat.ts +335 -0
  113. package/vendored/hooks/useChatPanel.tsx +101 -0
  114. package/vendored/lib/anthropic-client.ts +19 -0
  115. package/vendored/lib/build/extract-tarball.ts +150 -0
  116. package/vendored/lib/chat-prompt.ts +56 -0
  117. package/vendored/lib/docs-types.ts +14 -0
  118. package/vendored/lib/docs.ts +22 -4
  119. package/vendored/lib/embedding-chunker.ts +173 -0
  120. package/vendored/lib/generate-starter-questions.ts +98 -0
  121. package/vendored/lib/isr-build-executor.ts +2 -1
  122. package/vendored/lib/middleware-helpers.ts +21 -0
  123. package/vendored/lib/route-helpers.ts +96 -0
  124. package/vendored/lib/snippet-loader-isr.ts +107 -1
  125. package/vendored/lib/static-artifacts.ts +3 -2
  126. package/vendored/lib/validate-config.ts +1 -0
  127. package/vendored/lib/vector-store.ts +213 -0
  128. package/vendored/schema/docs-schema.json +33 -0
  129. package/vendored/scripts/dev-project.cjs +6 -0
  130. package/vendored/shared/types.ts +6 -5
  131. package/vendored/tailwind.config.ts +9 -0
  132. package/vendored/themes/jam/variables.css +2 -2
@@ -0,0 +1,79 @@
1
+ 'use client';
2
+
3
+ import { memo, useMemo, useState, useCallback } from 'react';
4
+
5
+ interface ChatEmptyStateProps {
6
+ starterQuestions?: string[];
7
+ onSelectQuestion: (question: string) => void;
8
+ }
9
+
10
+ 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)]';
11
+
12
+ function starterButtonClass(question: string, selectedQuestion: string | null): string {
13
+ if (selectedQuestion === question) {
14
+ return `${STARTER_BASE} border-[var(--color-accent)] bg-[var(--color-accent)]/10 text-[var(--color-text-primary)]`;
15
+ }
16
+ if (selectedQuestion !== null) {
17
+ return `${STARTER_BASE} border-[var(--color-border)] bg-[var(--color-bg-secondary)] text-[var(--color-text-muted)] opacity-50 cursor-default pointer-events-none`;
18
+ }
19
+ return `${STARTER_BASE} border-[var(--color-border)] bg-[var(--color-bg-secondary)] text-[var(--color-text-primary)] hover:border-[var(--color-accent)] hover:bg-[var(--color-bg-primary)] cursor-pointer`;
20
+ }
21
+
22
+ /**
23
+ * Empty state shown when the chat has no messages.
24
+ * Displays a title, subtitle, and optional starter question cards.
25
+ * Starter questions are auto-generated by Haiku during builds when not
26
+ * defined in docs.json — see lib/generate-starter-questions.ts.
27
+ */
28
+ export const ChatEmptyState = memo(function ChatEmptyState({ starterQuestions, onSelectQuestion }: ChatEmptyStateProps) {
29
+ const shortcutKey = useMemo(
30
+ () => typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.userAgent) ? '⌘' : 'Ctrl+',
31
+ [],
32
+ );
33
+
34
+ const [selectedQuestion, setSelectedQuestion] = useState<string | null>(null);
35
+
36
+ const handleSelect = useCallback((question: string) => {
37
+ setSelectedQuestion(question);
38
+ // Brief highlight before sending — gives visual feedback that tap registered
39
+ setTimeout(() => {
40
+ onSelectQuestion(question);
41
+ }, 250);
42
+ }, [onSelectQuestion]);
43
+
44
+ return (
45
+ <div className="flex flex-col items-center justify-center h-full px-6 py-12 text-center">
46
+ <div className="mb-6">
47
+ <i
48
+ className="fa-solid fa-sparkles text-2xl text-[var(--color-accent)]"
49
+ aria-hidden="true"
50
+ />
51
+ </div>
52
+ <h2 className="text-lg font-semibold text-[var(--color-text-primary)] mb-1">
53
+ Ask AI
54
+ </h2>
55
+ <p className="text-sm text-[var(--color-text-secondary)] mb-6 sm:mb-1">
56
+ Get answers from the documentation
57
+ </p>
58
+ <p className="hidden sm:block text-xs text-[var(--color-text-muted)] mb-6">
59
+ <kbd className="px-1.5 py-0.5 rounded border border-[var(--color-border)] bg-[var(--color-bg-secondary)] text-[10px] font-mono">{shortcutKey}I</kbd>
60
+ {' '}to toggle
61
+ </p>
62
+
63
+ {starterQuestions && starterQuestions.length > 0 && (
64
+ <div className="w-full max-w-sm space-y-2">
65
+ {starterQuestions.map((question) => (
66
+ <button
67
+ key={question}
68
+ onClick={() => !selectedQuestion && handleSelect(question)}
69
+ aria-disabled={selectedQuestion !== null}
70
+ className={starterButtonClass(question, selectedQuestion)}
71
+ >
72
+ {question}
73
+ </button>
74
+ ))}
75
+ </div>
76
+ )}
77
+ </div>
78
+ );
79
+ });
@@ -0,0 +1,36 @@
1
+ 'use client';
2
+
3
+ interface ChatFABProps {
4
+ isOpen: boolean;
5
+ onClick: () => void;
6
+ }
7
+
8
+ /**
9
+ * Floating action button that opens the AI chat panel.
10
+ * Pill-shaped with accent color, slides down/fades when chat panel is open.
11
+ * Positioned above common third-party chat widgets (Crisp, Intercom) which
12
+ * sit at bottom-right with z-index ~1,000,000+. We use z-[1000003] to ensure
13
+ * clickability and bottom-20 to avoid visual overlap.
14
+ */
15
+ export function ChatFAB({ isOpen, onClick }: ChatFABProps) {
16
+ return (
17
+ <button
18
+ onClick={onClick}
19
+ className={`
20
+ fixed bottom-20 right-6 z-[1000003]
21
+ flex items-center gap-2 px-4 py-2.5
22
+ bg-[var(--color-accent)] text-white
23
+ rounded-full shadow-lg
24
+ transition-all duration-200
25
+ hover:shadow-xl hover:scale-105 cursor-pointer
26
+ focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-accent)]
27
+ ${isOpen ? 'translate-y-16 opacity-0 pointer-events-none' : 'translate-y-0 opacity-100'}
28
+ `}
29
+ aria-label="Open AI chat"
30
+ title="Ask AI"
31
+ >
32
+ <i className="fa-solid fa-comment text-sm" aria-hidden="true" />
33
+ <span className="text-sm font-medium">Ask AI</span>
34
+ </button>
35
+ );
36
+ }
@@ -0,0 +1,106 @@
1
+ 'use client';
2
+
3
+ import { memo, useState, useRef, useCallback, useEffect, type KeyboardEvent } from 'react';
4
+
5
+ interface ChatInputProps {
6
+ onSend: (text: string) => void;
7
+ onAbort: () => void;
8
+ isLoading: boolean;
9
+ disabled?: boolean;
10
+ }
11
+
12
+ const MAX_LENGTH = 2000;
13
+
14
+ const ACTION_BUTTON = 'flex-shrink-0 w-9 h-9 flex items-center justify-center rounded-xl transition-colors cursor-pointer focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-accent)]';
15
+
16
+ /**
17
+ * Auto-growing textarea input for the chat panel.
18
+ * Enter sends the message, Shift+Enter inserts a newline.
19
+ */
20
+ export const ChatInput = memo(function ChatInput({ onSend, onAbort, isLoading, disabled }: ChatInputProps) {
21
+ const [value, setValue] = useState('');
22
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
23
+
24
+ // Auto-resize the textarea to fit content
25
+ const adjustHeight = useCallback(() => {
26
+ const textarea = textareaRef.current;
27
+ if (!textarea) return;
28
+ textarea.style.height = 'auto';
29
+ // Clamp to ~6 lines max
30
+ textarea.style.height = `${Math.min(textarea.scrollHeight, 150)}px`;
31
+ }, []);
32
+
33
+ useEffect(() => {
34
+ adjustHeight();
35
+ }, [value, adjustHeight]);
36
+
37
+ const handleSend = useCallback(() => {
38
+ const trimmed = value.trim();
39
+ if (!trimmed || isLoading || disabled) return;
40
+ onSend(trimmed);
41
+ setValue('');
42
+ // Reset textarea height after clearing
43
+ requestAnimationFrame(() => {
44
+ if (textareaRef.current) {
45
+ textareaRef.current.style.height = 'auto';
46
+ }
47
+ });
48
+ }, [value, isLoading, disabled, onSend]);
49
+
50
+ const handleKeyDown = useCallback(
51
+ (e: KeyboardEvent<HTMLTextAreaElement>) => {
52
+ if (e.key === 'Enter' && !e.shiftKey) {
53
+ e.preventDefault();
54
+ handleSend();
55
+ }
56
+ },
57
+ [handleSend],
58
+ );
59
+
60
+ const isOverLimit = value.length > MAX_LENGTH;
61
+ const canSend = value.trim().length > 0 && !isOverLimit && !disabled;
62
+
63
+ return (
64
+ <div className="p-3">
65
+ <div className="flex items-end gap-2 border border-[var(--color-border)] rounded-lg px-3 py-1">
66
+ <textarea
67
+ ref={textareaRef}
68
+ value={value}
69
+ onChange={(e) => setValue(e.target.value)}
70
+ onKeyDown={handleKeyDown}
71
+ placeholder="Ask a question…"
72
+ disabled={disabled}
73
+ rows={1}
74
+ className="flex-1 resize-none bg-transparent text-sm text-[var(--color-text-primary)] placeholder-[var(--color-text-muted)] outline-none py-2 min-h-[36px]"
75
+ aria-label="Chat message"
76
+ maxLength={MAX_LENGTH + 100} // Allow slight overshoot for UX, enforce on send
77
+ />
78
+ {isLoading ? (
79
+ <button
80
+ onClick={onAbort}
81
+ className={`${ACTION_BUTTON} bg-[var(--color-bg-secondary)] text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-tertiary)]`}
82
+ aria-label="Stop generating"
83
+ title="Stop generating"
84
+ >
85
+ <i className="fa-solid fa-stop text-xs" aria-hidden="true" />
86
+ </button>
87
+ ) : (
88
+ <button
89
+ onClick={handleSend}
90
+ disabled={!canSend}
91
+ className={`${ACTION_BUTTON} bg-[var(--color-accent)] text-white disabled:opacity-40 disabled:cursor-not-allowed hover:opacity-90`}
92
+ aria-label="Send message"
93
+ title="Send message"
94
+ >
95
+ <i className="fa-solid fa-paper-plane text-xs" aria-hidden="true" />
96
+ </button>
97
+ )}
98
+ </div>
99
+ {isOverLimit && (
100
+ <p className="text-xs text-red-500 mt-1 px-1">
101
+ Message is too long ({value.length}/{MAX_LENGTH})
102
+ </p>
103
+ )}
104
+ </div>
105
+ );
106
+ });
@@ -0,0 +1,176 @@
1
+ 'use client';
2
+
3
+ import { memo } from 'react';
4
+ import ReactMarkdown from 'react-markdown';
5
+ import remarkGfm from 'remark-gfm';
6
+ import { ChatCodeBlock } from './ChatCodeBlock';
7
+ import { useLinkPrefix } from '@/lib/link-prefix-context';
8
+ import type { ChatMessage as ChatMessageType } from '@/hooks/useChat';
9
+
10
+ /** Convert heading text to a URL-friendly slug (same as heading-extractor.ts). */
11
+ function slugify(text: string): string {
12
+ return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
13
+ }
14
+
15
+ interface ChatMessageProps {
16
+ message: ChatMessageType;
17
+ onSelectOption?: (option: string, messageId: string) => void;
18
+ }
19
+
20
+ /**
21
+ * Strip the trailing option list from content when clarification buttons will
22
+ * replace it. Handles numbered (1. 2. 3.) and unnumbered (plain lines) formats.
23
+ */
24
+ function stripOptionList(content: string): string {
25
+ // Numbered list: \n\n1. ...\n2. ...\n3. ...
26
+ const numbered = content.replace(/\n\n1\.\s+.+\n2\.\s+.+(?:\n3\.\s+.+)?\s*$/, '');
27
+ if (numbered !== content) return numbered.trimEnd();
28
+
29
+ // Unnumbered list: \n\nLine\nLine\nLine
30
+ const unnumbered = content.replace(/\n\n[^\n]{2,80}\n[^\n]{2,80}(?:\n[^\n]{2,80})?\s*$/, '');
31
+ if (unnumbered !== content) return unnumbered.trimEnd();
32
+
33
+ return content;
34
+ }
35
+
36
+ /**
37
+ * Renders a single chat message (user or assistant).
38
+ * Memoized to prevent re-rendering previous messages when only the
39
+ * streaming message changes — each SSE chunk updates a single message,
40
+ * but without memo every message in the list would re-render.
41
+ */
42
+ export const ChatMessage = memo(function ChatMessage({ message, onSelectOption }: ChatMessageProps) {
43
+ const linkPrefix = useLinkPrefix();
44
+ const isUser = message.role === 'user';
45
+ const hasClarificationOptions = !isUser && !!message.clarificationOptions?.length && !message.isStreaming;
46
+ const clarificationDisabled = !!message.clarificationSelected;
47
+
48
+ // When clarification buttons are shown, strip the option list text so
49
+ // buttons replace it instead of duplicating it below
50
+ const displayContent = hasClarificationOptions
51
+ ? stripOptionList(message.content)
52
+ : message.content;
53
+
54
+ return (
55
+ <div className={`px-4 py-3 ${isUser ? 'flex flex-col items-end' : 'animate-fd-fade-in'}`}>
56
+ {isUser ? (
57
+ /* User message — right-aligned accent bubble, label outside */
58
+ <>
59
+ <div className="text-xs font-medium text-[var(--color-text-muted)] mb-1 mr-1">You</div>
60
+ <div className="ml-auto max-w-[85%] bg-[var(--color-accent)]/10 rounded-xl px-3 py-2">
61
+ <p className="text-sm text-[var(--color-text-primary)] leading-relaxed whitespace-pre-wrap">{message.content}</p>
62
+ </div>
63
+ </>
64
+ ) : (
65
+ <>
66
+ {/* Role label */}
67
+ <div className="text-xs font-medium text-[var(--color-text-muted)] mb-1">
68
+ AI
69
+ </div>
70
+
71
+ {/* Message content */}
72
+ <div className="text-sm text-[var(--color-text-primary)] leading-relaxed max-w-none [&_p]:my-1.5 [&_ul]:my-1.5 [&_ol]:my-1.5 [&_li]:my-0.5 [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:list-decimal [&_ol]:pl-5 [&_h1]:text-base [&_h1]:font-semibold [&_h1]:mt-3 [&_h1]:mb-1.5 [&_h2]:text-sm [&_h2]:font-semibold [&_h2]:mt-3 [&_h2]:mb-1.5 [&_h3]:text-sm [&_h3]:font-medium [&_h3]:mt-2 [&_h3]:mb-1 [&_a]:text-[var(--color-accent)] [&_a]:underline [&_blockquote]:border-l-2 [&_blockquote]:border-[var(--color-border)] [&_blockquote]:pl-3 [&_blockquote]:text-[var(--color-text-secondary)] [&_hr]:border-[var(--color-border)] [&_table]:w-full [&_table]:my-2 [&_th]:text-left [&_th]:py-1 [&_th]:px-2 [&_th]:text-xs [&_th]:font-semibold [&_th]:border-b [&_th]:border-[var(--color-border)] [&_td]:py-1 [&_td]:px-2 [&_td]:text-xs [&_td]:border-b [&_td]:border-[var(--color-border)]">
73
+
74
+ {/* Thinking dots — shown while waiting for first text chunk */}
75
+ {message.isStreaming && !message.content && (
76
+ <div className="flex gap-1.5 py-1" aria-label="AI is thinking">
77
+ {[0, 150, 300].map((delay) => (
78
+ <span
79
+ key={delay}
80
+ className="w-1.5 h-1.5 rounded-full bg-[var(--color-text-muted)] animate-bounce"
81
+ style={{ animationDelay: `${delay}ms` }}
82
+ />
83
+ ))}
84
+ </div>
85
+ )}
86
+
87
+ {/* Rendered markdown with streaming cursor */}
88
+ {(message.content || !message.isStreaming) && (
89
+ <>
90
+ <ReactMarkdown
91
+ remarkPlugins={[remarkGfm]}
92
+ components={{
93
+ code: ({ children, className }) => {
94
+ if (!className) {
95
+ return (
96
+ <code className="px-1 py-0.5 rounded bg-[var(--color-bg-secondary)] text-[0.9em]">
97
+ {children}
98
+ </code>
99
+ );
100
+ }
101
+ return (
102
+ <ChatCodeBlock className={className}>
103
+ {String(children)}
104
+ </ChatCodeBlock>
105
+ );
106
+ },
107
+ a: ({ href, children }) => {
108
+ const isExternal = href?.startsWith('http');
109
+ return (
110
+ <a
111
+ href={href}
112
+ {...(isExternal ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
113
+ >
114
+ {children}
115
+ </a>
116
+ );
117
+ },
118
+ }}
119
+ >
120
+ {displayContent}
121
+ </ReactMarkdown>
122
+ {message.isStreaming && (
123
+ <span className="inline-block w-1.5 h-4 bg-[var(--color-accent)] rounded-sm animate-pulse ml-0.5 align-text-bottom" />
124
+ )}
125
+ </>
126
+ )}
127
+ </div>
128
+
129
+ {/* Clarification options — tappable buttons for disambiguation */}
130
+ {hasClarificationOptions && (
131
+ <div className="mt-3">
132
+ <div className="flex flex-col gap-2" role="group" aria-label="Clarification options">
133
+ {message.clarificationOptions!.map((option) => (
134
+ <button
135
+ key={option}
136
+ onClick={() => onSelectOption?.(option, message.id)}
137
+ disabled={clarificationDisabled}
138
+ className={`px-3 py-1.5 rounded-md border text-sm text-left transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-accent)] ${
139
+ clarificationDisabled
140
+ ? 'border-[var(--color-border)] bg-[var(--color-bg-secondary)] text-[var(--color-text-muted)] cursor-default opacity-50'
141
+ : 'border-[var(--color-border)] bg-[var(--color-bg-secondary)] text-[var(--color-text-primary)] hover:border-[var(--color-accent)] hover:text-[var(--color-accent)] cursor-pointer'
142
+ }`}
143
+ >
144
+ {option}
145
+ </button>
146
+ ))}
147
+ </div>
148
+ {!clarificationDisabled && (
149
+ <p className="mt-3 text-sm text-[var(--color-text-primary)]">Select one to continue...</p>
150
+ )}
151
+ </div>
152
+ )}
153
+
154
+ {/* Citations */}
155
+ {message.citations && message.citations.length > 0 && (
156
+ <div className="mt-4 pt-4 border-t border-[var(--color-border)]">
157
+ <p className="text-[11px] font-medium text-[var(--color-text-muted)] mb-1.5">Related docs</p>
158
+ <div className="flex flex-wrap gap-1.5">
159
+ {message.citations.map((citation) => (
160
+ <a
161
+ key={citation.slug}
162
+ href={`${linkPrefix}/${citation.slug}${citation.section ? `#${slugify(citation.section)}` : ''}`}
163
+ className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg border border-[var(--color-border)] bg-[var(--color-bg-secondary)] text-xs text-[var(--color-text-secondary)] hover:border-[var(--color-accent)] hover:text-[var(--color-accent)] transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-accent)]"
164
+ >
165
+ <i className="fa-solid fa-file-lines text-[10px]" aria-hidden="true" />
166
+ {citation.title}
167
+ </a>
168
+ ))}
169
+ </div>
170
+ </div>
171
+ )}
172
+ </>
173
+ )}
174
+ </div>
175
+ );
176
+ });
@@ -0,0 +1,206 @@
1
+ 'use client';
2
+
3
+ import { useRef, useEffect, useCallback } from 'react';
4
+ import { useChat } from '@/hooks/useChat';
5
+ import { ChatMessage } from './ChatMessage';
6
+ import { ChatInput } from './ChatInput';
7
+ import { ChatEmptyState } from './ChatEmptyState';
8
+
9
+ const SOMETHING_ELSE_PATTERNS = ['something else', 'none of the above', 'none of these'];
10
+
11
+ /**
12
+ * Rewrite generic "something else" / "none of the above" options to a more
13
+ * descriptive message so Claude gets useful follow-up context.
14
+ */
15
+ export function rewriteOptionText(option: string): string {
16
+ const isGeneric = SOMETHING_ELSE_PATTERNS.some(p => option.toLowerCase().includes(p));
17
+ return isGeneric
18
+ ? "I'm looking for something different than those options"
19
+ : option;
20
+ }
21
+
22
+ interface ChatPanelProps {
23
+ isOpen: boolean;
24
+ onClose: () => void;
25
+ starterQuestions?: string[];
26
+ chatEndpoint?: string;
27
+ /** 'inline' renders as a persistent column (no fixed positioning).
28
+ * 'overlay' (default) renders as a fixed slide-in panel with backdrop. */
29
+ mode?: 'inline' | 'overlay';
30
+ }
31
+
32
+ /**
33
+ * Chat panel for AI-powered documentation Q&A.
34
+ *
35
+ * mode="overlay" (default): Fixed slide-in panel with backdrop blur.
36
+ * mode="inline": Persistent column embedded in page layout (no positioning).
37
+ */
38
+ export function ChatPanel({ isOpen, onClose, starterQuestions, chatEndpoint, mode = 'overlay' }: ChatPanelProps) {
39
+ const { messages, sendMessage, isLoading, abort, retry, clearChat, error, markClarificationSelected } = useChat(chatEndpoint);
40
+ const messagesEndRef = useRef<HTMLDivElement>(null);
41
+ const inputRef = useRef<HTMLDivElement>(null);
42
+
43
+ // Auto-scroll to bottom when new messages arrive — debounced to avoid
44
+ // scroll thrashing during SSE streaming (messages changes per animation frame)
45
+ useEffect(() => {
46
+ const id = requestAnimationFrame(() => {
47
+ messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
48
+ });
49
+ return () => cancelAnimationFrame(id);
50
+ }, [messages]);
51
+
52
+ // Escape key handling is in useChatPanel (capture phase, with search-modal awareness)
53
+
54
+ // Focus the input when panel opens
55
+ useEffect(() => {
56
+ if (isOpen) {
57
+ // Small delay to allow animation to start
58
+ const timer = setTimeout(() => {
59
+ const textarea = inputRef.current?.querySelector('textarea');
60
+ textarea?.focus();
61
+ }, 100);
62
+ return () => clearTimeout(timer);
63
+ }
64
+ }, [isOpen]);
65
+
66
+ const handleSelectOption = useCallback((option: string, messageId: string) => {
67
+ markClarificationSelected(messageId);
68
+ sendMessage(rewriteOptionText(option));
69
+ }, [markClarificationSelected, sendMessage]);
70
+
71
+ const hasMessages = messages.length > 0;
72
+ const isInline = mode === 'inline';
73
+
74
+ // Shared panel content (header, messages, error, input)
75
+ const panelContent = (
76
+ <>
77
+ {/* Header */}
78
+ <div className="flex items-center justify-between h-14 px-4 border-b border-[var(--color-border)] flex-shrink-0" style={{ borderBottomWidth: '1px' }}>
79
+ <div className="flex items-center gap-2">
80
+ <i className="fa-solid fa-sparkles text-sm text-[var(--color-accent)]" aria-hidden="true" />
81
+ <span className="text-sm font-semibold text-[var(--color-text-primary)]">
82
+ AI Chat
83
+ </span>
84
+ </div>
85
+ <div className="flex items-center gap-1">
86
+ {hasMessages && (
87
+ <button
88
+ onClick={clearChat}
89
+ 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)]"
90
+ >
91
+ New chat
92
+ </button>
93
+ )}
94
+ <button
95
+ onClick={onClose}
96
+ className="p-1.5 rounded-lg hover:bg-[var(--color-bg-secondary)] text-[var(--color-text-muted)] hover:text-[var(--color-text-primary)] transition-colors cursor-pointer focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-accent)]"
97
+ aria-label="Close chat"
98
+ >
99
+ <i className="fa-solid fa-xmark text-sm" aria-hidden="true" />
100
+ </button>
101
+ </div>
102
+ </div>
103
+
104
+ {/* Messages area */}
105
+ <div
106
+ className="flex-1 overflow-y-auto min-h-0"
107
+ style={{ overscrollBehavior: 'contain' }}
108
+ aria-live="polite"
109
+ aria-label="Chat messages"
110
+ >
111
+ {hasMessages ? (
112
+ <div className="py-2">
113
+ {messages.map((msg) => (
114
+ <ChatMessage
115
+ key={msg.id}
116
+ message={msg}
117
+ onSelectOption={handleSelectOption}
118
+ />
119
+ ))}
120
+ <div ref={messagesEndRef} />
121
+ </div>
122
+ ) : (
123
+ <ChatEmptyState
124
+ starterQuestions={starterQuestions}
125
+ onSelectQuestion={sendMessage}
126
+ />
127
+ )}
128
+ </div>
129
+
130
+ {/* Error banner with retry */}
131
+ {error && (
132
+ <div className="px-4 py-2 bg-red-500/10 border-t border-red-500/20 flex-shrink-0 flex items-center justify-between gap-3">
133
+ <p className="text-xs text-red-600 dark:text-red-400">
134
+ {error}
135
+ </p>
136
+ <button
137
+ onClick={retry}
138
+ className="text-xs font-medium text-red-600 dark:text-red-400 hover:underline flex-shrink-0 cursor-pointer"
139
+ >
140
+ Try again
141
+ </button>
142
+ </div>
143
+ )}
144
+
145
+ {/* Input */}
146
+ <div ref={inputRef} className="flex-shrink-0">
147
+ <ChatInput
148
+ onSend={sendMessage}
149
+ onAbort={abort}
150
+ isLoading={isLoading}
151
+ />
152
+ </div>
153
+ </>
154
+ );
155
+
156
+ // Inline mode: no fixed positioning, no backdrop, fills parent container
157
+ if (isInline) {
158
+ return (
159
+ <div
160
+ aria-label="AI Chat"
161
+ className="flex flex-col h-full"
162
+ style={{ backgroundColor: 'var(--color-bg-primary)', touchAction: 'manipulation', fontFamily: 'var(--font-primary, inherit)' }}
163
+ >
164
+ {panelContent}
165
+ </div>
166
+ );
167
+ }
168
+
169
+ // Overlay mode: fixed positioning with backdrop blur
170
+ return (
171
+ <>
172
+ {/* Mobile backdrop (<lg) */}
173
+ {isOpen && (
174
+ <div
175
+ className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[54] lg:hidden"
176
+ onClick={onClose}
177
+ aria-hidden="true"
178
+ />
179
+ )}
180
+
181
+ {/* Panel */}
182
+ <div
183
+ role="dialog"
184
+ aria-label="AI Chat"
185
+ aria-hidden={!isOpen}
186
+ aria-modal={isOpen || undefined}
187
+ data-open={isOpen}
188
+ data-chat-panel
189
+ className={`
190
+ fixed z-[55] flex flex-col
191
+ transition-all duration-200
192
+ inset-x-2 top-4 max-h-[80dvh] rounded-2xl shadow-xl border border-[var(--color-border)]
193
+ lg:inset-x-auto lg:top-0 lg:max-h-none lg:rounded-none lg:shadow-none lg:border-0
194
+ lg:right-0 lg:h-dvh lg:w-[400px] 2xl:w-[460px] lg:border-l lg:border-[var(--color-border)]
195
+ ${isOpen
196
+ ? 'translate-y-0 opacity-100 lg:translate-x-0'
197
+ : '-translate-y-4 opacity-0 pointer-events-none lg:translate-y-0 lg:translate-x-full lg:opacity-100'
198
+ }
199
+ `}
200
+ style={{ backgroundColor: 'var(--color-bg-primary)', touchAction: 'manipulation', fontFamily: 'var(--font-primary, inherit)' }}
201
+ >
202
+ {panelContent}
203
+ </div>
204
+ </>
205
+ );
206
+ }