Chat search (#1224)

Based on https://github.com/dyad-sh/dyad/pull/1116
    
<!-- This is an auto-generated description by cubic. -->
---

## 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.

<!-- End of auto-generated description by cubic. -->

---------

Co-authored-by: Evans Obeng <iamevansobeng@outlook.com>
Co-authored-by: Evans Obeng <60653146+iamevansobeng@users.noreply.github.com>
This commit is contained in:
Will Chen
2025-09-09 00:18:48 -07:00
committed by GitHub
parent d21497659b
commit 7818f2950a
12 changed files with 655 additions and 12 deletions

View File

@@ -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" });
});

19
package-lock.json generated
View File

@@ -28,7 +28,7 @@
"@radix-ui/react-accordion": "^1.2.4", "@radix-ui/react-accordion": "^1.2.4",
"@radix-ui/react-alert-dialog": "^1.1.13", "@radix-ui/react-alert-dialog": "^1.1.13",
"@radix-ui/react-checkbox": "^1.3.2", "@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-dropdown-menu": "^2.1.7",
"@radix-ui/react-label": "^2.1.4", "@radix-ui/react-label": "^2.1.4",
"@radix-ui/react-popover": "^1.1.7", "@radix-ui/react-popover": "^1.1.7",
@@ -52,6 +52,7 @@
"better-sqlite3": "^11.9.1", "better-sqlite3": "^11.9.1",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"drizzle-orm": "^0.41.0", "drizzle-orm": "^0.41.0",
@@ -8769,6 +8770,22 @@
"node": ">=6" "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": { "node_modules/color": {
"version": "4.2.3", "version": "4.2.3",
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",

View File

@@ -104,7 +104,7 @@
"@radix-ui/react-accordion": "^1.2.4", "@radix-ui/react-accordion": "^1.2.4",
"@radix-ui/react-alert-dialog": "^1.1.13", "@radix-ui/react-alert-dialog": "^1.1.13",
"@radix-ui/react-checkbox": "^1.3.2", "@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-dropdown-menu": "^2.1.7",
"@radix-ui/react-label": "^2.1.4", "@radix-ui/react-label": "^2.1.4",
"@radix-ui/react-popover": "^1.1.7", "@radix-ui/react-popover": "^1.1.7",
@@ -128,6 +128,7 @@
"better-sqlite3": "^11.9.1", "better-sqlite3": "^11.9.1",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"drizzle-orm": "^0.41.0", "drizzle-orm": "^0.41.0",

View File

@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
import { useNavigate, useRouterState } from "@tanstack/react-router"; import { useNavigate, useRouterState } from "@tanstack/react-router";
import { formatDistanceToNow } from "date-fns"; 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 { useAtom } from "jotai";
import { selectedChatIdAtom } from "@/atoms/chatAtoms"; import { selectedChatIdAtom } from "@/atoms/chatAtoms";
import { selectedAppIdAtom } from "@/atoms/appAtoms"; import { selectedAppIdAtom } from "@/atoms/appAtoms";
@@ -27,11 +27,14 @@ import { useChats } from "@/hooks/useChats";
import { RenameChatDialog } from "@/components/chat/RenameChatDialog"; import { RenameChatDialog } from "@/components/chat/RenameChatDialog";
import { DeleteChatDialog } from "@/components/chat/DeleteChatDialog"; import { DeleteChatDialog } from "@/components/chat/DeleteChatDialog";
import { ChatSearchDialog } from "./ChatSearchDialog";
export function ChatList({ show }: { show?: boolean }) { export function ChatList({ show }: { show?: boolean }) {
const navigate = useNavigate(); const navigate = useNavigate();
const [selectedChatId, setSelectedChatId] = useAtom(selectedChatIdAtom); const [selectedChatId, setSelectedChatId] = useAtom(selectedChatIdAtom);
const [selectedAppId, setSelectedAppId] = useAtom(selectedAppIdAtom); const [selectedAppId, setSelectedAppId] = useAtom(selectedAppIdAtom);
const [, setIsDropdownOpen] = useAtom(dropdownOpenAtom); const [, setIsDropdownOpen] = useAtom(dropdownOpenAtom);
const { chats, loading, refreshChats } = useChats(selectedAppId); const { chats, loading, refreshChats } = useChats(selectedAppId);
const routerState = useRouterState(); const routerState = useRouterState();
const isChatRoute = routerState.location.pathname === "/chat"; const isChatRoute = routerState.location.pathname === "/chat";
@@ -46,6 +49,9 @@ export function ChatList({ show }: { show?: boolean }) {
const [deleteChatId, setDeleteChatId] = useState<number | null>(null); const [deleteChatId, setDeleteChatId] = useState<number | null>(null);
const [deleteChatTitle, setDeleteChatTitle] = useState(""); const [deleteChatTitle, setDeleteChatTitle] = useState("");
// search dialog state
const [isSearchDialogOpen, setIsSearchDialogOpen] = useState(false);
// Update selectedChatId when route changes // Update selectedChatId when route changes
useEffect(() => { useEffect(() => {
if (isChatRoute) { if (isChatRoute) {
@@ -70,6 +76,7 @@ export function ChatList({ show }: { show?: boolean }) {
}) => { }) => {
setSelectedChatId(chatId); setSelectedChatId(chatId);
setSelectedAppId(appId); setSelectedAppId(appId);
setIsSearchDialogOpen(false);
navigate({ navigate({
to: "/chat", to: "/chat",
search: { id: chatId }, search: { id: chatId },
@@ -151,7 +158,10 @@ export function ChatList({ show }: { show?: boolean }) {
return ( return (
<> <>
<SidebarGroup className="overflow-y-auto h-[calc(100vh-112px)]"> <SidebarGroup
className="overflow-y-auto h-[calc(100vh-112px)]"
data-testid="chat-list-container"
>
<SidebarGroupLabel>Recent Chats</SidebarGroupLabel> <SidebarGroupLabel>Recent Chats</SidebarGroupLabel>
<SidebarGroupContent> <SidebarGroupContent>
<div className="flex flex-col space-y-4"> <div className="flex flex-col space-y-4">
@@ -163,6 +173,15 @@ export function ChatList({ show }: { show?: boolean }) {
<PlusCircle size={16} /> <PlusCircle size={16} />
<span>New Chat</span> <span>New Chat</span>
</Button> </Button>
<Button
onClick={() => setIsSearchDialogOpen(!isSearchDialogOpen)}
variant="outline"
className="flex items-center justify-start gap-2 mx-2 py-3"
data-testid="search-chats-button"
>
<Search size={16} />
<span>Search chats</span>
</Button>
{loading ? ( {loading ? (
<div className="py-3 px-4 text-sm text-gray-500"> <div className="py-3 px-4 text-sm text-gray-500">
@@ -273,6 +292,15 @@ export function ChatList({ show }: { show?: boolean }) {
onConfirmDelete={handleConfirmDelete} onConfirmDelete={handleConfirmDelete}
chatTitle={deleteChatTitle} chatTitle={deleteChatTitle}
/> />
{/* Chat Search Dialog */}
<ChatSearchDialog
open={isSearchDialogOpen}
onOpenChange={setIsSearchDialogOpen}
onSelectChat={handleChatClick}
appId={selectedAppId}
allChats={chats}
/>
</> </>
); );
} }

View File

@@ -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<string>("");
function useDebouncedValue<T>(value: T, delay: number): T {
const [debounced, setDebounced] = useState<T>(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 (
<CommandDialog
open={open}
onOpenChange={onOpenChange}
data-testid="chat-search-dialog"
filter={commandFilter}
>
<CommandInput
placeholder="Search chats"
value={searchQuery}
onValueChange={setSearchQuery}
/>
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup heading="Chats">
{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 (
<CommandItem
key={chat.id}
onSelect={() =>
onSelectChat({ chatId: chat.id, appId: chat.appId })
}
value={
(chat.title || "Untitled Chat") +
(snippet ? ` ${snippet.raw}` : "")
}
keywords={snippet ? [snippet.raw] : []}
>
<div className="flex flex-col">
<span>{chat.title || "Untitled Chat"}</span>
{snippet && (
<span className="text-xs text-muted-foreground mt-1 line-clamp-2">
{snippet.before}
<mark className="bg-transparent underline decoration-2 decoration-primary">
{snippet.match}
</mark>
{snippet.after}
</span>
)}
</div>
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</CommandDialog>
);
}

View File

@@ -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<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
className,
)}
{...props}
/>
);
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = true,
filter,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string;
description?: string;
className?: string;
showCloseButton?: boolean;
filter?: (value: string, search: string, keywords?: string[]) => number;
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn("overflow-hidden p-0", className)}
showCloseButton={showCloseButton}
>
<Command
filter={filter}
className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5"
>
{children}
</Command>
</DialogContent>
</Dialog>
);
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
>
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
</div>
);
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className,
)}
{...props}
/>
);
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
);
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className,
)}
{...props}
/>
);
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("bg-border -mx-1 h-px", className)}
{...props}
/>
);
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className,
)}
{...props}
/>
);
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
};

View File

@@ -47,8 +47,11 @@ function DialogOverlay({
function DialogContent({ function DialogContent({
className, className,
children, children,
showCloseButton,
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Content>) { }: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
}) {
return ( return (
<DialogPortal data-slot="dialog-portal"> <DialogPortal data-slot="dialog-portal">
<DialogOverlay /> <DialogOverlay />
@@ -61,10 +64,12 @@ function DialogContent({
{...props} {...props}
> >
{children} {children}
{showCloseButton !== false && (
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"> <DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<XIcon /> <XIcon />
<span className="sr-only">Close</span> <span className="sr-only">Close</span>
</DialogPrimitive.Close> </DialogPrimitive.Close>
)}
</DialogPrimitive.Content> </DialogPrimitive.Content>
</DialogPortal> </DialogPortal>
); );

View File

@@ -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<ChatSearchResult[]> => {
// 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,
};
}

View File

@@ -1,8 +1,8 @@
import { ipcMain } from "electron"; import { ipcMain } from "electron";
import { db } from "../../db"; import { db } from "../../db";
import { apps, chats, messages } from "../../db/schema"; import { apps, chats, messages } from "../../db/schema";
import { desc, eq } from "drizzle-orm"; import { desc, eq, and, like } from "drizzle-orm";
import type { ChatSummary } from "../../lib/schemas"; import type { ChatSearchResult, ChatSummary } from "../../lib/schemas";
import * as git from "isomorphic-git"; import * as git from "isomorphic-git";
import * as fs from "fs"; import * as fs from "fs";
import { createLoggedHandler } from "./safe_handle"; import { createLoggedHandler } from "./safe_handle";
@@ -115,4 +115,61 @@ export function registerChatHandlers() {
handle("delete-messages", async (_, chatId: number): Promise<void> => { handle("delete-messages", async (_, chatId: number): Promise<void> => {
await db.delete(messages).where(eq(messages.chatId, chatId)); await db.delete(messages).where(eq(messages.chatId, chatId));
}); });
handle(
"search-chats",
async (_, appId: number, query: string): Promise<ChatSearchResult[]> => {
// 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;
},
);
} }

View File

@@ -4,6 +4,7 @@ import {
ChatSummariesSchema, ChatSummariesSchema,
type UserSettings, type UserSettings,
type ContextPathResults, type ContextPathResults,
ChatSearchResultsSchema,
} from "../lib/schemas"; } from "../lib/schemas";
import type { import type {
AppOutput, AppOutput,
@@ -63,7 +64,11 @@ import type {
UpdatePromptParamsDto, UpdatePromptParamsDto,
} from "./ipc_types"; } from "./ipc_types";
import type { Template } from "../shared/templates"; 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"; import { showError } from "@/lib/toast";
export interface ChatStreamCallbacks { export interface ChatStreamCallbacks {
@@ -288,6 +293,20 @@ export class IpcClient {
} }
} }
// search for chats
public async searchChats(
appId: number,
query: string,
): Promise<ChatSearchResult[]> {
try {
const data = await this.ipcRenderer.invoke("search-chats", appId, query);
return ChatSearchResultsSchema.parse(data);
} catch (error) {
showError(error);
throw error;
}
}
// Get all apps // Get all apps
public async listApps(): Promise<ListAppsResponse> { public async listApps(): Promise<ListAppsResponse> {
return this.ipcRenderer.invoke("list-apps"); return this.ipcRenderer.invoke("list-apps");

View File

@@ -26,6 +26,24 @@ export type ChatSummary = z.infer<typeof ChatSummarySchema>;
*/ */
export const ChatSummariesSchema = z.array(ChatSummarySchema); 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<typeof ChatSearchResultSchema>;
export const ChatSearchResultsSchema = z.array(ChatSearchResultSchema);
const providers = [ const providers = [
"openai", "openai",
"anthropic", "anthropic",

View File

@@ -23,6 +23,7 @@ const validInvokeChannels = [
"copy-app", "copy-app",
"get-chat", "get-chat",
"get-chats", "get-chats",
"search-chats",
"get-chat-logs", "get-chat-logs",
"list-apps", "list-apps",
"get-app", "get-app",