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",

View File

@@ -4,23 +4,18 @@ import { useState, useEffect, useRef, useCallback, useMemo } from "react"
import { formatDistanceToNow } from "date-fns"
import { enUS, zhCN, zhTW } from "date-fns/locale"
import { File, Folder } from "lucide-react"
import ig from "ignore"
import { useLocale, useTranslations } from "next-intl"
import { useAuxPanelContext } from "@/contexts/aux-panel-context"
import { useFolderContext } from "@/contexts/folder-context"
import { useTabContext } from "@/contexts/tab-context"
import { useWorkspaceContext } from "@/contexts/workspace-context"
import {
getFileTree,
listFolderConversations,
readFilePreview,
} from "@/lib/api"
import { listFolderConversations } from "@/lib/api"
import type {
AgentType,
ConversationStatus,
DbConversationSummary,
FileTreeNode,
} from "@/lib/types"
import { useFileTree, type FlatFileEntry } from "@/hooks/use-file-tree"
import { AGENT_LABELS, STATUS_COLORS, compareAgentType } from "@/lib/types"
import { AgentIcon } from "@/components/agent-icon"
import {
@@ -35,49 +30,6 @@ import { cn } from "@/lib/utils"
type SearchTab = "conversations" | "files"
interface FlatFileEntry {
name: string
/** Relative path from folder root (same as FileTreeNode.path) */
relativePath: string
kind: "file" | "dir"
/** Pre-computed lowercase relativePath for filtering */
lowerPath: string
/** Pre-computed lowercase name for filtering */
lowerName: string
}
function flattenTree(nodes: FileTreeNode[]): FlatFileEntry[] {
const entries: FlatFileEntry[] = []
function walk(node: FileTreeNode) {
entries.push({
name: node.name,
relativePath: node.path,
kind: node.kind,
lowerPath: node.path.toLowerCase(),
lowerName: node.name.toLowerCase(),
})
if (node.kind === "dir" && node.children) {
for (const child of node.children) {
walk(child)
}
}
}
for (const node of nodes) {
walk(node)
}
return entries
}
/** Check whether any ancestor directory of `path` is in `ignoredDirs`. */
function hasIgnoredAncestor(path: string, ignoredDirs: Set<string>): boolean {
let idx = path.indexOf("/")
while (idx !== -1) {
if (ignoredDirs.has(path.slice(0, idx))) return true
idx = path.indexOf("/", idx + 1)
}
return false
}
interface SearchCommandDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
@@ -103,98 +55,23 @@ export function SearchCommandDialog({
const [searching, setSearching] = useState(false)
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined)
// File search state
const [allFiles, setAllFiles] = useState<FlatFileEntry[]>([])
const [filesLoading, setFilesLoading] = useState(false)
const filesLoadedRef = useRef(false)
const folderPath = folder?.path ?? ""
// File search via shared hook (lazy-loaded when files tab is active)
const {
allFiles,
loading: filesLoading,
reset: resetFileTree,
} = useFileTree({
folderPath: folderPath || undefined,
enabled: activeTab === "files",
})
// Compute which agent types exist in current folder
const availableAgents = Array.from(
new Set(conversations.map((c) => c.agent_type))
).sort(compareAgentType)
// Load file tree when switching to files tab, filtering by .gitignore
useEffect(() => {
if (activeTab !== "files" || !folderPath || filesLoadedRef.current) return
let canceled = false
setFilesLoading(true)
async function load() {
try {
const tree = await getFileTree(folderPath, 10)
const flat = flattenTree(tree)
// Collect all .gitignore files from the tree
const gitignoreEntries = flat.filter(
(f) => f.kind === "file" && f.name === ".gitignore"
)
// Build matchers keyed by directory prefix
const matchers: { prefix: string; matcher: ReturnType<typeof ig> }[] =
[]
await Promise.all(
gitignoreEntries.map(async (entry) => {
try {
const result = await readFilePreview(
folderPath,
entry.relativePath
)
const lastSlash = entry.relativePath.lastIndexOf("/")
const dir =
lastSlash === -1 ? "" : entry.relativePath.slice(0, lastSlash)
matchers.push({
prefix: dir ? dir + "/" : "",
matcher: ig().add(result.content),
})
} catch {
// skip unreadable .gitignore
}
})
)
// Sort matchers by prefix length (shortest/root first) so that
// parent rules are evaluated before child rules.
matchers.sort((a, b) => a.prefix.length - b.prefix.length)
// Filter: check each entry against all applicable .gitignore matchers
const ignoredDirs = new Set<string>()
const filtered = flat.filter((f) => {
// Skip .gitignore files themselves from results
if (f.name === ".gitignore") return false
// If an ancestor directory is already ignored, skip — O(depth) lookup
if (hasIgnoredAncestor(f.relativePath, ignoredDirs)) return false
for (const { prefix, matcher } of matchers) {
if (!f.relativePath.startsWith(prefix)) continue
const relPath = f.relativePath.slice(prefix.length)
if (!relPath) continue
const testPath = f.kind === "dir" ? `${relPath}/` : relPath
if (matcher.ignores(testPath)) {
if (f.kind === "dir") ignoredDirs.add(f.relativePath)
return false
}
}
return true
})
if (!canceled) {
setAllFiles(filtered)
filesLoadedRef.current = true
}
} catch {
if (!canceled) setAllFiles([])
} finally {
if (!canceled) setFilesLoading(false)
}
}
void load()
return () => {
canceled = true
}
}, [activeTab, folderPath])
// Filter files by query using pre-computed lowercase fields
const filteredFiles = useMemo(() => {
const trimmed = query.trim()
@@ -253,10 +130,9 @@ export function SearchCommandDialog({
setAgentFilter(null)
setResults([])
setActiveTab("conversations")
filesLoadedRef.current = false
setAllFiles([])
resetFileTree()
}
}, [open])
}, [open, resetFileTree])
const handleSelectConversation = useCallback(
(conv: DbConversationSummary) => {

View File

@@ -36,10 +36,7 @@ import {
startFileTreeWatch,
stopFileTreeWatch,
} from "@/lib/api"
import {
emitAttachFileToSession,
emitAppendTextToSession,
} from "@/lib/session-attachment-events"
import { emitAttachFileToSession } from "@/lib/session-attachment-events"
import type {
FileTreeChangedEvent,
FileTreeNode,
@@ -86,16 +83,7 @@ import {
CollapsibleTrigger,
} from "@/components/ui/collapsible"
import { Skeleton } from "@/components/ui/skeleton"
function joinFsPath(basePath: string, relPath: string): string {
if (!relPath) return basePath
const separator = basePath.includes("\\") ? "\\" : "/"
const normalizedRel = relPath.replace(/[\\/]/g, separator)
if (basePath.endsWith("/") || basePath.endsWith("\\")) {
return `${basePath}${normalizedRel}`
}
return `${basePath}${separator}${normalizedRel}`
}
import { joinFsPath } from "@/lib/path-utils"
function parentDir(filePath: string): string {
const slashIndex = filePath.lastIndexOf("/")
@@ -618,12 +606,9 @@ function RenderNode({
const handleAttachDirToSession = () => {
if (!activeSessionTabId) return
const relativePath = node.path.endsWith("/")
? `@${node.path} `
: `@${node.path}/ `
emitAppendTextToSession({
emitAttachFileToSession({
tabId: activeSessionTabId,
text: relativePath,
path: absolutePath,
})
}

166
src/hooks/use-file-tree.ts Normal file
View File

@@ -0,0 +1,166 @@
"use client"
import { useState, useEffect, useRef, useCallback } from "react"
import ig from "ignore"
import { getFileTree, readFilePreview } from "@/lib/api"
import type { FileTreeNode } from "@/lib/types"
export interface FlatFileEntry {
name: string
/** Relative path from folder root (same as FileTreeNode.path) */
relativePath: string
kind: "file" | "dir"
/** Pre-computed lowercase relativePath for filtering */
lowerPath: string
/** Pre-computed lowercase name for filtering */
lowerName: string
}
export function flattenTree(nodes: FileTreeNode[]): FlatFileEntry[] {
const entries: FlatFileEntry[] = []
function walk(node: FileTreeNode) {
entries.push({
name: node.name,
relativePath: node.path,
kind: node.kind,
lowerPath: node.path.toLowerCase(),
lowerName: node.name.toLowerCase(),
})
if (node.kind === "dir" && node.children) {
for (const child of node.children) {
walk(child)
}
}
}
for (const node of nodes) {
walk(node)
}
return entries
}
/** Check whether any ancestor directory of `path` is in `ignoredDirs`. */
export function hasIgnoredAncestor(
path: string,
ignoredDirs: Set<string>
): boolean {
let idx = path.indexOf("/")
while (idx !== -1) {
if (ignoredDirs.has(path.slice(0, idx))) return true
idx = path.indexOf("/", idx + 1)
}
return false
}
interface UseFileTreeOptions {
folderPath: string | undefined
enabled: boolean
}
interface UseFileTreeResult {
allFiles: FlatFileEntry[]
loading: boolean
loaded: boolean
/** Clear cached data so the next `enabled=true` triggers a fresh load. */
reset: () => void
}
export function useFileTree({
folderPath,
enabled,
}: UseFileTreeOptions): UseFileTreeResult {
const [allFiles, setAllFiles] = useState<FlatFileEntry[]>([])
const [loading, setLoading] = useState(false)
const loadedForPathRef = useRef<string | null>(null)
useEffect(() => {
if (!enabled || !folderPath) return
if (loadedForPathRef.current === folderPath) return
let canceled = false
setLoading(true)
async function load() {
try {
const tree = await getFileTree(folderPath!, 10)
const flat = flattenTree(tree)
// Collect all .gitignore files from the tree
const gitignoreEntries = flat.filter(
(f) => f.kind === "file" && f.name === ".gitignore"
)
// Build matchers keyed by directory prefix
const matchers: {
prefix: string
matcher: ReturnType<typeof ig>
}[] = []
await Promise.all(
gitignoreEntries.map(async (entry) => {
try {
const result = await readFilePreview(
folderPath!,
entry.relativePath
)
const lastSlash = entry.relativePath.lastIndexOf("/")
const dir =
lastSlash === -1 ? "" : entry.relativePath.slice(0, lastSlash)
matchers.push({
prefix: dir ? dir + "/" : "",
matcher: ig().add(result.content),
})
} catch {
// skip unreadable .gitignore
}
})
)
// Sort matchers by prefix length (shortest/root first)
matchers.sort((a, b) => a.prefix.length - b.prefix.length)
// Filter: check each entry against all applicable .gitignore matchers
const ignoredDirs = new Set<string>()
const filtered = flat.filter((f) => {
if (f.name === ".gitignore") return false
if (hasIgnoredAncestor(f.relativePath, ignoredDirs)) return false
for (const { prefix, matcher } of matchers) {
if (!f.relativePath.startsWith(prefix)) continue
const relPath = f.relativePath.slice(prefix.length)
if (!relPath) continue
const testPath = f.kind === "dir" ? `${relPath}/` : relPath
if (matcher.ignores(testPath)) {
if (f.kind === "dir") ignoredDirs.add(f.relativePath)
return false
}
}
return true
})
if (!canceled) {
setAllFiles(filtered)
loadedForPathRef.current = folderPath!
}
} catch {
if (!canceled) setAllFiles([])
} finally {
if (!canceled) setLoading(false)
}
}
void load()
return () => {
canceled = true
}
}, [enabled, folderPath])
const reset = useCallback(() => {
loadedForPathRef.current = null
setAllFiles([])
}, [])
return {
allFiles,
loading,
loaded: loadedForPathRef.current === folderPath,
reset,
}
}

9
src/lib/path-utils.ts Normal file
View File

@@ -0,0 +1,9 @@
export function joinFsPath(basePath: string, relPath: string): string {
if (!relPath) return basePath
const separator = basePath.includes("\\") ? "\\" : "/"
const normalizedRel = relPath.replace(/[\\/]/g, separator)
if (basePath.endsWith("/") || basePath.endsWith("\\")) {
return `${basePath}${normalizedRel}`
}
return `${basePath}${separator}${normalizedRel}`
}