Implementing app search feature (#1302)
This PR implements app search feature and addresses the issue #1182. <!-- This is an auto-generated description by cubic. --> --- ## 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. <!-- End of auto-generated description by cubic. -->
This commit is contained in:
committed by
GitHub
parent
2edd122d9b
commit
a547aa3ac1
291
e2e-tests/app_search.spec.ts
Normal file
291
e2e-tests/app_search.spec.ts
Normal file
@@ -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");
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
import { formatDistanceToNow } from "date-fns";
|
import { formatDistanceToNow } from "date-fns";
|
||||||
import { PlusCircle } from "lucide-react";
|
import { PlusCircle, Search } from "lucide-react";
|
||||||
import { useAtom, useSetAtom } from "jotai";
|
import { useAtom, useSetAtom } from "jotai";
|
||||||
import { selectedAppIdAtom } from "@/atoms/appAtoms";
|
import { selectedAppIdAtom } from "@/atoms/appAtoms";
|
||||||
import {
|
import {
|
||||||
@@ -13,13 +13,28 @@ import {
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { selectedChatIdAtom } from "@/atoms/chatAtoms";
|
import { selectedChatIdAtom } from "@/atoms/chatAtoms";
|
||||||
import { useLoadApps } from "@/hooks/useLoadApps";
|
import { useLoadApps } from "@/hooks/useLoadApps";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { AppSearchDialog } from "./AppSearchDialog";
|
||||||
|
|
||||||
export function AppList({ show }: { show?: boolean }) {
|
export function AppList({ show }: { show?: boolean }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [selectedAppId, setSelectedAppId] = useAtom(selectedAppIdAtom);
|
const [selectedAppId, setSelectedAppId] = useAtom(selectedAppIdAtom);
|
||||||
const setSelectedChatId = useSetAtom(selectedChatIdAtom);
|
const setSelectedChatId = useSetAtom(selectedChatIdAtom);
|
||||||
const { apps, loading, error } = useLoadApps();
|
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) {
|
if (!show) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -27,6 +42,7 @@ export function AppList({ show }: { show?: boolean }) {
|
|||||||
const handleAppClick = (id: number) => {
|
const handleAppClick = (id: number) => {
|
||||||
setSelectedAppId(id);
|
setSelectedAppId(id);
|
||||||
setSelectedChatId(null);
|
setSelectedChatId(null);
|
||||||
|
setIsSearchDialogOpen(false);
|
||||||
navigate({
|
navigate({
|
||||||
to: "/",
|
to: "/",
|
||||||
search: { appId: id },
|
search: { appId: id },
|
||||||
@@ -39,7 +55,11 @@ export function AppList({ 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="app-list-container"
|
||||||
|
>
|
||||||
<SidebarGroupLabel>Your Apps</SidebarGroupLabel>
|
<SidebarGroupLabel>Your Apps</SidebarGroupLabel>
|
||||||
<SidebarGroupContent>
|
<SidebarGroupContent>
|
||||||
<div className="flex flex-col space-y-2">
|
<div className="flex flex-col space-y-2">
|
||||||
@@ -51,6 +71,15 @@ export function AppList({ show }: { show?: boolean }) {
|
|||||||
<PlusCircle size={16} />
|
<PlusCircle size={16} />
|
||||||
<span>New App</span>
|
<span>New App</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => setIsSearchDialogOpen(!isSearchDialogOpen)}
|
||||||
|
variant="outline"
|
||||||
|
className="flex items-center justify-start gap-2 mx-2 py-3"
|
||||||
|
data-testid="search-apps-button"
|
||||||
|
>
|
||||||
|
<Search size={16} />
|
||||||
|
<span>Search Apps</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="py-2 px-4 text-sm text-gray-500">
|
<div className="py-2 px-4 text-sm text-gray-500">
|
||||||
@@ -61,7 +90,9 @@ export function AppList({ show }: { show?: boolean }) {
|
|||||||
Error loading apps
|
Error loading apps
|
||||||
</div>
|
</div>
|
||||||
) : apps.length === 0 ? (
|
) : apps.length === 0 ? (
|
||||||
<div className="py-2 px-4 text-sm text-gray-500">No apps found</div>
|
<div className="py-2 px-4 text-sm text-gray-500">
|
||||||
|
No apps found
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<SidebarMenu className="space-y-1" data-testid="app-list">
|
<SidebarMenu className="space-y-1" data-testid="app-list">
|
||||||
{apps.map((app) => (
|
{apps.map((app) => (
|
||||||
@@ -92,5 +123,12 @@ export function AppList({ show }: { show?: boolean }) {
|
|||||||
</div>
|
</div>
|
||||||
</SidebarGroupContent>
|
</SidebarGroupContent>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
|
<AppSearchDialog
|
||||||
|
open={isSearchDialogOpen}
|
||||||
|
onOpenChange={setIsSearchDialogOpen}
|
||||||
|
onSelectApp={handleAppClick}
|
||||||
|
allApps={allApps}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
153
src/components/AppSearchDialog.tsx
Normal file
153
src/components/AppSearchDialog.tsx
Normal file
@@ -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<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 { 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 (
|
||||||
|
<CommandDialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
data-testid="app-search-dialog"
|
||||||
|
filter={commandFilter}
|
||||||
|
>
|
||||||
|
<CommandInput
|
||||||
|
placeholder="Search apps"
|
||||||
|
value={searchQuery}
|
||||||
|
onValueChange={setSearchQuery}
|
||||||
|
data-testid="app-search-input"
|
||||||
|
/>
|
||||||
|
<CommandList data-testid="app-search-list">
|
||||||
|
<CommandEmpty data-testid="app-search-empty">
|
||||||
|
No results found.
|
||||||
|
</CommandEmpty>
|
||||||
|
<CommandGroup heading="Apps" data-testid="app-search-group">
|
||||||
|
{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 (
|
||||||
|
<CommandItem
|
||||||
|
key={app.id}
|
||||||
|
onSelect={() => onSelectApp(app.id)}
|
||||||
|
value={app.name + (snippet ? ` ${snippet.raw}` : "")}
|
||||||
|
keywords={snippet ? [snippet.raw] : []}
|
||||||
|
data-testid={`app-search-item-${app.id}`}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span>{app.name}</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -43,6 +43,7 @@ function CommandDialog({
|
|||||||
className?: string;
|
className?: string;
|
||||||
showCloseButton?: boolean;
|
showCloseButton?: boolean;
|
||||||
filter?: (value: string, search: string, keywords?: string[]) => number;
|
filter?: (value: string, search: string, keywords?: string[]) => number;
|
||||||
|
"data-testid"?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Dialog {...props}>
|
<Dialog {...props}>
|
||||||
@@ -53,6 +54,7 @@ function CommandDialog({
|
|||||||
<DialogContent
|
<DialogContent
|
||||||
className={cn("overflow-hidden p-0", className)}
|
className={cn("overflow-hidden p-0", className)}
|
||||||
showCloseButton={showCloseButton}
|
showCloseButton={showCloseButton}
|
||||||
|
data-testid={props["data-testid"]}
|
||||||
>
|
>
|
||||||
<Command
|
<Command
|
||||||
filter={filter}
|
filter={filter}
|
||||||
|
|||||||
22
src/hooks/useSearchApps.ts
Normal file
22
src/hooks/useSearchApps.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { IpcClient } from "@/ipc/ipc_client";
|
||||||
|
import { AppSearchResult } from "@/lib/schemas";
|
||||||
|
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
export function useSearchApps(query: string) {
|
||||||
|
const enabled = Boolean(query && query.trim().length > 0);
|
||||||
|
|
||||||
|
const { data, isFetching, isLoading } = useQuery({
|
||||||
|
queryKey: ["search-apps", query],
|
||||||
|
enabled,
|
||||||
|
queryFn: async (): Promise<AppSearchResult[]> => {
|
||||||
|
return IpcClient.getInstance().searchApps(query);
|
||||||
|
},
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
retry: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
apps: data ?? [],
|
||||||
|
loading: enabled ? isFetching || isLoading : false,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ipcMain, app } from "electron";
|
import { ipcMain, app } from "electron";
|
||||||
import { db, getDatabasePath } from "../../db";
|
import { db, getDatabasePath } from "../../db";
|
||||||
import { apps, chats } from "../../db/schema";
|
import { apps, chats, messages } from "../../db/schema";
|
||||||
import { desc, eq } from "drizzle-orm";
|
import { desc, eq, like } from "drizzle-orm";
|
||||||
import type {
|
import type {
|
||||||
App,
|
App,
|
||||||
CreateAppParams,
|
CreateAppParams,
|
||||||
@@ -50,6 +50,7 @@ import { normalizePath } from "../../../shared/normalizePath";
|
|||||||
import { isServerFunction } from "@/supabase_admin/supabase_utils";
|
import { isServerFunction } from "@/supabase_admin/supabase_utils";
|
||||||
import { getVercelTeamSlug } from "../utils/vercel_utils";
|
import { getVercelTeamSlug } from "../utils/vercel_utils";
|
||||||
import { storeDbTimestampAtCurrentVersion } from "../utils/neon_timestamp_utils";
|
import { storeDbTimestampAtCurrentVersion } from "../utils/neon_timestamp_utils";
|
||||||
|
import { AppSearchResult } from "@/lib/schemas";
|
||||||
|
|
||||||
const DEFAULT_COMMAND =
|
const DEFAULT_COMMAND =
|
||||||
"(pnpm install && pnpm run dev --port 32100) || (npm install --legacy-peer-deps && npm run dev -- --port 32100)";
|
"(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<AppSearchResult[]> => {
|
||||||
|
// 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({
|
function getCommand({
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
type UserSettings,
|
type UserSettings,
|
||||||
type ContextPathResults,
|
type ContextPathResults,
|
||||||
ChatSearchResultsSchema,
|
ChatSearchResultsSchema,
|
||||||
|
AppSearchResultsSchema,
|
||||||
} from "../lib/schemas";
|
} from "../lib/schemas";
|
||||||
import type {
|
import type {
|
||||||
AppOutput,
|
AppOutput,
|
||||||
@@ -66,6 +67,7 @@ import type {
|
|||||||
import type { Template } from "../shared/templates";
|
import type { Template } from "../shared/templates";
|
||||||
import type {
|
import type {
|
||||||
AppChatContext,
|
AppChatContext,
|
||||||
|
AppSearchResult,
|
||||||
ChatSearchResult,
|
ChatSearchResult,
|
||||||
ProposalResult,
|
ProposalResult,
|
||||||
} from "@/lib/schemas";
|
} from "@/lib/schemas";
|
||||||
@@ -312,6 +314,17 @@ export class IpcClient {
|
|||||||
return this.ipcRenderer.invoke("list-apps");
|
return this.ipcRenderer.invoke("list-apps");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Search apps by name
|
||||||
|
public async searchApps(searchQuery: string): Promise<AppSearchResult[]> {
|
||||||
|
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<string> {
|
public async readAppFile(appId: number, filePath: string): Promise<string> {
|
||||||
return this.ipcRenderer.invoke("read-app-file", {
|
return this.ipcRenderer.invoke("read-app-file", {
|
||||||
appId,
|
appId,
|
||||||
|
|||||||
@@ -44,6 +44,20 @@ export type ChatSearchResult = z.infer<typeof ChatSearchResultSchema>;
|
|||||||
|
|
||||||
export const ChatSearchResultsSchema = z.array(ChatSearchResultSchema);
|
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<typeof AppSearchResultSchema>;
|
||||||
|
|
||||||
|
export const AppSearchResultsSchema = z.array(AppSearchResultSchema);
|
||||||
|
|
||||||
const providers = [
|
const providers = [
|
||||||
"openai",
|
"openai",
|
||||||
"anthropic",
|
"anthropic",
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ const validInvokeChannels = [
|
|||||||
"stop-app",
|
"stop-app",
|
||||||
"restart-app",
|
"restart-app",
|
||||||
"respond-to-app-input",
|
"respond-to-app-input",
|
||||||
|
"search-app",
|
||||||
"list-versions",
|
"list-versions",
|
||||||
"revert-version",
|
"revert-version",
|
||||||
"checkout-version",
|
"checkout-version",
|
||||||
|
|||||||
Reference in New Issue
Block a user