From 7818f2950a5124d361a9554a85a8408700fe8d69 Mon Sep 17 00:00:00 2001 From: Will Chen Date: Tue, 9 Sep 2025 00:18:48 -0700 Subject: [PATCH] Chat search (#1224) Based on https://github.com/dyad-sh/dyad/pull/1116 --- ## Summary by cubic Adds a fast chat search dialog (Command Palette) to find and jump between chats. Open via the sidebar button or Ctrl/Cmd+K, with title and message text search plus inline snippets. - New Features - Command palette using cmdk with keyboard shortcut (Ctrl/Cmd+K). - Searches within the selected app across chat titles and message content via a new IPC route (search-chats). - Debounced queries (150ms) with React Query; results de-duplicated and sorted by newest. - Snippet preview with highlighted matches and custom ranking; selecting a result navigates and closes the dialog. - Search button added to ChatList; basic e2e tests added (currently skipped). - Dependencies - Added cmdk@1.1.1. - Bumped @radix-ui/react-dialog to ^1.1.15 and updated Dialog to support an optional close button. --------- Co-authored-by: Evans Obeng Co-authored-by: Evans Obeng <60653146+iamevansobeng@users.noreply.github.com> --- e2e-tests/chat_search.spec.ts | 126 +++++++++++++++++++ package-lock.json | 19 ++- package.json | 3 +- src/components/ChatList.tsx | 32 ++++- src/components/ChatSearchDialog.tsx | 159 +++++++++++++++++++++++ src/components/ui/command.tsx | 189 ++++++++++++++++++++++++++++ src/components/ui/dialog.tsx | 15 ++- src/hooks/useSearchChats.ts | 23 ++++ src/ipc/handlers/chat_handlers.ts | 61 ++++++++- src/ipc/ipc_client.ts | 21 +++- src/lib/schemas.ts | 18 +++ src/preload.ts | 1 + 12 files changed, 655 insertions(+), 12 deletions(-) create mode 100644 e2e-tests/chat_search.spec.ts create mode 100644 src/components/ChatSearchDialog.tsx create mode 100644 src/components/ui/command.tsx create mode 100644 src/hooks/useSearchChats.ts diff --git a/e2e-tests/chat_search.spec.ts b/e2e-tests/chat_search.spec.ts new file mode 100644 index 0000000..3162449 --- /dev/null +++ b/e2e-tests/chat_search.spec.ts @@ -0,0 +1,126 @@ +import { test } from "./helpers/test_helper"; + +test.skip("chat search - basic search dialog functionality", async ({ po }) => { + await po.setUp({ autoApprove: true }); + await po.importApp("minimal"); + + // Create some chats with specific names for testing + await po.sendPrompt("[dump] create a todo application"); + await po.waitForChatCompletion(); + + await po.clickNewChat(); + await po.sendPrompt("[dump] build a weather dashboard"); + await po.waitForChatCompletion(); + + await po.clickNewChat(); + await po.sendPrompt("[dump] create a blog system"); + await po.waitForChatCompletion(); + + // Test 1: Open search dialog using the search button + await po.page.getByTestId("search-chats-button").click(); + + // Wait for search dialog to appear + await po.page.getByTestId("chat-search-dialog").waitFor(); + + // Test 2: Close dialog with escape key + await po.page.keyboard.press("Escape"); + await po.page.getByTestId("chat-search-dialog").waitFor({ state: "hidden" }); + + // Test 3: Open dialog again and verify it shows chats + await po.page.getByTestId("search-chats-button").click(); + await po.page.getByTestId("chat-search-dialog").waitFor(); + + // Test 4: Search for specific term + await po.page.getByPlaceholder("Search chats").fill("todo"); + + // Wait a moment for search results + await po.page.waitForTimeout(500); + + // Test 5: Clear search and close + await po.page.getByPlaceholder("Search chats").clear(); + await po.page.keyboard.press("Escape"); +}); + +test.skip("chat search - with named chats for easier testing", async ({ + po, +}) => { + await po.setUp({ autoApprove: true }); + await po.importApp("minimal"); + + // Create chats with descriptive names that will be useful for testing + await po.sendPrompt("[dump] hello world app"); + await po.waitForChatCompletion(); + + // Use a timeout to ensure the UI has updated before trying to interact + await po.page.waitForTimeout(1000); + + await po.clickNewChat(); + await po.sendPrompt("[dump] todo list manager"); + await po.waitForChatCompletion(); + + await po.page.waitForTimeout(1000); + + await po.clickNewChat(); + await po.sendPrompt("[dump] weather forecast widget"); + await po.waitForChatCompletion(); + + await po.page.waitForTimeout(1000); + + // Test search functionality + await po.page.getByTestId("search-chats-button").click(); + await po.page.getByTestId("chat-search-dialog").waitFor(); + + // Search for "todo" - should find the todo list manager chat + await po.page.getByPlaceholder("Search chats").fill("todo"); + await po.page.waitForTimeout(500); + + // Search for "weather" - should find the weather forecast widget chat + await po.page.getByPlaceholder("Search chats").fill("weather"); + await po.page.waitForTimeout(500); + + // Search for non-existent term + await po.page.getByPlaceholder("Search chats").fill("nonexistent"); + await po.page.waitForTimeout(500); + + await po.page.keyboard.press("Escape"); +}); + +test.skip("chat search - keyboard shortcut functionality", async ({ po }) => { + await po.setUp({ autoApprove: true }); + await po.importApp("minimal"); + + // Create a chat + await po.sendPrompt("[dump] sample app"); + await po.waitForChatCompletion(); + + // Test keyboard shortcut (Ctrl+K) + await po.page.keyboard.press("Control+k"); + await po.page.getByTestId("chat-search-dialog").waitFor(); + + // Close with escape + await po.page.keyboard.press("Escape"); + await po.page.getByTestId("chat-search-dialog").waitFor({ state: "hidden" }); +}); + +test.skip("chat search - navigation and selection", async ({ po }) => { + await po.setUp({ autoApprove: true }); + await po.importApp("minimal"); + + // Create multiple chats + await po.sendPrompt("[dump] first application"); + await po.waitForChatCompletion(); + + await po.clickNewChat(); + await po.sendPrompt("[dump] second application"); + await po.waitForChatCompletion(); + + // Test selecting a chat through search + await po.page.getByTestId("search-chats-button").click(); + await po.page.getByTestId("chat-search-dialog").waitFor(); + + // Select the first chat item (assuming it shows "Untitled Chat" as default title) + await po.page.getByText("Untitled Chat").first().click(); + + // Dialog should close + await po.page.getByTestId("chat-search-dialog").waitFor({ state: "hidden" }); +}); diff --git a/package-lock.json b/package-lock.json index 36a1d3d..4cf1bb9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,7 +28,7 @@ "@radix-ui/react-accordion": "^1.2.4", "@radix-ui/react-alert-dialog": "^1.1.13", "@radix-ui/react-checkbox": "^1.3.2", - "@radix-ui/react-dialog": "^1.1.7", + "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.7", "@radix-ui/react-label": "^2.1.4", "@radix-ui/react-popover": "^1.1.7", @@ -52,6 +52,7 @@ "better-sqlite3": "^11.9.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", "date-fns": "^4.1.0", "dotenv": "^16.4.7", "drizzle-orm": "^0.41.0", @@ -8769,6 +8770,22 @@ "node": ">=6" } }, + "node_modules/cmdk": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz", + "integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "^1.1.1", + "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-id": "^1.1.0", + "@radix-ui/react-primitive": "^2.0.2" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "react-dom": "^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/color": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", diff --git a/package.json b/package.json index 519e5a5..f00c078 100644 --- a/package.json +++ b/package.json @@ -104,7 +104,7 @@ "@radix-ui/react-accordion": "^1.2.4", "@radix-ui/react-alert-dialog": "^1.1.13", "@radix-ui/react-checkbox": "^1.3.2", - "@radix-ui/react-dialog": "^1.1.7", + "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.7", "@radix-ui/react-label": "^2.1.4", "@radix-ui/react-popover": "^1.1.7", @@ -128,6 +128,7 @@ "better-sqlite3": "^11.9.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", "date-fns": "^4.1.0", "dotenv": "^16.4.7", "drizzle-orm": "^0.41.0", diff --git a/src/components/ChatList.tsx b/src/components/ChatList.tsx index cbe2760..0dd1691 100644 --- a/src/components/ChatList.tsx +++ b/src/components/ChatList.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; import { useNavigate, useRouterState } from "@tanstack/react-router"; import { formatDistanceToNow } from "date-fns"; -import { PlusCircle, MoreVertical, Trash2, Edit3 } from "lucide-react"; +import { PlusCircle, MoreVertical, Trash2, Edit3, Search } from "lucide-react"; import { useAtom } from "jotai"; import { selectedChatIdAtom } from "@/atoms/chatAtoms"; import { selectedAppIdAtom } from "@/atoms/appAtoms"; @@ -27,11 +27,14 @@ import { useChats } from "@/hooks/useChats"; import { RenameChatDialog } from "@/components/chat/RenameChatDialog"; import { DeleteChatDialog } from "@/components/chat/DeleteChatDialog"; +import { ChatSearchDialog } from "./ChatSearchDialog"; + export function ChatList({ show }: { show?: boolean }) { const navigate = useNavigate(); const [selectedChatId, setSelectedChatId] = useAtom(selectedChatIdAtom); const [selectedAppId, setSelectedAppId] = useAtom(selectedAppIdAtom); const [, setIsDropdownOpen] = useAtom(dropdownOpenAtom); + const { chats, loading, refreshChats } = useChats(selectedAppId); const routerState = useRouterState(); const isChatRoute = routerState.location.pathname === "/chat"; @@ -46,6 +49,9 @@ export function ChatList({ show }: { show?: boolean }) { const [deleteChatId, setDeleteChatId] = useState(null); const [deleteChatTitle, setDeleteChatTitle] = useState(""); + // search dialog state + const [isSearchDialogOpen, setIsSearchDialogOpen] = useState(false); + // Update selectedChatId when route changes useEffect(() => { if (isChatRoute) { @@ -70,6 +76,7 @@ export function ChatList({ show }: { show?: boolean }) { }) => { setSelectedChatId(chatId); setSelectedAppId(appId); + setIsSearchDialogOpen(false); navigate({ to: "/chat", search: { id: chatId }, @@ -151,7 +158,10 @@ export function ChatList({ show }: { show?: boolean }) { return ( <> - + Recent Chats
@@ -163,6 +173,15 @@ export function ChatList({ show }: { show?: boolean }) { New Chat + {loading ? (
@@ -273,6 +292,15 @@ export function ChatList({ show }: { show?: boolean }) { onConfirmDelete={handleConfirmDelete} chatTitle={deleteChatTitle} /> + + {/* Chat Search Dialog */} + ); } diff --git a/src/components/ChatSearchDialog.tsx b/src/components/ChatSearchDialog.tsx new file mode 100644 index 0000000..4717454 --- /dev/null +++ b/src/components/ChatSearchDialog.tsx @@ -0,0 +1,159 @@ +import { + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, +} from "./ui/command"; +import { useState, useEffect } from "react"; +import { useSearchChats } from "@/hooks/useSearchChats"; +import type { ChatSummary, ChatSearchResult } from "@/lib/schemas"; + +type ChatSearchDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + onSelectChat: ({ chatId, appId }: { chatId: number; appId: number }) => void; + appId: number | null; + allChats: ChatSummary[]; +}; + +export function ChatSearchDialog({ + open, + onOpenChange, + appId, + onSelectChat, + allChats, +}: ChatSearchDialogProps) { + const [searchQuery, setSearchQuery] = useState(""); + function useDebouncedValue(value: T, delay: number): T { + const [debounced, setDebounced] = useState(value); + useEffect(() => { + const handle = setTimeout(() => setDebounced(value), delay); + return () => clearTimeout(handle); + }, [value, delay]); + return debounced; + } + + const debouncedQuery = useDebouncedValue(searchQuery, 150); + const { chats: searchResults } = useSearchChats(appId, debouncedQuery); + + // Show all chats if search is empty, otherwise show search results + const chatsToShow = debouncedQuery.trim() === "" ? allChats : searchResults; + + const commandFilter = ( + value: string, + search: string, + keywords?: string[], + ): number => { + const q = search.trim().toLowerCase(); + if (!q) return 1; + const v = (value || "").toLowerCase(); + if (v.includes(q)) { + // Higher score for earlier match in title/value + return 100 - Math.max(0, v.indexOf(q)); + } + const foundInKeywords = (keywords || []).some((k) => + (k || "").toLowerCase().includes(q), + ); + return foundInKeywords ? 50 : 0; + }; + + function getSnippet( + text: string, + query: string, + radius = 50, + ): { + before: string; + match: string; + after: string; + raw: string; + } { + const q = query.trim(); + const lowerText = text; + const lowerQuery = q.toLowerCase(); + const idx = lowerText.toLowerCase().indexOf(lowerQuery); + if (idx === -1) { + const raw = + text.length > radius * 2 ? text.slice(0, radius * 2) + "…" : text; + return { before: "", match: "", after: "", raw }; + } + const start = Math.max(0, idx - radius); + const end = Math.min(text.length, idx + q.length + radius); + const before = (start > 0 ? "…" : "") + text.slice(start, idx); + const match = text.slice(idx, idx + q.length); + const after = + text.slice(idx + q.length, end) + (end < text.length ? "…" : ""); + return { before, match, after, raw: before + match + after }; + } + + useEffect(() => { + const down = (e: KeyboardEvent) => { + if (e.key === "k" && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + onOpenChange(!open); + } + }; + document.addEventListener("keydown", down); + return () => document.removeEventListener("keydown", down); + }, [open, onOpenChange]); + + return ( + + + + No results found. + + {chatsToShow.map((chat) => { + const isSearch = searchQuery.trim() !== ""; + const hasSnippet = + isSearch && + "matchedMessageContent" in chat && + (chat as ChatSearchResult).matchedMessageContent; + const snippet = hasSnippet + ? getSnippet( + (chat as ChatSearchResult).matchedMessageContent as string, + searchQuery, + ) + : null; + return ( + + onSelectChat({ chatId: chat.id, appId: chat.appId }) + } + value={ + (chat.title || "Untitled Chat") + + (snippet ? ` ${snippet.raw}` : "") + } + keywords={snippet ? [snippet.raw] : []} + > +
+ {chat.title || "Untitled Chat"} + {snippet && ( + + {snippet.before} + + {snippet.match} + + {snippet.after} + + )} +
+
+ ); + })} +
+
+
+ ); +} diff --git a/src/components/ui/command.tsx b/src/components/ui/command.tsx new file mode 100644 index 0000000..a814c3c --- /dev/null +++ b/src/components/ui/command.tsx @@ -0,0 +1,189 @@ +"use client"; + +import * as React from "react"; +import { Command as CommandPrimitive } from "cmdk"; +import { SearchIcon } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +function Command({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function CommandDialog({ + title = "Command Palette", + description = "Search for a command to run...", + children, + className, + showCloseButton = true, + filter, + ...props +}: React.ComponentProps & { + title?: string; + description?: string; + className?: string; + showCloseButton?: boolean; + filter?: (value: string, search: string, keywords?: string[]) => number; +}) { + return ( + + + {title} + {description} + + + + {children} + + + + ); +} + +function CommandInput({ + className, + ...props +}: React.ComponentProps) { + return ( +
+ + +
+ ); +} + +function CommandList({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function CommandEmpty({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function CommandGroup({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function CommandSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function CommandItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function CommandShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ); +} + +export { + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator, +}; diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index 3b37abd..39801b4 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -47,8 +47,11 @@ function DialogOverlay({ function DialogContent({ className, children, + showCloseButton, ...props -}: React.ComponentProps) { +}: React.ComponentProps & { + showCloseButton?: boolean; +}) { return ( @@ -61,10 +64,12 @@ function DialogContent({ {...props} > {children} - - - Close - + {showCloseButton !== false && ( + + + Close + + )} ); diff --git a/src/hooks/useSearchChats.ts b/src/hooks/useSearchChats.ts new file mode 100644 index 0000000..b9db455 --- /dev/null +++ b/src/hooks/useSearchChats.ts @@ -0,0 +1,23 @@ +import { IpcClient } from "@/ipc/ipc_client"; +import type { ChatSearchResult } from "@/lib/schemas"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; + +export function useSearchChats(appId: number | null, query: string) { + const enabled = Boolean(appId && query && query.trim().length > 0); + + const { data, isFetching, isLoading } = useQuery({ + queryKey: ["search-chats", appId, query], + enabled, + queryFn: async (): Promise => { + // Non-null assertion safe due to enabled guard + return IpcClient.getInstance().searchChats(appId as number, query); + }, + placeholderData: keepPreviousData, + retry: 0, + }); + + return { + chats: data ?? [], + loading: enabled ? isFetching || isLoading : false, + }; +} diff --git a/src/ipc/handlers/chat_handlers.ts b/src/ipc/handlers/chat_handlers.ts index bb3fa81..d0c558f 100644 --- a/src/ipc/handlers/chat_handlers.ts +++ b/src/ipc/handlers/chat_handlers.ts @@ -1,8 +1,8 @@ import { ipcMain } from "electron"; import { db } from "../../db"; import { apps, chats, messages } from "../../db/schema"; -import { desc, eq } from "drizzle-orm"; -import type { ChatSummary } from "../../lib/schemas"; +import { desc, eq, and, like } from "drizzle-orm"; +import type { ChatSearchResult, ChatSummary } from "../../lib/schemas"; import * as git from "isomorphic-git"; import * as fs from "fs"; import { createLoggedHandler } from "./safe_handle"; @@ -115,4 +115,61 @@ export function registerChatHandlers() { handle("delete-messages", async (_, chatId: number): Promise => { await db.delete(messages).where(eq(messages.chatId, chatId)); }); + + handle( + "search-chats", + async (_, appId: number, query: string): Promise => { + // 1) Find chats by title and map to ChatSearchResult with no matched message + const chatTitleMatches = await db + .select({ + id: chats.id, + appId: chats.appId, + title: chats.title, + createdAt: chats.createdAt, + }) + .from(chats) + .where(and(eq(chats.appId, appId), like(chats.title, `%${query}%`))) + .orderBy(desc(chats.createdAt)) + .limit(10); + + const titleResults: ChatSearchResult[] = chatTitleMatches.map((c) => ({ + id: c.id, + appId: c.appId, + title: c.title, + createdAt: c.createdAt, + matchedMessageContent: null, + })); + + // 2) Find messages that match and join to chats to build one result per message + const messageResults = await db + .select({ + id: chats.id, + appId: chats.appId, + title: chats.title, + createdAt: chats.createdAt, + matchedMessageContent: messages.content, + }) + .from(messages) + .innerJoin(chats, eq(messages.chatId, chats.id)) + .where( + and(eq(chats.appId, appId), like(messages.content, `%${query}%`)), + ) + .orderBy(desc(chats.createdAt)) + .limit(10); + + // Combine: keep title matches and per-message matches + const combined: ChatSearchResult[] = [...titleResults, ...messageResults]; + const uniqueChats = Array.from( + new Map(combined.map((item) => [item.id, item])).values(), + ); + + // Sort newest chats first + uniqueChats.sort( + (a, b) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + ); + + return uniqueChats; + }, + ); } diff --git a/src/ipc/ipc_client.ts b/src/ipc/ipc_client.ts index e3510ed..a1fad3b 100644 --- a/src/ipc/ipc_client.ts +++ b/src/ipc/ipc_client.ts @@ -4,6 +4,7 @@ import { ChatSummariesSchema, type UserSettings, type ContextPathResults, + ChatSearchResultsSchema, } from "../lib/schemas"; import type { AppOutput, @@ -63,7 +64,11 @@ import type { UpdatePromptParamsDto, } from "./ipc_types"; import type { Template } from "../shared/templates"; -import type { AppChatContext, ProposalResult } from "@/lib/schemas"; +import type { + AppChatContext, + ChatSearchResult, + ProposalResult, +} from "@/lib/schemas"; import { showError } from "@/lib/toast"; export interface ChatStreamCallbacks { @@ -288,6 +293,20 @@ export class IpcClient { } } + // search for chats + public async searchChats( + appId: number, + query: string, + ): Promise { + try { + const data = await this.ipcRenderer.invoke("search-chats", appId, query); + return ChatSearchResultsSchema.parse(data); + } catch (error) { + showError(error); + throw error; + } + } + // Get all apps public async listApps(): Promise { return this.ipcRenderer.invoke("list-apps"); diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts index ff95654..982fd93 100644 --- a/src/lib/schemas.ts +++ b/src/lib/schemas.ts @@ -26,6 +26,24 @@ export type ChatSummary = z.infer; */ export const ChatSummariesSchema = z.array(ChatSummarySchema); +/** + * Zod schema for chat search result objects returned by the search-chats IPC + */ +export const ChatSearchResultSchema = z.object({ + id: z.number(), + appId: z.number(), + title: z.string().nullable(), + createdAt: z.date(), + matchedMessageContent: z.string().nullable(), +}); + +/** + * Type derived from the ChatSearchResultSchema + */ +export type ChatSearchResult = z.infer; + +export const ChatSearchResultsSchema = z.array(ChatSearchResultSchema); + const providers = [ "openai", "anthropic", diff --git a/src/preload.ts b/src/preload.ts index 7b18a10..f731439 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -23,6 +23,7 @@ const validInvokeChannels = [ "copy-app", "get-chat", "get-chats", + "search-chats", "get-chat-logs", "list-apps", "get-app",