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-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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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({
|
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>
|
||||||
);
|
);
|
||||||
|
|||||||
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 { 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;
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user