161 lines
5.2 KiB
JavaScript
161 lines
5.2 KiB
JavaScript
import * as React from 'react';
|
|
import { ArrowUp, Square } from 'lucide-react';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
/**
|
|
* PromptInput — the assistant's primary action.
|
|
*
|
|
* A textarea rather than an input, because these prompts are sentences: it grows
|
|
* with the content up to a cap, keeps Enter as send, and leaves Shift+Enter for a
|
|
* deliberate newline. That combination is what makes typing a real question feel
|
|
* natural instead of cramped.
|
|
*
|
|
* `placeholders` rotate while the field is empty and unfocused, which teaches
|
|
* the range of what can be asked without spending vertical space on a legend.
|
|
*/
|
|
export function PromptInput({
|
|
value,
|
|
onChange,
|
|
onSubmit,
|
|
onStop,
|
|
busy = false,
|
|
placeholders = ['Ask anything…'],
|
|
autoFocus = false,
|
|
align = 'left',
|
|
className,
|
|
}) {
|
|
const textareaRef = React.useRef(null);
|
|
const [focused, setFocused] = React.useState(false);
|
|
const [placeholderIndex, setPlaceholderIndex] = React.useState(0);
|
|
|
|
/* Grow to fit the content, up to a cap, then scroll internally. */
|
|
React.useEffect(() => {
|
|
const el = textareaRef.current;
|
|
if (!el) return;
|
|
el.style.height = 'auto';
|
|
el.style.height = `${Math.min(el.scrollHeight, 148)}px`;
|
|
}, [value]);
|
|
|
|
/* Rotate the examples only while the field is idle — changing the placeholder
|
|
under someone who is mid-thought is distracting. The crossfade is done with
|
|
a real element rather than the native placeholder, which cannot animate. */
|
|
const [fading, setFading] = React.useState(false);
|
|
|
|
React.useEffect(() => {
|
|
if (value || focused || placeholders.length < 2) return undefined;
|
|
const timer = setInterval(() => {
|
|
setFading(true);
|
|
// Swap the text at the midpoint of the fade so neither string is visible
|
|
// while it changes.
|
|
setTimeout(() => {
|
|
setPlaceholderIndex((i) => (i + 1) % placeholders.length);
|
|
setFading(false);
|
|
}, 220);
|
|
}, 4000);
|
|
return () => clearInterval(timer);
|
|
}, [value, focused, placeholders.length]);
|
|
|
|
/* A shorter list on a context change must not leave the index out of range. */
|
|
React.useEffect(() => {
|
|
setPlaceholderIndex(0);
|
|
}, [placeholders]);
|
|
|
|
React.useEffect(() => {
|
|
if (autoFocus) textareaRef.current?.focus();
|
|
}, [autoFocus]);
|
|
|
|
const submit = () => {
|
|
if (!value.trim() || busy) return;
|
|
onSubmit(value);
|
|
};
|
|
|
|
const onKeyDown = (event) => {
|
|
if (event.key === 'Enter' && !event.shiftKey) {
|
|
event.preventDefault();
|
|
submit();
|
|
}
|
|
};
|
|
|
|
const isCenter = align === 'center';
|
|
|
|
return (
|
|
<form
|
|
onSubmit={(e) => { e.preventDefault(); submit(); }}
|
|
className={cn(
|
|
'relative flex items-center gap-2 rounded-2xl border bg-surface transition-all duration-base ease-out',
|
|
'px-3 py-2',
|
|
focused
|
|
? 'border-krow-blue/50 shadow-[0_8px_28px_-8px_rgba(10,57,223,0.28)] ring-2 ring-krow-blue/15'
|
|
: 'border-border shadow-md hover:border-krow-blue/30',
|
|
className
|
|
)}
|
|
>
|
|
<div className="relative min-w-0 flex-1">
|
|
<textarea
|
|
ref={textareaRef}
|
|
rows={1}
|
|
value={value}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
onKeyDown={onKeyDown}
|
|
onFocus={() => setFocused(true)}
|
|
onBlur={() => setFocused(false)}
|
|
disabled={busy}
|
|
aria-label="Ask Krow AI"
|
|
aria-busy={busy || undefined}
|
|
className={cn(
|
|
'block w-full resize-none bg-transparent leading-relaxed text-ink-1',
|
|
'no-scrollbar outline-none disabled:cursor-wait disabled:text-ink-3',
|
|
'py-0.5 text-body-sm',
|
|
isCenter ? 'text-center' : 'text-left'
|
|
)}
|
|
/>
|
|
|
|
{/* Animated placeholder. Hidden from assistive tech — the textarea's
|
|
aria-label is the accessible name. */}
|
|
{!value && (
|
|
<span
|
|
aria-hidden="true"
|
|
className={cn(
|
|
'pointer-events-none absolute inset-0 truncate text-ink-4 transition-opacity duration-base',
|
|
'py-0.5 text-body-sm',
|
|
isCenter ? 'text-center' : 'text-left',
|
|
fading ? 'opacity-0' : 'opacity-100'
|
|
)}
|
|
>
|
|
{busy ? 'Thinking…' : placeholders[placeholderIndex]}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{busy ? (
|
|
<button
|
|
type="button"
|
|
onClick={onStop}
|
|
aria-label="Stop generating"
|
|
className={cn(
|
|
'grid shrink-0 place-items-center rounded-full border border-border bg-surface text-ink-2',
|
|
'transition-colors duration-base hover:bg-surface-sunken focus-ring',
|
|
'h-8 w-8'
|
|
)}
|
|
>
|
|
<Square className="h-3 w-3" aria-hidden="true" />
|
|
</button>
|
|
) : (
|
|
<button
|
|
type="submit"
|
|
disabled={!value.trim()}
|
|
aria-label="Send message"
|
|
className={cn(
|
|
'grid shrink-0 place-items-center rounded-full bg-krow-blue text-white',
|
|
'transition-all duration-base ease-out hover:bg-krow-blue-dark',
|
|
'disabled:bg-surface-sunken disabled:text-ink-4 focus-ring',
|
|
'h-8 w-8'
|
|
)}
|
|
>
|
|
<ArrowUp className="h-3.5 w-3.5" aria-hidden="true" />
|
|
</button>
|
|
)}
|
|
</form>
|
|
);
|
|
}
|