feat: add @ file mention support in chat input and unify directory attachment

Support typing "@" in the chat input to trigger a file list popup with
filtering, keyboard navigation, and file/directory attachment. Directories
from the sidebar file tree now attach as resource links instead of injecting
text into the input.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
xintaofei
2026-04-02 22:43:27 +08:00
parent e87f930ec1
commit 9396127cd9
6 changed files with 379 additions and 158 deletions

View File

@@ -0,0 +1,62 @@
"use client"
import { useEffect, useRef } from "react"
import { File, Folder } from "lucide-react"
import { cn } from "@/lib/utils"
import type { FlatFileEntry } from "@/hooks/use-file-tree"
interface FileMentionMenuProps {
files: FlatFileEntry[]
selectedIndex: number
onSelect: (entry: FlatFileEntry) => void
}
export function FileMentionMenu({
files,
selectedIndex,
onSelect,
}: FileMentionMenuProps) {
const listRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const el = listRef.current?.children[selectedIndex] as
| HTMLElement
| undefined
el?.scrollIntoView({ block: "nearest" })
}, [selectedIndex])
if (files.length === 0) return null
return (
<div
ref={listRef}
className="absolute bottom-full left-0 right-0 z-50 mb-1 max-h-48 overflow-y-auto rounded-xl border border-border bg-popover p-1 shadow-lg"
>
{files.map((entry, i) => (
<button
key={entry.relativePath}
type="button"
className={cn(
"flex w-full items-center gap-2 rounded-lg px-3 py-1.5 text-left text-sm",
i === selectedIndex
? "bg-accent text-accent-foreground"
: "hover:bg-muted"
)}
onMouseDown={(e) => {
e.preventDefault()
onSelect(entry)
}}
>
{entry.kind === "dir" ? (
<Folder className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
) : (
<File className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
)}
<span className="truncate font-mono text-xs">
{entry.relativePath}
</span>
</button>
))}
</div>
)
}

View File

@@ -53,6 +53,9 @@ import {
import { ModeSelector } from "@/components/chat/mode-selector"
import { SessionConfigSelector } from "@/components/chat/session-config-selector"
import { SlashCommandMenu } from "@/components/chat/slash-command-menu"
import { FileMentionMenu } from "@/components/chat/file-mention-menu"
import { useFileTree } from "@/hooks/use-file-tree"
import { joinFsPath } from "@/lib/path-utils"
import {
clearMessageInputDraft,
loadMessageInputDraft,
@@ -433,6 +436,37 @@ export function MessageInput({
)
}, [slashMenuOpen, slashCommands, text])
// ── @ file mention autocomplete ──
const [atMenuOpen, setAtMenuOpen] = useState(false)
const [atSelectedIndex, setAtSelectedIndex] = useState(0)
const [atTriggerPos, setAtTriggerPos] = useState<number | null>(null)
const [atFileTreeEnabled, setAtFileTreeEnabled] = useState(false)
const { allFiles: atAllFiles } = useFileTree({
folderPath: defaultPath,
enabled: atFileTreeEnabled,
})
const filteredAtFiles = useMemo(() => {
if (!atMenuOpen || atTriggerPos == null) return []
// Extract the query after "@" up to the next space or end of text
const afterAt = text.slice(atTriggerPos + 1)
const spaceIdx = afterAt.indexOf(" ")
const filter =
spaceIdx === -1
? afterAt.toLowerCase()
: afterAt.slice(0, spaceIdx).toLowerCase()
if (!filter) return atAllFiles.slice(0, 50)
const matched: typeof atAllFiles = []
for (const f of atAllFiles) {
if (f.lowerName.includes(filter) || f.lowerPath.includes(filter)) {
matched.push(f)
if (matched.length >= 50) break
}
}
return matched
}, [atMenuOpen, atTriggerPos, text, atAllFiles])
const appendResourceLinks = useCallback(
(
links: Array<{
@@ -744,18 +778,69 @@ export function MessageInput({
setSlashMenuOpen(false)
}, [])
const atTriggerPosRef = useRef(atTriggerPos)
useEffect(() => {
atTriggerPosRef.current = atTriggerPos
}, [atTriggerPos])
const handleAtSelect = useCallback(
(entry: { relativePath: string }) => {
const pos = atTriggerPosRef.current
if (!defaultPath || pos == null) return
// Remove the @... token from text
const current = textRef.current
const beforeAt = current.slice(0, pos)
const afterAt = current.slice(pos)
const spaceIdx = afterAt.indexOf(" ", 1)
const afterToken = spaceIdx === -1 ? "" : afterAt.slice(spaceIdx)
setText(beforeAt + afterToken)
// Attach the file
const absPath = joinFsPath(defaultPath, entry.relativePath)
appendResourceAttachments([absPath])
setAtMenuOpen(false)
setAtTriggerPos(null)
requestAnimationFrame(() => textareaRef.current?.focus())
},
[defaultPath, appendResourceAttachments]
)
const handleTextChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value
setText(value)
// Slash command detection (only at start of input)
if (slashCommands.length > 0 && /^\/(\S*)$/.test(value)) {
setSlashSelectedIndex(0)
setSlashMenuOpen(true)
setAtMenuOpen(false)
return
} else {
setSlashMenuOpen(false)
}
// @ file mention detection (at any cursor position)
const cursorPos = e.target.selectionStart
if (cursorPos != null && defaultPath) {
const beforeCursor = value.slice(0, cursorPos)
const atMatch = beforeCursor.match(/(^|[\s])@([^\s]*)$/)
if (atMatch) {
const atPos =
beforeCursor.length - atMatch[0].length + atMatch[1].length
setAtTriggerPos(atPos)
setAtSelectedIndex(0)
setAtMenuOpen(true)
setAtFileTreeEnabled(true)
return
}
}
setAtMenuOpen(false)
},
[slashCommands.length]
[slashCommands.length, defaultPath]
)
const handlePickFiles = useCallback(async () => {
@@ -1064,6 +1149,33 @@ export function MessageInput({
}
}
if (atMenuOpen && filteredAtFiles.length > 0) {
if (e.key === "ArrowDown") {
e.preventDefault()
setAtSelectedIndex((i) =>
i < filteredAtFiles.length - 1 ? i + 1 : 0
)
return
}
if (e.key === "ArrowUp") {
e.preventDefault()
setAtSelectedIndex((i) =>
i > 0 ? i - 1 : filteredAtFiles.length - 1
)
return
}
if (e.key === "Enter" || e.key === "Tab") {
e.preventDefault()
handleAtSelect(filteredAtFiles[atSelectedIndex])
return
}
if (e.key === "Escape") {
e.preventDefault()
setAtMenuOpen(false)
return
}
}
if (isEditingQueueItem && e.key === "Escape") {
e.preventDefault()
onCancelQueueEdit?.()
@@ -1097,6 +1209,10 @@ export function MessageInput({
filteredSlashCommands,
slashSelectedIndex,
handleSlashSelect,
atMenuOpen,
filteredAtFiles,
atSelectedIndex,
handleAtSelect,
]
)
@@ -1270,6 +1386,13 @@ export function MessageInput({
onSelect={handleSlashSelect}
/>
)}
{atMenuOpen && filteredAtFiles.length > 0 && (
<FileMentionMenu
files={filteredAtFiles}
selectedIndex={atSelectedIndex}
onSelect={handleAtSelect}
/>
)}
<div
className={cn(
"flex flex-col rounded-xl border border-input bg-transparent transition-colors focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50",