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

@@ -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");
});

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}

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

View File

@@ -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<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({

View File

@@ -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<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> {
return this.ipcRenderer.invoke("read-app-file", {
appId,

View File

@@ -44,6 +44,20 @@ export type ChatSearchResult = z.infer<typeof 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 = [
"openai",
"anthropic",

View File

@@ -36,6 +36,7 @@ const validInvokeChannels = [
"stop-app",
"restart-app",
"respond-to-app-input",
"search-app",
"list-versions",
"revert-version",
"checkout-version",