From a547aa3ac1253fa649f7e546cdb996026f91c3fb Mon Sep 17 00:00:00 2001 From: Mohamed Aziz Mejri Date: Wed, 17 Sep 2025 23:03:07 +0100 Subject: [PATCH] Implementing app search feature (#1302) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR implements app search feature and addresses the issue #1182. --- ## Summary by cubic Adds a fast app search with a command-style dialog so users can find apps by name or chat content and jump to them quickly. Implements the search experience requested in #1182. - New Features - Search dialog (Ctrl+K or “Search Apps” button) with result snippets from matching chat titles/messages. - Searches across app names, chat titles, and message content; case-insensitive; supports partial matches; empty query lists all apps. - Selecting a result navigates to the app and closes the dialog. - New IPC endpoint search-app with Zod-validated results, debounced React Query hook, and preload allowlist update. - Added E2E tests for dialog open/close, shortcuts, matching behavior, empty state, and navigation. --- e2e-tests/app_search.spec.ts | 291 +++++++++++++++++++++++++++++ src/components/AppList.tsx | 144 ++++++++------ src/components/AppSearchDialog.tsx | 153 +++++++++++++++ src/components/ui/command.tsx | 2 + src/hooks/useSearchApps.ts | 22 +++ src/ipc/handlers/app_handlers.ts | 90 ++++++++- src/ipc/ipc_client.ts | 13 ++ src/lib/schemas.ts | 14 ++ src/preload.ts | 1 + 9 files changed, 675 insertions(+), 55 deletions(-) create mode 100644 e2e-tests/app_search.spec.ts create mode 100644 src/components/AppSearchDialog.tsx create mode 100644 src/hooks/useSearchApps.ts diff --git a/e2e-tests/app_search.spec.ts b/e2e-tests/app_search.spec.ts new file mode 100644 index 0000000..a346c0a --- /dev/null +++ b/e2e-tests/app_search.spec.ts @@ -0,0 +1,291 @@ +import { test } from "./helpers/test_helper"; + +test("app search - basic search dialog functionality", async ({ po }) => { + await po.setUp({ autoApprove: true }); + + await po.goToAppsTab(); + await po.page.getByTestId("search-apps-button").waitFor(); + + // Create some apps for testing + await po.sendPrompt("create a todo application"); + + // Go back to apps list + await po.goToAppsTab(); + await po.page.getByTestId("search-apps-button").waitFor(); + + // Create second app + await po.sendPrompt("build a weather dashboard"); + + // Go back to apps list + await po.goToAppsTab(); + await po.page.getByTestId("search-apps-button").waitFor(); + + // Create third app + await po.sendPrompt("create a blog system"); + + // Go back to apps list + await po.goToAppsTab(); + await po.page.getByTestId("search-apps-button").waitFor(); + + // Test 1: Open search dialog using the search button + const searchButton = po.page.getByTestId("search-apps-button"); + await searchButton.click(); + + // Wait for search dialog to appear + const dialog = po.page.getByTestId("app-search-dialog"); + await dialog.waitFor({ state: "visible", timeout: 10000 }); + + // Test 2: Close dialog with Ctrl+K (shortcut toggles) + await po.page.keyboard.press("Control+k"); + await dialog.waitFor({ state: "hidden", timeout: 5000 }); + + // Test 3: Open dialog again with Ctrl+K (shortcut toggles) + await po.page.keyboard.press("Control+k"); + await dialog.waitFor({ state: "visible", timeout: 10000 }); + + // Test 4: Search for specific term + await po.page.getByPlaceholder("Search apps").fill("app"); + await po.page.waitForTimeout(500); + + // Test 5: Clear search and close with Escape + await po.page.getByPlaceholder("Search apps").clear(); + await po.page.keyboard.press("Escape"); + await dialog.waitFor({ state: "hidden", timeout: 5000 }); +}); + +test("app search - search functionality with different terms", async ({ + po, +}) => { + await po.setUp({ autoApprove: true }); + + // Create apps with specific content for testing + await po.sendPrompt("create a calculator application with advanced features"); + await po.goToAppsTab(); + + await po.sendPrompt("build a task management system with priority levels"); + await po.goToAppsTab(); + + await po.sendPrompt("create a weather monitoring dashboard"); + await po.goToAppsTab(); + + // Open search dialog + await po.page.getByTestId("search-apps-button").click(); + await po.page.getByTestId("app-search-dialog").waitFor(); + + // Search for "calculator" - should find the calculator app through chat content + await po.page.getByPlaceholder("Search apps").fill("calculator"); + await po.page.waitForTimeout(500); + + // Search for "task" - should find the task management app + await po.page.getByPlaceholder("Search apps").fill("task"); + await po.page.waitForTimeout(500); + + // Search for "weather" - should find the weather dashboard + await po.page.getByPlaceholder("Search apps").fill("weather"); + await po.page.waitForTimeout(500); + + // Search for non-existent term + await po.page.getByPlaceholder("Search apps").fill("nonexistent"); + await po.page.waitForTimeout(500); + + // Should show empty state + await po.page.getByTestId("app-search-empty").waitFor(); + + await po.page.keyboard.press("Escape"); +}); + +test("app search - keyboard shortcut functionality", async ({ po }) => { + await po.setUp({ autoApprove: true }); + + // Create an app first + await po.sendPrompt("create sample application"); + await po.goToAppsTab(); + + // Test keyboard shortcut (Ctrl+K) to open dialog + await po.page.keyboard.press("Control+k"); + await po.page.getByTestId("app-search-dialog").waitFor(); + + // Close with escape + await po.page.keyboard.press("Escape"); + await po.page.getByTestId("app-search-dialog").waitFor({ state: "hidden" }); + + // Test keyboard shortcut again + await po.page.keyboard.press("Control+k"); + await po.page.getByTestId("app-search-dialog").waitFor(); + + // Close with Ctrl+K (toggle) + await po.page.keyboard.press("Control+k"); + await po.page.getByTestId("app-search-dialog").waitFor({ state: "hidden" }); +}); + +test("app search - navigation and selection", async ({ po }) => { + await po.setUp({ autoApprove: true }); + + // Create multiple apps + await po.sendPrompt("create first application"); + await po.goToAppsTab(); + + await po.sendPrompt("create second application"); + await po.goToAppsTab(); + + await po.sendPrompt("create third application"); + await po.goToAppsTab(); + + // Open search dialog + await po.page.getByTestId("search-apps-button").click(); + await po.page.getByTestId("app-search-dialog").waitFor(); + + // Get all app items in the search results + const searchItems = await po.page.getByTestId(/^app-search-item-/).all(); + + if (searchItems.length > 0) { + // Click on the first search result + await searchItems[0].click(); + + // Dialog should close after selection + await po.page.getByTestId("app-search-dialog").waitFor({ state: "hidden" }); + + // Should navigate to the selected app + await po.page.waitForTimeout(1000); + } else { + // If no items found, just close the dialog + await po.page.keyboard.press("Escape"); + } +}); + +test("app search - empty search shows all apps", async ({ po }) => { + await po.setUp({ autoApprove: true }); + + // Create a few apps + await po.sendPrompt("create alpha application"); + await po.goToAppsTab(); + + await po.sendPrompt("create beta application"); + await po.goToAppsTab(); + + await po.sendPrompt("create gamma application"); + await po.goToAppsTab(); + + // Open search dialog + await po.page.getByTestId("search-apps-button").click(); + await po.page.getByTestId("app-search-dialog").waitFor(); + + // Clear any existing search (should show all apps) + await po.page.getByPlaceholder("Search apps").clear(); + await po.page.waitForTimeout(500); + + // Should show all apps in the list + const searchItems = await po.page.getByTestId(/^app-search-item-/).all(); + console.log(`Found ${searchItems.length} apps in search results`); + + await po.page.keyboard.press("Escape"); +}); + +test("app search - case insensitive search", async ({ po }) => { + await po.setUp({ autoApprove: true }); + + // Create an app with mixed case content + await po.sendPrompt("create a Test Application with Mixed Case Content"); + await po.goToAppsTab(); + + // Open search dialog + await po.page.getByTestId("search-apps-button").click(); + await po.page.getByTestId("app-search-dialog").waitFor(); + + // Search with different cases + await po.page.getByPlaceholder("Search apps").fill("test"); + await po.page.waitForTimeout(500); + + await po.page.getByPlaceholder("Search apps").fill("TEST"); + await po.page.waitForTimeout(500); + + await po.page.getByPlaceholder("Search apps").fill("Test"); + await po.page.waitForTimeout(500); + + await po.page.getByPlaceholder("Search apps").fill("MIXED"); + await po.page.waitForTimeout(500); + + await po.page.keyboard.press("Escape"); +}); + +test("app search - partial word matching", async ({ po }) => { + await po.setUp({ autoApprove: true }); + + // Create an app with a long descriptive name + await po.sendPrompt("create a comprehensive project management solution"); + await po.goToAppsTab(); + + // Open search dialog + await po.page.getByTestId("search-apps-button").click(); + await po.page.getByTestId("app-search-dialog").waitFor(); + + // Search with partial words + await po.page.getByPlaceholder("Search apps").fill("proj"); + await po.page.waitForTimeout(500); + + await po.page.getByPlaceholder("Search apps").fill("manage"); + await po.page.waitForTimeout(500); + + await po.page.getByPlaceholder("Search apps").fill("comp"); + await po.page.waitForTimeout(500); + + await po.page.getByPlaceholder("Search apps").fill("sol"); + await po.page.waitForTimeout(500); + + await po.page.keyboard.press("Escape"); +}); + +test("app search - search by app name", async ({ po }) => { + await po.setUp({ autoApprove: true }); + + // Create apps - note that app names are randomly generated + await po.sendPrompt("create a todo application"); + await po.goToAppsTab(); + + await po.sendPrompt("build a weather dashboard"); + await po.goToAppsTab(); + + await po.sendPrompt("create a blog system"); + await po.goToAppsTab(); + + // Get the actual app names from the UI (these are randomly generated) + const appItems = await po.page.getByTestId(/^app-list-item-/).all(); + const appNames: string[] = []; + for (const item of appItems) { + const testId = await item.getAttribute("data-testid"); + if (testId) { + const appName = testId.replace("app-list-item-", ""); + appNames.push(appName); + } + } + + // Open search dialog + await po.page.getByTestId("search-apps-button").click(); + await po.page.getByTestId("app-search-dialog").waitFor(); + + // Test searching by actual app names (randomly generated) + if (appNames.length > 0) { + // Search for the first few characters of the first app name + const firstAppName = appNames[0]; + const searchTerm = firstAppName.substring( + 0, + Math.min(4, firstAppName.length), + ); + await po.page.getByPlaceholder("Search apps").fill(searchTerm); + await po.page.waitForTimeout(500); + + // Clear and search for second app if available + if (appNames.length > 1) { + await po.page.getByPlaceholder("Search apps").clear(); + const secondAppName = appNames[1]; + const secondSearchTerm = secondAppName.substring( + 0, + Math.min(4, secondAppName.length), + ); + await po.page.getByPlaceholder("Search apps").fill(secondSearchTerm); + await po.page.waitForTimeout(500); + } + } + + await po.page.keyboard.press("Escape"); +}); diff --git a/src/components/AppList.tsx b/src/components/AppList.tsx index 8c5eafb..ef24da2 100644 --- a/src/components/AppList.tsx +++ b/src/components/AppList.tsx @@ -1,6 +1,6 @@ import { useNavigate } from "@tanstack/react-router"; import { formatDistanceToNow } from "date-fns"; -import { PlusCircle } from "lucide-react"; +import { PlusCircle, Search } from "lucide-react"; import { useAtom, useSetAtom } from "jotai"; import { selectedAppIdAtom } from "@/atoms/appAtoms"; import { @@ -13,13 +13,28 @@ import { import { Button } from "@/components/ui/button"; import { selectedChatIdAtom } from "@/atoms/chatAtoms"; import { useLoadApps } from "@/hooks/useLoadApps"; +import { useMemo, useState } from "react"; +import { AppSearchDialog } from "./AppSearchDialog"; export function AppList({ show }: { show?: boolean }) { const navigate = useNavigate(); const [selectedAppId, setSelectedAppId] = useAtom(selectedAppIdAtom); const setSelectedChatId = useSetAtom(selectedChatIdAtom); const { apps, loading, error } = useLoadApps(); + // search dialog state + const [isSearchDialogOpen, setIsSearchDialogOpen] = useState(false); + const allApps = useMemo( + () => + apps.map((a) => ({ + id: a.id, + name: a.name, + createdAt: a.createdAt, + matchedChatTitle: null, + matchedChatMessage: null, + })), + [apps], + ); if (!show) { return null; } @@ -27,6 +42,7 @@ export function AppList({ show }: { show?: boolean }) { const handleAppClick = (id: number) => { setSelectedAppId(id); setSelectedChatId(null); + setIsSearchDialogOpen(false); navigate({ to: "/", search: { appId: id }, @@ -39,58 +55,80 @@ export function AppList({ show }: { show?: boolean }) { }; return ( - - Your Apps - -
- + <> + + Your Apps + +
+ + - {loading ? ( -
- Loading apps... -
- ) : error ? ( -
- Error loading apps -
- ) : apps.length === 0 ? ( -
No apps found
- ) : ( - - {apps.map((app) => ( - - - - ))} - - )} -
-
-
+ {loading ? ( +
+ Loading apps... +
+ ) : error ? ( +
+ Error loading apps +
+ ) : apps.length === 0 ? ( +
+ No apps found +
+ ) : ( + + {apps.map((app) => ( + + + + ))} + + )} +
+
+
+ + ); } diff --git a/src/components/AppSearchDialog.tsx b/src/components/AppSearchDialog.tsx new file mode 100644 index 0000000..f04ea0e --- /dev/null +++ b/src/components/AppSearchDialog.tsx @@ -0,0 +1,153 @@ +import { + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, +} from "./ui/command"; +import { useState, useEffect } from "react"; +import { useSearchApps } from "@/hooks/useSearchApps"; +import type { AppSearchResult } from "@/lib/schemas"; + +type AppSearchDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + onSelectApp: (appId: number) => void; + allApps: AppSearchResult[]; +}; + +export function AppSearchDialog({ + open, + onOpenChange, + onSelectApp, + allApps, +}: AppSearchDialogProps) { + const [searchQuery, setSearchQuery] = useState(""); + function useDebouncedValue(value: T, delay: number): T { + const [debounced, setDebounced] = useState(value); + useEffect(() => { + const handle = setTimeout(() => setDebounced(value), delay); + return () => clearTimeout(handle); + }, [value, delay]); + return debounced; + } + + const debouncedQuery = useDebouncedValue(searchQuery, 150); + const { apps: searchResults } = useSearchApps(debouncedQuery); + + // Show all apps if search is empty, otherwise show search results + const appsToShow: AppSearchResult[] = + debouncedQuery.trim() === "" ? allApps : 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.toLowerCase(); + const lowerQuery = q.toLowerCase(); + const idx = lowerText.indexOf(lowerQuery); + if (idx === -1) { + const raw = + text.length > radius * 2 ? text.slice(0, radius * 2) + "…" : text; + return { before: "", match: "", after: "", raw }; + } + const start = Math.max(0, idx - radius); + const end = Math.min(text.length, idx + q.length + radius); + const before = (start > 0 ? "…" : "") + text.slice(start, idx); + const match = text.slice(idx, idx + q.length); + const after = + text.slice(idx + q.length, end) + (end < text.length ? "…" : ""); + return { before, match, after, raw: before + match + after }; + } + + useEffect(() => { + const down = (e: KeyboardEvent) => { + if (e.key === "k" && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + onOpenChange(!open); + } + }; + document.addEventListener("keydown", down); + return () => document.removeEventListener("keydown", down); + }, [open, onOpenChange]); + + return ( + + + + + No results found. + + + {appsToShow.map((app) => { + const isSearch = searchQuery.trim() !== ""; + let snippet = null; + if (isSearch && app.matchedChatMessage) { + snippet = getSnippet(app.matchedChatMessage, searchQuery); + } else if (isSearch && app.matchedChatTitle) { + snippet = getSnippet(app.matchedChatTitle, searchQuery); + } + return ( + onSelectApp(app.id)} + value={app.name + (snippet ? ` ${snippet.raw}` : "")} + keywords={snippet ? [snippet.raw] : []} + data-testid={`app-search-item-${app.id}`} + > +
+ {app.name} + {snippet && ( + + {snippet.before} + + {snippet.match} + + {snippet.after} + + )} +
+
+ ); + })} +
+
+
+ ); +} diff --git a/src/components/ui/command.tsx b/src/components/ui/command.tsx index a814c3c..20acf21 100644 --- a/src/components/ui/command.tsx +++ b/src/components/ui/command.tsx @@ -43,6 +43,7 @@ function CommandDialog({ className?: string; showCloseButton?: boolean; filter?: (value: string, search: string, keywords?: string[]) => number; + "data-testid"?: string; }) { return ( @@ -53,6 +54,7 @@ function CommandDialog({ 0); + + const { data, isFetching, isLoading } = useQuery({ + queryKey: ["search-apps", query], + enabled, + queryFn: async (): Promise => { + return IpcClient.getInstance().searchApps(query); + }, + placeholderData: keepPreviousData, + retry: 0, + }); + + return { + apps: data ?? [], + loading: enabled ? isFetching || isLoading : false, + }; +} diff --git a/src/ipc/handlers/app_handlers.ts b/src/ipc/handlers/app_handlers.ts index 4f03e9c..f02bc88 100644 --- a/src/ipc/handlers/app_handlers.ts +++ b/src/ipc/handlers/app_handlers.ts @@ -1,7 +1,7 @@ import { ipcMain, app } from "electron"; import { db, getDatabasePath } from "../../db"; -import { apps, chats } from "../../db/schema"; -import { desc, eq } from "drizzle-orm"; +import { apps, chats, messages } from "../../db/schema"; +import { desc, eq, like } from "drizzle-orm"; import type { App, CreateAppParams, @@ -50,6 +50,7 @@ import { normalizePath } from "../../../shared/normalizePath"; import { isServerFunction } from "@/supabase_admin/supabase_utils"; import { getVercelTeamSlug } from "../utils/vercel_utils"; import { storeDbTimestampAtCurrentVersion } from "../utils/neon_timestamp_utils"; +import { AppSearchResult } from "@/lib/schemas"; const DEFAULT_COMMAND = "(pnpm install && pnpm run dev --port 32100) || (npm install --legacy-peer-deps && npm run dev -- --port 32100)"; @@ -1340,6 +1341,91 @@ export function registerAppHandlers() { } }, ); + + handle( + "search-app", + async (_, searchQuery: string): Promise => { + // Use parameterized query to prevent SQL injection + const pattern = `%${searchQuery.replace(/[%_]/g, "\\$&")}%`; + + // 1) Apps whose name matches + const appNameMatches = await db + .select({ + id: apps.id, + name: apps.name, + createdAt: apps.createdAt, + }) + .from(apps) + .where(like(apps.name, pattern)) + .orderBy(desc(apps.createdAt)); + + const appNameMatchesResult: AppSearchResult[] = appNameMatches.map( + (r) => ({ + id: r.id, + name: r.name, + createdAt: r.createdAt, + matchedChatTitle: null, + matchedChatMessage: null, + }), + ); + + // 2) Apps whose chat title matches + const chatTitleMatches = await db + .select({ + id: apps.id, + name: apps.name, + createdAt: apps.createdAt, + matchedChatTitle: chats.title, + }) + .from(apps) + .innerJoin(chats, eq(apps.id, chats.appId)) + .where(like(chats.title, pattern)) + .orderBy(desc(apps.createdAt)); + + const chatTitleMatchesResult: AppSearchResult[] = chatTitleMatches.map( + (r) => ({ + id: r.id, + name: r.name, + createdAt: r.createdAt, + matchedChatTitle: r.matchedChatTitle, + matchedChatMessage: null, + }), + ); + + // 3) Apps whose chat message content matches + const chatMessageMatches = await db + .select({ + id: apps.id, + name: apps.name, + createdAt: apps.createdAt, + matchedChatTitle: chats.title, + matchedChatMessage: messages.content, + }) + .from(apps) + .innerJoin(chats, eq(apps.id, chats.appId)) + .innerJoin(messages, eq(chats.id, messages.chatId)) + .where(like(messages.content, pattern)) + .orderBy(desc(apps.createdAt)); + + // Flatten and dedupe by app id + const allMatches: AppSearchResult[] = [ + ...appNameMatchesResult, + ...chatTitleMatchesResult, + ...chatMessageMatches, + ]; + const uniqueApps = Array.from( + new Map(allMatches.map((app) => [app.id, app])).values(), + ); + + // Sort newest apps first + uniqueApps.sort( + (a, b) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + ); + + return uniqueApps; + }, + ); } function getCommand({ diff --git a/src/ipc/ipc_client.ts b/src/ipc/ipc_client.ts index df8d9be..2963ea6 100644 --- a/src/ipc/ipc_client.ts +++ b/src/ipc/ipc_client.ts @@ -5,6 +5,7 @@ import { type UserSettings, type ContextPathResults, ChatSearchResultsSchema, + AppSearchResultsSchema, } from "../lib/schemas"; import type { AppOutput, @@ -66,6 +67,7 @@ import type { import type { Template } from "../shared/templates"; import type { AppChatContext, + AppSearchResult, ChatSearchResult, ProposalResult, } from "@/lib/schemas"; @@ -312,6 +314,17 @@ export class IpcClient { return this.ipcRenderer.invoke("list-apps"); } + // Search apps by name + public async searchApps(searchQuery: string): Promise { + try { + const data = await this.ipcRenderer.invoke("search-app", searchQuery); + return AppSearchResultsSchema.parse(data); + } catch (error) { + showError(error); + throw error; + } + } + public async readAppFile(appId: number, filePath: string): Promise { return this.ipcRenderer.invoke("read-app-file", { appId, diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts index 982fd93..cd76476 100644 --- a/src/lib/schemas.ts +++ b/src/lib/schemas.ts @@ -44,6 +44,20 @@ export type ChatSearchResult = z.infer; export const ChatSearchResultsSchema = z.array(ChatSearchResultSchema); +// Zod schema for app search result objects returned by the search-app IPC +export const AppSearchResultSchema = z.object({ + id: z.number(), + name: z.string(), + createdAt: z.date(), + matchedChatTitle: z.string().nullable(), + matchedChatMessage: z.string().nullable(), +}); + +// Type derived from AppSearchResultSchema +export type AppSearchResult = z.infer; + +export const AppSearchResultsSchema = z.array(AppSearchResultSchema); + const providers = [ "openai", "anthropic", diff --git a/src/preload.ts b/src/preload.ts index b79b212..248c908 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -36,6 +36,7 @@ const validInvokeChannels = [ "stop-app", "restart-app", "respond-to-app-input", + "search-app", "list-versions", "revert-version", "checkout-version",