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.
- package/README.md +89 -4
- package/dist/__tests__/unit/auth.test.d.ts +2 -0
- package/dist/__tests__/unit/auth.test.d.ts.map +1 -0
- package/dist/__tests__/unit/auth.test.js +200 -0
- package/dist/__tests__/unit/auth.test.js.map +1 -0
- package/dist/__tests__/unit/config.test.d.ts +2 -0
- package/dist/__tests__/unit/config.test.d.ts.map +1 -0
- package/dist/__tests__/unit/config.test.js +76 -0
- package/dist/__tests__/unit/config.test.js.map +1 -0
- package/dist/__tests__/unit/deploy.test.d.ts +2 -0
- package/dist/__tests__/unit/deploy.test.d.ts.map +1 -0
- package/dist/__tests__/unit/deploy.test.js +273 -0
- package/dist/__tests__/unit/deploy.test.js.map +1 -0
- package/dist/__tests__/unit/deps-sync.test.js +3 -1
- package/dist/__tests__/unit/deps-sync.test.js.map +1 -1
- package/dist/__tests__/unit/dev-loading-server.test.d.ts +2 -0
- package/dist/__tests__/unit/dev-loading-server.test.d.ts.map +1 -0
- package/dist/__tests__/unit/dev-loading-server.test.js +141 -0
- package/dist/__tests__/unit/dev-loading-server.test.js.map +1 -0
- package/dist/__tests__/unit/docs-json-writer.test.d.ts +2 -0
- package/dist/__tests__/unit/docs-json-writer.test.d.ts.map +1 -0
- package/dist/__tests__/unit/docs-json-writer.test.js +71 -0
- package/dist/__tests__/unit/docs-json-writer.test.js.map +1 -0
- package/dist/__tests__/unit/loading-page.test.d.ts +2 -0
- package/dist/__tests__/unit/loading-page.test.d.ts.map +1 -0
- package/dist/__tests__/unit/loading-page.test.js +73 -0
- package/dist/__tests__/unit/loading-page.test.js.map +1 -0
- package/dist/__tests__/unit/login.test.d.ts +2 -0
- package/dist/__tests__/unit/login.test.d.ts.map +1 -0
- package/dist/__tests__/unit/login.test.js +125 -0
- package/dist/__tests__/unit/login.test.js.map +1 -0
- package/dist/__tests__/unit/logout.test.d.ts +2 -0
- package/dist/__tests__/unit/logout.test.d.ts.map +1 -0
- package/dist/__tests__/unit/logout.test.js +39 -0
- package/dist/__tests__/unit/logout.test.js.map +1 -0
- package/dist/__tests__/unit/tarball.test.d.ts +2 -0
- package/dist/__tests__/unit/tarball.test.d.ts.map +1 -0
- package/dist/__tests__/unit/tarball.test.js +126 -0
- package/dist/__tests__/unit/tarball.test.js.map +1 -0
- package/dist/__tests__/unit/whoami.test.d.ts +2 -0
- package/dist/__tests__/unit/whoami.test.d.ts.map +1 -0
- package/dist/__tests__/unit/whoami.test.js +47 -0
- package/dist/__tests__/unit/whoami.test.js.map +1 -0
- package/dist/commands/deploy.d.ts +13 -0
- package/dist/commands/deploy.d.ts.map +1 -0
- package/dist/commands/deploy.js +265 -0
- package/dist/commands/deploy.js.map +1 -0
- package/dist/commands/dev.d.ts.map +1 -1
- package/dist/commands/dev.js +48 -25
- package/dist/commands/dev.js.map +1 -1
- package/dist/commands/login.d.ts +8 -0
- package/dist/commands/login.d.ts.map +1 -0
- package/dist/commands/login.js +136 -0
- package/dist/commands/login.js.map +1 -0
- package/dist/commands/logout.d.ts +5 -0
- package/dist/commands/logout.d.ts.map +1 -0
- package/dist/commands/logout.js +17 -0
- package/dist/commands/logout.js.map +1 -0
- package/dist/commands/whoami.d.ts +5 -0
- package/dist/commands/whoami.d.ts.map +1 -0
- package/dist/commands/whoami.js +24 -0
- package/dist/commands/whoami.js.map +1 -0
- package/dist/index.js +50 -7
- package/dist/index.js.map +1 -1
- package/dist/lib/auth.d.ts +33 -0
- package/dist/lib/auth.d.ts.map +1 -0
- package/dist/lib/auth.js +106 -0
- package/dist/lib/auth.js.map +1 -0
- package/dist/lib/config.d.ts +10 -0
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js +7 -1
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/dev-loading-server.d.ts +22 -0
- package/dist/lib/dev-loading-server.d.ts.map +1 -0
- package/dist/lib/dev-loading-server.js +117 -0
- package/dist/lib/dev-loading-server.js.map +1 -0
- package/dist/lib/docs-config.d.ts +1 -0
- package/dist/lib/docs-config.d.ts.map +1 -1
- package/dist/lib/docs-config.js.map +1 -1
- package/dist/lib/docs-json-writer.d.ts +2 -0
- package/dist/lib/docs-json-writer.d.ts.map +1 -0
- package/dist/lib/docs-json-writer.js +35 -0
- package/dist/lib/docs-json-writer.js.map +1 -0
- package/dist/lib/loading-page.d.ts +11 -0
- package/dist/lib/loading-page.d.ts.map +1 -0
- package/dist/lib/loading-page.js +222 -0
- package/dist/lib/loading-page.js.map +1 -0
- package/dist/lib/output.d.ts +13 -5
- package/dist/lib/output.d.ts.map +1 -1
- package/dist/lib/output.js +22 -5
- package/dist/lib/output.js.map +1 -1
- package/dist/lib/tarball.d.ts +28 -0
- package/dist/lib/tarball.d.ts.map +1 -0
- package/dist/lib/tarball.js +117 -0
- package/dist/lib/tarball.js.map +1 -0
- package/package.json +5 -2
- package/vendored/app/[[...slug]]/page.tsx +6 -20
- package/vendored/app/api/chat/[project]/route.ts +323 -0
- package/vendored/app/api/mcp/[project]/route.ts +2 -63
- package/vendored/components/chat/ChatCodeBlock.tsx +63 -0
- package/vendored/components/chat/ChatEmptyState.tsx +79 -0
- package/vendored/components/chat/ChatFAB.tsx +36 -0
- package/vendored/components/chat/ChatInput.tsx +106 -0
- package/vendored/components/chat/ChatMessage.tsx +176 -0
- package/vendored/components/chat/ChatPanel.tsx +206 -0
- package/vendored/components/chat/ChatResizeHandle.tsx +108 -0
- package/vendored/components/chat/LazyChatPanel.tsx +19 -0
- package/vendored/components/layout/LayoutWrapper.tsx +134 -44
- package/vendored/components/layout/PageColumns.tsx +40 -0
- package/vendored/components/navigation/Header.tsx +74 -29
- package/vendored/components/navigation/Sidebar.tsx +17 -2
- package/vendored/hooks/useChat.ts +335 -0
- package/vendored/hooks/useChatPanel.tsx +101 -0
- package/vendored/lib/anthropic-client.ts +19 -0
- package/vendored/lib/build/extract-tarball.ts +150 -0
- package/vendored/lib/chat-prompt.ts +56 -0
- package/vendored/lib/docs-types.ts +14 -0
- package/vendored/lib/docs.ts +22 -4
- package/vendored/lib/embedding-chunker.ts +173 -0
- package/vendored/lib/generate-starter-questions.ts +98 -0
- package/vendored/lib/isr-build-executor.ts +2 -1
- package/vendored/lib/middleware-helpers.ts +21 -0
- package/vendored/lib/route-helpers.ts +96 -0
- package/vendored/lib/snippet-loader-isr.ts +107 -1
- package/vendored/lib/static-artifacts.ts +3 -2
- package/vendored/lib/validate-config.ts +1 -0
- package/vendored/lib/vector-store.ts +213 -0
- package/vendored/schema/docs-schema.json +33 -0
- package/vendored/scripts/dev-project.cjs +6 -0
- package/vendored/shared/types.ts +6 -5
- package/vendored/tailwind.config.ts +9 -0
- 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
|
+
}
|