Files
PiAgentUI/src/features/chat/InputBox.tsx
T
Evandro b3489e1ede
Build & Push Frontend Docker Image / build-and-push (push) Canceled after 0s
Build Validation / validate (push) Canceled after 6s
Build & Push Backend Docker Image / build-and-push (push) Failing after 10m10s
Build & Push Gateway Docker Image / build-and-push (push) Failing after 10m9s
🎉 Initial commit: PiAgentUI — AI Agent Desktop & Web UI
A professional workspace for AI coding agents with:
- Real-time streaming chat with SSE
- Session management (fork, clone, tree navigation)
- MCP tools browser & metadata panel
- Skills browser (global, project, package sources)
- Voice input & STT provider configuration
- Integrated terminal & file explorer
- Model selector with capability metadata
- Keyboard-first UI with command palette
- 8 languages (en, pt-BR, es, ja, zh-CN, ar, bn, hi)
- Tauri 2 desktop app
- React 19 + TypeScript + Vite 8 + Tailwind CSS 4
- Pi Agent extension runtime

Originally forked from OpenCodeUI, now an independent project.
License: GPL-3.0-only
2026-06-01 00:50:06 -03:00

1179 lines
40 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 { 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> | boolean
onAbort?: () => void
onCommand?: (command: string) => Promise<boolean> | 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<ModelSelectorHandle | null>
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<Attachment[]>([])
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<HTMLTextAreaElement>(null)
const inputContainerRef = useRef<HTMLDivElement>(null)
const attachmentRailRef = useRef<HTMLDivElement>(null)
const mentionMenuRef = useRef<MentionMenuHandle>(null)
const slashMenuRef = useRef<SlashCommandMenuHandle>(null)
const prevRevertedTextRef = useRef<string | undefined>(undefined)
const latestDraftRef = useRef<HistoryEntry>({ text: '', attachments: [] })
const contentWrapRef = useRef<HTMLDivElement>(null)
const footerRef = useRef<HTMLDivElement>(null)
const isComposingRef = useRef(false)
const compositionEndTimerRef = useRef<number | null>(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> | 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<HTMLTextAreaElement>) => {
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<HTMLTextAreaElement>) => {
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<string>()
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 (
<div className="w-full">
<div
className={`mx-auto max-w-3xl pointer-events-auto transition-[max-width] duration-300 ease-in-out ${isCompact ? 'px-2' : 'px-4'}`}
style={{ paddingBottom: bottomDockPadding }}
>
<div
ref={contentWrapRef}
onPointerDown={handleContainerPointerDown}
className={`relative flex flex-col gap-2 ${isCollapsed ? 'justify-end' : ''}`}
style={isCollapsed && expandedHeight > 0 ? { minHeight: expandedHeight } : undefined}
>
{/* FloatingActions —
展开态:absolute 定位在内容区上方,不占文档流,避免显隐变化影响高度导致滚动抖动
收起态:正常文档流,紧贴胶囊上方
始终同一 DOM 节点,切换时 FloatingActions 不 remount,避免入场动画闪烁 */}
<div
data-floating-actions
className={
isCollapsed
? 'flex justify-center pb-2'
: 'absolute bottom-full left-0 right-0 flex justify-center pb-2 pointer-events-none'
}
>
<div className={isCollapsed ? undefined : 'pointer-events-auto'}>
<FloatingActions
showScrollToBottom={showScrollToBottom}
isCollapsed={isCollapsed}
canRedo={canRedo}
revertSteps={revertSteps}
onRedo={onRedo}
onRedoAll={onRedoAll}
onScrollToBottom={onScrollToBottom}
collapsedPermission={collapsedPermission}
collapsedQuestion={collapsedQuestion}
/>
</div>
</div>
{/* Collapsed Capsule - 移动端收起状态 */}
{isCollapsed && (
<CollapsedCapsule
onExpand={handleExpandInput}
showScrollToBottom={showScrollToBottom}
onScrollToBottom={onScrollToBottom}
/>
)}
{!isCollapsed && (
<>
{/* Wrapper — 菜单在 glass 容器外,避免嵌套 backdrop-filter 导致模糊失效 */}
<div className="relative z-30">
{/* @ Mention Menu */}
<MentionMenu
ref={mentionMenuRef}
isOpen={mentionOpen}
query={mentionQuery}
agents={agents}
rootPath={rootPath}
excludeValues={excludeValues}
onSelect={handleMentionSelect}
onNavigate={updateMentionQuery}
onClose={handleMentionClose}
/>
{/* / Slash Command Menu */}
<SlashCommandMenu
ref={slashMenuRef}
isOpen={slashOpen}
query={slashQuery}
rootPath={rootPath}
onSelect={handleSlashSelect}
onClose={handleSlashClose}
/>
{/* Input Container */}
<div
ref={inputContainerRef}
data-input-box
data-pane-id={paneId}
onPointerDown={handleContainerPointerDown}
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={`glass rounded-2xl relative transition-all focus-within:outline-none shadow-lg ${
isDragging
? 'border border-accent-main-100 ring-2 ring-accent-main-100/30'
: isStreaming
? 'border border-accent-main-100/50 animate-border-pulse'
: 'border border-border-200/60'
}`}
>
{/* Drop overlay */}
{isDragging && (
<div className="absolute inset-0 z-50 rounded-2xl bg-accent-main-100/5 backdrop-blur-[1px] flex items-center justify-center pointer-events-none">
<span className="text-[length:var(--fs-base)] text-accent-main-100 font-medium">
{t('inputBox.dropFilesHere')}
</span>
</div>
)}
<div className="relative">
<div className="overflow-hidden">
{/* Attachments Preview - 显示在输入框上方 */}
<div
className={`grid transition-[grid-template-rows,opacity] duration-300 ease-out ${
attachments.length > 0 ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0'
}`}
>
<div className="overflow-hidden">
<div className="px-4 pt-3 pb-1">
<div className="relative">
<div
ref={attachmentRailRef}
onScroll={syncAttachmentRailState}
onWheel={handleAttachmentRailWheel}
className="overflow-x-auto overflow-y-hidden overscroll-x-contain no-scrollbar touch-pan-x"
style={{ WebkitOverflowScrolling: 'touch' }}
>
<AttachmentPreview
attachments={attachments}
onRemove={handleRemoveAttachment}
variant="rail"
className={isSubmitting ? 'pr-4 pointer-events-none opacity-70' : 'pr-4'}
/>
</div>
{attachmentsOverflowing && showAttachmentLeftFade && (
<div className="pointer-events-none absolute inset-y-0 left-0 w-8 bg-gradient-to-r from-bg-000/50 to-transparent" />
)}
{attachmentsOverflowing && showAttachmentRightFade && (
<div className="pointer-events-none absolute inset-y-0 right-0 w-10 bg-gradient-to-l from-bg-000/50 to-transparent" />
)}
</div>
</div>
</div>
</div>
{/* Text Input - 简单的 textarea,直接显示文本 */}
<div className="pt-4 pb-2">
<textarea
ref={textareaRef}
value={text}
onChange={handleChange}
onKeyDown={handleKeyDown}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
onPaste={handlePaste}
onScroll={handleScroll}
onFocus={handleFocus}
onBlur={handleBlur}
disabled={inputDisabled}
placeholder={isCompact ? t('inputBox.replyToAgentMobile') : t('inputBox.replyToAgent')}
className={`w-full resize-none focus:outline-none focus:ring-0 bg-transparent text-text-100 placeholder:text-text-400 custom-scrollbar ${isCompact ? 'px-3' : 'px-4'}`}
style={{
...TEXT_STYLE,
minHeight: '24px',
maxHeight: isCompact ? 'calc(var(--app-height, 100vh) - 220px)' : '35vh',
}}
rows={1}
/>
</div>
{/* Bottom Bar -> InputToolbar */}
<InputToolbar
agents={agents}
selectedAgent={selectedAgent}
onAgentChange={onAgentChange}
variants={variants}
selectedVariant={selectedVariant}
onVariantChange={onVariantChange}
fileCapabilities={fileCaps}
onFilesSelected={handleFilesSelected}
onVoiceTranscription={handleVoiceTranscription}
isStreaming={isStreaming}
isSending={isSubmitting}
onAbort={onAbort}
canSend={canSend || false}
onSend={handleSend}
models={models}
selectedModelKey={selectedModelKey}
onModelChange={onModelChange}
modelsLoading={modelsLoading}
inputContainerRef={inputContainerRef}
modelSelectorRef={modelSelectorRef}
directory={rootPath}
/>
</div>
</div>
</div>
</div>
</>
)}
</div>
{/* Footer: 输入框下方固定高度区域,内容垂直水平居中 */}
{!isCollapsed && (
<div
ref={footerRef}
onPointerDown={handleContainerPointerDown}
className="h-8 flex items-center justify-center"
>
<InputFooter
paneId={paneId}
sessionId={sessionId}
onNewChat={onNewChat}
inputContainerRef={inputContainerRef}
/>
</div>
)}
</div>
</div>
)
}
// ============================================
// Export with memo for performance optimization
// ============================================
export const InputBox = memo(InputBoxComponent)