import { useState, useRef, useEffect, useCallback, useMemo, memo } from 'react' import { useTranslation } from 'react-i18next' import { AttachmentPreview, type Attachment } from '../attachment' import { MentionMenu, detectMentionTrigger, normalizePath, toFileUrl, type MentionMenuHandle, type MentionItem, } from '../mention' import { SlashCommandMenu, type SlashCommandMenuHandle } from '../slash-command' import { InputToolbar } from './input/InputToolbar' import type { ModelSelectorHandle } from './ModelSelector' import { InputFooter } from './input/InputFooter' import { FloatingActions, CollapsedCapsule } from './input/InputActions' import { useMobileCollapse } from './input/useMobileCollapse' import { useAttachmentRail } from './input/useAttachmentRail' import { useInputHistory } from './input/useInputHistory' import { TEXT_STYLE, detectSlashTrigger, isFileSupported, ensureFileMime, readFileAsDataUrl } from './input/inputUtils' import { keybindingStore, matchesKeybinding } from '../../store/keybindingStore' import { useChatViewport } from './chatViewport' import type { ApiAgent } from '../../api/client' import type { ModelInfo, FileCapabilities } from '../../api' import type { Command } from '../../api/command' import type { SttInsertMode } from '../../types/api/stt' // ============================================ // Types // ============================================ interface HistoryEntry { text: string attachments: Attachment[] } export interface CollapsedDialogInfo { label: string queueLength: number onExpand: () => void } export interface InputBoxProps { paneId: string onSend: ( text: string, attachments: Attachment[], options?: { agent?: string; variant?: string }, ) => Promise | boolean onAbort?: () => void onCommand?: (command: string) => Promise | boolean // 斜杠命令回调,接收完整命令字符串如 "/help" onNewChat?: () => void // 新建对话回调 disabled?: boolean isStreaming?: boolean agents?: ApiAgent[] selectedAgent?: string onAgentChange?: (agentName: string) => void variants?: string[] selectedVariant?: string onVariantChange?: (variant: string | undefined) => void supportsImages?: boolean // 保留向后兼容(deprecated,优先用 fileCapabilities) fileCapabilities?: FileCapabilities // Model(移动端 InputToolbar 用) models?: ModelInfo[] selectedModelKey?: string | null onModelChange?: (modelKey: string, model: ModelInfo) => void modelsLoading?: boolean modelSelectorRef?: React.RefObject rootPath?: string sessionId?: string | null // Undo/Redo revertedText?: string revertedAttachments?: Attachment[] canRedo?: boolean revertSteps?: number onRedo?: () => void onRedoAll?: () => void onClearRevert?: () => void // Animation registerInputBox?: (element: HTMLElement | null) => void isAtBottom?: boolean showScrollToBottom?: boolean onScrollToBottom?: () => void // Collapsed dialog capsules collapsedPermission?: CollapsedDialogInfo collapsedQuestion?: CollapsedDialogInfo } // ============================================ // InputBox Component // ============================================ function InputBoxComponent({ paneId, onSend, onAbort, onCommand, onNewChat, disabled, isStreaming, agents = [], selectedAgent, onAgentChange, variants = [], selectedVariant, onVariantChange, supportsImages = false, fileCapabilities: fileCapabilitiesProp, models = [], selectedModelKey = null, onModelChange, modelsLoading = false, modelSelectorRef, rootPath = '', sessionId, revertedText, revertedAttachments, canRedo = false, revertSteps = 0, onRedo, onRedoAll, onClearRevert, registerInputBox, isAtBottom = true, showScrollToBottom = false, onScrollToBottom, collapsedPermission, collapsedQuestion, }: InputBoxProps) { const { t } = useTranslation('chat') // 合并文件能力:优先用 fileCapabilities,回退到 supportsImages const fileCaps: FileCapabilities = useMemo( () => fileCapabilitiesProp ?? { image: supportsImages, pdf: false, audio: false, video: false, }, [fileCapabilitiesProp, supportsImages], ) // 是否有任何文件附件能力 const supportsAnyFile = fileCaps.image || fileCaps.pdf || fileCaps.audio || fileCaps.video // 文本状态 const [text, setText] = useState('') // 附件状态(图片、文件、文件夹、agent) const [attachments, setAttachments] = useState([]) const [isSubmitting, setIsSubmitting] = useState(false) // @ Mention 状态 const [mentionOpen, setMentionOpen] = useState(false) const [mentionQuery, setMentionQuery] = useState('') const [mentionStartIndex, setMentionStartIndex] = useState(-1) // / Slash Command 状态 const [slashOpen, setSlashOpen] = useState(false) const [slashQuery, setSlashQuery] = useState('') const [slashStartIndex, setSlashStartIndex] = useState(-1) // 拖拽状态 const [isDragging, setIsDragging] = useState(false) const dragCounterRef = useRef(0) const { presentation, interaction } = useChatViewport() const isCompact = presentation.isCompact // Refs const textareaRef = useRef(null) const inputContainerRef = useRef(null) const attachmentRailRef = useRef(null) const mentionMenuRef = useRef(null) const slashMenuRef = useRef(null) const prevRevertedTextRef = useRef(undefined) const latestDraftRef = useRef({ text: '', attachments: [] }) const contentWrapRef = useRef(null) const footerRef = useRef(null) const isComposingRef = useRef(false) const compositionEndTimerRef = useRef(null) // 附件横向轨道 const { overflowing: attachmentsOverflowing, showLeftFade: showAttachmentLeftFade, showRightFade: showAttachmentRightFade, handleScroll: syncAttachmentRailState, handleWheel: handleAttachmentRailWheel, } = useAttachmentRail({ attachmentCount: attachments.length, railRef: attachmentRailRef }) // ============================================ // 历史消息导航(类终端体验,逻辑在 useInputHistory hook 中) // ============================================ const { handleHistoryKeyDown, handleHistoryChange, resetHistoryIndex } = useInputHistory({ textareaRef }) // ============================================ // Mobile Input Dock: 滚动收起/展开(逻辑在 useMobileCollapse hook 中) // ============================================ const hasContent = text.trim().length > 0 || attachments.length > 0 const { isCollapsed, expandedHeight, handleExpandInput, handleFocus, handleBlur, handleContainerPointerDown } = useMobileCollapse({ enabled: interaction.enableCollapsedInputDock, hasContent, isAtBottom, textareaRef, inputContainerRef, contentWrapRef, footerRef, registerInputBox, collapsedPermission, collapsedQuestion, }) // 处理 revert 恢复 useEffect(() => { latestDraftRef.current = { text, attachments } }, [text, attachments]) useEffect(() => { let frameId: number | null = null if (revertedText !== undefined) { frameId = requestAnimationFrame(() => { setText(revertedText) setAttachments(revertedAttachments || []) // 聚焦并移动光标到末尾 if (textareaRef.current) { textareaRef.current.focus() textareaRef.current.setSelectionRange(revertedText.length, revertedText.length) } }) } else if (prevRevertedTextRef.current !== undefined && revertedText === undefined && !isSubmitting) { frameId = requestAnimationFrame(() => { setText('') setAttachments([]) }) } prevRevertedTextRef.current = revertedText return () => { if (frameId !== null) { cancelAnimationFrame(frameId) } } }, [revertedText, revertedAttachments, isSubmitting]) useEffect( () => () => { if (compositionEndTimerRef.current !== null) { clearTimeout(compositionEndTimerRef.current) } }, [], ) // 自动调整 textarea 高度 useEffect(() => { const textarea = textareaRef.current if (!textarea) return // 只有真正空字符串时才重置高度;保留仅空格/空行时的换行高度 if (text.length === 0) { textarea.style.height = '24px' return } textarea.style.height = 'auto' const scrollHeight = textarea.scrollHeight // 原生层已处理键盘 resize,window.innerHeight 即可用高度 const viewportH = window.innerHeight // 可用高度 = viewport - header(48px) - toolbar/padding/footer(~100px) - 安全余量 const maxH = isCompact ? Math.max(80, viewportH - 48 - 100 - 72) : viewportH * 0.35 textarea.style.height = Math.max(24, Math.min(scrollHeight, maxH)) + 'px' }, [text, isCompact]) // 计算 const inputDisabled = !!disabled const canSend = (text.trim().length > 0 || attachments.length > 0) && !inputDisabled // ============================================ // Handlers // ============================================ const resetDraft = useCallback(() => { latestDraftRef.current = { text: '', attachments: [] } setText('') setAttachments([]) resetHistoryIndex() }, [resetHistoryIndex]) const restoreDraft = useCallback( (draft: HistoryEntry) => { latestDraftRef.current = draft setText(draft.text) setAttachments(draft.attachments) resetHistoryIndex() requestAnimationFrame(() => { if (!textareaRef.current) return const cursorPos = draft.text.length textareaRef.current.focus() textareaRef.current.setSelectionRange(cursorPos, cursorPos) }) }, [resetHistoryIndex], ) const submitCommandOptimistically = useCallback( (commandStr: string) => { if (!onCommand) return const draftSnapshot: HistoryEntry = { text, attachments: [...attachments], } resetDraft() requestAnimationFrame(() => { if (!textareaRef.current) return textareaRef.current.focus() textareaRef.current.setSelectionRange(0, 0) }) void (async () => { let result: boolean | void try { result = await onCommand(commandStr) } catch { result = false } if (result !== false) { onClearRevert?.() return } const currentDraft = latestDraftRef.current if (currentDraft.text.length === 0 && currentDraft.attachments.length === 0) { restoreDraft(draftSnapshot) } })() }, [attachments, onClearRevert, onCommand, resetDraft, restoreDraft, text], ) const runSubmit = useCallback( async (submit: () => Promise | boolean | void, onSuccess?: () => void, onFailure?: () => void) => { if (isSubmitting) return false setIsSubmitting(true) try { const result = await submit() if (result === false) { onFailure?.() return false } onSuccess?.() return true } finally { setIsSubmitting(false) } }, [isSubmitting], ) const handleSend = useCallback(() => { if (!canSend || isSubmitting) return // 检测 command attachment const commandAttachment = attachments.find(a => a.type === 'command') if (commandAttachment && commandAttachment.commandName) { if (!onCommand) return // 提取命令后的参数文本 const textRange = commandAttachment.textRange const afterCommand = textRange ? text.slice(textRange.end).trim() : '' const commandStr = `/${commandAttachment.commandName}${afterCommand ? ' ' + afterCommand : ''}` submitCommandOptimistically(commandStr) return } // 从 attachments 中找 agent mention const agentAttachment = attachments.find(a => a.type === 'agent') const mentionedAgent = agentAttachment?.agentName void runSubmit( () => onSend(text, attachments, { agent: mentionedAgent || selectedAgent, variant: selectedVariant, }), () => { resetDraft() onClearRevert?.() }, ) }, [ attachments, canSend, isSubmitting, onCommand, onClearRevert, onSend, resetDraft, runSubmit, selectedAgent, selectedVariant, submitCommandOptimistically, text, ]) // 更新 @ 查询文本(用于进入/退出文件夹) const updateMentionQuery = useCallback( (newQuery: string) => { if (!textareaRef.current) return const beforeAt = text.slice(0, mentionStartIndex) const afterQuery = text.slice(mentionStartIndex + 1 + mentionQuery.length) const newText = beforeAt + '@' + newQuery + afterQuery setText(newText) setMentionQuery(newQuery) // 移动光标到 @ 查询末尾 requestAnimationFrame(() => { if (!textareaRef.current) return const pos = mentionStartIndex + 1 + newQuery.length textareaRef.current.setSelectionRange(pos, pos) textareaRef.current.focus() }) }, [text, mentionStartIndex, mentionQuery], ) const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { const nativeEvent = e.nativeEvent const isImeComposing = isComposingRef.current || nativeEvent.isComposing || nativeEvent.keyCode === 229 if (isImeComposing && (e.key === 'Enter' || e.key === 'Tab')) return // Slash Command 菜单打开时,拦截导航键 if (slashOpen && slashMenuRef.current) { switch (e.key) { case 'ArrowUp': e.preventDefault() slashMenuRef.current.moveUp() return case 'ArrowDown': e.preventDefault() slashMenuRef.current.moveDown() return case 'Enter': case 'Tab': e.preventDefault() slashMenuRef.current.selectCurrent() return case 'Escape': e.preventDefault() setSlashOpen(false) return } } // Mention 菜单打开时,拦截导航键 if (mentionOpen && mentionMenuRef.current) { switch (e.key) { case 'ArrowUp': e.preventDefault() mentionMenuRef.current.moveUp() return case 'ArrowDown': e.preventDefault() mentionMenuRef.current.moveDown() return case 'ArrowRight': { // 进入文件夹 const selected = mentionMenuRef.current.getSelectedItem() if (selected?.type === 'folder') { e.preventDefault() const basePath = (selected.relativePath || selected.displayName).replace(/\/+$/, '') const folderPath = basePath + '/' updateMentionQuery(folderPath) } return } case 'ArrowLeft': { // 返回上一级 if (mentionQuery.includes('/')) { e.preventDefault() const parts = mentionQuery.replace(/\/$/, '').split('/') // 记住当前目录名,返回后定位到它 const folderName = parts[parts.length - 1] if (folderName) { mentionMenuRef.current.setRestoreFolder(folderName) } parts.pop() const parentPath = parts.length > 0 ? parts.join('/') + '/' : '' updateMentionQuery(parentPath) } return } case 'Enter': case 'Tab': e.preventDefault() mentionMenuRef.current.selectCurrent() return case 'Escape': e.preventDefault() setMentionOpen(false) return } } // Tab 键:mention 菜单关闭时,不做任何事(阻止跳到工具栏) if (e.key === 'Tab') { e.preventDefault() return } // 历史消息导航(类终端体验) const historyResult = handleHistoryKeyDown(e, text, attachments) if (historyResult) { setText(historyResult.text) setAttachments(historyResult.attachments) requestAnimationFrame(() => { if (!textareaRef.current) return const cursorPos = historyResult.cursor === 'start' ? 0 : historyResult.text.length textareaRef.current.focus() textareaRef.current.setSelectionRange(cursorPos, cursorPos) }) return } // 发送消息(读取 keybinding 配置) const sendKey = keybindingStore.getKey('sendMessage') if (sendKey && !isImeComposing && matchesKeybinding(nativeEvent, sendKey)) { e.preventDefault() handleSend() } }, [mentionOpen, slashOpen, mentionQuery, updateMentionQuery, handleSend, text, attachments, handleHistoryKeyDown], ) const handleChange = useCallback( (e: React.ChangeEvent) => { const newText = e.target.value setText(newText) // 用户修改了内容,检查是否应退出历史模式 handleHistoryChange(newText) // 同步检测 mention 是否被破坏/删除 // 比对 attachments 的 textRange:如果文本中对应位置不再匹配,删除该 attachment setAttachments(prev => { const surviving = prev.filter(a => { if (!a.textRange) return true // 图片等无 textRange 的保留 const { start, end, value } = a.textRange const actual = newText.slice(start, end) return actual === value }) // 只在数量变化时更新(避免不必要的 re-render) return surviving.length === prev.length ? prev : surviving }) // 检测 @ 触发 const cursorPos = e.target.selectionStart || 0 const trigger = detectMentionTrigger(newText, cursorPos, '@') if (trigger) { setMentionQuery(trigger.query) setMentionStartIndex(trigger.startIndex) setMentionOpen(true) setSlashOpen(false) // 关闭斜杠菜单 } else { setMentionOpen(false) // 检测 / 触发(只在行首或空白后) const slashTrigger = detectSlashTrigger(newText, cursorPos) if (slashTrigger) { setSlashQuery(slashTrigger.query) setSlashStartIndex(slashTrigger.startIndex) setSlashOpen(true) } else { setSlashOpen(false) } } }, [handleHistoryChange], ) const handleCompositionStart = useCallback(() => { if (compositionEndTimerRef.current !== null) { clearTimeout(compositionEndTimerRef.current) compositionEndTimerRef.current = null } isComposingRef.current = true }, []) const handleCompositionEnd = useCallback(() => { if (compositionEndTimerRef.current !== null) { clearTimeout(compositionEndTimerRef.current) } compositionEndTimerRef.current = window.setTimeout(() => { isComposingRef.current = false compositionEndTimerRef.current = null }, 0) }, []) // @ Mention 选择处理 const handleMentionSelect = useCallback( (item: MentionItem & { _enterFolder?: boolean }) => { if (!textareaRef.current) return // 如果是进入文件夹 if (item._enterFolder && item.type === 'folder') { const basePath = (item.relativePath || item.displayName).replace(/\/+$/, '') const folderPath = basePath + '/' updateMentionQuery(folderPath) return } // 构建 @ 文本 const mentionText = item.type === 'agent' ? `@${item.displayName}` : `@${item.relativePath || item.displayName}` // 计算新文本 const beforeAt = text.slice(0, mentionStartIndex) const afterQuery = text.slice(mentionStartIndex + 1 + mentionQuery.length) const newText = beforeAt + mentionText + ' ' + afterQuery // 创建附件 const attachment: Attachment = { id: crypto.randomUUID(), type: item.type, displayName: item.displayName, relativePath: item.relativePath, url: item.type !== 'agent' ? item.value : undefined, mime: item.type !== 'agent' ? 'text/plain' : undefined, agentName: item.type === 'agent' ? item.displayName : undefined, textRange: { value: mentionText, start: mentionStartIndex, end: mentionStartIndex + mentionText.length, }, } setText(newText) setAttachments(prev => [...prev, attachment]) setMentionOpen(false) // 移动光标到 mention 后 requestAnimationFrame(() => { if (!textareaRef.current) return const newCursorPos = mentionStartIndex + mentionText.length + 1 textareaRef.current.setSelectionRange(newCursorPos, newCursorPos) textareaRef.current.focus() }) }, [text, mentionStartIndex, mentionQuery, updateMentionQuery], ) const handleMentionClose = useCallback(() => { setMentionOpen(false) textareaRef.current?.focus() }, []) // / Slash Command 选择处理 - 类似 @ mention const handleSlashSelect = useCallback( (command: Command) => { if (command.source === 'frontend') { if (!onCommand) return setSlashOpen(false) submitCommandOptimistically(`/${command.name}`) requestAnimationFrame(() => textareaRef.current?.focus()) return } if (!textareaRef.current) return // 构建 /command 文本 const commandText = `/${command.name}` // 计算新文本:替换 /query 为 /command const beforeSlash = text.slice(0, slashStartIndex) const afterQuery = text.slice(slashStartIndex + 1 + slashQuery.length) const newText = beforeSlash + commandText + ' ' + afterQuery // 创建 command attachment const attachment: Attachment = { id: crypto.randomUUID(), type: 'command', displayName: command.name, commandName: command.name, textRange: { value: commandText, start: slashStartIndex, end: slashStartIndex + commandText.length, }, } setText(newText) setAttachments(prev => [...prev, attachment]) setSlashOpen(false) // 移动光标到命令后 requestAnimationFrame(() => { if (!textareaRef.current) return const newCursorPos = slashStartIndex + commandText.length + 1 textareaRef.current.setSelectionRange(newCursorPos, newCursorPos) textareaRef.current.focus() }) }, [text, slashStartIndex, slashQuery, onCommand, submitCommandOptimistically], ) const handleSlashClose = useCallback(() => { setSlashOpen(false) textareaRef.current?.focus() }, []) // 通用文件上传 — 根据模型能力判断是否接受 const handleFilesSelected = useCallback( async (files: File[]) => { if (files.length === 0 || !supportsAnyFile || isSubmitting) return const nextAttachments: Attachment[] = [] for (const rawFile of files) { const file = ensureFileMime(rawFile) // 按 MIME 类型检查模型能力 if (!isFileSupported(file.type, fileCaps)) continue try { const dataUrl = await readFileAsDataUrl(file) nextAttachments.push({ id: crypto.randomUUID(), type: 'file', displayName: file.name, url: dataUrl, mime: file.type, }) } catch (err) { console.warn('[InputBox] Failed to process file:', err) } } if (nextAttachments.length > 0) { setAttachments(prev => [...prev, ...nextAttachments]) } }, [supportsAnyFile, fileCaps, isSubmitting], ) const handleVoiceTranscription = useCallback((transcribedText: string, insertMode: SttInsertMode) => { const cleanText = transcribedText.trim() if (!cleanText) return setText(current => { if (insertMode === 'replace' || !current.trim()) return cleanText const needsSpace = !current.endsWith(' ') && !current.endsWith('\n') return `${current}${needsSpace ? ' ' : ''}${cleanText}` }) requestAnimationFrame(() => { const textarea = textareaRef.current if (!textarea) return textarea.focus() textarea.setSelectionRange(textarea.value.length, textarea.value.length) }) }, []) // 删除附件 const handleRemoveAttachment = useCallback( (id: string) => { if (isSubmitting) return const attachment = attachments.find(a => a.id === id) if (!attachment) return // 如果有 textRange,从文本中删除 @mention if (attachment.textRange) { const { value } = attachment.textRange // 删除 @mention 和后面的空格 const newText = text.replace(value + ' ', '').replace(value, '') setText(newText) } setAttachments(prev => prev.filter(a => a.id !== id)) }, [attachments, isSubmitting, text], ) // 粘贴处理 — 根据模型能力过滤可粘贴的文件类型 const handlePaste = useCallback( (e: React.ClipboardEvent) => { if (supportsAnyFile) { const items = e.clipboardData?.items const files: File[] = [] if (items) { for (let i = 0; i < items.length; i++) { if (items[i].kind === 'file') { const file = items[i].getAsFile() if (file && isFileSupported(ensureFileMime(file).type, fileCaps)) files.push(file) } } } if (files.length > 0) { e.preventDefault() void handleFilesSelected(files) return } } // 文本粘贴:让 textarea 默认处理(天然支持换行和 undo) }, [supportsAnyFile, fileCaps, handleFilesSelected], ) // 拖拽文件到输入框 const handleDragEnter = useCallback( (e: React.DragEvent) => { e.preventDefault() e.stopPropagation() dragCounterRef.current++ // 内部拖拽(FileExplorer)或原生文件拖拽都高亮 if ( e.dataTransfer.types.includes('application/pi-file') || (supportsAnyFile && e.dataTransfer.types.includes('Files')) ) { setIsDragging(true) } }, [supportsAnyFile], ) const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault() e.stopPropagation() }, []) const handleDragLeave = useCallback((e: React.DragEvent) => { e.preventDefault() e.stopPropagation() dragCounterRef.current-- if (dragCounterRef.current === 0) { setIsDragging(false) } }, []) // 将拖入的文件信息插入为 @mention 附件 const insertDraggedFile = useCallback( (fileInfo: { type: 'file' | 'folder'; path: string; absolute: string; name: string }) => { const relativePath = normalizePath(fileInfo.path) const mentionText = `@${relativePath}` const cursorPos = textareaRef.current?.selectionStart ?? text.length // 在光标位置插入 @mention 文本 const beforeCursor = text.slice(0, cursorPos) const afterCursor = text.slice(cursorPos) // 如果光标前不是空格或空文本,插入空格分隔 const needSpaceBefore = beforeCursor.length > 0 && !beforeCursor.endsWith(' ') && !beforeCursor.endsWith('\n') const prefix = needSpaceBefore ? ' ' : '' const newText = beforeCursor + prefix + mentionText + ' ' + afterCursor const mentionStart = cursorPos + prefix.length // 创建附件 const attachment: Attachment = { id: crypto.randomUUID(), type: fileInfo.type, displayName: fileInfo.name, relativePath, url: toFileUrl(fileInfo.absolute), mime: fileInfo.type === 'file' ? 'text/plain' : undefined, textRange: { value: mentionText, start: mentionStart, end: mentionStart + mentionText.length, }, } setText(newText) setAttachments(prev => [...prev, attachment]) // 聚焦并移动光标到 @mention 之后 requestAnimationFrame(() => { if (!textareaRef.current) return const newCursorPos = mentionStart + mentionText.length + 1 textareaRef.current.setSelectionRange(newCursorPos, newCursorPos) textareaRef.current.focus() }) }, [text], ) const handleDrop = useCallback( (e: React.DragEvent) => { e.preventDefault() e.stopPropagation() dragCounterRef.current = 0 setIsDragging(false) // 来自 FileExplorer 的内部拖拽(自定义 data type) const opencodeData = e.dataTransfer.getData('application/pi-file') if (opencodeData) { try { insertDraggedFile(JSON.parse(opencodeData)) } catch (err) { console.warn('[InputBox] Failed to parse pi-file drag data:', err) } return } // 原生文件拖拽(从操作系统拖入) if (supportsAnyFile && e.dataTransfer.files.length > 0) { void handleFilesSelected(Array.from(e.dataTransfer.files)) } }, [supportsAnyFile, handleFilesSelected, insertDraggedFile], ) // 滚动同步(备用,overlay 内部也监听了 scroll) const handleScroll = useCallback(() => { // overlay 通过 useEffect 自动同步,这里留空 }, []) // ============================================ // Render // ============================================ // 计算已选择的 items (用于过滤菜单) const excludeValues = new Set() attachments.forEach(a => { if (a.url) excludeValues.add(a.url) if (a.agentName) excludeValues.add(a.agentName) }) // 底部 padding 计算: // - isCollapsed (收起态 / 移动端「回复胶囊」): // 公式 max(12, env)。Safari env()=0 → 12px breathing;PWA env()=34 → 34px, // 胶囊底部刚好贴 safe-area 顶部,避免 env+12 叠加出现的「多一截」间距。 // - 展开态:Footer (h-8 = 2rem) 已是一个天然的视觉缓冲,只需补足 safe-area // 超出 Footer 的部分。这避免 PWA 下 Footer + 完整 safe-area 叠加出现 // 「双倍 safe-area」的视觉 gap。 // 公式:max(0, env - 2rem) → 总缓冲 = Footer + padding = max(32px, env) const bottomDockPadding = isCollapsed ? 'max(12px, var(--safe-area-inset-bottom, 0px))' : 'max(0px, calc(var(--safe-area-inset-bottom, 0px) - 2rem))' return (
0 ? { minHeight: expandedHeight } : undefined} > {/* FloatingActions — 展开态:absolute 定位在内容区上方,不占文档流,避免显隐变化影响高度导致滚动抖动 收起态:正常文档流,紧贴胶囊上方 始终同一 DOM 节点,切换时 FloatingActions 不 remount,避免入场动画闪烁 */}
{/* Collapsed Capsule - 移动端收起状态 */} {isCollapsed && ( )} {!isCollapsed && ( <> {/* Wrapper — 菜单在 glass 容器外,避免嵌套 backdrop-filter 导致模糊失效 */}
{/* @ Mention Menu */} {/* / Slash Command Menu */} {/* Input Container */}
{/* Drop overlay */} {isDragging && (
{t('inputBox.dropFilesHere')}
)}
{/* Attachments Preview - 显示在输入框上方 */}
0 ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0' }`} >
{attachmentsOverflowing && showAttachmentLeftFade && (
)} {attachmentsOverflowing && showAttachmentRightFade && (
)}
{/* Text Input - 简单的 textarea,直接显示文本 */}