diff --git a/biome.json b/biome.json index 9d4ae766..b8c4cf98 100644 --- a/biome.json +++ b/biome.json @@ -7,7 +7,13 @@ }, "files": { "ignoreUnknown": false, - "includes": ["**", "!src-tauri/gen", "!.agents"] + "includes": [ + "**", + "!src-tauri/gen", + "!.agents", + "!.worktrees", + "!.claude/worktrees" + ] }, "formatter": { "enabled": true, diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 52274c70..9733c7db 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -457,6 +457,16 @@ dependencies = [ "alloc-stdlib", ] +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -788,6 +798,25 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -1694,6 +1723,19 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + [[package]] name = "gobject-sys" version = "0.18.0" @@ -1717,6 +1759,7 @@ dependencies = [ "doctor", "etcetera", "futures", + "ignore", "keyring", "log", "serde", @@ -2122,6 +2165,22 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "ignore" +version = "0.4.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + [[package]] name = "indexmap" version = "1.9.3" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 7d10a187..625206e2 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -39,6 +39,7 @@ agent-client-protocol = { version = "0.10.4", features = ["unstable_session_fork tokio-tungstenite = "0.21.0" acp-client = { git = "https://github.com/block/builderbot", rev = "db184d20cb48e0c90bbd3fea4a4a871fc9d8a6ad" } doctor = { git = "https://github.com/block/builderbot", rev = "8e1c3ec145edc0df5f04b4427cfd758378036862" } +ignore = "0.4.25" [target.'cfg(target_os = "macos")'.dependencies] keyring = { version = "3", features = ["apple-native"] } diff --git a/src-tauri/src/commands/system.rs b/src-tauri/src/commands/system.rs index 858919d1..12f178ff 100644 --- a/src-tauri/src/commands/system.rs +++ b/src-tauri/src/commands/system.rs @@ -1,6 +1,13 @@ use tauri::Window; use tauri_plugin_dialog::DialogExt; +use std::collections::HashSet; +use std::path::PathBuf; + +const DEFAULT_FILE_MENTION_LIMIT: usize = 1500; +const MAX_FILE_MENTION_LIMIT: usize = 5000; +const MAX_SCAN_DEPTH: usize = 8; + #[tauri::command] pub fn get_home_dir() -> Result { let home_dir = dirs::home_dir().ok_or("Could not determine home directory")?; @@ -47,3 +54,150 @@ pub async fn save_exported_session_file( pub fn path_exists(path: String) -> bool { std::path::Path::new(&path).exists() } + +fn normalize_roots(roots: Vec) -> Vec { + let mut dedup = HashSet::new(); + let mut normalized = Vec::new(); + for root in roots { + let trimmed = root.trim(); + if trimmed.is_empty() { + continue; + } + let path = PathBuf::from(trimmed); + let key = path.to_string_lossy().to_lowercase(); + if dedup.insert(key) { + normalized.push(path); + } + } + normalized +} + +fn scan_files_for_mentions(roots: Vec, max_results: Option) -> Vec { + let roots = normalize_roots(roots); + if roots.is_empty() { + return Vec::new(); + } + + let limit = max_results + .unwrap_or(DEFAULT_FILE_MENTION_LIMIT) + .clamp(1, MAX_FILE_MENTION_LIMIT); + + let mut builder = ignore::WalkBuilder::new(&roots[0]); + for root in &roots[1..] { + builder.add(root); + } + builder + .max_depth(Some(MAX_SCAN_DEPTH)) + .follow_links(false) // don't traverse symlinks + .hidden(true) // skip hidden files/dirs + .git_ignore(true) // respect .gitignore + .git_global(true) // respect global gitignore + .git_exclude(true); // respect .git/info/exclude + + // Canonicalize roots so we can reject paths that escape via symlink targets + let canonical_roots: Vec = roots + .iter() + .filter_map(|root| root.canonicalize().ok()) + .collect(); + + let mut seen = HashSet::new(); + let mut files = Vec::new(); + + for entry in builder.build().flatten() { + if files.len() >= limit { + break; + } + let Some(ft) = entry.file_type() else { + continue; + }; + if !ft.is_file() { + continue; + } + // Reject any path that resolved outside the project roots + let canonical = match entry.path().canonicalize() { + Ok(c) => c, + Err(_) => continue, + }; + if !canonical_roots + .iter() + .any(|root| canonical.starts_with(root)) + { + continue; + } + let path_str = entry.path().to_string_lossy().to_string(); + let dedup_key = path_str.to_lowercase(); + if seen.insert(dedup_key) { + files.push(path_str); + } + } + + files.sort_by_key(|path| path.to_lowercase()); + files +} + +#[tauri::command] +pub async fn list_files_for_mentions( + roots: Vec, + max_results: Option, +) -> Result, String> { + tokio::task::spawn_blocking(move || scan_files_for_mentions(roots, max_results)) + .await + .map_err(|error| format!("Failed to scan files for mentions: {}", error)) +} + +#[cfg(test)] +mod tests { + use super::scan_files_for_mentions; + use std::fs; + use std::process::Command; + use tempfile::tempdir; + + /// Create a temp dir with `git init` so the ignore crate picks up `.gitignore`. + fn git_tempdir() -> tempfile::TempDir { + let dir = tempdir().expect("tempdir"); + Command::new("git") + .args(["init", "--quiet"]) + .current_dir(dir.path()) + .output() + .expect("git init"); + dir + } + + #[test] + fn respects_gitignore() { + let dir = git_tempdir(); + let root = dir.path(); + let src = root.join("src"); + let ignored = root.join("node_modules").join("pkg"); + + fs::create_dir_all(&src).expect("src dir"); + fs::create_dir_all(&ignored).expect("ignored dir"); + fs::write(src.join("main.ts"), "export {}").expect("source file"); + fs::write(ignored.join("index.js"), "module.exports = {}").expect("ignored file"); + fs::write(root.join(".gitignore"), "node_modules/\n").expect(".gitignore"); + + let files = scan_files_for_mentions(vec![root.to_string_lossy().to_string()], Some(50)); + + let joined = files.join("\n"); + assert!(joined.contains("main.ts"), "should include source files"); + assert!( + !joined.contains("node_modules"), + "should respect .gitignore" + ); + } + + #[test] + fn skips_hidden_files() { + let dir = git_tempdir(); + let root = dir.path(); + + fs::write(root.join("visible.ts"), "").expect("visible file"); + fs::write(root.join(".hidden"), "").expect("hidden file"); + + let files = scan_files_for_mentions(vec![root.to_string_lossy().to_string()], Some(50)); + + let joined = files.join("\n"); + assert!(joined.contains("visible.ts")); + assert!(!joined.contains(".hidden")); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4d87eb3a..86d88756 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -86,6 +86,7 @@ pub fn run() { commands::system::get_home_dir, commands::system::save_exported_session_file, commands::system::path_exists, + commands::system::list_files_for_mentions, ]) .setup(|_app| Ok(())) .build(tauri::generate_context!()) diff --git a/src/features/chat/hooks/useMentionHandlers.ts b/src/features/chat/hooks/useMentionHandlers.ts new file mode 100644 index 00000000..1263d5b8 --- /dev/null +++ b/src/features/chat/hooks/useMentionHandlers.ts @@ -0,0 +1,232 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { listFilesForMentions } from "@/shared/api/system"; +import type { Persona } from "@/shared/types/agents"; +import { + useMentionDetection, + type FileMentionItem, + type MentionItem, +} from "../ui/MentionAutocomplete"; +import { useArtifactPolicyContext } from "./ArtifactPolicyContext"; + +interface MentionHandlersOptions { + personas: Persona[]; + projectWorkingDirs?: string[] | undefined; + text: string; + setText: (value: string) => void; + textareaRef: React.RefObject; + onPersonaChange?: ((id: string | null) => void) | undefined; +} + +function basename(path: string): string { + const parts = path.split(/[\\/]+/).filter(Boolean); + return parts[parts.length - 1] ?? path; +} + +function normalizeRoots(roots: string[] | undefined): string[] { + return Array.from( + new Set( + (roots ?? []) + .map((root) => root.trim()) + .filter((root) => root.length > 0), + ), + ); +} + +function toDisplayPath(path: string, roots: string[]): string { + const normalizedPath = path.replace(/\\/g, "/"); + for (const root of roots) { + const normalizedRoot = root.replace(/\\/g, "/").replace(/\/+$/, ""); + const prefix = `${normalizedRoot}/`; + if (normalizedPath.startsWith(prefix)) { + const relative = normalizedPath.slice(prefix.length); + const rootName = basename(normalizedRoot); + return `${rootName}/${relative}`; + } + } + return path; +} + +function sameStringArray(a: string[], b: string[]): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i += 1) { + if (a[i] !== b[i]) return false; + } + return true; +} + +/** + * Combines persona + file mention detection, filtering, and selection handlers. + * Keeps ChatInput under the file-size limit by centralising mention logic. + */ +export function useMentionHandlers({ + personas, + projectWorkingDirs, + text, + setText, + textareaRef, + onPersonaChange, +}: MentionHandlersOptions) { + const { getAllSessionArtifacts } = useArtifactPolicyContext(); + const normalizedProjectRoots = useMemo( + () => normalizeRoots(projectWorkingDirs), + [projectWorkingDirs], + ); + const rootsKey = useMemo( + () => normalizedProjectRoots.join("\n"), + [normalizedProjectRoots], + ); + const [projectFilePaths, setProjectFilePaths] = useState([]); + + useEffect(() => { + // Clear stale results immediately so users never see files from the + // previous project while the new scan is in flight. + setProjectFilePaths([]); + + if (!rootsKey) { + return; + } + + let cancelled = false; + + void listFilesForMentions(normalizedProjectRoots) + .then((paths) => { + if (cancelled) return; + setProjectFilePaths((prev) => + sameStringArray(prev, paths) ? prev : paths, + ); + }) + .catch((error) => { + if (cancelled) return; + console.error("Failed to load project files for mentions:", error); + setProjectFilePaths((prev) => (prev.length === 0 ? prev : [])); + }); + + return () => { + cancelled = true; + }; + }, [rootsKey, normalizedProjectRoots]); + + const fileMentionItems: FileMentionItem[] = useMemo(() => { + const dedup = new Map(); + + for (const artifact of getAllSessionArtifacts()) { + const key = artifact.resolvedPath.trim().toLowerCase(); + if (!key || dedup.has(key)) continue; + dedup.set(key, { + resolvedPath: artifact.resolvedPath, + displayPath: artifact.displayPath, + filename: artifact.filename, + kind: artifact.kind, + }); + } + + for (const path of projectFilePaths) { + const key = path.trim().toLowerCase(); + if (!key || dedup.has(key)) continue; + dedup.set(key, { + resolvedPath: path, + displayPath: toDisplayPath(path, normalizedProjectRoots), + filename: basename(path), + kind: "file", + }); + } + + return Array.from(dedup.values()); + }, [getAllSessionArtifacts, projectFilePaths, normalizedProjectRoots]); + + const { + mentionOpen, + mentionQuery, + mentionStartIndex, + mentionSelectedIndex, + filteredPersonas, + filteredFiles, + detectMention, + closeMention, + navigateMention, + confirmMention, + } = useMentionDetection(personas, fileMentionItems); + + // ---- post-selection cursor placement ------------------------------------ + // After a mention is confirmed we update `text` via setState. A useEffect + // watches for a pending cursor position and applies focus + cursor once + // React has flushed the new text into the textarea. + + const pendingCursorRef = useRef(null); + + // biome-ignore lint/correctness/useExhaustiveDependencies: text triggers the effect after setText flushes + useEffect(() => { + if (pendingCursorRef.current == null) return; + const ta = textareaRef.current; + if (!ta) return; + const pos = pendingCursorRef.current; + pendingCursorRef.current = null; + ta.focus(); + ta.style.height = "auto"; + ta.style.height = `${Math.min(ta.scrollHeight, 200)}px`; + ta.setSelectionRange(pos, pos); + }, [text, textareaRef]); + + // ---- 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}`.trimStart(); + pendingCursorRef.current = Math.min(before.length, newText.length); + setText(newText); + closeMention(); + onPersonaChange?.(persona.id); + }, + [ + text, + mentionStartIndex, + mentionQuery, + closeMention, + onPersonaChange, + setText, + ], + ); + + 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}`; + pendingCursorRef.current = before.length + inserted.length + 1; + setText(newText); + closeMention(); + }, + [text, mentionStartIndex, mentionQuery, closeMention, setText], + ); + + const handleMentionConfirm = useCallback( + (item: MentionItem) => { + if (item.type === "persona") { + handlePersonaMentionSelect(item.persona); + } else { + handleFileMentionSelect(item.file); + } + }, + [handlePersonaMentionSelect, handleFileMentionSelect], + ); + + return { + fileMentionItems, + mentionOpen, + mentionQuery, + mentionStartIndex, + mentionSelectedIndex, + filteredPersonas, + filteredFiles, + 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..f7f089d3 100644 --- a/src/features/chat/ui/ChatInput.tsx +++ b/src/features/chat/ui/ChatInput.tsx @@ -6,10 +6,9 @@ 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 { Popover, PopoverAnchor } from "@/shared/ui/popover"; +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"; @@ -165,6 +164,12 @@ export function ChatInput({ () => personas.find((persona) => persona.id === selectedPersonaId) ?? null, [personas, selectedPersonaId], ); + const selectedProject = useMemo( + () => + availableProjects.find((project) => project.id === selectedProjectId) ?? + null, + [availableProjects, selectedProjectId], + ); const stickyPersona = activePersona; const hasQueuedMessage = queuedMessage !== null; @@ -175,14 +180,24 @@ export function ChatInput({ const { mentionOpen, - mentionQuery, - mentionStartIndex, mentionSelectedIndex, + filteredPersonas, + filteredFiles, detectMention, closeMention, navigateMention, confirmMention, - } = useMentionDetection(personas); + handlePersonaMentionSelect, + handleFileMentionSelect, + handleMentionConfirm, + } = useMentionHandlers({ + personas, + projectWorkingDirs: selectedProject?.workingDirs, + text, + setText, + textareaRef, + onPersonaChange, + }); useEffect(() => { const el = containerRef.current; @@ -229,36 +244,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 +257,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; } } @@ -373,124 +358,129 @@ 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 && ( -
- {images.map((img, i) => ( - - ))} -
- )} + {images.length > 0 && ( +
+ {images.map((img, i) => ( + + ))} +
+ )} - {stickyPersona && ( -
- - - @{stickyPersona.displayName} - + +
+ )} + + {queuedMessage && ( +
+ + {t("queue.label", { text: queuedMessage.text })} + + - -
- )} - - {queuedMessage && ( -
- - {t("queue.label", { text: queuedMessage.text })} - - -
- )} + + +
+ )} -