diff --git a/.story_kit/work/2_current/269_story_file_references_in_web_ui_chat_input.md b/.story_kit/work/2_current/269_story_file_references_in_web_ui_chat_input.md new file mode 100644 index 0000000..23363e6 --- /dev/null +++ b/.story_kit/work/2_current/269_story_file_references_in_web_ui_chat_input.md @@ -0,0 +1,23 @@ +--- +name: "@ file references in web UI chat input" +merge_failure: "Rename/rename conflict on story file: master moved it to 4_merge/ while the feature branch has it in 2_current/. The auto-resolver cannot handle rename/rename conflicts. The story file needs to be resolved manually (likely by accepting the 4_merge/ location on master and removing the 2_current/ version from the feature branch), then re-triggering the merge." +--- + +# Story 269: @ file references in web UI chat input + +## User Story + +As a user chatting in the web UI, I want to type @ to get an autocomplete overlay listing project files, so that I can reference specific files in my messages the same way Zed and Claude Code do. + +## Acceptance Criteria + +- [ ] Typing @ in the chat input triggers a file picker overlay +- [ ] Overlay searches project files with fuzzy matching as the user types after @ +- [ ] Selecting a file inserts a reference into the message (e.g. @path/to/file.rs) +- [ ] The referenced file contents are included as context when the message is sent to the LLM +- [ ] Overlay is dismissable with Escape +- [ ] Multiple @ references can be used in a single message + +## Out of Scope + +- TBD diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 763a17b..807eab3 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -277,6 +277,9 @@ export const api = { getHomeDirectory(baseUrl?: string) { return requestJson("/io/fs/home", {}, baseUrl); }, + listProjectFiles(baseUrl?: string) { + return requestJson("/io/fs/files", {}, baseUrl); + }, searchFiles(query: string, baseUrl?: string) { return requestJson( "/fs/search", diff --git a/frontend/src/components/ChatInput.tsx b/frontend/src/components/ChatInput.tsx index d6141bb..a69b147 100644 --- a/frontend/src/components/ChatInput.tsx +++ b/frontend/src/components/ChatInput.tsx @@ -1,6 +1,7 @@ import * as React from "react"; +import { api } from "../api/client"; -const { forwardRef, useEffect, useImperativeHandle, useRef, useState } = React; +const { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } = React; export interface ChatInputHandle { appendToInput(text: string): void; @@ -14,6 +15,97 @@ interface ChatInputProps { onRemoveQueuedMessage: (id: string) => void; } +/** Fuzzy-match: returns true if all chars of `query` appear in order in `str`. */ +function fuzzyMatch(str: string, query: string): boolean { + if (!query) return true; + const lower = str.toLowerCase(); + const q = query.toLowerCase(); + let qi = 0; + for (let i = 0; i < lower.length && qi < q.length; i++) { + if (lower[i] === q[qi]) qi++; + } + return qi === q.length; +} + +/** Score a fuzzy match: lower is better. Exact prefix match wins, then shorter paths. */ +function fuzzyScore(str: string, query: string): number { + const lower = str.toLowerCase(); + const q = query.toLowerCase(); + // Prefer matches where query appears as a contiguous substring + if (lower.includes(q)) return lower.indexOf(q); + return str.length; +} + +interface FilePickerOverlayProps { + query: string; + files: string[]; + selectedIndex: number; + onSelect: (file: string) => void; + onDismiss: () => void; + anchorRef: React.RefObject; +} + +function FilePickerOverlay({ + query, + files, + selectedIndex, + onSelect, +}: FilePickerOverlayProps) { + const filtered = files + .filter((f) => fuzzyMatch(f, query)) + .sort((a, b) => fuzzyScore(a, query) - fuzzyScore(b, query)) + .slice(0, 10); + + if (filtered.length === 0) return null; + + return ( +
+ {filtered.map((file, idx) => ( + + ))} +
+ ); +} + export const ChatInput = forwardRef( function ChatInput( { loading, queuedMessages, onSubmit, onCancel, onRemoveQueuedMessage }, @@ -22,6 +114,12 @@ export const ChatInput = forwardRef( const [input, setInput] = useState(""); const inputRef = useRef(null); + // File picker state + const [projectFiles, setProjectFiles] = useState([]); + const [pickerQuery, setPickerQuery] = useState(null); + const [pickerSelectedIndex, setPickerSelectedIndex] = useState(0); + const [pickerAtStart, setPickerAtStart] = useState(0); + useImperativeHandle(ref, () => ({ appendToInput(text: string) { setInput((prev) => (prev ? `${prev}\n${text}` : text)); @@ -32,10 +130,104 @@ export const ChatInput = forwardRef( inputRef.current?.focus(); }, []); + // Compute filtered files for current picker query + const filteredFiles = pickerQuery !== null + ? projectFiles + .filter((f) => fuzzyMatch(f, pickerQuery)) + .sort((a, b) => fuzzyScore(a, pickerQuery) - fuzzyScore(b, pickerQuery)) + .slice(0, 10) + : []; + + const dismissPicker = useCallback(() => { + setPickerQuery(null); + setPickerSelectedIndex(0); + }, []); + + const selectFile = useCallback( + (file: string) => { + // Replace the @query portion with @file + const before = input.slice(0, pickerAtStart); + const cursorPos = inputRef.current?.selectionStart ?? input.length; + const after = input.slice(cursorPos); + setInput(`${before}@${file}${after}`); + dismissPicker(); + // Restore focus after state update + setTimeout(() => inputRef.current?.focus(), 0); + }, + [input, pickerAtStart, dismissPicker], + ); + + const handleInputChange = useCallback( + (e: React.ChangeEvent) => { + const val = e.target.value; + setInput(val); + + const cursor = e.target.selectionStart ?? val.length; + // Find the last @ before the cursor that starts a reference token + const textUpToCursor = val.slice(0, cursor); + // Match @ not preceded by non-whitespace (i.e. @ at start or after space/newline) + const atMatch = textUpToCursor.match(/(^|[\s\n])@([^\s@]*)$/); + + if (atMatch) { + const query = atMatch[2]; + const atPos = textUpToCursor.lastIndexOf("@"); + setPickerAtStart(atPos); + setPickerQuery(query); + setPickerSelectedIndex(0); + + // Lazily load files on first trigger + if (projectFiles.length === 0) { + api.listProjectFiles().then(setProjectFiles).catch(() => {}); + } + } else { + if (pickerQuery !== null) dismissPicker(); + } + }, + [projectFiles.length, pickerQuery, dismissPicker], + ); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (pickerQuery !== null && filteredFiles.length > 0) { + if (e.key === "ArrowDown") { + e.preventDefault(); + setPickerSelectedIndex((i) => Math.min(i + 1, filteredFiles.length - 1)); + return; + } + if (e.key === "ArrowUp") { + e.preventDefault(); + setPickerSelectedIndex((i) => Math.max(i - 1, 0)); + return; + } + if (e.key === "Enter" || e.key === "Tab") { + e.preventDefault(); + selectFile(filteredFiles[pickerSelectedIndex] ?? filteredFiles[0]); + return; + } + if (e.key === "Escape") { + e.preventDefault(); + dismissPicker(); + return; + } + } else if (e.key === "Escape" && pickerQuery !== null) { + e.preventDefault(); + dismissPicker(); + return; + } + + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSubmit(); + } + }, + [pickerQuery, filteredFiles, pickerSelectedIndex, selectFile, dismissPicker], + ); + const handleSubmit = () => { if (!input.trim()) return; onSubmit(input); setInput(""); + dismissPicker(); }; return ( @@ -135,24 +327,30 @@ export const ChatInput = forwardRef( ))} - {/* Input row */} + {/* Input row with file picker overlay */}
+ {pickerQuery !== null && ( + + )}