mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-11-10 21:55:30 +00:00
Fix the Ask AI bar's send button and let multiline inputs shrink
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { readStreamableValue } from 'ai/rsc';
|
||||
import { useReducedMotion } from 'motion/react';
|
||||
import * as React from 'react';
|
||||
|
||||
import { useAIChatController, useAIConfig } from '../AI';
|
||||
@@ -26,12 +27,13 @@ export function AIChatPageBar() {
|
||||
const chatController = useAIChatController();
|
||||
const trackEvent = useTrackEvent();
|
||||
const [expanded, setExpanded] = React.useState(false);
|
||||
const [hasText, setHasText] = React.useState(false);
|
||||
const [value, setValue] = React.useState('');
|
||||
const inputRef = React.useRef<HTMLTextAreaElement>(null);
|
||||
// The card's height assumes a one-line input, so the suggestions give way once the visitor types.
|
||||
const showSuggestions = expanded && !hasText;
|
||||
const showSuggestions = expanded && !value;
|
||||
|
||||
const configuredSuggestions = config.suggestions?.length ? config.suggestions : null;
|
||||
const [recommended, setRecommended] = React.useState<string[] | null>(null);
|
||||
const configuredSuggestions = config.suggestions?.length ? config.suggestions : undefined;
|
||||
const [recommended, setRecommended] = React.useState<string[]>();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (configuredSuggestions || !siteSpaceId) {
|
||||
@@ -53,14 +55,16 @@ export function AIChatPageBar() {
|
||||
() =>
|
||||
getSuggestedQuestions({
|
||||
language,
|
||||
suggestions: configuredSuggestions ?? recommended ?? undefined,
|
||||
suggestions: configuredSuggestions ?? recommended,
|
||||
}).slice(0, SUGGESTIONS_COUNT),
|
||||
[language, configuredSuggestions, recommended]
|
||||
);
|
||||
const typedSuggestion = useTypewriter({ texts: suggestions, enabled: !expanded && ready });
|
||||
const typing = !expanded && ready;
|
||||
useTypewriter({ ref: inputRef, texts: suggestions, enabled: typing });
|
||||
|
||||
const ask = (message: string) => {
|
||||
trackEvent({ type: 'ask_trigger_click', trigger: 'page' });
|
||||
setValue('');
|
||||
setExpanded(false);
|
||||
chatController.open('page');
|
||||
chatController.postMessage({ message });
|
||||
@@ -68,100 +72,104 @@ export function AIChatPageBar() {
|
||||
|
||||
return (
|
||||
// z-30 sits above page content (cards, tables and code blocks go up to z-20) and below sheets and dialogs (z-40).
|
||||
<div className="pointer-events-none sticky bottom-4 z-30 mt-8 flex justify-center chat-open:hidden print:hidden">
|
||||
<div
|
||||
className={tcls(
|
||||
'pointer-events-auto relative max-w-full transition-[width] duration-300 ease-out motion-reduce:transition-none',
|
||||
expanded ? 'w-[42rem]' : 'w-[28rem]'
|
||||
<div
|
||||
className={tcls(
|
||||
'sticky bottom-6 z-30 mx-auto mt-8 max-w-full transition-[width] duration-300 ease-out motion-reduce:transition-none chat-open:hidden print:hidden',
|
||||
expanded ? 'w-[42rem]' : 'w-[28rem]'
|
||||
)}
|
||||
>
|
||||
{showSuggestions ? (
|
||||
// The input stays in flow on top of the card, so focusing it never moves it; pb-13 leaves its room.
|
||||
<div className="pb-13 absolute -inset-x-2 -bottom-2 flex flex-col border border-tint bg-tint-base p-2 shadow-lg rounded-corners:rounded-[1.25rem] circular-corners:rounded-[1.75rem]">
|
||||
{suggestions.map((question, index) => (
|
||||
<Button
|
||||
key={question}
|
||||
truncate={false}
|
||||
variant="blank"
|
||||
size="small"
|
||||
className="animate-blur-in-slow w-full"
|
||||
style={{ animationDelay: `${index * 50}ms` }}
|
||||
// Keep the input focused so the bar doesn't collapse before the click lands.
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => ask(question)}
|
||||
>
|
||||
{question}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<Input
|
||||
name="ai-page-bar-input"
|
||||
multiline
|
||||
resize
|
||||
rows={1}
|
||||
label={tString(
|
||||
language,
|
||||
'ai_chat_ask',
|
||||
config.assistantName ?? getAIChatName(language, config.trademark)
|
||||
)}
|
||||
>
|
||||
{showSuggestions ? (
|
||||
// The input stays in flow on top of the card, so focusing it never moves it; pb-13 leaves its room.
|
||||
<div className="pb-13 absolute -inset-x-2 -bottom-2 flex flex-col border border-tint bg-tint-base p-2 shadow-lg rounded-corners:rounded-[1.25rem] circular-corners:rounded-[1.75rem]">
|
||||
{suggestions.map((question, index) => (
|
||||
<Button
|
||||
key={question}
|
||||
truncate={false}
|
||||
variant="blank"
|
||||
size="small"
|
||||
className="animate-blur-in-slow w-full"
|
||||
style={{ animationDelay: `${index * 50}ms` }}
|
||||
// Keep the input focused so the bar doesn't collapse before the click lands.
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => ask(question)}
|
||||
>
|
||||
{question}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<Input
|
||||
name="ai-page-bar-input"
|
||||
multiline
|
||||
resize
|
||||
rows={1}
|
||||
label={tString(
|
||||
language,
|
||||
'ai_chat_ask',
|
||||
config.assistantName ?? getAIChatName(language, config.trademark)
|
||||
)}
|
||||
placeholder={
|
||||
expanded || !ready
|
||||
? tString(language, 'ai_chat_input_placeholder')
|
||||
: typedSuggestion
|
||||
ref={inputRef}
|
||||
// While typing, useTypewriter writes the placeholder itself.
|
||||
placeholder={typing ? '' : tString(language, 'ai_chat_input_placeholder')}
|
||||
maxLength={2048}
|
||||
keyboardShortcut={false}
|
||||
value={value}
|
||||
onValueChange={setValue}
|
||||
onKeyDown={(event) => {
|
||||
// Shift+Enter already adds a line in a textarea; Cmd/Ctrl+Enter would otherwise submit.
|
||||
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault();
|
||||
const field = event.currentTarget;
|
||||
field.setRangeText('\n', field.selectionStart, field.selectionEnd, 'end');
|
||||
setValue(field.value);
|
||||
}
|
||||
maxLength={2048}
|
||||
keyboardShortcut={false}
|
||||
submitButton={{
|
||||
icon: 'arrow-up',
|
||||
label: tString(language, 'send'),
|
||||
iconOnly: true,
|
||||
className: 'ml-auto -my-1 -mr-1.5',
|
||||
}}
|
||||
onValueChange={(value) => setHasText(value.length > 0)}
|
||||
onKeyDown={(event) => {
|
||||
// Shift+Enter already adds a line in a textarea; Cmd/Ctrl+Enter would otherwise submit.
|
||||
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault();
|
||||
const field = event.currentTarget;
|
||||
const end = field.value.length;
|
||||
field.setRangeText(
|
||||
'\n',
|
||||
field.selectionStart ?? end,
|
||||
field.selectionEnd ?? end,
|
||||
'end'
|
||||
);
|
||||
// Lets React's onChange see the edit, so the value and the height stay in sync.
|
||||
field.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
}}
|
||||
onFocus={() => setExpanded(true)}
|
||||
onBlur={(event) => {
|
||||
if (!event.currentTarget.value) {
|
||||
setExpanded(false);
|
||||
}
|
||||
}}
|
||||
onSubmit={ask}
|
||||
className={tcls(
|
||||
// Keeps the one-line pill layout: textarea and send button side by side.
|
||||
'flex-row items-end bg-tint-base/9 px-3 py-2 text-sm circular-corners:rounded-3xl contrast-more:bg-tint-base',
|
||||
showSuggestions ? null : 'shadow-lg backdrop-blur-lg'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
}}
|
||||
onFocus={() => setExpanded(true)}
|
||||
onBlur={(event) => {
|
||||
if (!event.currentTarget.value) {
|
||||
setExpanded(false);
|
||||
}
|
||||
}}
|
||||
onSubmit={ask}
|
||||
className={tcls(
|
||||
// The send button is overlaid, so the textarea spans the full width and scrolls at the right edge.
|
||||
'bg-tint-base/9 pl-3 text-sm contrast-more:bg-tint-base [&_textarea]:max-h-32 [&_textarea]:pr-9',
|
||||
showSuggestions ? null : 'shadow-lg backdrop-blur-lg'
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="xsmall"
|
||||
iconOnly
|
||||
icon="arrow-up"
|
||||
label={tString(language, 'send')}
|
||||
disabled={!value.trim()}
|
||||
onClick={() => ask(value)}
|
||||
className="absolute bottom-1.5 right-1.5"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useTypewriter({ texts, enabled }: { texts: string[]; enabled: boolean }) {
|
||||
const [text, setText] = React.useState('');
|
||||
// Writes the placeholder straight to the field: a state update per tick would re-render the whole bar.
|
||||
function useTypewriter({
|
||||
ref,
|
||||
texts,
|
||||
enabled,
|
||||
}: {
|
||||
ref: React.RefObject<HTMLTextAreaElement | null>;
|
||||
texts: string[];
|
||||
enabled: boolean;
|
||||
}) {
|
||||
const reduceMotion = useReducedMotion();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!enabled || texts.length === 0) {
|
||||
const field = ref.current;
|
||||
if (!enabled || !field) {
|
||||
return;
|
||||
}
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
setText(texts[0]!);
|
||||
if (reduceMotion) {
|
||||
field.placeholder = texts[0]!;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -173,7 +181,7 @@ function useTypewriter({ texts, enabled }: { texts: string[]; enabled: boolean }
|
||||
const tick = () => {
|
||||
const current = texts[index % texts.length]!;
|
||||
length += erasing ? -1 : 1;
|
||||
setText(current.slice(0, length));
|
||||
field.placeholder = current.slice(0, length);
|
||||
|
||||
let delay = erasing ? 20 : 45;
|
||||
if (!erasing && length >= current.length) {
|
||||
@@ -187,12 +195,10 @@ function useTypewriter({ texts, enabled }: { texts: string[]; enabled: boolean }
|
||||
timeout = setTimeout(tick, delay);
|
||||
};
|
||||
|
||||
setText('');
|
||||
field.placeholder = '';
|
||||
timeout = setTimeout(tick, 600);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [texts, enabled]);
|
||||
|
||||
return text;
|
||||
}, [ref, texts, enabled, reduceMotion]);
|
||||
}
|
||||
|
||||
function fetchRecommendedQuestions(siteSpaceId: string): Promise<string[]> {
|
||||
|
||||
@@ -120,7 +120,6 @@ export const Input = React.forwardRef<InputElement, InputProps>((props, passedRe
|
||||
|
||||
const [value, setValue] = useControlledState(passedValue, passedValue ?? '', onValueChange);
|
||||
const [submitted, setSubmitted] = React.useState(false);
|
||||
const [height, setHeight] = React.useState<number>();
|
||||
const inputRef = React.useRef<InputElement>(null);
|
||||
const ref = (passedRef as React.RefObject<HTMLInputElement | HTMLTextAreaElement>) ?? inputRef;
|
||||
|
||||
@@ -163,14 +162,20 @@ export const Input = React.forwardRef<InputElement, InputProps>((props, passedRe
|
||||
if (submitted) {
|
||||
setSubmitted(false);
|
||||
}
|
||||
|
||||
if (multiline && resize && ref.current) {
|
||||
// TODO: replace with `field-sizing: content` when more broadly supported. https://caniuse.com/?search=field-sizing
|
||||
// Reset the height to auto, then set it to the scroll height. If we don't reset, the height will only ever grow.
|
||||
setHeight(ref.current.scrollHeight);
|
||||
}
|
||||
};
|
||||
|
||||
// Runs on every value change, typed or set by the caller, so the height can shrink as well as grow.
|
||||
React.useLayoutEffect(() => {
|
||||
const field = ref.current;
|
||||
if (!multiline || !resize || !field) {
|
||||
return;
|
||||
}
|
||||
// TODO: replace with `field-sizing: content` when more broadly supported. https://caniuse.com/?search=field-sizing
|
||||
// Reset the height to auto, then set it to the scroll height. If we don't reset, the height will only ever grow.
|
||||
field.style.height = 'auto';
|
||||
field.style.height = value.toString().trim() ? `${field.scrollHeight}px` : '';
|
||||
}, [ref, value, multiline, resize]);
|
||||
|
||||
const handleClear = () => {
|
||||
if (!ref.current) return;
|
||||
setValue('');
|
||||
@@ -229,9 +234,6 @@ export const Input = React.forwardRef<InputElement, InputProps>((props, passedRe
|
||||
readOnly: readOnly,
|
||||
maxLength: maxLength,
|
||||
minLength: minLength,
|
||||
style: {
|
||||
height: multiline && resize && hasValue && height ? `${height}px` : undefined,
|
||||
},
|
||||
};
|
||||
|
||||
// A rich display replaces the control, so it only makes sense when there is nothing to type into.
|
||||
|
||||
Reference in New Issue
Block a user