Fix the Ask AI bar's send button and let multiline inputs shrink

This commit is contained in:
Nolann Biron
2026-11-04 10:22:27 +01:00
parent efe977025d
commit d59caa6c5c
2 changed files with 113 additions and 105 deletions
@@ -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.