learning_ai_common_plat/packages/ai-ui/src/PromptComposer.tsx
2026-05-27 12:07:23 -07:00

210 lines
6.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
forwardRef,
useImperativeHandle,
useRef,
type CSSProperties,
type FormEvent,
type KeyboardEvent,
type ReactNode,
} from 'react';
export interface PromptComposerProps {
/** Controlled input value. */
value: string;
/** Standard onChange — accepts the Vercel-AI-SDK-shaped event. */
onChange: (e: { target: { value: string } }) => void;
/** Called on Enter (without Shift) or send-button click. */
onSubmit: (e?: { preventDefault?: () => void }) => void | Promise<void>;
/** Disable input + submit (e.g. while streaming). */
disabled?: boolean;
/** Placeholder when empty. */
placeholder?: string;
/** Render a Stop button instead of Send (e.g. during streaming). */
showStop?: boolean;
/** Called when the Stop button is clicked. */
onStop?: () => void;
/** Auto-grow up to this many lines before scrolling. Default 8. */
maxLines?: number;
/** Extra controls rendered to the left of the send button. */
leadingActions?: ReactNode;
/** Tailwind escape hatch. */
className?: string;
/** Inline style escape hatch. */
style?: CSSProperties;
/** ARIA label for the textarea. */
ariaLabel?: string;
}
export interface PromptComposerHandle {
focus: () => void;
clear: () => void;
}
/**
* Multi-line prompt input with submit-on-Enter + Shift-Enter newline,
* auto-growing textarea, optional Stop affordance during streaming, and
* slots for slash-commands or other leading actions (Wave 2 extension).
*
* Keyboard:
* Enter — submit
* Shift+Enter — newline
* Cmd/Ctrl+Enter — submit (alternate; useful when textarea grows)
* Escape — blur
*/
export const PromptComposer = forwardRef<PromptComposerHandle, PromptComposerProps>(
function PromptComposer(
{
value,
onChange,
onSubmit,
disabled = false,
placeholder = 'Send a message…',
showStop = false,
onStop,
maxLines = 8,
leadingActions,
className,
style,
ariaLabel = 'Message',
},
ref,
) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
useImperativeHandle(
ref,
() => ({
focus: () => textareaRef.current?.focus(),
clear: () => {
onChange({ target: { value: '' } });
textareaRef.current?.focus();
},
}),
[onChange],
);
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Escape') {
e.currentTarget.blur();
return;
}
const isCmdEnter = (e.metaKey || e.ctrlKey) && e.key === 'Enter';
const isPlainEnter = e.key === 'Enter' && !e.shiftKey && !e.metaKey && !e.ctrlKey;
if ((isPlainEnter || isCmdEnter) && !disabled && value.trim()) {
e.preventDefault();
void onSubmit({ preventDefault: () => undefined });
}
};
const handleFormSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!disabled && value.trim()) void onSubmit(e);
};
// Compute auto-grow height — line-height (1.5) × maxLines, capped.
const maxHeight = `calc(1.5em * ${maxLines} + var(--bl-space-3, 12px) * 2)`;
return (
<form
data-testid="bl-prompt-composer"
onSubmit={handleFormSubmit}
className={className}
style={{
display: 'flex',
alignItems: 'flex-end',
gap: 'var(--bl-space-2, 8px)',
padding: 'var(--bl-space-2, 8px)',
border: '1px solid var(--bl-border, rgba(0,0,0,0.12))',
borderRadius: 'var(--bl-radius-card, 12px)',
background: 'var(--bl-surface-card, #fff)',
...style,
}}
>
{leadingActions && (
<div
data-testid="bl-prompt-leading"
style={{ display: 'flex', alignItems: 'center', gap: 'var(--bl-space-1, 4px)' }}
>
{leadingActions}
</div>
)}
<textarea
ref={textareaRef}
data-testid="bl-prompt-textarea"
aria-label={ariaLabel}
value={value}
placeholder={placeholder}
disabled={disabled}
rows={1}
onChange={onChange}
onKeyDown={handleKeyDown}
// Auto-grow: reset, then expand to scrollHeight on each keystroke.
onInput={e => {
const ta = e.currentTarget;
ta.style.height = 'auto';
ta.style.height = `${ta.scrollHeight}px`;
}}
style={{
flex: 1,
resize: 'none',
border: 'none',
outline: 'none',
background: 'transparent',
color: 'var(--bl-text-primary, inherit)',
fontFamily: 'inherit',
fontSize: '0.95rem',
lineHeight: 1.5,
padding: 'var(--bl-space-2, 8px)',
maxHeight,
overflowY: 'auto',
}}
/>
{showStop ? (
<button
type="button"
data-testid="bl-prompt-stop"
onClick={() => onStop?.()}
aria-label="Stop generating"
style={sendButtonStyle('var(--bl-danger, #ef4444)')}
>
</button>
) : (
<button
type="submit"
data-testid="bl-prompt-send"
disabled={disabled || !value.trim()}
aria-label="Send message"
style={{
...sendButtonStyle('var(--bl-accent, #6366f1)'),
opacity: disabled || !value.trim() ? 0.4 : 1,
cursor: disabled || !value.trim() ? 'not-allowed' : 'pointer',
}}
>
</button>
)}
</form>
);
},
);
function sendButtonStyle(bg: string): CSSProperties {
return {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: 36,
height: 36,
border: 'none',
borderRadius: 'var(--bl-radius-pill, 999px)',
background: bg,
color: 'var(--bl-accent-foreground, #fff)',
fontSize: 16,
cursor: 'pointer',
transition: 'transform 80ms ease-out, opacity 120ms ease-out',
};
}