Skip to content

Commit 3588ed2

Browse files
committed
Add conversation search feature
- Add search query parameter to GET /api/v2/conversations endpoint that filters by title using case-insensitive regex matching - Add search input UI to NavMenu sidebar with debounced input (300ms) - Show search results replacing date-grouped conversations when searching - Include loading state and "No conversations found" message
1 parent 999f616 commit 3588ed2

File tree

3 files changed

+143
-9
lines changed

3 files changed

+143
-9
lines changed

src/lib/components/NavMenu.svelte

Lines changed: 109 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
import IconMoon from "$lib/components/icons/IconMoon.svelte";
1616
import { switchTheme, subscribeToTheme } from "$lib/switchTheme";
1717
import { isAborted } from "$lib/stores/isAborted";
18-
import { onDestroy } from "svelte";
18+
import { onDestroy, tick } from "svelte";
1919
2020
import NavConversationItem from "./NavConversationItem.svelte";
2121
import type { LayoutData } from "../../routes/$types";
@@ -30,6 +30,7 @@
3030
import { requireAuthUser } from "$lib/utils/auth";
3131
import { enabledServersCount } from "$lib/stores/mcpServers";
3232
import MCPServerManager from "./mcp/MCPServerManager.svelte";
33+
import IconSearch from "$lib/components/icons/IconSearch.svelte";
3334
3435
const publicConfig = usePublicConfig();
3536
const client = useAPIClient();
@@ -52,6 +53,58 @@
5253
5354
let hasMore = $state(true);
5455
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+
55108
function handleNewChatClick(e: MouseEvent) {
56109
isAborted.set(true);
57110
@@ -147,22 +200,70 @@
147200
</a>
148201
</div>
149202

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+
150230
<div
151231
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"
152232
>
153233
<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
158247
</h4>
159-
{#each convs as conv}
248+
{#each searchResults as conv}
160249
<NavConversationItem {conv} {oneditConversationTitle} {ondeleteConversation} />
161250
{/each}
162251
{/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}
164265
</div>
165-
{#if hasMore}
266+
{#if !isSearchActive && hasMore}
166267
<InfiniteScroll onvisible={handleVisible} />
167268
{/if}
168269
</div>
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<script lang="ts">
2+
interface Props {
3+
classNames?: string;
4+
}
5+
6+
let { classNames = "" }: Props = $props();
7+
</script>
8+
9+
<svg
10+
width="1em"
11+
height="1em"
12+
viewBox="0 0 24 24"
13+
fill="none"
14+
stroke="currentColor"
15+
stroke-width="2"
16+
stroke-linecap="round"
17+
stroke-linejoin="round"
18+
xmlns="http://www.w3.org/2000/svg"
19+
class={classNames}
20+
>
21+
<circle cx="11" cy="11" r="8" />
22+
<path d="m21 21-4.3-4.3" />
23+
</svg>

src/lib/server/api/routes/groups/conversations.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,17 @@ export const conversationGroup = new Elysia().use(authPlugin).group("/conversati
2424
"",
2525
async ({ locals, query }) => {
2626
const pageSize = CONV_NUM_PER_PAGE;
27+
28+
// Build filter with auth condition and optional search
29+
const filter = {
30+
...authCondition(locals),
31+
...(query.search && {
32+
title: { $regex: query.search, $options: "i" },
33+
}),
34+
};
35+
2736
const convs = await collections.conversations
28-
.find(authCondition(locals))
37+
.find(filter)
2938
.project<Pick<Conversation, "_id" | "title" | "updatedAt" | "model">>({
3039
title: 1,
3140
updatedAt: 1,
@@ -51,6 +60,7 @@ export const conversationGroup = new Elysia().use(authPlugin).group("/conversati
5160
{
5261
query: t.Object({
5362
p: t.Optional(t.Number()),
63+
search: t.Optional(t.String()),
5464
}),
5565
}
5666
)

0 commit comments

Comments
 (0)