|
15 | 15 | import IconMoon from "$lib/components/icons/IconMoon.svelte"; |
16 | 16 | import { switchTheme, subscribeToTheme } from "$lib/switchTheme"; |
17 | 17 | import { isAborted } from "$lib/stores/isAborted"; |
18 | | - import { onDestroy } from "svelte"; |
| 18 | + import { onDestroy, tick } from "svelte"; |
19 | 19 |
|
20 | 20 | import NavConversationItem from "./NavConversationItem.svelte"; |
21 | 21 | import type { LayoutData } from "../../routes/$types"; |
|
30 | 30 | import { requireAuthUser } from "$lib/utils/auth"; |
31 | 31 | import { enabledServersCount } from "$lib/stores/mcpServers"; |
32 | 32 | import MCPServerManager from "./mcp/MCPServerManager.svelte"; |
| 33 | + import IconSearch from "$lib/components/icons/IconSearch.svelte"; |
33 | 34 |
|
34 | 35 | const publicConfig = usePublicConfig(); |
35 | 36 | const client = useAPIClient(); |
|
52 | 53 |
|
53 | 54 | let hasMore = $state(true); |
54 | 55 |
|
| 56 | + // Search state |
| 57 | + let searchQuery = $state(""); |
| 58 | + let searchResults = $state<ConvSidebar[]>([]); |
| 59 | + let isSearching = $state(false); |
| 60 | + let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null; |
| 61 | +
|
| 62 | + const isSearchActive = $derived(searchQuery.trim().length > 0); |
| 63 | +
|
| 64 | + async function performSearch(query: string) { |
| 65 | + if (!query.trim()) { |
| 66 | + searchResults = []; |
| 67 | + isSearching = false; |
| 68 | + return; |
| 69 | + } |
| 70 | +
|
| 71 | + isSearching = true; |
| 72 | + try { |
| 73 | + const results = await client.conversations |
| 74 | + .get({ query: { search: query.trim() } }) |
| 75 | + .then(handleResponse) |
| 76 | + .then((r) => r.conversations) |
| 77 | + .catch((): ConvSidebar[] => []); |
| 78 | + searchResults = results; |
| 79 | + } finally { |
| 80 | + isSearching = false; |
| 81 | + } |
| 82 | + } |
| 83 | +
|
| 84 | + function handleSearchInput(e: Event) { |
| 85 | + const target = e.target as HTMLInputElement; |
| 86 | + searchQuery = target.value; |
| 87 | +
|
| 88 | + // Clear previous debounce timer |
| 89 | + if (searchDebounceTimer) { |
| 90 | + clearTimeout(searchDebounceTimer); |
| 91 | + } |
| 92 | +
|
| 93 | + // Debounce search requests |
| 94 | + searchDebounceTimer = setTimeout(() => { |
| 95 | + performSearch(searchQuery); |
| 96 | + }, 300); |
| 97 | + } |
| 98 | +
|
| 99 | + function clearSearch() { |
| 100 | + searchQuery = ""; |
| 101 | + searchResults = []; |
| 102 | + isSearching = false; |
| 103 | + if (searchDebounceTimer) { |
| 104 | + clearTimeout(searchDebounceTimer); |
| 105 | + } |
| 106 | + } |
| 107 | +
|
55 | 108 | function handleNewChatClick(e: MouseEvent) { |
56 | 109 | isAborted.set(true); |
57 | 110 |
|
|
147 | 200 | </a> |
148 | 201 | </div> |
149 | 202 |
|
| 203 | +<!-- Search input --> |
| 204 | +<div class="px-3 pb-2"> |
| 205 | + <div class="relative"> |
| 206 | + <div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-2.5"> |
| 207 | + <IconSearch classNames="size-4 text-gray-400" /> |
| 208 | + </div> |
| 209 | + <input |
| 210 | + type="text" |
| 211 | + placeholder="Search conversations..." |
| 212 | + value={searchQuery} |
| 213 | + oninput={handleSearchInput} |
| 214 | + class="w-full rounded-lg border border-gray-200 bg-white py-1.5 pl-8 pr-8 text-sm placeholder-gray-400 focus:border-gray-300 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:placeholder-gray-500 dark:focus:border-gray-500" |
| 215 | + /> |
| 216 | + {#if isSearchActive} |
| 217 | + <button |
| 218 | + onclick={clearSearch} |
| 219 | + class="absolute inset-y-0 right-0 flex items-center pr-2.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300" |
| 220 | + aria-label="Clear search" |
| 221 | + > |
| 222 | + <svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> |
| 223 | + <path d="M18 6L6 18M6 6l12 12" /> |
| 224 | + </svg> |
| 225 | + </button> |
| 226 | + {/if} |
| 227 | + </div> |
| 228 | +</div> |
| 229 | + |
150 | 230 | <div |
151 | 231 | class="scrollbar-custom flex touch-pan-y flex-col gap-1 overflow-y-auto rounded-r-xl border border-l-0 border-gray-100 from-gray-50 px-3 pb-3 pt-2 text-[.9rem] dark:border-transparent dark:from-gray-800/30 max-sm:bg-gradient-to-t md:bg-gradient-to-l" |
152 | 232 | > |
153 | 233 | <div class="flex flex-col gap-0.5"> |
154 | | - {#each Object.entries(groupedConversations) as [group, convs]} |
155 | | - {#if convs.length} |
156 | | - <h4 class="mb-1.5 mt-4 pl-0.5 text-sm text-gray-400 first:mt-0 dark:text-gray-500"> |
157 | | - {titles[group]} |
| 234 | + {#if isSearchActive} |
| 235 | + <!-- Search results --> |
| 236 | + {#if isSearching} |
| 237 | + <div class="flex items-center justify-center py-4 text-sm text-gray-400"> |
| 238 | + Searching... |
| 239 | + </div> |
| 240 | + {:else if searchResults.length === 0} |
| 241 | + <div class="flex items-center justify-center py-4 text-sm text-gray-400"> |
| 242 | + No conversations found |
| 243 | + </div> |
| 244 | + {:else} |
| 245 | + <h4 class="mb-1.5 pl-0.5 text-sm text-gray-400 dark:text-gray-500"> |
| 246 | + Search results |
158 | 247 | </h4> |
159 | | - {#each convs as conv} |
| 248 | + {#each searchResults as conv} |
160 | 249 | <NavConversationItem {conv} {oneditConversationTitle} {ondeleteConversation} /> |
161 | 250 | {/each} |
162 | 251 | {/if} |
163 | | - {/each} |
| 252 | + {:else} |
| 253 | + <!-- Regular grouped conversations --> |
| 254 | + {#each Object.entries(groupedConversations) as [group, convs]} |
| 255 | + {#if convs.length} |
| 256 | + <h4 class="mb-1.5 mt-4 pl-0.5 text-sm text-gray-400 first:mt-0 dark:text-gray-500"> |
| 257 | + {titles[group]} |
| 258 | + </h4> |
| 259 | + {#each convs as conv} |
| 260 | + <NavConversationItem {conv} {oneditConversationTitle} {ondeleteConversation} /> |
| 261 | + {/each} |
| 262 | + {/if} |
| 263 | + {/each} |
| 264 | + {/if} |
164 | 265 | </div> |
165 | | - {#if hasMore} |
| 266 | + {#if !isSearchActive && hasMore} |
166 | 267 | <InfiniteScroll onvisible={handleVisible} /> |
167 | 268 | {/if} |
168 | 269 | </div> |
|
0 commit comments