From 4f5190f27d97eefa7b52fcf0495ca02659ff98ed Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Thu, 9 Apr 2026 08:00:08 -1000 Subject: [PATCH 01/20] feat: file @-mention with fuzzy search in chat input Extend the mention system to show session files alongside personas: - MentionAutocomplete now renders two sections: Personas and Files - FileMentionItem type for file suggestions (resolvedPath, displayPath, filename, kind) - MentionItem union type (persona | file) for unified selection handling - useMentionDetection accepts files param, filters both lists on query - confirmMention returns MentionItem instead of Persona - Extract mention handlers into useMentionHandlers hook (keeps ChatInput under 500 lines) - File data sourced from ArtifactPolicyContext.getAllSessionArtifacts() - Selecting a file inserts its resolved path into the message text - Selecting a persona still switches the active persona (unchanged behavior) - Add i18n key mention.filesTitle for the files section header --- src/features/chat/hooks/useMentionHandlers.ts | 134 +++++++++++ src/features/chat/ui/ChatInput.tsx | 59 ++--- src/features/chat/ui/MentionAutocomplete.tsx | 213 ++++++++++++++---- src/shared/i18n/locales/en/chat.json | 3 +- 4 files changed, 329 insertions(+), 80 deletions(-) create mode 100644 src/features/chat/hooks/useMentionHandlers.ts diff --git a/src/features/chat/hooks/useMentionHandlers.ts b/src/features/chat/hooks/useMentionHandlers.ts new file mode 100644 index 00000000..f4755bde --- /dev/null +++ b/src/features/chat/hooks/useMentionHandlers.ts @@ -0,0 +1,134 @@ +import { useCallback, useMemo } from "react"; +import type { Persona } from "@/shared/types/agents"; +import { + useMentionDetection, + type FileMentionItem, + type MentionItem, +} from "../ui/MentionAutocomplete"; +import { useArtifactPolicyContext } from "./ArtifactPolicyContext"; + +interface MentionHandlersOptions { + personas: Persona[]; + text: string; + setText: (value: string) => void; + textareaRef: React.RefObject; + onPersonaChange?: ((id: string | null) => void) | undefined; +} + +/** + * Combines persona + file mention detection, filtering, and selection handlers. + * Keeps ChatInput under the file-size limit by centralising mention logic. + */ +export function useMentionHandlers({ + personas, + text, + setText, + textareaRef, + onPersonaChange, +}: MentionHandlersOptions) { + const { getAllSessionArtifacts } = useArtifactPolicyContext(); + + const fileMentionItems: FileMentionItem[] = useMemo( + () => + getAllSessionArtifacts().map((a) => ({ + resolvedPath: a.resolvedPath, + displayPath: a.displayPath, + filename: a.filename, + kind: a.kind, + })), + [getAllSessionArtifacts], + ); + + const { + mentionOpen, + mentionQuery, + mentionStartIndex, + mentionSelectedIndex, + detectMention, + closeMention, + navigateMention, + confirmMention, + } = useMentionDetection(personas, fileMentionItems); + + // ---- selection handlers ------------------------------------------------ + + const handlePersonaMentionSelect = useCallback( + (persona: Persona) => { + const before = text.slice(0, mentionStartIndex); + const after = text.slice(mentionStartIndex + 1 + mentionQuery.length); + const newText = `${before}${after}`.replace(/\s{2,}/g, " "); + setText(newText); + closeMention(); + onPersonaChange?.(persona.id); + + requestAnimationFrame(() => { + const ta = textareaRef.current; + if (ta) { + ta.focus(); + ta.style.height = "auto"; + ta.style.height = `${Math.min(ta.scrollHeight, 200)}px`; + const cursorPos = Math.min(before.length, newText.length); + ta.setSelectionRange(cursorPos, cursorPos); + } + }); + }, + [ + text, + mentionStartIndex, + mentionQuery, + closeMention, + onPersonaChange, + setText, + textareaRef, + ], + ); + + const handleFileMentionSelect = useCallback( + (file: FileMentionItem) => { + const before = text.slice(0, mentionStartIndex); + const after = text.slice(mentionStartIndex + 1 + mentionQuery.length); + const inserted = file.resolvedPath; + const newText = `${before}${inserted} ${after}`.replace(/\s{2,}/g, " "); + setText(newText); + closeMention(); + + requestAnimationFrame(() => { + const ta = textareaRef.current; + if (ta) { + ta.focus(); + ta.style.height = "auto"; + ta.style.height = `${Math.min(ta.scrollHeight, 200)}px`; + const cursorPos = before.length + inserted.length + 1; + ta.setSelectionRange(cursorPos, cursorPos); + } + }); + }, + [text, mentionStartIndex, mentionQuery, closeMention, setText, textareaRef], + ); + + const handleMentionConfirm = useCallback( + (item: MentionItem) => { + if (item.type === "persona") { + handlePersonaMentionSelect(item.persona); + } else { + handleFileMentionSelect(item.file); + } + }, + [handlePersonaMentionSelect, handleFileMentionSelect], + ); + + return { + fileMentionItems, + mentionOpen, + mentionQuery, + mentionStartIndex, + mentionSelectedIndex, + detectMention, + closeMention, + navigateMention, + confirmMention, + handlePersonaMentionSelect, + handleFileMentionSelect, + handleMentionConfirm, + }; +} diff --git a/src/features/chat/ui/ChatInput.tsx b/src/features/chat/ui/ChatInput.tsx index 84079331..35baabaf 100644 --- a/src/features/chat/ui/ChatInput.tsx +++ b/src/features/chat/ui/ChatInput.tsx @@ -6,10 +6,8 @@ import type { Persona } from "@/shared/types/agents"; import { cn } from "@/shared/lib/cn"; import { Badge } from "@/shared/ui/badge"; import { Button } from "@/shared/ui/button"; -import { - MentionAutocomplete, - useMentionDetection, -} from "./MentionAutocomplete"; +import { MentionAutocomplete } from "./MentionAutocomplete"; +import { useMentionHandlers } from "../hooks/useMentionHandlers"; import { ChatInputToolbar } from "./ChatInputToolbar"; import { formatProviderLabel } from "@/shared/ui/icons/ProviderIcons"; import { TooltipProvider } from "@/shared/ui/tooltip"; @@ -174,15 +172,24 @@ export function ChatInput({ !disabled; const { + fileMentionItems, mentionOpen, mentionQuery, - mentionStartIndex, mentionSelectedIndex, detectMention, closeMention, navigateMention, confirmMention, - } = useMentionDetection(personas); + handlePersonaMentionSelect, + handleFileMentionSelect, + handleMentionConfirm, + } = useMentionHandlers({ + personas, + text, + setText, + textareaRef, + onPersonaChange, + }); useEffect(() => { const el = containerRef.current; @@ -229,36 +236,6 @@ export function ChatInput({ } }, [canSend, text, images, onSend, selectedPersonaId, setText]); - const handleMentionSelect = useCallback( - (persona: Persona) => { - const before = text.slice(0, mentionStartIndex); - const after = text.slice(mentionStartIndex + 1 + mentionQuery.length); - const newText = `${before}${after}`.replace(/\s{2,}/g, " "); - setText(newText); - closeMention(); - onPersonaChange?.(persona.id); - - requestAnimationFrame(() => { - const ta = textareaRef.current; - if (ta) { - ta.focus(); - ta.style.height = "auto"; - ta.style.height = `${Math.min(ta.scrollHeight, 200)}px`; - const cursorPos = Math.min(before.length, newText.length); - ta.setSelectionRange(cursorPos, cursorPos); - } - }); - }, - [ - text, - mentionStartIndex, - mentionQuery, - closeMention, - onPersonaChange, - setText, - ], - ); - const handleKeyDown = (e: React.KeyboardEvent) => { if (mentionOpen) { if (e.key === "Escape") { @@ -272,10 +249,10 @@ export function ChatInput({ return; } if (e.key === "Enter" || e.key === "Tab") { - const persona = confirmMention(); - if (persona) { + const item = confirmMention(); + if (item) { e.preventDefault(); - handleMentionSelect(persona); + handleMentionConfirm(item); return; } } @@ -396,9 +373,11 @@ export function ChatInput({ )} diff --git a/src/features/chat/ui/MentionAutocomplete.tsx b/src/features/chat/ui/MentionAutocomplete.tsx index 831984eb..8b9525e0 100644 --- a/src/features/chat/ui/MentionAutocomplete.tsx +++ b/src/features/chat/ui/MentionAutocomplete.tsx @@ -1,27 +1,52 @@ import { useState, useEffect, useRef, useCallback, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { Sparkles, User } from "lucide-react"; +import { IconFile, IconFolder } from "@tabler/icons-react"; import { cn } from "@/shared/lib/cn"; import { useAvatarSrc } from "@/shared/hooks/useAvatarSrc"; import type { Persona } from "@/shared/types/agents"; +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface FileMentionItem { + /** Absolute path used when inserting into the message. */ + resolvedPath: string; + /** Shortened display path (e.g. ~/project/src/foo.ts). */ + displayPath: string; + /** Just the filename portion. */ + filename: string; + kind: "file" | "folder" | "path"; +} + +export type MentionItem = + | { type: "persona"; persona: Persona } + | { type: "file"; file: FileMentionItem }; + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + interface MentionAutocompleteProps { personas: Persona[]; + files?: FileMentionItem[]; query: string; isOpen: boolean; - onSelect: (persona: Persona) => void; - /** Optional close handler (called on Escape). */ + onSelectPersona: (persona: Persona) => void; + onSelectFile?: (file: FileMentionItem) => void; onClose?: (() => void) | undefined; anchorRect?: DOMRect | null; - /** Index of the currently highlighted item (controlled by parent). */ selectedIndex?: number; } export function MentionAutocomplete({ personas, + files = [], query, isOpen, - onSelect, + onSelectPersona, + onSelectFile, anchorRect, selectedIndex: controlledIndex, }: MentionAutocompleteProps) { @@ -30,31 +55,61 @@ export function MentionAutocomplete({ const selectedIndex = controlledIndex ?? internalIndex; const listRef = useRef(null); - const filtered = useMemo(() => { + const { filteredPersonas, filteredFiles } = useMemo(() => { const q = query.toLowerCase(); - if (!q) return personas; - return personas.filter((p) => p.displayName.toLowerCase().includes(q)); - }, [personas, query]); + const fp = q + ? personas.filter((p) => p.displayName.toLowerCase().includes(q)) + : personas; + const ff = q + ? files.filter( + (f) => + f.filename.toLowerCase().includes(q) || + f.displayPath.toLowerCase().includes(q), + ) + : files; + return { filteredPersonas: fp, filteredFiles: ff }; + }, [personas, files, query]); + + const items: MentionItem[] = useMemo(() => { + const result: MentionItem[] = filteredPersonas.map((p) => ({ + type: "persona" as const, + persona: p, + })); + for (const f of filteredFiles) { + result.push({ type: "file" as const, file: f }); + } + return result; + }, [filteredPersonas, filteredFiles]); // Reset index when results change // biome-ignore lint/correctness/useExhaustiveDependencies: reset on query/result changes useEffect(() => { setInternalIndex(0); - }, [filtered.length, query]); + }, [items.length, query]); const handleSelect = useCallback( - (persona: Persona) => { - onSelect(persona); + (item: MentionItem) => { + if (item.type === "persona") { + onSelectPersona(item.persona); + } else { + onSelectFile?.(item.file); + } }, - [onSelect], + [onSelectPersona, onSelectFile], ); - if (!isOpen || filtered.length === 0) return null; + if (!isOpen || items.length === 0) return null; + + // Determine section boundaries for headers + const personaCount = filteredPersonas.length; + const fileCount = filteredFiles.length; + const showPersonaHeader = personaCount > 0 && fileCount > 0; + const showFileHeader = fileCount > 0; return (
-
- {t("mention.title")} -
-
- {filtered.map((persona, index) => ( +
+ {showPersonaHeader && ( +
+ {t("mention.title")} +
+ )} + {!showPersonaHeader && personaCount > 0 && ( +
+ {t("mention.title")} +
+ )} + {filteredPersonas.map((persona, index) => (
))} + + {showFileHeader && ( +
+ {t("mention.filesTitle")} +
+ )} + {filteredFiles.map((file, i) => { + const globalIndex = personaCount + i; + return ( + + ); + })}
); } +// --------------------------------------------------------------------------- +// Avatar helper (unchanged) +// --------------------------------------------------------------------------- + function MentionAvatar({ persona }: { persona: Persona }) { const avatarSrc = useAvatarSrc(persona.avatar); if (avatarSrc) { @@ -130,8 +235,14 @@ function MentionAvatar({ persona }: { persona: Persona }) { ); } -// Hook to manage mention detection and keyboard navigation in a textarea -export function useMentionDetection(personas: Persona[] = []) { +// --------------------------------------------------------------------------- +// Hook — mention detection + keyboard navigation +// --------------------------------------------------------------------------- + +export function useMentionDetection( + personas: Persona[] = [], + files: FileMentionItem[] = [], +) { const [mentionState, setMentionState] = useState<{ isOpen: boolean; query: string; @@ -139,16 +250,28 @@ export function useMentionDetection(personas: Persona[] = []) { selectedIndex: number; }>({ isOpen: false, query: "", startIndex: -1, selectedIndex: 0 }); - const filtered = useMemo(() => { - if (!mentionState.isOpen) return personas; + const { filteredPersonas, filteredFiles } = useMemo(() => { + if (!mentionState.isOpen) { + return { filteredPersonas: personas, filteredFiles: files }; + } const q = mentionState.query.toLowerCase(); - if (!q) return personas; - return personas.filter((p) => p.displayName.toLowerCase().includes(q)); - }, [personas, mentionState.isOpen, mentionState.query]); + if (!q) return { filteredPersonas: personas, filteredFiles: files }; + return { + filteredPersonas: personas.filter((p) => + p.displayName.toLowerCase().includes(q), + ), + filteredFiles: files.filter( + (f) => + f.filename.toLowerCase().includes(q) || + f.displayPath.toLowerCase().includes(q), + ), + }; + }, [personas, files, mentionState.isOpen, mentionState.query]); + + const totalCount = filteredPersonas.length + filteredFiles.length; const detectMention = useCallback( (value: string, cursorPos: number) => { - // Look backwards from cursor for an unmatched @ const beforeCursor = value.slice(0, cursorPos); const lastAt = beforeCursor.lastIndexOf("@"); @@ -180,7 +303,7 @@ export function useMentionDetection(personas: Persona[] = []) { const query = beforeCursor.slice(lastAt + 1); // Close if there's a space after the query (mention completed) or too long - if (query.includes(" ") || query.length > 30) { + if (query.includes(" ") || query.length > 50) { if (mentionState.isOpen) { setMentionState({ isOpen: false, @@ -211,26 +334,38 @@ export function useMentionDetection(personas: Persona[] = []) { }); }, []); - /** Move highlight up/down. Returns true if the event was consumed. */ const navigateMention = useCallback( (direction: "up" | "down"): boolean => { - if (!mentionState.isOpen || filtered.length === 0) return false; + if (!mentionState.isOpen || totalCount === 0) return false; setMentionState((prev) => { const delta = direction === "down" ? 1 : -1; - const next = - (prev.selectedIndex + delta + filtered.length) % filtered.length; + const next = (prev.selectedIndex + delta + totalCount) % totalCount; return { ...prev, selectedIndex: next }; }); return true; }, - [mentionState.isOpen, filtered.length], + [mentionState.isOpen, totalCount], ); - /** Confirm the currently highlighted item. Returns the persona or null. */ - const confirmMention = useCallback((): Persona | null => { - if (!mentionState.isOpen || filtered.length === 0) return null; - return filtered[mentionState.selectedIndex] ?? null; - }, [mentionState.isOpen, mentionState.selectedIndex, filtered]); + /** Confirm the currently highlighted item. Returns persona, file, or null. */ + const confirmMention = useCallback((): MentionItem | null => { + if (!mentionState.isOpen || totalCount === 0) return null; + const idx = mentionState.selectedIndex; + if (idx < filteredPersonas.length) { + return { type: "persona", persona: filteredPersonas[idx] }; + } + const fileIdx = idx - filteredPersonas.length; + if (fileIdx < filteredFiles.length) { + return { type: "file", file: filteredFiles[fileIdx] }; + } + return null; + }, [ + mentionState.isOpen, + mentionState.selectedIndex, + totalCount, + filteredPersonas, + filteredFiles, + ]); return { mentionOpen: mentionState.isOpen, diff --git a/src/shared/i18n/locales/en/chat.json b/src/shared/i18n/locales/en/chat.json index 3a554129..6458b704 100644 --- a/src/shared/i18n/locales/en/chat.json +++ b/src/shared/i18n/locales/en/chat.json @@ -64,7 +64,8 @@ }, "mention": { "ariaLabel": "Mention suggestions", - "title": "Mention a persona" + "title": "Mention a persona", + "filesTitle": "Files" }, "message": { "copied": "Copied", From 08be63cc1bba61911e471558187da4b078a0cb62 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Thu, 9 Apr 2026 09:32:37 -1000 Subject: [PATCH 02/20] fix: use Radix Popover for @-mention autocomplete positioning Replace manual absolute positioning with shared/ui Popover primitives so the dropdown gets viewport collision detection and portal rendering. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/features/chat/ui/ChatInput.tsx | 91 +++++++++++--------- src/features/chat/ui/MentionAutocomplete.tsx | 26 +++--- 2 files changed, 61 insertions(+), 56 deletions(-) diff --git a/src/features/chat/ui/ChatInput.tsx b/src/features/chat/ui/ChatInput.tsx index 35baabaf..b2d2c334 100644 --- a/src/features/chat/ui/ChatInput.tsx +++ b/src/features/chat/ui/ChatInput.tsx @@ -6,6 +6,7 @@ import type { Persona } from "@/shared/types/agents"; import { cn } from "@/shared/lib/cn"; import { Badge } from "@/shared/ui/badge"; import { Button } from "@/shared/ui/button"; +import { Popover, PopoverAnchor } from "@/shared/ui/popover"; import { MentionAutocomplete } from "./MentionAutocomplete"; import { useMentionHandlers } from "../hooks/useMentionHandlers"; import { ChatInputToolbar } from "./ChatInputToolbar"; @@ -350,37 +351,38 @@ export function ChatInput({
- {/* biome-ignore lint/a11y/noStaticElementInteractions: drop zone for image files */} -
- {isImageDragOver && ( -
- - {t("attachments.dropToAttach")} - -
- )} - + + {/* biome-ignore lint/a11y/noStaticElementInteractions: drop zone for image files */} +
+ {isImageDragOver && ( +
+ + {t("attachments.dropToAttach")} + +
+ )} + {images.length > 0 && (
@@ -430,18 +432,20 @@ export function ChatInput({
)} -