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:
Mohamed Aziz Mejri
2025-09-17 23:03:07 +01:00
committed by GitHub
parent 2edd122d9b
commit a547aa3ac1
9 changed files with 675 additions and 55 deletions

View File

@@ -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 (
<SidebarGroup className="overflow-y-auto h-[calc(100vh-112px)]">
<SidebarGroupLabel>Your Apps</SidebarGroupLabel>
<SidebarGroupContent>
<div className="flex flex-col space-y-2">
<Button
onClick={handleNewApp}
variant="outline"
className="flex items-center justify-start gap-2 mx-2 py-2"
>
<PlusCircle size={16} />
<span>New App</span>
</Button>
<>
<SidebarGroup
className="overflow-y-auto h-[calc(100vh-112px)]"
data-testid="app-list-container"
>
<SidebarGroupLabel>Your Apps</SidebarGroupLabel>
<SidebarGroupContent>
<div className="flex flex-col space-y-2">
<Button
onClick={handleNewApp}
variant="outline"
className="flex items-center justify-start gap-2 mx-2 py-2"
>
<PlusCircle size={16} />
<span>New App</span>
</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 ? (
<div className="py-2 px-4 text-sm text-gray-500">
Loading apps...
</div>
) : error ? (
<div className="py-2 px-4 text-sm text-red-500">
Error loading apps
</div>
) : apps.length === 0 ? (
<div className="py-2 px-4 text-sm text-gray-500">No apps found</div>
) : (
<SidebarMenu className="space-y-1" data-testid="app-list">
{apps.map((app) => (
<SidebarMenuItem key={app.id} className="mb-1">
<Button
variant="ghost"
onClick={() => handleAppClick(app.id)}
className={`justify-start w-full text-left py-3 hover:bg-sidebar-accent/80 ${
selectedAppId === app.id
? "bg-sidebar-accent text-sidebar-accent-foreground"
: ""
}`}
data-testid={`app-list-item-${app.name}`}
>
<div className="flex flex-col w-full">
<span className="truncate">{app.name}</span>
<span className="text-xs text-gray-500">
{formatDistanceToNow(new Date(app.createdAt), {
addSuffix: true,
})}
</span>
</div>
</Button>
</SidebarMenuItem>
))}
</SidebarMenu>
)}
</div>
</SidebarGroupContent>
</SidebarGroup>
{loading ? (
<div className="py-2 px-4 text-sm text-gray-500">
Loading apps...
</div>
) : error ? (
<div className="py-2 px-4 text-sm text-red-500">
Error loading apps
</div>
) : apps.length === 0 ? (
<div className="py-2 px-4 text-sm text-gray-500">
No apps found
</div>
) : (
<SidebarMenu className="space-y-1" data-testid="app-list">
{apps.map((app) => (
<SidebarMenuItem key={app.id} className="mb-1">
<Button
variant="ghost"
onClick={() => handleAppClick(app.id)}
className={`justify-start w-full text-left py-3 hover:bg-sidebar-accent/80 ${
selectedAppId === app.id
? "bg-sidebar-accent text-sidebar-accent-foreground"
: ""
}`}
data-testid={`app-list-item-${app.name}`}
>
<div className="flex flex-col w-full">
<span className="truncate">{app.name}</span>
<span className="text-xs text-gray-500">
{formatDistanceToNow(new Date(app.createdAt), {
addSuffix: true,
})}
</span>
</div>
</Button>
</SidebarMenuItem>
))}
</SidebarMenu>
)}
</div>
</SidebarGroupContent>
</SidebarGroup>
<AppSearchDialog
open={isSearchDialogOpen}
onOpenChange={setIsSearchDialogOpen}
onSelectApp={handleAppClick}
allApps={allApps}
/>
</>
);
}

View 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>
);
}

View File

@@ -43,6 +43,7 @@ function CommandDialog({
className?: string;
showCloseButton?: boolean;
filter?: (value: string, search: string, keywords?: string[]) => number;
"data-testid"?: string;
}) {
return (
<Dialog {...props}>
@@ -53,6 +54,7 @@ function CommandDialog({
<DialogContent
className={cn("overflow-hidden p-0", className)}
showCloseButton={showCloseButton}
data-testid={props["data-testid"]}
>
<Command
filter={filter}