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:
126
e2e-tests/chat_search.spec.ts
Normal file
126
e2e-tests/chat_search.spec.ts
Normal 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
19
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<number | null>(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 (
|
||||
<>
|
||||
<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>
|
||||
<SidebarGroupContent>
|
||||
<div className="flex flex-col space-y-4">
|
||||
@@ -163,6 +173,15 @@ export function ChatList({ show }: { show?: boolean }) {
|
||||
<PlusCircle size={16} />
|
||||
<span>New Chat</span>
|
||||
</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 ? (
|
||||
<div className="py-3 px-4 text-sm text-gray-500">
|
||||
@@ -273,6 +292,15 @@ export function ChatList({ show }: { show?: boolean }) {
|
||||
onConfirmDelete={handleConfirmDelete}
|
||||
chatTitle={deleteChatTitle}
|
||||
/>
|
||||
|
||||
{/* Chat Search Dialog */}
|
||||
<ChatSearchDialog
|
||||
open={isSearchDialogOpen}
|
||||
onOpenChange={setIsSearchDialogOpen}
|
||||
onSelectChat={handleChatClick}
|
||||
appId={selectedAppId}
|
||||
allChats={chats}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
159
src/components/ChatSearchDialog.tsx
Normal file
159
src/components/ChatSearchDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
189
src/components/ui/command.tsx
Normal file
189
src/components/ui/command.tsx
Normal 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,
|
||||
};
|
||||
@@ -47,8 +47,11 @@ function DialogOverlay({
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
@@ -61,10 +64,12 @@ function DialogContent({
|
||||
{...props}
|
||||
>
|
||||
{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">
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
);
|
||||
|
||||
23
src/hooks/useSearchChats.ts
Normal file
23
src/hooks/useSearchChats.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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<void> => {
|
||||
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;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<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
|
||||
public async listApps(): Promise<ListAppsResponse> {
|
||||
return this.ipcRenderer.invoke("list-apps");
|
||||
|
||||
@@ -26,6 +26,24 @@ export type ChatSummary = z.infer<typeof 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 = [
|
||||
"openai",
|
||||
"anthropic",
|
||||
|
||||
@@ -23,6 +23,7 @@ const validInvokeChannels = [
|
||||
"copy-app",
|
||||
"get-chat",
|
||||
"get-chats",
|
||||
"search-chats",
|
||||
"get-chat-logs",
|
||||
"list-apps",
|
||||
"get-app",
|
||||
|
||||
Reference in New Issue
Block a user