From c4151f16a0b56718da4dba9b5d768a04d6cbd4a4 Mon Sep 17 00:00:00 2001 From: "Thomas G. Lopes" <26071571+TGlide@users.noreply.github.com> Date: Wed, 18 Jun 2025 17:17:40 +0100 Subject: [PATCH 1/4] first try --- src/lib/backend/convex/conversations.ts | 69 ++++++++++++++++++++++--- src/lib/utils/fuzzy-search.ts | 2 +- src/routes/chat/+layout.svelte | 2 + src/routes/chat/search-modal.svelte | 69 +++++++++++++++++++++++++ 4 files changed, 135 insertions(+), 7 deletions(-) create mode 100644 src/routes/chat/search-modal.svelte diff --git a/src/lib/backend/convex/conversations.ts b/src/lib/backend/convex/conversations.ts index 436b9e8..b12c08e 100644 --- a/src/lib/backend/convex/conversations.ts +++ b/src/lib/backend/convex/conversations.ts @@ -1,11 +1,12 @@ import { v } from 'convex/values'; -import { api } from './_generated/api'; -import { query } from './_generated/server'; -import { type Id } from './_generated/dataModel'; -import { type SessionObj } from './betterAuth'; -import { messageRoleValidator } from './schema'; -import { mutation } from './functions'; +import { fuzzyMatchString } from '../../utils/fuzzy-search'; import { getFirstSentence } from '../../utils/strings'; +import { api } from './_generated/api'; +import { Doc, type Id } from './_generated/dataModel'; +import { query } from './_generated/server'; +import { type SessionObj } from './betterAuth'; +import { mutation } from './functions'; +import { messageRoleValidator } from './schema'; export const get = query({ args: { @@ -278,3 +279,59 @@ export const remove = mutation({ await ctx.db.delete(args.conversation_id); }, }); + +export const search = query({ + args: { + session_token: v.string(), + search_term: v.string(), + }, + handler: async (ctx, args) => { + const session = await ctx.runQuery(api.betterAuth.publicGetSession, { + session_token: args.session_token, + }); + + if (!session) { + throw new Error('Unauthorized'); + } + + type SearchResult = { + conversation: Doc<'conversations'>; + messages: Doc<'messages'>[]; + }; + const res: SearchResult[] = []; + + if (!args.search_term.trim()) return res; + + const convQuery = ctx.db + .query('conversations') + .withIndex('by_user', (q) => q.eq('user_id', session.userId)); + + for await (const conversation of convQuery) { + const searchResult: SearchResult = { + conversation, + messages: [], + }; + + const msgQuery = ctx.db + .query('messages') + .withIndex('by_conversation', (q) => q.eq('conversation_id', conversation._id)) + .order('asc'); + + for await (const message of msgQuery) { + if (fuzzyMatchString(args.search_term, message.content)) { + console.log('Found message for search'); + searchResult.messages.push(message); + } + } + + if ( + searchResult.messages.length > 0 || + fuzzyMatchString(args.search_term, conversation.title) + ) { + res.push(searchResult); + } + } + + return res; + }, +}); diff --git a/src/lib/utils/fuzzy-search.ts b/src/lib/utils/fuzzy-search.ts index 69299fe..9ebea48 100644 --- a/src/lib/utils/fuzzy-search.ts +++ b/src/lib/utils/fuzzy-search.ts @@ -38,7 +38,7 @@ export default function fuzzysearch(options: { /** * Internal helper function that performs the actual fuzzy string matching */ -function fuzzyMatchString(needle: string, haystack: string): boolean { +export function fuzzyMatchString(needle: string, haystack: string): boolean { const hlen = haystack.length; const nlen = needle.length; diff --git a/src/routes/chat/+layout.svelte b/src/routes/chat/+layout.svelte index 8a46d42..2b20138 100644 --- a/src/routes/chat/+layout.svelte +++ b/src/routes/chat/+layout.svelte @@ -37,6 +37,7 @@ import ModelPicker from './model-picker.svelte'; import AppSidebar from '$lib/components/app-sidebar.svelte'; import { cn } from '$lib/utils/utils.js'; + import SearchModal from './search-modal.svelte'; const client = useConvexClient(); @@ -370,6 +371,7 @@
+ {#snippet trigger(tooltip)} + {/snippet} + Search + + + +

Search

+ + + {#if search.isLoading} +
+
+
+ {:else if search.data?.length} +
+ {#each search.data as { conversation, messages }} +
+
+
+ {conversation.title} +
+
+ {messages.length} message{messages.length > 1 ? 's' : ''} +
+
+ + + +
+ {/each} +
+ {/if} + From 10332fbaa90b9cd4824191b63ffef304da8b7bdc Mon Sep 17 00:00:00 2001 From: "Thomas G. Lopes" <26071571+TGlide@users.noreply.github.com> Date: Wed, 18 Jun 2025 17:29:07 +0100 Subject: [PATCH 2/4] better search --- src/lib/backend/convex/conversations.ts | 76 ++++++---- src/lib/backend/convex/user_settings.ts | 2 +- src/lib/utils/fuzzy-search.ts | 188 +++++++++++++++++++++--- src/routes/account/models/+page.svelte | 2 +- src/routes/chat/model-picker.svelte | 2 +- src/routes/chat/search-modal.svelte | 93 ++++++++---- 6 files changed, 288 insertions(+), 75 deletions(-) diff --git a/src/lib/backend/convex/conversations.ts b/src/lib/backend/convex/conversations.ts index b12c08e..e3b30d2 100644 --- a/src/lib/backend/convex/conversations.ts +++ b/src/lib/backend/convex/conversations.ts @@ -1,8 +1,8 @@ import { v } from 'convex/values'; -import { fuzzyMatchString } from '../../utils/fuzzy-search'; +import enhancedSearch, { type SearchResult } from '../../utils/fuzzy-search'; import { getFirstSentence } from '../../utils/strings'; import { api } from './_generated/api'; -import { Doc, type Id } from './_generated/dataModel'; +import { type Doc, type Id } from './_generated/dataModel'; import { query } from './_generated/server'; import { type SessionObj } from './betterAuth'; import { mutation } from './functions'; @@ -284,6 +284,7 @@ export const search = query({ args: { session_token: v.string(), search_term: v.string(), + search_mode: v.optional(v.union(v.literal('exact'), v.literal('words'), v.literal('fuzzy'))), }, handler: async (ctx, args) => { const session = await ctx.runQuery(api.betterAuth.publicGetSession, { @@ -294,44 +295,65 @@ export const search = query({ throw new Error('Unauthorized'); } - type SearchResult = { + type ConversationSearchResult = { conversation: Doc<'conversations'>; messages: Doc<'messages'>[]; + score: number; + titleMatch: boolean; }; - const res: SearchResult[] = []; - if (!args.search_term.trim()) return res; + if (!args.search_term.trim()) return []; - const convQuery = ctx.db + const searchMode = args.search_mode || 'words'; + const results: ConversationSearchResult[] = []; + + // Get all conversations for the user + const conversations = await ctx.db .query('conversations') - .withIndex('by_user', (q) => q.eq('user_id', session.userId)); + .withIndex('by_user', (q) => q.eq('user_id', session.userId)) + .collect(); - for await (const conversation of convQuery) { - const searchResult: SearchResult = { - conversation, - messages: [], - }; - - const msgQuery = ctx.db + // Search through conversations and messages + for (const conversation of conversations) { + // Get messages for this conversation + const conversationMessages = await ctx.db .query('messages') .withIndex('by_conversation', (q) => q.eq('conversation_id', conversation._id)) - .order('asc'); + .collect(); + + // Search title + const titleResults = enhancedSearch({ + needle: args.search_term, + haystack: [conversation], + property: 'title', + mode: searchMode, + minScore: 0.3, + }); - for await (const message of msgQuery) { - if (fuzzyMatchString(args.search_term, message.content)) { - console.log('Found message for search'); - searchResult.messages.push(message); - } - } + // Search messages + const messageResults = enhancedSearch({ + needle: args.search_term, + haystack: conversationMessages, + property: 'content', + mode: searchMode, + minScore: 0.3, + }); - if ( - searchResult.messages.length > 0 || - fuzzyMatchString(args.search_term, conversation.title) - ) { - res.push(searchResult); + // If we have matches in title or messages, add to results + if (titleResults.length > 0 || messageResults.length > 0) { + const titleScore = titleResults.length > 0 ? titleResults[0]?.score ?? 0 : 0; + const messageScore = messageResults.length > 0 ? Math.max(...messageResults.map(r => r.score)) : 0; + + results.push({ + conversation, + messages: messageResults.map(r => r.item), + score: Math.max(titleScore, messageScore), + titleMatch: titleResults.length > 0, + }); } } - return res; + // Sort by score (highest first) + return results.sort((a, b) => b.score - a.score); }, }); diff --git a/src/lib/backend/convex/user_settings.ts b/src/lib/backend/convex/user_settings.ts index ec13829..6179635 100644 --- a/src/lib/backend/convex/user_settings.ts +++ b/src/lib/backend/convex/user_settings.ts @@ -1,6 +1,6 @@ import { internal } from './_generated/api'; import { query } from './_generated/server'; -import { SessionObj } from './betterAuth'; +import { type SessionObj } from './betterAuth'; import { mutation } from './functions'; import { v } from 'convex/values'; diff --git a/src/lib/utils/fuzzy-search.ts b/src/lib/utils/fuzzy-search.ts index 9ebea48..b87f3d6 100644 --- a/src/lib/utils/fuzzy-search.ts +++ b/src/lib/utils/fuzzy-search.ts @@ -1,15 +1,22 @@ +export type SearchMode = 'exact' | 'words' | 'fuzzy'; + +export interface SearchResult { + item: T; + score: number; + matchType: 'exact' | 'word' | 'fuzzy'; +} + /** - * Generic fuzzy search function that searches through arrays and returns matching items - * - * @param options Configuration object for the fuzzy search - * @returns Array of items that match the search criteria + * Enhanced search function with scoring and multiple search modes */ -export default function fuzzysearch(options: { +export default function enhancedSearch(options: { needle: string; haystack: T[]; property: keyof T | ((item: T) => string); -}): T[] { - const { needle, haystack, property } = options; + mode?: SearchMode; + minScore?: number; +}): SearchResult[] { + const { needle, haystack, property, mode = 'words', minScore = 0.3 } = options; if (!Array.isArray(haystack)) { throw new Error('Haystack must be an array'); @@ -19,24 +26,171 @@ export default function fuzzysearch(options: { throw new Error('Property selector is required'); } - // Convert needle to lowercase for case-insensitive matching - const lowerNeedle = needle.toLowerCase(); + const lowerNeedle = needle.toLowerCase().trim(); + if (!lowerNeedle) return []; - // Filter the haystack to find matching items - return haystack.filter((item) => { - // Extract the string value from the item based on the property selector + const results: SearchResult[] = []; + + for (const item of haystack) { const value = typeof property === 'function' ? property(item) : String(item[property]); - - // Convert to lowercase for case-insensitive matching const lowerValue = value.toLowerCase(); - // Perform the fuzzy search - return fuzzyMatchString(lowerNeedle, lowerValue); + const result = scoreMatch(lowerNeedle, lowerValue, mode); + if (result && result.score >= minScore) { + results.push({ + item, + score: result.score, + matchType: result.matchType, + }); + } + } + + // Sort by score (highest first), then by match type priority + return results.sort((a, b) => { + if (a.score !== b.score) return b.score - a.score; + + const typePriority = { exact: 3, word: 2, fuzzy: 1 }; + return typePriority[b.matchType] - typePriority[a.matchType]; }); } /** - * Internal helper function that performs the actual fuzzy string matching + * Legacy fuzzy search function for backward compatibility + */ +export function fuzzysearch(options: { + needle: string; + haystack: T[]; + property: keyof T | ((item: T) => string); +}): T[] { + return enhancedSearch(options).map(result => result.item); +} + +/** + * Score a match between needle and haystack + */ +function scoreMatch(needle: string, haystack: string, mode: SearchMode): { score: number; matchType: 'exact' | 'word' | 'fuzzy' } | null { + // Exact match gets highest score + if (haystack === needle) { + return { score: 1.0, matchType: 'exact' }; + } + + // Check for exact substring match + if (haystack.includes(needle)) { + const score = needle.length / haystack.length; + return { score: Math.max(0.8, score), matchType: 'exact' }; + } + + // Word boundary matching - check if needle matches at word boundaries + if (mode === 'words' || mode === 'fuzzy') { + const wordScore = scoreWordMatch(needle, haystack); + if (wordScore > 0) { + return { score: wordScore, matchType: 'word' }; + } + } + + // Fuzzy matching as fallback + if (mode === 'fuzzy') { + const fuzzyScore = scoreFuzzyMatch(needle, haystack); + if (fuzzyScore > 0) { + return { score: fuzzyScore, matchType: 'fuzzy' }; + } + } + + return null; +} + +/** + * Score word boundary matches + */ +function scoreWordMatch(needle: string, haystack: string): number { + const words = haystack.split(/\s+/); + const needleWords = needle.split(/\s+/); + + // Check for exact word matches + let exactWordMatches = 0; + let partialWordMatches = 0; + + for (const needleWord of needleWords) { + let found = false; + for (const word of words) { + if (word === needleWord) { + exactWordMatches++; + found = true; + break; + } else if (word.startsWith(needleWord)) { + partialWordMatches++; + found = true; + break; + } + } + } + + const totalNeedleWords = needleWords.length; + if (exactWordMatches === totalNeedleWords) { + return 0.9; // High score for all words matching exactly + } + + if (exactWordMatches + partialWordMatches === totalNeedleWords) { + return 0.7 * (exactWordMatches / totalNeedleWords) + 0.3 * (partialWordMatches / totalNeedleWords); + } + + // Check if needle appears at the start of any word + for (const word of words) { + if (word.startsWith(needle)) { + return 0.6; + } + } + + return 0; +} + +/** + * Score fuzzy matches with distance penalty + */ +function scoreFuzzyMatch(needle: string, haystack: string): number { + if (!fuzzyMatchString(needle, haystack)) { + return 0; + } + + // Calculate a score based on how close the characters are + let score = 0; + let lastIndex = -1; + let consecutiveMatches = 0; + + for (let i = 0; i < needle.length; i++) { + const char = needle.charAt(i); + const index = haystack.indexOf(char, lastIndex + 1); + + if (index === -1) { + return 0; // This shouldn't happen if fuzzyMatchString returned true + } + + if (index === lastIndex + 1) { + consecutiveMatches++; + score += 0.1; // Bonus for consecutive matches + } else { + consecutiveMatches = 0; + } + + // Penalty based on distance + const distance = index - lastIndex - 1; + score += Math.max(0, 0.05 - distance * 0.01); + + lastIndex = index; + } + + // Normalize score + score = score / needle.length; + + // Length ratio bonus + const lengthRatio = needle.length / haystack.length; + score *= (0.5 + lengthRatio * 0.5); + + return Math.min(0.5, Math.max(0.1, score)); // Cap fuzzy scores at 0.5 +} + +/** + * Legacy fuzzy match function for backward compatibility */ export function fuzzyMatchString(needle: string, haystack: string): boolean { const hlen = haystack.length; diff --git a/src/routes/account/models/+page.svelte b/src/routes/account/models/+page.svelte index 768e399..6c9a5ef 100644 --- a/src/routes/account/models/+page.svelte +++ b/src/routes/account/models/+page.svelte @@ -11,7 +11,7 @@ import XIcon from '~icons/lucide/x'; import PlusIcon from '~icons/lucide/plus'; import { models } from '$lib/state/models.svelte'; - import fuzzysearch from '$lib/utils/fuzzy-search'; + import { fuzzysearch } from '$lib/utils/fuzzy-search'; const openRouterKeyQuery = useCachedQuery(api.user_keys.get, { provider: Provider.OpenRouter, diff --git a/src/routes/chat/model-picker.svelte b/src/routes/chat/model-picker.svelte index 13e5ef6..58c1a5c 100644 --- a/src/routes/chat/model-picker.svelte +++ b/src/routes/chat/model-picker.svelte @@ -10,7 +10,7 @@ import { session } from '$lib/state/session.svelte'; import { settings } from '$lib/state/settings.svelte'; import { Provider } from '$lib/types'; - import fuzzysearch from '$lib/utils/fuzzy-search'; + import { fuzzysearch } from '$lib/utils/fuzzy-search'; import { supportsImages } from '$lib/utils/model-capabilities'; import { capitalize } from '$lib/utils/strings'; import { cn } from '$lib/utils/utils'; diff --git a/src/routes/chat/search-modal.svelte b/src/routes/chat/search-modal.svelte index 76ac229..57c925d 100644 --- a/src/routes/chat/search-modal.svelte +++ b/src/routes/chat/search-modal.svelte @@ -8,15 +8,16 @@ import { Debounced } from 'runed'; import SearchIcon from '~icons/lucide/search'; - let open = $state(true); - + let open = $state(false); let input = $state(''); + let searchMode = $state<'exact' | 'words' | 'fuzzy'>('words'); let inputEl = $state(); const debouncedInput = new Debounced(() => input, 500); const search = useQuery(api.conversations.search, () => ({ search_term: debouncedInput.current, + search_mode: searchMode, session_token: session.current?.session.token ?? '', })); @@ -38,32 +39,68 @@ -

Search

- - - {#if search.isLoading} -
-
-
- {:else if search.data?.length} -
- {#each search.data as { conversation, messages }} -
+

Search Conversations

+ +
+ + +
+ + +
- {/if} + + {#if search.isLoading} +
+
+
+ {:else if search.data?.length} +
+ {#each search.data as { conversation, messages, score, titleMatch }} +
+
+
+
+ {conversation.title} +
+
+ {Math.round(score * 100)}% +
+
+
+ {messages.length} matching message{messages.length !== 1 ? 's' : ''} + {#if titleMatch} + • Title match + {/if} +
+
+ +
+ {/each} +
+ {:else if debouncedInput.current.trim()} +
+

No results found for "{debouncedInput.current}"

+

Try a different search term or mode

+
+ {:else} +
+

Start typing to search your conversations

+
+ {/if} +
From 9d36105fbdd7073f63c44a4bf0d91ae09fd64393 Mon Sep 17 00:00:00 2001 From: "Thomas G. Lopes" <26071571+TGlide@users.noreply.github.com> Date: Wed, 18 Jun 2025 18:31:21 +0100 Subject: [PATCH 3/4] better search --- src/lib/utils/fuzzy-search.ts | 48 +++++++++++--------- src/routes/chat/search-modal.svelte | 68 +++++++++++++++++++---------- 2 files changed, 73 insertions(+), 43 deletions(-) diff --git a/src/lib/utils/fuzzy-search.ts b/src/lib/utils/fuzzy-search.ts index b87f3d6..942ee66 100644 --- a/src/lib/utils/fuzzy-search.ts +++ b/src/lib/utils/fuzzy-search.ts @@ -18,6 +18,8 @@ export default function enhancedSearch(options: { }): SearchResult[] { const { needle, haystack, property, mode = 'words', minScore = 0.3 } = options; + if (!needle) return haystack.map((item) => ({ item, score: 1, matchType: 'exact' })); + if (!Array.isArray(haystack)) { throw new Error('Haystack must be an array'); } @@ -48,7 +50,7 @@ export default function enhancedSearch(options: { // Sort by score (highest first), then by match type priority return results.sort((a, b) => { if (a.score !== b.score) return b.score - a.score; - + const typePriority = { exact: 3, word: 2, fuzzy: 1 }; return typePriority[b.matchType] - typePriority[a.matchType]; }); @@ -62,13 +64,17 @@ export function fuzzysearch(options: { haystack: T[]; property: keyof T | ((item: T) => string); }): T[] { - return enhancedSearch(options).map(result => result.item); + return enhancedSearch(options).map((result) => result.item); } /** * Score a match between needle and haystack */ -function scoreMatch(needle: string, haystack: string, mode: SearchMode): { score: number; matchType: 'exact' | 'word' | 'fuzzy' } | null { +function scoreMatch( + needle: string, + haystack: string, + mode: SearchMode +): { score: number; matchType: 'exact' | 'word' | 'fuzzy' } | null { // Exact match gets highest score if (haystack === needle) { return { score: 1.0, matchType: 'exact' }; @@ -105,11 +111,11 @@ function scoreMatch(needle: string, haystack: string, mode: SearchMode): { score function scoreWordMatch(needle: string, haystack: string): number { const words = haystack.split(/\s+/); const needleWords = needle.split(/\s+/); - + // Check for exact word matches let exactWordMatches = 0; let partialWordMatches = 0; - + for (const needleWord of needleWords) { let found = false; for (const word of words) { @@ -124,23 +130,25 @@ function scoreWordMatch(needle: string, haystack: string): number { } } } - + const totalNeedleWords = needleWords.length; if (exactWordMatches === totalNeedleWords) { return 0.9; // High score for all words matching exactly } - + if (exactWordMatches + partialWordMatches === totalNeedleWords) { - return 0.7 * (exactWordMatches / totalNeedleWords) + 0.3 * (partialWordMatches / totalNeedleWords); + return ( + 0.7 * (exactWordMatches / totalNeedleWords) + 0.3 * (partialWordMatches / totalNeedleWords) + ); } - + // Check if needle appears at the start of any word for (const word of words) { if (word.startsWith(needle)) { return 0.6; } } - + return 0; } @@ -151,41 +159,41 @@ function scoreFuzzyMatch(needle: string, haystack: string): number { if (!fuzzyMatchString(needle, haystack)) { return 0; } - + // Calculate a score based on how close the characters are let score = 0; let lastIndex = -1; let consecutiveMatches = 0; - + for (let i = 0; i < needle.length; i++) { const char = needle.charAt(i); const index = haystack.indexOf(char, lastIndex + 1); - + if (index === -1) { return 0; // This shouldn't happen if fuzzyMatchString returned true } - + if (index === lastIndex + 1) { consecutiveMatches++; score += 0.1; // Bonus for consecutive matches } else { consecutiveMatches = 0; } - + // Penalty based on distance const distance = index - lastIndex - 1; score += Math.max(0, 0.05 - distance * 0.01); - + lastIndex = index; } - + // Normalize score score = score / needle.length; - + // Length ratio bonus const lengthRatio = needle.length / haystack.length; - score *= (0.5 + lengthRatio * 0.5); - + score *= 0.5 + lengthRatio * 0.5; + return Math.min(0.5, Math.max(0.1, score)); // Cap fuzzy scores at 0.5 } diff --git a/src/routes/chat/search-modal.svelte b/src/routes/chat/search-modal.svelte index 57c925d..6016e81 100644 --- a/src/routes/chat/search-modal.svelte +++ b/src/routes/chat/search-modal.svelte @@ -6,6 +6,7 @@ import { session } from '$lib/state/session.svelte'; import { useQuery } from 'convex-svelte'; import { Debounced } from 'runed'; + import { tick } from 'svelte'; import SearchIcon from '~icons/lucide/search'; let open = $state(false); @@ -41,21 +42,30 @@

Search Conversations

- +
- { + if (!open) return; + setTimeout(() => { + console.log('focus', node, open); + if (open) node.focus(); + }, 50); + }} /> - -
- - @@ -66,39 +76,51 @@ {#if search.isLoading}
-
+
{:else if search.data?.length} -
+
{#each search.data as { conversation, messages, score, titleMatch }} -
-
-
-
+
+
+
+
{conversation.title}
-
+
{Math.round(score * 100)}%
-
+
{messages.length} matching message{messages.length !== 1 ? 's' : ''} {#if titleMatch} - • Title match + • Title match {/if}
- +
{/each}
{:else if debouncedInput.current.trim()} -
+

No results found for "{debouncedInput.current}"

-

Try a different search term or mode

+

Try a different search term or mode

{:else} -
+

Start typing to search your conversations

{/if} From 12fcaa4afc466702ec6330c0e0460b2d9efbadf5 Mon Sep 17 00:00:00 2001 From: "Thomas G. Lopes" <26071571+TGlide@users.noreply.github.com> Date: Wed, 18 Jun 2025 18:40:49 +0100 Subject: [PATCH 4/4] search shortcut --- src/routes/+layout.svelte | 3 +- src/routes/chat/search-modal.svelte | 92 +++++++++++++++++++++++++++-- 2 files changed, 89 insertions(+), 6 deletions(-) diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index e4df54b..03f7bcb 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -5,8 +5,9 @@ import { PUBLIC_CONVEX_URL } from '$env/static/public'; import { models } from '$lib/state/models.svelte'; import GlobalModal from '$lib/components/ui/modal/global-modal.svelte'; - import { shortcut } from '$lib/actions/shortcut.svelte'; + import { attachShortcut, shortcut } from '$lib/actions/shortcut.svelte'; import { goto } from '$app/navigation'; + import { openSearchModal } from './chat/search-modal.svelte'; let { children } = $props(); diff --git a/src/routes/chat/search-modal.svelte b/src/routes/chat/search-modal.svelte index 6016e81..8fc98c3 100644 --- a/src/routes/chat/search-modal.svelte +++ b/src/routes/chat/search-modal.svelte @@ -7,12 +7,16 @@ import { useQuery } from 'convex-svelte'; import { Debounced } from 'runed'; import { tick } from 'svelte'; + import { goto } from '$app/navigation'; import SearchIcon from '~icons/lucide/search'; + import { shortcut } from '$lib/actions/shortcut.svelte'; + import { cmdOrCtrl } from '$lib/hooks/is-mac.svelte'; let open = $state(false); let input = $state(''); let searchMode = $state<'exact' | 'words' | 'fuzzy'>('words'); let inputEl = $state(); + let selectedIndex = $state(-1); const debouncedInput = new Debounced(() => input, 500); @@ -21,8 +25,63 @@ search_mode: searchMode, session_token: session.current?.session.token ?? '', })); + + // Reset selected index when search results change + $effect(() => { + if (search.data) { + selectedIndex = -1; + } + }); + + // Reset selected index when input changes + $effect(() => { + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + input; // Track input changes + selectedIndex = -1; + }); + + function handleKeydown(event: KeyboardEvent) { + if (!search.data?.length) return; + + switch (event.key) { + case 'ArrowDown': + event.preventDefault(); + selectedIndex = Math.min(selectedIndex + 1, search.data.length - 1); + scrollToSelected(); + break; + case 'ArrowUp': + event.preventDefault(); + selectedIndex = Math.max(selectedIndex - 1, -1); + scrollToSelected(); + break; + case 'Enter': + event.preventDefault(); + if (selectedIndex >= 0 && selectedIndex < search.data.length) { + const result = search.data[selectedIndex]; + if (result) { + goto(`/chat/${result.conversation._id}`); + open = false; + } + } + break; + case 'Escape': + event.preventDefault(); + open = false; + break; + } + } + + async function scrollToSelected() { + await tick(); + const selectedElement = document.querySelector(`[data-result-index="${selectedIndex}"]`); + if (selectedElement) { + selectedElement.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + } + } + (open = true) }} /> + {#snippet trigger(tooltip)} {/snippet} - Search + Search ({cmdOrCtrl} + K) @@ -47,6 +106,7 @@ { @@ -82,9 +142,27 @@
{:else if search.data?.length}
- {#each search.data as { conversation, messages, score, titleMatch }} + {#each search.data as { conversation, messages, score, titleMatch }, index}
{ + goto(`/chat/${conversation._id}`); + open = false; + }} + onkeydown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + goto(`/chat/${conversation._id}`); + open = false; + } + }} + onmouseenter={() => (selectedIndex = index)} >
@@ -106,8 +184,11 @@ variant="secondary" size="sm" class="shrink-0 text-xs" - href="/chat/{conversation._id}" - onclick={() => (open = false)} + onclick={(e: MouseEvent) => { + e.stopPropagation(); + goto(`/chat/${conversation._id}`); + open = false; + }} > View @@ -122,6 +203,7 @@ {:else}

Start typing to search your conversations

+

Use ↑↓ to navigate, Enter to select, Esc to close

{/if}