import { useState, useRef, useEffect, useCallback, useMemo } from 'react' import { useTranslation } from '../../i18n' import { useChatStore } from '../../stores/chatStore' import { SETTINGS_TAB_ID, useTabStore } from '../../stores/tabStore' import { useUIStore } from '../../stores/uiStore' import { useSessionStore } from '../../stores/sessionStore' import { useSessionRuntimeStore } from '../../stores/sessionRuntimeStore' import { useTeamStore } from '../../stores/teamStore' import { useSettingsStore } from '../../stores/settingsStore' import { formatWorkspaceReferencePrompt, useWorkspaceChatContextStore, type WorkspaceChatReference, } from '../../stores/workspaceChatContextStore' import { sessionsApi } from '../../api/sessions' import { PermissionModeSelector } from '../controls/PermissionModeSelector' import { ModelSelector } from '../controls/ModelSelector' import type { AttachmentRef } from '../../types/chat' import { AttachmentGallery } from './AttachmentGallery' import { ProjectContextChip } from '../shared/ProjectContextChip' import { DirectoryPicker } from '../shared/DirectoryPicker' import { FileSearchMenu, type FileSearchMenuHandle } from './FileSearchMenu' import { LocalSlashCommandPanel, type LocalSlashCommandName } from './LocalSlashCommandPanel' import { ContextUsageIndicator } from './ContextUsageIndicator' import { FALLBACK_SLASH_COMMANDS, findSlashTrigger, mergeSlashCommands, replaceSlashToken, resolveSlashUiAction, } from './composerUtils' type GitInfo = { branch: string | null; repoName: string | null; workDir: string; changedFiles: number } type Attachment = { id: string name: string type: 'image' | 'file' path?: string mimeType?: string previewUrl?: string data?: string lineStart?: number lineEnd?: number note?: string quote?: string } type ChatInputProps = { variant?: 'default' | 'hero' compact?: boolean } const EMPTY_WORKSPACE_REFERENCES: WorkspaceChatReference[] = [] function workspaceReferenceToAttachment(reference: WorkspaceChatReference): Attachment { return { id: reference.id, name: reference.name, type: 'file', path: reference.path, lineStart: reference.lineStart, lineEnd: reference.lineEnd, note: reference.note, quote: reference.quote, } } export function ChatInput({ variant = 'default', compact = false }: ChatInputProps) { const t = useTranslation() const [input, setInput] = useState('') const [attachments, setAttachments] = useState([]) const [plusMenuOpen, setPlusMenuOpen] = useState(false) const [slashMenuOpen, setSlashMenuOpen] = useState(false) const [fileSearchOpen, setFileSearchOpen] = useState(false) const [localSlashPanel, setLocalSlashPanel] = useState(null) const [atFilter, setAtFilter] = useState('') const [atCursorPos, setAtCursorPos] = useState(-1) const [slashFilter, setSlashFilter] = useState('') const [slashSelectedIndex, setSlashSelectedIndex] = useState(0) const composingRef = useRef(false) const textareaRef = useRef(null) const fileInputRef = useRef(null) const plusMenuRef = useRef(null) const slashMenuRef = useRef(null) const fileSearchRef = useRef(null) const slashItemRefs = useRef<(HTMLButtonElement | null)[]>([]) const { sendMessage, stopGeneration } = useChatStore() const activeTabId = useTabStore((s) => s.activeTabId) const sessionState = useChatStore((s) => activeTabId ? s.sessions[activeTabId] : undefined) const chatState = sessionState?.chatState ?? 'idle' const slashCommands = sessionState?.slashCommands ?? [] const composerPrefill = sessionState?.composerPrefill ?? null const messageCount = sessionState?.messages?.length ?? 0 const runtimeSelection = useSessionRuntimeStore((state) => activeTabId ? state.selections[activeTabId] : undefined, ) const currentModel = useSettingsStore((state) => state.currentModel) const runtimeSelectionKey = runtimeSelection ? `${runtimeSelection.providerId ?? 'official'}:${runtimeSelection.modelId}` : undefined const runtimeModelLabel = runtimeSelection?.modelId ?? currentModel?.name ?? currentModel?.id const activeSession = useSessionStore((state) => activeTabId ? state.sessions.find((session) => session.id === activeTabId) ?? null : null) const memberInfo = useTeamStore((s) => activeTabId ? s.getMemberBySessionId(activeTabId) : null) const [gitInfo, setGitInfo] = useState(null) const hasMessages = useChatStore((s) => activeTabId ? (s.sessions[activeTabId]?.messages?.length ?? 0) > 0 : false) const workspaceReferences = useWorkspaceChatContextStore( (s) => activeTabId ? s.referencesBySession[activeTabId] ?? EMPTY_WORKSPACE_REFERENCES : EMPTY_WORKSPACE_REFERENCES, ) const addWorkspaceReference = useWorkspaceChatContextStore((s) => s.addReference) const removeWorkspaceReference = useWorkspaceChatContextStore((s) => s.removeReference) const clearWorkspaceReferences = useWorkspaceChatContextStore((s) => s.clearReferences) const isMemberSession = !!memberInfo const isActive = chatState !== 'idle' const isWorkspaceMissing = activeSession?.workDirExists === false const hasWorkspaceReferences = !isMemberSession && workspaceReferences.length > 0 const canSubmit = !isWorkspaceMissing && (input.trim().length > 0 || (!isMemberSession && (attachments.length > 0 || hasWorkspaceReferences))) const isHeroComposer = variant === 'hero' && !isMemberSession && !compact const resolvedWorkDir = activeSession?.workDir || gitInfo?.workDir || undefined const composerAttachments = useMemo( () => [ ...attachments, ...workspaceReferences.map(workspaceReferenceToAttachment), ], [attachments, workspaceReferences], ) useEffect(() => { textareaRef.current?.focus() }, [isActive]) useEffect(() => { if (!composerPrefill) return setInput(composerPrefill.text) setAttachments( (composerPrefill.attachments ?? []) .filter((attachment) => attachment.type === 'image' || attachment.data) .map((attachment, index) => ({ id: `rewind-prefill-${composerPrefill.nonce}-${index}`, name: attachment.name, type: attachment.type, mimeType: attachment.mimeType, previewUrl: attachment.type === 'image' ? attachment.data : undefined, data: attachment.data, })), ) setPlusMenuOpen(false) setSlashMenuOpen(false) setFileSearchOpen(false) setSlashFilter('') setAtFilter('') setAtCursorPos(-1) requestAnimationFrame(() => { const el = textareaRef.current el?.focus() const cursor = composerPrefill.text.length el?.setSelectionRange(cursor, cursor) }) }, [composerPrefill]) useEffect(() => { if (!activeTabId) { setGitInfo(null) return } if (isMemberSession) { setGitInfo(null) return } sessionsApi.getGitInfo(activeTabId).then(setGitInfo).catch(() => setGitInfo(null)) }, [activeTabId, isMemberSession]) useEffect(() => { if (!isMemberSession) return setAttachments([]) setPlusMenuOpen(false) setSlashMenuOpen(false) setFileSearchOpen(false) }, [isMemberSession, activeTabId]) useEffect(() => { const el = textareaRef.current if (!el) return el.style.height = 'auto' el.style.height = `${Math.min(el.scrollHeight, 200)}px` }, [input]) useEffect(() => { if (!plusMenuOpen) return const handleClick = (event: MouseEvent) => { if (plusMenuRef.current && !plusMenuRef.current.contains(event.target as Node)) { setPlusMenuOpen(false) } } document.addEventListener('mousedown', handleClick) return () => document.removeEventListener('mousedown', handleClick) }, [plusMenuOpen]) useEffect(() => { if (!slashMenuOpen) return const handleClick = (event: MouseEvent) => { if ( slashMenuRef.current && !slashMenuRef.current.contains(event.target as Node) && textareaRef.current && !textareaRef.current.contains(event.target as Node) ) { setSlashMenuOpen(false) } } document.addEventListener('mousedown', handleClick) return () => document.removeEventListener('mousedown', handleClick) }, [slashMenuOpen]) useEffect(() => { if (!localSlashPanel) return const handleClick = (event: MouseEvent) => { if ( slashMenuRef.current && !slashMenuRef.current.contains(event.target as Node) && textareaRef.current && !textareaRef.current.contains(event.target as Node) ) { setLocalSlashPanel(null) } } document.addEventListener('mousedown', handleClick) return () => document.removeEventListener('mousedown', handleClick) }, [localSlashPanel]) useEffect(() => { if (!fileSearchOpen) return const handleClick = (event: MouseEvent) => { const menu = document.getElementById('file-search-menu') if ( menu && !menu.contains(event.target as Node) && textareaRef.current && !textareaRef.current.contains(event.target as Node) ) { setFileSearchOpen(false) } } document.addEventListener('mousedown', handleClick) return () => document.removeEventListener('mousedown', handleClick) }, [fileSearchOpen]) const allSlashCommands = useMemo( () => mergeSlashCommands(slashCommands, FALLBACK_SLASH_COMMANDS), [slashCommands], ) const filteredCommands = useMemo(() => { const source = allSlashCommands if (!slashFilter) return source const lower = slashFilter.toLowerCase() return source.filter((command) => ( command.name.toLowerCase().includes(lower) || command.description.toLowerCase().includes(lower) )) }, [allSlashCommands, slashFilter]) const exactSlashCommand = useMemo(() => { const normalized = slashFilter.trim().toLowerCase() if (!normalized) return null return filteredCommands.find((command) => command.name.toLowerCase() === normalized) ?? null }, [filteredCommands, slashFilter]) useEffect(() => { setSlashSelectedIndex(0) }, [slashFilter]) useEffect(() => { const activeItem = slashMenuOpen ? slashItemRefs.current[slashSelectedIndex] : null if (activeItem && typeof activeItem.scrollIntoView === 'function') { activeItem.scrollIntoView({ block: 'nearest' }) } }, [slashMenuOpen, slashSelectedIndex]) const detectSlashTrigger = useCallback((value: string, cursorPos: number) => { const token = findSlashTrigger(value, cursorPos) if (!token) { setSlashMenuOpen(false) return } setFileSearchOpen(false) setSlashFilter(token.filter) setSlashMenuOpen(true) }, []) // Detect @ trigger (file search) const detectAtTrigger = useCallback((value: string, cursorPos: number) => { const textBeforeCursor = value.slice(0, cursorPos) let pos = -1 for (let i = textBeforeCursor.length - 1; i >= 0; i--) { const ch = textBeforeCursor[i]! if (ch === '@') { if (i === 0 || /\s/.test(textBeforeCursor[i - 1]!)) { pos = i break } break } if (/\s/.test(ch)) { break } } if (pos < 0) { setFileSearchOpen(false) setAtFilter('') setAtCursorPos(-1) return } // Extract filter text after @ const filter = textBeforeCursor.slice(pos + 1) setAtFilter(filter) setAtCursorPos(pos) setSlashMenuOpen(false) setFileSearchOpen(true) }, []) const handleInputChange = (event: React.ChangeEvent) => { const value = event.target.value if (isMemberSession) { setInput(value) return } const cursorPos = event.target.selectionStart ?? value.length setInput(value) detectSlashTrigger(value, cursorPos) detectAtTrigger(value, cursorPos) } const selectSlashCommand = useCallback((command: string) => { const el = textareaRef.current if (!el) return const cursorPos = el.selectionStart ?? input.length const replacement = replaceSlashToken(input, cursorPos, command) setInput(replacement.value) setSlashMenuOpen(false) requestAnimationFrame(() => { el.focus() el.setSelectionRange(replacement.cursorPos, replacement.cursorPos) }) }, [input]) const handleSubmit = () => { const text = input.trim() if ((!text && ((!attachments.length && !hasWorkspaceReferences) || isMemberSession)) || isWorkspaceMissing) return const slashUiAction = !isMemberSession && text.startsWith('/') ? resolveSlashUiAction(text.slice(1)) : null if (slashUiAction?.type === 'panel') { setLocalSlashPanel(slashUiAction.command as LocalSlashCommandName) setInput('') setSlashMenuOpen(false) setFileSearchOpen(false) setPlusMenuOpen(false) return } if (slashUiAction?.type === 'settings') { useUIStore.getState().setPendingSettingsTab(slashUiAction.tab) useTabStore.getState().openTab(SETTINGS_TAB_ID, 'Settings', 'settings') setInput('') setSlashMenuOpen(false) setFileSearchOpen(false) setPlusMenuOpen(false) return } const workspaceReferencePrompt = !isMemberSession ? formatWorkspaceReferencePrompt(workspaceReferences) : '' const contentForModel = [workspaceReferencePrompt, text].filter(Boolean).join('\n\n') const displayContent = text || ( workspaceReferences.length > 0 ? t('chat.workspaceReferencesOnly', { count: workspaceReferences.length }) : '' ) const uploadAttachmentPayload: AttachmentRef[] = attachments.map((attachment) => ({ type: attachment.type, name: attachment.name, path: attachment.path, data: attachment.data, mimeType: attachment.mimeType, lineStart: attachment.lineStart, lineEnd: attachment.lineEnd, note: attachment.note, quote: attachment.quote, })) const workspaceAttachmentPayload: AttachmentRef[] = workspaceReferences.map((reference) => ({ type: 'file' as const, name: reference.name, path: reference.absolutePath ?? reference.path, lineStart: reference.lineStart, lineEnd: reference.lineEnd, note: reference.note, quote: reference.quote, })) const visibleAttachmentPayload: AttachmentRef[] = [ ...uploadAttachmentPayload, ...workspaceReferences.map((reference) => ({ type: 'file' as const, name: reference.name, path: reference.path, lineStart: reference.lineStart, lineEnd: reference.lineEnd, note: reference.note, quote: reference.quote, })), ] sendMessage(activeTabId!, contentForModel, [...uploadAttachmentPayload, ...workspaceAttachmentPayload], { displayContent, displayAttachments: visibleAttachmentPayload, }) setInput('') setAttachments([]) if (!isMemberSession) clearWorkspaceReferences(activeTabId!) setPlusMenuOpen(false) setSlashMenuOpen(false) setFileSearchOpen(false) setLocalSlashPanel(null) } const handleKeyDown = (event: React.KeyboardEvent) => { // Ignore key events during IME composition (e.g. Chinese input method) if (composingRef.current || event.nativeEvent.isComposing || event.keyCode === 229) return // Route file search navigation keys to FileSearchMenu if (fileSearchOpen) { const key = event.key if (key === 'ArrowDown' || key === 'ArrowUp' || key === 'Enter' || key === 'Tab' || key === 'Escape') { event.preventDefault() if (key === 'Escape') { setFileSearchOpen(false) setAtFilter('') setAtCursorPos(-1) return } fileSearchRef.current?.handleKeyDown(event.nativeEvent) return } // Other keys (typing) should go to the textarea - let it propagate return } if (localSlashPanel) { if (event.key === 'Escape') { event.preventDefault() setLocalSlashPanel(null) return } } if (slashMenuOpen && filteredCommands.length > 0) { if (event.key === 'ArrowDown') { event.preventDefault() setSlashSelectedIndex((prev) => (prev + 1) % filteredCommands.length) return } if (event.key === 'ArrowUp') { event.preventDefault() setSlashSelectedIndex((prev) => (prev - 1 + filteredCommands.length) % filteredCommands.length) return } if (event.key === 'Enter') { if (exactSlashCommand && slashFilter.trim().toLowerCase() === exactSlashCommand.name.toLowerCase()) { event.preventDefault() handleSubmit() return } event.preventDefault() const selected = filteredCommands[slashSelectedIndex] if (selected) selectSlashCommand(selected.name) return } if (event.key === 'Tab') { event.preventDefault() const selected = filteredCommands[slashSelectedIndex] if (selected) selectSlashCommand(selected.name) return } if (event.key === 'Escape') { event.preventDefault() setSlashMenuOpen(false) return } } if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault() handleSubmit() } } const handlePaste = (event: React.ClipboardEvent) => { if (isMemberSession) return const items = event.clipboardData?.items if (!items) return let hasImage = false for (let i = 0; i < items.length; i += 1) { const item = items[i] if (!item || !item.type.startsWith('image/')) continue hasImage = true event.preventDefault() const file = item.getAsFile() if (!file) continue const id = `att-${Date.now()}-${Math.random().toString(36).slice(2)}` const reader = new FileReader() reader.onload = () => { setAttachments((prev) => [ ...prev, { id, name: `pasted-image-${Date.now()}.png`, type: 'image', mimeType: file.type || 'image/png', previewUrl: reader.result as string, data: reader.result as string, }, ]) } reader.readAsDataURL(file) } if (!hasImage) return } const handleFileSelect = (event: React.ChangeEvent) => { if (isMemberSession) return const files = event.target.files if (!files) return Array.from(files).forEach((file) => { const id = `att-${Date.now()}-${Math.random().toString(36).slice(2)}` const isImage = file.type.startsWith('image/') const reader = new FileReader() reader.onload = () => { setAttachments((prev) => [ ...prev, { id, name: file.name, type: isImage ? 'image' : 'file', mimeType: file.type || undefined, previewUrl: isImage ? (reader.result as string) : undefined, data: reader.result as string, }, ]) } reader.readAsDataURL(file) }) event.target.value = '' } const handleDrop = (event: React.DragEvent) => { event.preventDefault() if (isMemberSession) return const files = event.dataTransfer.files if (files.length > 0) { const fakeEvent = { target: { files } } as React.ChangeEvent handleFileSelect(fakeEvent) } } const removeAttachment = (id: string) => { setAttachments((prev) => prev.filter((attachment) => attachment.id !== id)) if (activeTabId) removeWorkspaceReference(activeTabId, id) } const insertSlashCommand = () => { if (isMemberSession) return const el = textareaRef.current const cursorPos = el?.selectionStart ?? input.length const replacement = replaceSlashToken(input, cursorPos, '', { trailingSpace: false }) setInput(replacement.value) setPlusMenuOpen(false) setSlashFilter('') setSlashMenuOpen(true) requestAnimationFrame(() => { textareaRef.current?.focus() textareaRef.current?.setSelectionRange(replacement.cursorPos, replacement.cursorPos) }) } const composerPlaceholder = isHeroComposer ? t('empty.placeholder') : isWorkspaceMissing ? t('chat.placeholderMissing') : isMemberSession ? t('teams.memberPlaceholder') : t('chat.placeholder') const addFilesLabel = isHeroComposer ? t('empty.addFiles') : t('chat.addFiles') const slashCommandsLabel = isHeroComposer ? t('empty.slashCommands') : t('chat.slashCommands') return (
event.preventDefault()} onDrop={handleDrop} > {!isMemberSession && fileSearchOpen && ( { if (atCursorPos < 0) return const replacement = `@${relativePath}` const tokenEnd = atCursorPos + 1 + atFilter.length const newValue = `${input.slice(0, atCursorPos)}${replacement}${input.slice(tokenEnd)}` const newCursorPos = atCursorPos + replacement.length setInput(newValue) setAtFilter(relativePath) requestAnimationFrame(() => { textareaRef.current?.focus() textareaRef.current?.setSelectionRange(newCursorPos, newCursorPos) }) }} onSelect={(path, name) => { if (atCursorPos >= 0) { const referenceName = name.split('/').filter(Boolean).pop() ?? name const tokenEnd = atCursorPos + 1 + atFilter.length const beforeToken = input.slice(0, atCursorPos) const afterToken = beforeToken ? input.slice(tokenEnd) : input.slice(tokenEnd).replace(/^\s+/, '') const spacer = beforeToken && afterToken && !/\s$/.test(beforeToken) && !/^\s/.test(afterToken) ? ' ' : '' const newValue = `${beforeToken}${spacer}${afterToken}` const newCursorPos = atCursorPos + spacer.length if (activeTabId) { addWorkspaceReference(activeTabId, { kind: 'file', path, absolutePath: path, name: referenceName, }) } setInput(newValue) setFileSearchOpen(false) setAtFilter('') setAtCursorPos(-1) void textareaRef.current?.focus() requestAnimationFrame(() => { textareaRef.current?.setSelectionRange(newCursorPos, newCursorPos) }) } }} /> )} {!isMemberSession && localSlashPanel && (
setLocalSlashPanel(null)} />
)} {!isMemberSession && slashMenuOpen && filteredCommands.length > 0 && (
{filteredCommands.map((command, index) => ( ))}
Up/Down {t('chat.navigate')} Enter {t('chat.select')} Esc {t('chat.dismiss')}
)} {composerAttachments.length > 0 && ( isHeroComposer ? ( ) : (
) )} {isHeroComposer ? (