diff --git a/web-app/package.json b/web-app/package.json index 3df983e64..d54759b9e 100644 --- a/web-app/package.json +++ b/web-app/package.json @@ -37,7 +37,7 @@ "@uiw/react-textarea-code-editor": "^3.1.1", "class-variance-authority": "^0.7.1", "culori": "^4.0.1", - "fuse.js": "^7.1.0", + "fzf": "^0.5.2", "i18next": "^25.0.1", "katex": "^0.16.22", "lodash.debounce": "^4.0.8", diff --git a/web-app/src/containers/LeftPanel.tsx b/web-app/src/containers/LeftPanel.tsx index 5b3ebb1b1..6473e33b3 100644 --- a/web-app/src/containers/LeftPanel.tsx +++ b/web-app/src/containers/LeftPanel.tsx @@ -118,7 +118,7 @@ const LeftPanel = () => { setSearchTerm(e.target.value)} /> diff --git a/web-app/src/containers/ThreadList.tsx b/web-app/src/containers/ThreadList.tsx index 56dc61294..00130cd69 100644 --- a/web-app/src/containers/ThreadList.tsx +++ b/web-app/src/containers/ThreadList.tsx @@ -80,7 +80,13 @@ const SortableItem = memo(({ thread }: { thread: Thread }) => { } } - const [title, setTitle] = useState(thread.title || 'New Thread') + const plainTitleForRename = useMemo(() => { + // Basic HTML stripping for simple span tags. + // If thread.title is undefined or null, treat as empty string before replace. + return (thread.title || '').replace(/]*>|<\/span>/g, ''); + }, [thread.title]); + + const [title, setTitle] = useState(plainTitleForRename || 'New Thread'); return (
{ )} >
- - {thread.title || 'New Thread'} - +
{ onOpenChange={(open) => { if (!open) { setOpenDropdown(false) - setTitle(thread.title) + setTitle(plainTitleForRename || 'New Thread'); } }} > diff --git a/web-app/src/hooks/useThreads.ts b/web-app/src/hooks/useThreads.ts index dbc979596..b8bb58035 100644 --- a/web-app/src/hooks/useThreads.ts +++ b/web-app/src/hooks/useThreads.ts @@ -3,7 +3,8 @@ import { persist, createJSONStorage } from 'zustand/middleware' import { localStorageKey } from '@/constants/localStorage' import { ulid } from 'ulidx' import { createThread, deleteThread, updateThread } from '@/services/threads' -import Fuse from 'fuse.js' +import { Fzf } from 'fzf' +import { highlightFzfMatch } from '../utils/highlight' type ThreadState = { threads: Record currentThreadId?: string @@ -25,19 +26,7 @@ type ThreadState = { updateCurrentThreadModel: (model: ThreadModel) => void getFilteredThreads: (searchTerm: string) => Thread[] updateCurrentThreadAssistant: (assistant: Assistant) => void - searchIndex: Fuse | null -} - -const fuseOptions = { - keys: ['title'], - threshold: 0.4, // Increased threshold to require more exact matches - includeMatches: true, // Keeping this to show where matches occur - ignoreLocation: true, // Consider the location of matches - useExtendedSearch: true, // Disable extended search for more precise matching - distance: 40, // Reduced edit distance for stricter fuzzy matching - tokenize: false, // Keep tokenization for word-level matching - matchAllTokens: true, // Require all tokens to match for better precision - findAllMatches: false, // Only find the first match to reduce noise + searchIndex: Fzf | null } export const useThreads = create()( @@ -54,13 +43,14 @@ export const useThreads = create()( }) }) const threadMap = threads.reduce( - (acc, thread) => { + (acc: Record, thread) => { acc[thread.id] = thread return acc - }, - {} as Record - ) - set({ threads: threadMap }) + }, {} as Record) + set({ + threads: threadMap, + searchIndex: new Fzf(Object.values(threadMap), { selector: (item: Thread) => item.title }) + }) }, getFilteredThreads: (searchTerm: string) => { const { threads, searchIndex } = get() @@ -71,23 +61,27 @@ export const useThreads = create()( return Object.values(threads) } - const currentIndex = - searchIndex && searchIndex.search != undefined - ? searchIndex - : new Fuse( - Object.values(threads).map((item) => item), - fuseOptions - ) - - set({ searchIndex: currentIndex }) + let currentIndex = searchIndex + if (!currentIndex) { + currentIndex = new Fzf( + Object.values(threads), + { selector: (item: Thread) => item.title } + ) + set({ searchIndex: currentIndex }) + } // Use the index to search and return matching threads - - const searchResults = currentIndex.search(searchTerm) - const validIds = searchResults.map((result) => result.item.id) - return Object.values(get().threads).filter((thread) => - validIds.includes(thread.id) - ) + const fzfResults = currentIndex.find(searchTerm) + return fzfResults.map((result: { item: Thread; positions: Set }) => { + const thread = result.item; // Fzf stores the original item here + // Ensure result.positions is an array, default to empty if undefined + const positions = Array.from(result.positions) || []; + const highlightedTitle = highlightFzfMatch(thread.title, positions); + return { + ...thread, + title: highlightedTitle, // Override title with highlighted version + }; + }); }, toggleFavorite: (threadId) => { set((state) => { @@ -113,7 +107,7 @@ export const useThreads = create()( deleteThread(threadId) return { threads: remainingThreads, - searchIndex: new Fuse(Object.values(remainingThreads), fuseOptions), + searchIndex: new Fzf(Object.values(remainingThreads), { selector: (item: Thread) => item.title }) } }) }, @@ -125,6 +119,7 @@ export const useThreads = create()( }) return { threads: {}, + searchIndex: null // Or new Fzf([], {selector...}) } }) }, @@ -166,17 +161,18 @@ export const useThreads = create()( updated: Date.now() / 1000, assistants: assistant ? [assistant] : [], } - set((state) => ({ - searchIndex: new Fuse(Object.values(state.threads), fuseOptions), - })) return await createThread(newThread).then((createdThread) => { - set((state) => ({ - threads: { + set((state) => { + const newThreads = { ...state.threads, [createdThread.id]: createdThread, - }, - currentThreadId: createdThread.id, - })) + }; + return { + threads: newThreads, + currentThreadId: createdThread.id, + searchIndex: new Fzf(Object.values(newThreads), { selector: (item: Thread) => item.title }), + }; + }) return createdThread }) }, @@ -224,20 +220,11 @@ export const useThreads = create()( ...thread, title: newTitle, } - updateThread(updatedThread) + updateThread(updatedThread) // External call, order is fine + const newThreads = { ...state.threads, [threadId]: updatedThread }; return { - threads: { - ...state.threads, - [threadId]: updatedThread, - }, - // Update search index with the new title - searchIndex: new Fuse( - Object.values({ - ...state.threads, - [threadId]: updatedThread, - }), - fuseOptions - ), + threads: newThreads, + searchIndex: new Fzf(Object.values(newThreads), { selector: (item: Thread) => item.title }), } }) }, diff --git a/web-app/src/index.css b/web-app/src/index.css index 3e4924f3d..5804fde90 100644 --- a/web-app/src/index.css +++ b/web-app/src/index.css @@ -88,3 +88,10 @@ scrollbar-width: none; /* Firefox */ } } + +@layer components { + .search-highlight { + @apply font-bold; + color: color-mix(in srgb, currentColor 80%, white 20%); + } +} \ No newline at end of file diff --git a/web-app/src/utils/highlight.ts b/web-app/src/utils/highlight.ts new file mode 100644 index 000000000..fe7cedcef --- /dev/null +++ b/web-app/src/utils/highlight.ts @@ -0,0 +1,41 @@ +// web-app/src/utils/highlight.ts +export function highlightFzfMatch(text: string, positions: number[], highlightClassName: string = "search-highlight") { + if (!text || !positions || !positions.length) return text; + + const parts: { text: string; highlight: boolean }[] = []; + let lastIndex = 0; + + // Sort positions to ensure we process them in order + const sortedPositions = [...positions].sort((a, b) => a - b); + + sortedPositions.forEach((pos) => { + if (pos > lastIndex) { + parts.push({ + text: text.substring(lastIndex, pos), + highlight: false + }); + } + if (pos < text.length) { // Ensure pos is within bounds + parts.push({ + text: text[pos], + highlight: true + }); + } + lastIndex = pos + 1; + }); + + if (lastIndex < text.length) { + parts.push({ + text: text.substring(lastIndex), + highlight: false + }); + } + + return parts + .map(part => + part.highlight + ? `${part.text}` + : part.text + ) + .join(''); +} \ No newline at end of file