import React, {useEffect, useRef, useState, type ReactNode} from 'react';
import Markdown, {type Components} from 'react-markdown';
import remarkGfm from 'remark-gfm';
import CodeBlock from '@theme/CodeBlock';
import {translate} from '@docusaurus/Translate';
import {LIBRARY_PAGE} from './fallback';
import {useAskAiSession} from './session';
import styles from './styles.module.css';
// Fenced code goes through the site's own code block, so an answer is highlighted by the same
// Prism setup and theme as the pages.
const markdownComponents: Components = {
pre: ({children}) => <>{children}</>,
code: ({className, children}) => {
const text = String(children ?? '');
const language = /language-(\S+)/.exec(className ?? '')?.[1];
if (!language && !text.includes('\n')) {
return <code>{children}</code>;
}
return <CodeBlock language={language}>{text.replace(/\n$/, '')}</CodeBlock>;
},
};
export default function Panel(): ReactNode {
const {open, turns, streaming, unavailable, ask, restart, close} = useAskAiSession();
const [draft, setDraft] = useState('');
const input = useRef<HTMLTextAreaElement>(null);
const log = useRef<HTMLDivElement>(null);
useEffect(() => {
if (open) {
input.current?.focus();
}
}, [open]);
useEffect(() => {
log.current?.scrollTo({top: log.current.scrollHeight});
}, [turns]);
useEffect(() => {
if (!open) {
return undefined;
}
const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
close();
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open, close]);
const submit = () => {
const question = draft.trim();
if (question && !streaming) {
setDraft('');
ask(question);
}
};
const title = translate({id: 'askAi.title', message: 'Ask AI'});
return (
<aside className={styles.panel} role="dialog" aria-label={title}>
<header className={styles.header}>
<span className={styles.title}>{title}</span>
<button
type="button"
className={styles.headerButton}
onClick={() => {
restart();
input.current?.focus();
}}>
{translate({id: 'askAi.newChat', message: 'New chat'})}
</button>
<button
type="button"
className={styles.headerButton}
onClick={close}
aria-label={translate({id: 'askAi.close', message: 'Close'})}>
✕
</button>
</header>
<div className={styles.log} ref={log} aria-live="polite">
{turns.length === 0 && !unavailable && (
<p className={styles.hint}>
{translate({
id: 'askAi.welcome',
message:
'Ask a question about RustStream. Answers are generated by Context7 from this documentation and may contain mistakes.',
})}
</p>
)}
{turns.map((turn, index) => (
<div key={index} className={turn.role === 'user' ? styles.question : styles.answer}>
{turn.role === 'user' ? (
turn.text
) : turn.text ? (
<Markdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{turn.text}
</Markdown>
) : (
<span className={styles.typing}>…</span>
)}
</div>
))}
{unavailable && (
<p className={styles.notice} role="status">
{translate({
id: 'askAi.unavailable',
message:
'The assistant cannot answer here right now. The Context7 widget is opening in the corner of the page; the same assistant is also on the library page:',
})}{' '}
<a href={LIBRARY_PAGE} target="_blank" rel="noopener noreferrer">
context7.com/powersemmi/ruststream
</a>
</p>
)}
</div>
<form
className={styles.form}
onSubmit={(event) => {
event.preventDefault();
submit();
}}>
<textarea
ref={input}
className={styles.input}
rows={2}
value={draft}
placeholder={translate({id: 'askAi.placeholder', message: 'Ask about RustStream'})}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
submit();
}
}}
/>
<button type="submit" className={styles.send} disabled={streaming || !draft.trim()}>
{translate({id: 'askAi.send', message: 'Send'})}
</button>
</form>
</aside>
);
}