Files
codeg/src/components/chat/message-input.tsx

1202 lines
35 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.
"use client"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { TauriEvent } from "@tauri-apps/api/event"
import { getCurrentWebview } from "@tauri-apps/api/webview"
import { open } from "@tauri-apps/plugin-dialog"
import Image from "next/image"
import { useTranslations } from "next-intl"
import { Button } from "@/components/ui/button"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { Textarea } from "@/components/ui/textarea"
import { Ellipsis, FileSearch, Plus, Send, Square, X } from "lucide-react"
import { cn } from "@/lib/utils"
import { matchShortcutEvent } from "@/lib/keyboard-shortcuts"
import { useShortcutSettings } from "@/hooks/use-shortcut-settings"
import { readFileBase64 } from "@/lib/tauri"
import { disposeTauriListener } from "@/lib/tauri-listener"
import type {
AvailableCommandInfo,
PromptCapabilitiesInfo,
PromptDraft,
PromptInputBlock,
SessionConfigOptionInfo,
SessionModeInfo,
} from "@/lib/types"
import {
ATTACH_FILE_TO_SESSION_EVENT,
type AttachFileToSessionDetail,
} from "@/lib/session-attachment-events"
import { ModeSelector } from "@/components/chat/mode-selector"
import { SessionConfigSelector } from "@/components/chat/session-config-selector"
import { SlashCommandMenu } from "@/components/chat/slash-command-menu"
import {
clearMessageInputDraft,
loadMessageInputDraft,
saveMessageInputDraft,
} from "@/lib/message-input-draft"
interface MessageInputProps {
onSend: (draft: PromptDraft, modeId?: string | null) => void
placeholder?: string
defaultPath?: string
disabled?: boolean
autoFocus?: boolean
onFocus?: () => void
className?: string
isPrompting?: boolean
onCancel?: () => void
modes?: SessionModeInfo[]
configOptions?: SessionConfigOptionInfo[]
modeLoading?: boolean
configOptionsLoading?: boolean
selectedModeId?: string | null
onModeChange?: (modeId: string) => void
onConfigOptionChange?: (configId: string, valueId: string) => void
availableCommands?: AvailableCommandInfo[] | null
promptCapabilities: PromptCapabilitiesInfo
attachmentTabId?: string | null
draftStorageKey?: string | null
isActive?: boolean
}
interface ResourceInputAttachment {
id: string
type: "resource"
kind: "link" | "embedded"
uri: string
name: string
mimeType: string | null
text?: string | null
blob?: string | null
}
interface ImageInputAttachment {
id: string
type: "image"
data: string
uri: string | null
name: string
mimeType: string
}
type InputAttachment = ResourceInputAttachment | ImageInputAttachment
const MIME_BY_EXT: Record<string, string> = {
txt: "text/plain",
md: "text/markdown",
json: "application/json",
yaml: "application/yaml",
yml: "application/yaml",
csv: "text/csv",
html: "text/html",
css: "text/css",
js: "text/javascript",
mjs: "text/javascript",
cjs: "text/javascript",
ts: "text/typescript",
tsx: "text/tsx",
jsx: "text/jsx",
py: "text/x-python",
rs: "text/rust",
go: "text/x-go",
java: "text/x-java-source",
xml: "application/xml",
toml: "application/toml",
pdf: "application/pdf",
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
webp: "image/webp",
svg: "image/svg+xml",
}
function fileNameFromPath(path: string): string {
return path.split(/[/\\]/).pop() || path
}
function mimeTypeFromPath(path: string): string | null {
const ext = path.split(".").pop()?.toLowerCase() ?? ""
return MIME_BY_EXT[ext] ?? null
}
function toFileUri(path: string): string {
const normalized = path.replace(/\\/g, "/")
const encoded = normalized.split("/").map(encodeURIComponent).join("/")
if (normalized.startsWith("/")) {
return `file://${encoded}`
}
return `file:///${encoded}`
}
function hasDragFiles(dataTransfer: DataTransfer | null): boolean {
if (!dataTransfer?.types) return false
return Array.from(dataTransfer.types).includes("Files")
}
function pointWithinElement(
position: { x: number; y: number },
element: HTMLElement
): boolean {
const rect = element.getBoundingClientRect()
const dpr = window.devicePixelRatio || 1
const candidates = [
{ x: position.x, y: position.y },
{ x: position.x / dpr, y: position.y / dpr },
]
return candidates.some(
(point) =>
point.x >= rect.left &&
point.x <= rect.right &&
point.y >= rect.top &&
point.y <= rect.bottom
)
}
function blobToBase64(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onerror = () => {
reject(reader.error ?? new Error("Failed to read blob"))
}
reader.onload = () => {
if (typeof reader.result !== "string") {
reject(new Error("Unexpected non-string blob reader result"))
return
}
const markerIndex = reader.result.indexOf(",")
resolve(
markerIndex >= 0 ? reader.result.slice(markerIndex + 1) : reader.result
)
}
reader.readAsDataURL(blob)
})
}
function getFilePath(file: File): string | null {
const withPath = file as File & { path?: string; webkitRelativePath?: string }
if (typeof withPath.path === "string" && withPath.path.trim().length > 0) {
return withPath.path
}
if (
typeof withPath.webkitRelativePath === "string" &&
withPath.webkitRelativePath.trim().length > 0
) {
return withPath.webkitRelativePath
}
return null
}
const TEXT_LIKE_MIME_PREFIXES = [
"text/",
"application/json",
"application/xml",
"application/yaml",
"application/x-yaml",
"application/toml",
"application/javascript",
"application/typescript",
]
const DRAG_DROP_IMAGE_MAX_BYTES = 20_000_000
function isTextLikeFile(file: File): boolean {
const mime = file.type.toLowerCase()
if (mime) {
if (TEXT_LIKE_MIME_PREFIXES.some((prefix) => mime.startsWith(prefix))) {
return true
}
}
const ext = file.name.split(".").pop()?.toLowerCase()
if (!ext) return false
return Boolean(
MIME_BY_EXT[ext]?.startsWith("text/") ||
["json", "yaml", "yml", "xml", "toml", "md", "csv"].includes(ext)
)
}
function buildClipboardResourceUri(name: string): string {
const normalizedName = name.trim() || "clipboard-resource"
return `clipboard://${encodeURIComponent(normalizedName)}-${crypto.randomUUID()}`
}
function buildDataUri(base64Data: string, mimeType: string | null): string {
const safeMime =
mimeType && mimeType.trim() ? mimeType : "application/octet-stream"
return `data:${safeMime};base64,${base64Data}`
}
function SelectorLoadingChip({ label }: { label: string }) {
return (
<div className="inline-flex h-6 shrink-0 items-center gap-1 rounded-full border border-border/70 bg-muted/40 px-2 text-[11px] text-muted-foreground">
<span className="h-1.5 w-1.5 rounded-full bg-primary animate-pulse" />
<span>{label}</span>
</div>
)
}
export function MessageInput({
onSend,
placeholder,
defaultPath,
disabled = false,
autoFocus = false,
onFocus,
className,
isPrompting = false,
onCancel,
modes,
configOptions,
modeLoading = false,
configOptionsLoading = false,
selectedModeId,
onModeChange,
onConfigOptionChange,
availableCommands,
promptCapabilities,
attachmentTabId,
draftStorageKey,
isActive = false,
}: MessageInputProps) {
const t = useTranslations("Folder.chat.messageInput")
const { shortcuts } = useShortcutSettings()
const effectiveDraftStorageKey = draftStorageKey ?? attachmentTabId ?? null
const resolvedPlaceholder = placeholder ?? t("askAnything")
const [text, setText] = useState(() => {
if (!effectiveDraftStorageKey) return ""
return loadMessageInputDraft(effectiveDraftStorageKey) ?? ""
})
const [attachments, setAttachments] = useState<InputAttachment[]>([])
const [isDragActive, setIsDragActive] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const lastDomDropAtRef = useRef(0)
const composingRef = useRef(false)
const textRef = useRef(text)
const disabledRef = useRef(disabled)
const isPromptingRef = useRef(isPrompting)
useEffect(() => {
if (isActive && !disabled && !isPrompting) {
requestAnimationFrame(() => {
textareaRef.current?.focus()
})
}
}, [isActive, disabled, isPrompting])
const dragActiveRef = useRef(false)
const canAttachImages = promptCapabilities.image
useEffect(() => {
textRef.current = text
}, [text])
useEffect(() => {
disabledRef.current = disabled
}, [disabled])
useEffect(() => {
isPromptingRef.current = isPrompting
}, [isPrompting])
const setDragActiveIfChanged = useCallback((next: boolean) => {
if (dragActiveRef.current === next) return
dragActiveRef.current = next
setIsDragActive(next)
}, [])
useEffect(() => {
if (!effectiveDraftStorageKey) return
saveMessageInputDraft(effectiveDraftStorageKey, text)
}, [effectiveDraftStorageKey, text])
const availableModes = useMemo(() => modes ?? [], [modes])
const availableConfigOptions = useMemo(
() => configOptions ?? [],
[configOptions]
)
const hasConfigOptions = availableConfigOptions.length > 0
const hasModes = availableModes.length > 0
const effectiveModeId = useMemo(() => {
if (!hasModes) return null
if (
selectedModeId &&
availableModes.some((mode) => mode.id === selectedModeId)
) {
return selectedModeId
}
return availableModes[0]?.id ?? null
}, [hasModes, selectedModeId, availableModes])
const showModeSelector =
hasModes && Boolean(effectiveModeId) && !hasConfigOptions
const showModeLoading = modeLoading && !hasConfigOptions && !showModeSelector
const showConfigLoading = configOptionsLoading && !hasConfigOptions
const hasAnySelector =
showConfigLoading || hasConfigOptions || showModeLoading || showModeSelector
const imageAttachments = useMemo(
() =>
attachments.filter(
(attachment): attachment is ImageInputAttachment =>
attachment.type === "image"
),
[attachments]
)
const resourceAttachments = useMemo(
() =>
attachments.filter(
(attachment): attachment is ResourceInputAttachment =>
attachment.type === "resource"
),
[attachments]
)
const hasAttachments = attachments.length > 0
const hasSendableContent = text.trim().length > 0 || hasAttachments
// ── Slash command autocomplete ──
const [slashMenuOpen, setSlashMenuOpen] = useState(false)
const [slashSelectedIndex, setSlashSelectedIndex] = useState(0)
const slashCommands = useMemo(
() => availableCommands ?? [],
[availableCommands]
)
const filteredSlashCommands = useMemo(() => {
if (!slashMenuOpen || slashCommands.length === 0) return []
const match = text.match(/^\/(\S*)$/)
if (!match) return []
const filter = match[1].toLowerCase()
return slashCommands.filter((cmd) =>
cmd.name.toLowerCase().startsWith(filter)
)
}, [slashMenuOpen, slashCommands, text])
const appendResourceLinks = useCallback(
(
links: Array<{
uri: string
name: string
mimeType: string | null
dedupeKey: string
}>
) => {
if (links.length === 0) return
setAttachments((prev) => {
const seen = new Set(
prev.flatMap((item) =>
item.type === "resource" && item.kind === "link" ? [item.uri] : []
)
)
const next = [...prev]
for (const link of links) {
if (!link.uri || seen.has(link.dedupeKey)) continue
seen.add(link.dedupeKey)
next.push({
id: `resource-link:${link.dedupeKey}`,
type: "resource",
kind: "link",
uri: link.uri,
name: link.name,
mimeType: link.mimeType,
})
}
return next
})
},
[]
)
const appendResourceAttachments = useCallback(
(paths: string[]) => {
const normalized = paths
.filter(
(path): path is string => typeof path === "string" && path.length > 0
)
.map((path) => {
const uri = toFileUri(path)
return {
uri,
name: fileNameFromPath(path),
mimeType: mimeTypeFromPath(path),
dedupeKey: uri,
}
})
appendResourceLinks(normalized)
},
[appendResourceLinks]
)
const appendEmbeddedResources = useCallback(
(
resources: Array<{
uri: string
name: string
mimeType: string | null
text?: string | null
blob?: string | null
}>
) => {
if (resources.length === 0) return
setAttachments((prev) => [
...prev,
...resources.map((resource) => ({
id: `resource-embedded:${crypto.randomUUID()}`,
type: "resource" as const,
kind: "embedded" as const,
uri: resource.uri,
name: resource.name,
mimeType: resource.mimeType,
text: resource.text ?? null,
blob: resource.blob ?? null,
})),
])
},
[]
)
const appendFilesAsResources = useCallback(
async (files: File[]) => {
if (files.length === 0) return
const pathLinks: Array<{
uri: string
name: string
mimeType: string | null
dedupeKey: string
}> = []
const fallbackDataLinks: Array<{
uri: string
name: string
mimeType: string | null
dedupeKey: string
}> = []
const embeddedResources: Array<{
uri: string
name: string
mimeType: string | null
text?: string | null
blob?: string | null
}> = []
for (const file of files) {
const path = getFilePath(file)
const name = file.name || `resource-${crypto.randomUUID()}`
const mimeType = file.type || mimeTypeFromPath(name)
if (path) {
const uri = toFileUri(path)
pathLinks.push({
uri,
name: fileNameFromPath(path),
mimeType: mimeTypeFromPath(path) ?? mimeType ?? null,
dedupeKey: uri,
})
continue
}
if (!promptCapabilities.embedded_context) {
const base64 = await blobToBase64(file)
const dataUri = buildDataUri(base64, mimeType ?? null)
fallbackDataLinks.push({
uri: dataUri,
name,
mimeType: mimeType ?? null,
dedupeKey: `${name}:${file.size}:${file.lastModified}`,
})
continue
}
const uri = buildClipboardResourceUri(name)
if (isTextLikeFile(file)) {
const textContent = await file.text()
embeddedResources.push({
uri,
name,
mimeType: mimeType ?? null,
text: textContent,
})
} else {
const blobContent = await blobToBase64(file)
embeddedResources.push({
uri,
name,
mimeType: mimeType ?? null,
blob: blobContent,
})
}
}
appendResourceLinks(pathLinks)
appendResourceLinks(fallbackDataLinks)
appendEmbeddedResources(embeddedResources)
},
[
appendEmbeddedResources,
appendResourceLinks,
promptCapabilities.embedded_context,
]
)
const appendImageAttachments = useCallback(async (files: File[]) => {
if (files.length === 0) return
const parsed = await Promise.all(
files.map(async (file, index) => {
const mimeType =
file.type && file.type.startsWith("image/")
? file.type
: (mimeTypeFromPath(file.name) ?? "image/png")
const base64Data = await blobToBase64(file)
return {
id: `image:${Date.now()}:${index}:${crypto.randomUUID()}`,
type: "image" as const,
data: base64Data,
uri: null,
name: file.name || `image-${Date.now()}-${index + 1}`,
mimeType,
}
})
)
setAttachments((prev) => [...prev, ...parsed])
}, [])
const appendImagePathAttachments = useCallback(
async (paths: string[]) => {
if (paths.length === 0 || !canAttachImages) return
const settled = await Promise.allSettled(
paths.map(async (path, index) => {
const data = await readFileBase64(path, DRAG_DROP_IMAGE_MAX_BYTES)
return {
id: `image:${Date.now()}:${index}:${crypto.randomUUID()}`,
type: "image" as const,
data,
uri: toFileUri(path),
name: fileNameFromPath(path),
mimeType: mimeTypeFromPath(path) ?? "image/png",
}
})
)
const parsed: ImageInputAttachment[] = []
settled.forEach((result, index) => {
if (result.status === "fulfilled") {
parsed.push(result.value)
return
}
console.error(
`[MessageInput] drop image path failed (${paths[index]}):`,
result.reason
)
})
if (parsed.length === 0) return
setAttachments((prev) => [...prev, ...parsed])
},
[canAttachImages]
)
const appendPathsFromDrop = useCallback(
async (paths: string[]) => {
if (paths.length === 0) return
const normalized = paths.filter(
(path): path is string => typeof path === "string" && path.length > 0
)
if (normalized.length === 0) return
const imagePaths: string[] = []
const resourcePaths: string[] = []
for (const path of normalized) {
const mimeType = mimeTypeFromPath(path) ?? ""
if (canAttachImages && mimeType.startsWith("image/")) {
imagePaths.push(path)
} else {
resourcePaths.push(path)
}
}
if (imagePaths.length > 0) {
await appendImagePathAttachments(imagePaths)
}
if (resourcePaths.length > 0) {
appendResourceAttachments(resourcePaths)
}
},
[appendImagePathAttachments, appendResourceAttachments, canAttachImages]
)
const appendPathsFromDropRef = useRef(appendPathsFromDrop)
useEffect(() => {
appendPathsFromDropRef.current = appendPathsFromDrop
}, [appendPathsFromDrop])
const appendFilesFromInput = useCallback(
async (files: File[]) => {
if (files.length === 0) return
const imageFiles: File[] = []
const resourceFiles: File[] = []
for (const file of files) {
const mimeType = file.type || mimeTypeFromPath(file.name) || ""
if (canAttachImages && mimeType.startsWith("image/")) {
imageFiles.push(file)
} else {
resourceFiles.push(file)
}
}
if (imageFiles.length > 0) {
await appendImageAttachments(imageFiles)
}
if (resourceFiles.length > 0) {
await appendFilesAsResources(resourceFiles)
}
},
[appendFilesAsResources, appendImageAttachments, canAttachImages]
)
const handlePaste = useCallback(
(event: React.ClipboardEvent<HTMLTextAreaElement>) => {
if (disabled || isPrompting) return
const files = Array.from(event.clipboardData?.files ?? [])
if (files.length === 0) return
event.preventDefault()
void appendFilesFromInput(files).catch((error) => {
console.error("[MessageInput] paste files failed:", error)
})
},
[appendFilesFromInput, disabled, isPrompting]
)
useEffect(() => {
if (!showModeSelector) return
if (!effectiveModeId || !onModeChange) return
if (effectiveModeId !== selectedModeId) {
onModeChange(effectiveModeId)
}
}, [showModeSelector, effectiveModeId, selectedModeId, onModeChange])
const handleModeSelect = useCallback(
(modeId: string) => {
onModeChange?.(modeId)
},
[onModeChange]
)
const handleSlashSelect = useCallback((cmd: AvailableCommandInfo) => {
setText(`/${cmd.name} `)
setSlashMenuOpen(false)
}, [])
const handleTextChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value
setText(value)
if (slashCommands.length > 0 && /^\/(\S*)$/.test(value)) {
setSlashSelectedIndex(0)
setSlashMenuOpen(true)
} else {
setSlashMenuOpen(false)
}
},
[slashCommands.length]
)
const handlePickFiles = useCallback(async () => {
if (disabled) return
try {
const selected = await open({
multiple: true,
directory: false,
defaultPath: defaultPath || undefined,
})
if (!selected) return
const picked = Array.isArray(selected) ? selected : [selected]
appendResourceAttachments(picked.filter((item): item is string => !!item))
} catch (error) {
console.error("[MessageInput] pick files failed:", error)
}
}, [appendResourceAttachments, defaultPath, disabled])
useEffect(() => {
if (!attachmentTabId) return
const handleAttachFile = (event: Event) => {
const customEvent = event as CustomEvent<AttachFileToSessionDetail>
if (!customEvent.detail) return
if (customEvent.detail.tabId !== attachmentTabId) return
appendResourceAttachments([customEvent.detail.path])
}
window.addEventListener(ATTACH_FILE_TO_SESSION_EVENT, handleAttachFile)
return () => {
window.removeEventListener(ATTACH_FILE_TO_SESSION_EVENT, handleAttachFile)
}
}, [appendResourceAttachments, attachmentTabId])
useEffect(() => {
let cancelled = false
const unlisteners: Array<() => void | Promise<void>> = []
const cleanupListeners = () => {
for (const fn of unlisteners.splice(0)) {
disposeTauriListener(fn, "MessageInput.dragDrop")
}
}
type DragDropPayload =
| {
type: "enter" | "drop"
paths: string[]
position: { x: number; y: number }
}
| {
type: "over"
position: { x: number; y: number }
}
| { type: "leave" }
const handlePayload = (payload: DragDropPayload) => {
const host = containerRef.current
if (!host) return
if (payload.type === "leave") {
setDragActiveIfChanged(false)
return
}
const inside = pointWithinElement(payload.position, host)
if (payload.type === "drop") {
setDragActiveIfChanged(false)
if (Date.now() - lastDomDropAtRef.current < 250) return
if (!inside || disabledRef.current || isPromptingRef.current) return
void appendPathsFromDropRef.current(payload.paths).catch((error) => {
console.error("[MessageInput] drag drop paths failed:", error)
})
return
}
setDragActiveIfChanged(
inside && !disabledRef.current && !isPromptingRef.current
)
}
const setup = async () => {
const webview = getCurrentWebview()
try {
const unlistenEnter = await webview.listen<{
paths: string[]
position: { x: number; y: number }
}>(TauriEvent.DRAG_ENTER, (event) => {
if (cancelled) return
handlePayload({
type: "enter",
paths: event.payload.paths,
position: event.payload.position,
})
})
unlisteners.push(unlistenEnter)
const unlistenOver = await webview.listen<{
position: { x: number; y: number }
}>(TauriEvent.DRAG_OVER, (event) => {
if (cancelled) return
handlePayload({
type: "over",
position: event.payload.position,
})
})
unlisteners.push(unlistenOver)
const unlistenDrop = await webview.listen<{
paths: string[]
position: { x: number; y: number }
}>(TauriEvent.DRAG_DROP, (event) => {
if (cancelled) return
handlePayload({
type: "drop",
paths: event.payload.paths,
position: event.payload.position,
})
})
unlisteners.push(unlistenDrop)
const unlistenLeave = await webview.listen(
TauriEvent.DRAG_LEAVE,
() => {
if (cancelled) return
handlePayload({ type: "leave" })
}
)
unlisteners.push(unlistenLeave)
} catch {
// Ignore non-Tauri environments.
} finally {
if (cancelled) {
cleanupListeners()
}
}
}
void setup()
return () => {
cancelled = true
cleanupListeners()
}
}, [setDragActiveIfChanged])
const removeAttachment = useCallback((id: string) => {
setAttachments((prev) => prev.filter((item) => item.id !== id))
}, [])
const handleSend = useCallback(() => {
const trimmed = textRef.current.trim()
if (!trimmed && attachments.length === 0) return
const blocks: PromptInputBlock[] = []
if (trimmed) {
blocks.push({ type: "text", text: trimmed })
}
for (const attachment of attachments) {
if (attachment.type === "resource") {
if (attachment.kind === "link") {
blocks.push({
type: "resource_link",
uri: attachment.uri,
name: attachment.name,
mime_type: attachment.mimeType,
description: null,
})
} else {
blocks.push({
type: "resource",
uri: attachment.uri,
mime_type: attachment.mimeType,
text: attachment.text ?? null,
blob: attachment.blob ?? null,
})
}
} else {
blocks.push({
type: "image",
data: attachment.data,
mime_type: attachment.mimeType,
uri: attachment.uri,
})
}
}
const displayText =
trimmed ||
`Attached ${attachments.length} attachment${attachments.length > 1 ? "s" : ""}`
onSend({ blocks, displayText }, showModeSelector ? effectiveModeId : null)
if (effectiveDraftStorageKey) {
clearMessageInputDraft(effectiveDraftStorageKey)
}
setText("")
setAttachments([])
}, [
attachments,
onSend,
effectiveModeId,
showModeSelector,
effectiveDraftStorageKey,
])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (
e.nativeEvent.isComposing ||
composingRef.current ||
e.key === "Process" ||
e.keyCode === 229
) {
return
}
if (slashMenuOpen && filteredSlashCommands.length > 0) {
if (e.key === "ArrowDown") {
e.preventDefault()
setSlashSelectedIndex((i) =>
i < filteredSlashCommands.length - 1 ? i + 1 : 0
)
return
}
if (e.key === "ArrowUp") {
e.preventDefault()
setSlashSelectedIndex((i) =>
i > 0 ? i - 1 : filteredSlashCommands.length - 1
)
return
}
if (e.key === "Enter" || e.key === "Tab") {
e.preventDefault()
handleSlashSelect(filteredSlashCommands[slashSelectedIndex])
return
}
if (e.key === "Escape") {
e.preventDefault()
setSlashMenuOpen(false)
return
}
}
if (matchShortcutEvent(e, shortcuts.send_message)) {
e.preventDefault()
if (!disabled) handleSend()
} else if (matchShortcutEvent(e, shortcuts.newline_in_message)) {
e.preventDefault()
const textarea = e.currentTarget as HTMLTextAreaElement
const start = textarea.selectionStart
const end = textarea.selectionEnd
const value = textarea.value
const newValue = value.substring(0, start) + "\n" + value.substring(end)
setText(newValue)
requestAnimationFrame(() => {
textarea.selectionStart = textarea.selectionEnd = start + 1
})
}
},
[
disabled,
handleSend,
shortcuts,
slashMenuOpen,
filteredSlashCommands,
slashSelectedIndex,
handleSlashSelect,
]
)
const handleContainerDragOver = useCallback(
(event: React.DragEvent<HTMLDivElement>) => {
if (!hasDragFiles(event.dataTransfer)) return
event.preventDefault()
if (!disabled && !isPrompting) {
setDragActiveIfChanged(true)
}
},
[disabled, isPrompting, setDragActiveIfChanged]
)
const handleContainerDragLeave = useCallback(
(event: React.DragEvent<HTMLDivElement>) => {
const related = event.relatedTarget
if (
related &&
related instanceof Node &&
event.currentTarget.contains(related)
) {
return
}
setDragActiveIfChanged(false)
},
[setDragActiveIfChanged]
)
const handleContainerDrop = useCallback(
(event: React.DragEvent<HTMLDivElement>) => {
if (!hasDragFiles(event.dataTransfer)) return
event.preventDefault()
lastDomDropAtRef.current = Date.now()
setDragActiveIfChanged(false)
if (disabled || isPrompting) return
const files = Array.from(event.dataTransfer.files ?? [])
if (files.length > 0) {
void appendFilesFromInput(files).catch((error) => {
console.error("[MessageInput] drop files failed:", error)
})
}
},
[appendFilesFromInput, disabled, isPrompting, setDragActiveIfChanged]
)
const hasImageAttachments = imageAttachments.length > 0
const hasResourceAttachments = resourceAttachments.length > 0
const topPaddingClass =
hasImageAttachments && hasResourceAttachments
? "pt-[6.25rem]"
: hasImageAttachments
? "pt-[4.5rem]"
: hasResourceAttachments
? "pt-10"
: "pt-3"
const bottomPaddingClass = "pb-10"
const showDragActive = isDragActive && !disabled && !isPrompting
const selectorItems = (
<>
{showConfigLoading && (
<SelectorLoadingChip label={t("loadingSettings")} />
)}
{hasConfigOptions &&
availableConfigOptions.map((option) => (
<SessionConfigSelector
key={option.id}
option={option}
onSelect={(configId, valueId) =>
onConfigOptionChange?.(configId, valueId)
}
/>
))}
{showModeLoading && <SelectorLoadingChip label={t("loadingMode")} />}
{showModeSelector && (
<ModeSelector
modes={availableModes}
selectedModeId={effectiveModeId!}
onSelect={handleModeSelect}
/>
)}
</>
)
return (
<div
ref={containerRef}
className="relative"
onDragOver={handleContainerDragOver}
onDragLeave={handleContainerDragLeave}
onDrop={handleContainerDrop}
>
{slashMenuOpen && filteredSlashCommands.length > 0 && (
<SlashCommandMenu
commands={filteredSlashCommands}
selectedIndex={slashSelectedIndex}
onSelect={handleSlashSelect}
/>
)}
<Textarea
ref={textareaRef}
value={text}
onChange={handleTextChange}
onKeyDown={handleKeyDown}
onCompositionStart={() => (composingRef.current = true)}
onCompositionEnd={() => (composingRef.current = false)}
onPaste={handlePaste}
onFocus={onFocus}
placeholder={resolvedPlaceholder}
className={cn(
"text-sm pr-12 resize-none bg-transparent",
showDragActive && "ring-1 ring-primary/40",
topPaddingClass,
bottomPaddingClass,
className
)}
autoFocus={autoFocus}
/>
{(hasImageAttachments || hasResourceAttachments) && (
<div className="absolute left-2 right-12 top-2 z-10 flex flex-col gap-1">
{hasImageAttachments && (
<div className="flex items-center gap-1 overflow-x-auto pb-0.5">
{imageAttachments.map((attachment) => (
<div
key={attachment.id}
className="relative shrink-0 overflow-hidden rounded-md border border-border/70 bg-muted/30"
>
<Image
src={`data:${attachment.mimeType};base64,${attachment.data}`}
alt={attachment.name}
width={56}
height={56}
unoptimized
className="h-14 w-14 object-cover"
/>
<button
type="button"
onClick={() => removeAttachment(attachment.id)}
className="absolute right-1 top-1 rounded-sm bg-background/70 p-0.5 hover:bg-background"
aria-label={t("removeAttachmentAria", {
name: attachment.name,
})}
>
<X className="h-3 w-3" />
</button>
</div>
))}
</div>
)}
{hasResourceAttachments && (
<div className="flex items-center gap-1 overflow-x-auto">
{resourceAttachments.map((attachment) => (
<div
key={attachment.id}
className="inline-flex h-6 shrink-0 items-center gap-1 rounded-full border border-border/70 bg-muted/40 px-2 text-[11px] text-muted-foreground"
>
<FileSearch className="h-3 w-3" />
<span className="max-w-40 truncate">{attachment.name}</span>
<button
type="button"
onClick={() => removeAttachment(attachment.id)}
className="rounded-sm p-0.5 hover:bg-muted-foreground/15"
aria-label={t("removeAttachmentAria", {
name: attachment.name,
})}
>
<X className="h-3 w-3" />
</button>
</div>
))}
</div>
)}
</div>
)}
{showDragActive && (
<div className="pointer-events-none absolute inset-1 z-20 flex items-center justify-center rounded-md border border-dashed border-primary/50 bg-background/80 text-xs text-muted-foreground">
{t("dropFilesToAttach")}
</div>
)}
<div className="@container absolute left-2 right-24 bottom-2">
<div className="flex items-center gap-1">
<Button
onClick={handlePickFiles}
disabled={disabled || isPrompting}
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0"
title={t("attachFiles")}
>
<Plus className="size-4" />
</Button>
{/* 宽屏内联显示,窄屏(<300px通过"更多"气泡显示 */}
<div className="hidden @[300px]:contents">{selectorItems}</div>
{hasAnySelector && (
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0 @[300px]:hidden"
>
<Ellipsis className="size-4" />
</Button>
</PopoverTrigger>
<PopoverContent
side="top"
align="start"
className="flex w-auto flex-col gap-1 rounded-xl p-1"
>
{selectorItems}
</PopoverContent>
</Popover>
)}
</div>
</div>
{isPrompting && onCancel ? (
<Button
onClick={onCancel}
variant="destructive"
size="icon"
className="absolute right-2 bottom-2"
title={t("cancel")}
>
<Square className="h-4 w-4" />
</Button>
) : (
<Button
onClick={handleSend}
disabled={disabled || !hasSendableContent}
size="icon"
className="absolute right-2 bottom-2"
title={t("send")}
>
<Send className="h-4 w-4" />
</Button>
)}
</div>
)
}