diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 10210e8a03..9917d66dc0 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -233,3 +233,11 @@ } } } + +/* Ghost text for chat autocomplete */ +.chat-ghost-text { + color: var(--muted-foreground); + opacity: 0.6; + pointer-events: none; + user-select: none; +} diff --git a/apps/web/src/components/cloud-agent-next/ChatInput.tsx b/apps/web/src/components/cloud-agent-next/ChatInput.tsx index 0b61ad4041..d4f167bc46 100644 --- a/apps/web/src/components/cloud-agent-next/ChatInput.tsx +++ b/apps/web/src/components/cloud-agent-next/ChatInput.tsx @@ -14,6 +14,7 @@ import { ModelCombobox, type ModelOption } from '@/components/shared/ModelCombob import { VariantCombobox } from '@/components/shared/VariantCombobox'; import { MobileToolbarPopover } from './MobileToolbarPopover'; import type { AgentMode } from './types'; +import { useChatGhostText } from './hooks/useChatGhostText'; type ChatInputProps = { onSend: (message: string) => void; @@ -44,6 +45,7 @@ type ChatInputProps = { showToolbar?: boolean; /** Pre-populate the textarea (e.g. to restore text after a failed send) */ initialValue?: string; + enableChatAutocomplete?: boolean; }; export function ChatInput({ @@ -64,12 +66,22 @@ export function ChatInput({ availableVariants = [], showToolbar = false, initialValue, + enableChatAutocomplete = true, }: ChatInputProps) { const [value, setValue] = useState(''); const [showAutocomplete, setShowAutocomplete] = useState(false); const [selectedIndex, setSelectedIndex] = useState(0); const textareaRef = useRef(null); + const { + ghostText, + handleKeyDown: handleGhostTextKeyDown, + handleInputChange: handleGhostTextInputChange, + } = useChatGhostText({ + textAreaRef: textareaRef, + enableChatAutocomplete: enableChatAutocomplete && !disabled && !isStreaming, + }); + // Restore text into the textarea when initialValue changes (e.g. after a failed send). // Treats undefined as "no opinion" (skip), but empty string actively clears the field. useEffect(() => { @@ -121,6 +133,7 @@ export function ChatInput({ onSend(trimmed); setValue(''); + handleGhostTextInputChange(''); setShowAutocomplete(false); if (textareaRef.current) { @@ -143,12 +156,14 @@ export function ChatInput({ // Send immediately onSend(expansion); setValue(''); + handleGhostTextInputChange(''); if (textareaRef.current) { textareaRef.current.style.height = 'auto'; } } else { // Just fill the input for editing setValue(expansion); + handleGhostTextInputChange(expansion); // Force height recalculation for expanded text if (textareaRef.current) { textareaRef.current.style.height = 'auto'; @@ -164,6 +179,10 @@ export function ChatInput({ // Ignore keyboard events during IME composition (Chinese, Japanese, Korean input) if (e.nativeEvent.isComposing || e.nativeEvent.keyCode === 229) return; + if (handleGhostTextKeyDown(e)) { + return; + } + if (showAutocomplete && filteredCommands.length > 0) { switch (e.key) { case 'ArrowDown': @@ -224,20 +243,37 @@ export function ChatInput({ {/* Textarea with slash command autocomplete */} -