Files
goose/ui/desktop/src/components/Input.tsx
2025-03-12 16:43:37 -07:00

201 lines
5.8 KiB
TypeScript

import React, { useRef, useState, useEffect } from 'react';
import { Button } from './ui/button';
import Stop from './ui/Stop';
import { Attach, Send } from './icons';
interface InputProps {
handleSubmit: (e: React.FormEvent) => void;
isLoading?: boolean;
onStop?: () => void;
commandHistory?: string[];
}
export default function Input({
handleSubmit,
isLoading = false,
onStop,
commandHistory = [],
}: InputProps) {
const [value, setValue] = useState('');
// State to track if the IME is composing (i.e., in the middle of Japanese IME input)
const [isComposing, setIsComposing] = useState(false);
const [historyIndex, setHistoryIndex] = useState(-1);
const [savedInput, setSavedInput] = useState('');
const textAreaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
if (textAreaRef.current) {
textAreaRef.current.focus();
}
}, [value]);
const useAutosizeTextArea = (textAreaRef: HTMLTextAreaElement | null, value: string) => {
useEffect(() => {
if (textAreaRef) {
textAreaRef.style.height = '0px'; // Reset height
const scrollHeight = textAreaRef.scrollHeight;
textAreaRef.style.height = Math.min(scrollHeight, maxHeight) + 'px';
}
}, [textAreaRef, value]);
};
const minHeight = '1rem';
const maxHeight = 10 * 24;
useAutosizeTextArea(textAreaRef.current, value);
const handleChange = (evt: React.ChangeEvent<HTMLTextAreaElement>) => {
const val = evt.target.value;
setValue(val);
};
// Handlers for composition events, which are crucial for proper IME behavior
const handleCompositionStart = (evt: React.CompositionEvent<HTMLTextAreaElement>) => {
setIsComposing(true);
};
const handleCompositionEnd = (evt: React.CompositionEvent<HTMLTextAreaElement>) => {
setIsComposing(false);
};
const handleHistoryNavigation = (evt: React.KeyboardEvent<HTMLTextAreaElement>) => {
evt.preventDefault();
// Save current input if we're just starting to navigate history
if (historyIndex === -1) {
setSavedInput(value);
}
// Calculate new history index
let newIndex = historyIndex;
if (evt.key === 'ArrowUp') {
// Move backwards through history
if (historyIndex < commandHistory.length - 1) {
newIndex = historyIndex + 1;
}
} else {
// Move forwards through history
if (historyIndex > -1) {
newIndex = historyIndex - 1;
}
}
// Update index and value
setHistoryIndex(newIndex);
if (newIndex === -1) {
// Restore saved input when going past the end of history
setValue(savedInput);
} else {
setValue(commandHistory[newIndex] || '');
}
};
const handleKeyDown = (evt: React.KeyboardEvent<HTMLTextAreaElement>) => {
// Handle command history navigation
if ((evt.metaKey || evt.ctrlKey) && (evt.key === 'ArrowUp' || evt.key === 'ArrowDown')) {
handleHistoryNavigation(evt);
return;
}
if (evt.key === 'Enter') {
// should not trigger submit on Enter if it's composing (IME input in progress) or shift is pressed
if (evt.shiftKey || isComposing) {
// Allow line break for Shift+Enter or during IME composition
return;
}
// Prevent default Enter behavior when loading or when not loading but has content
// So it won't trigger a new line
evt.preventDefault();
// Only submit if not loading and has content
if (!isLoading && value.trim()) {
handleSubmit(new CustomEvent('submit', { detail: { value } }));
setValue('');
setHistoryIndex(-1);
setSavedInput('');
}
}
};
const onFormSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (value.trim() && !isLoading) {
handleSubmit(new CustomEvent('submit', { detail: { value } }));
setValue('');
setHistoryIndex(-1);
setSavedInput('');
}
};
const handleFileSelect = async () => {
const path = await window.electron.selectFileOrDirectory();
if (path) {
setValue(path);
textAreaRef.current?.focus();
}
};
return (
<form
onSubmit={onFormSubmit}
className="flex relative h-auto px-[16px] pr-[68px] py-[1rem] border-t border-borderSubtle"
>
<textarea
autoFocus
id="dynamic-textarea"
placeholder="What can goose help with? ⌘↑/⌘↓"
value={value}
onChange={handleChange}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
onKeyDown={handleKeyDown}
ref={textAreaRef}
rows={1}
style={{
minHeight: `${minHeight}px`,
maxHeight: `${maxHeight}px`,
overflowY: 'auto',
}}
className="w-full outline-none border-none focus:ring-0 bg-transparent p-0 text-base resize-none text-textStandard"
/>
<Button
type="button"
size="icon"
variant="ghost"
onClick={handleFileSelect}
className="absolute right-[40px] top-1/2 -translate-y-1/2 text-textSubtle hover:text-textStandard"
>
<Attach />
</Button>
{isLoading ? (
<Button
type="button"
size="icon"
variant="ghost"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onStop();
}}
className="absolute right-2 top-1/2 -translate-y-1/2 [&_svg]:size-5 text-textSubtle hover:text-textStandard"
>
<Stop size={24} />
</Button>
) : (
<Button
type="submit"
size="icon"
variant="ghost"
disabled={!value.trim()}
className={`absolute right-2 top-1/2 -translate-y-1/2 text-textSubtle hover:text-textStandard ${
!value.trim() ? 'text-textSubtle cursor-not-allowed' : ''
}`}
>
<Send />
</Button>
)}
</form>
);
}