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 { 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}
/>
</>
); );
} }

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

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

View File

@@ -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,

View File

@@ -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",

View File

@@ -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",