diff --git a/web/src/app/(app)/layout.tsx b/web/src/app/(app)/layout.tsx index ac0e2c0..48db464 100644 --- a/web/src/app/(app)/layout.tsx +++ b/web/src/app/(app)/layout.tsx @@ -1,5 +1,11 @@ import type { ReactNode } from "react"; +import { KeyboardShortcuts } from "@/components/KeyboardShortcuts"; export default function ProductLayout({ children }: { children: ReactNode }) { - return children; + return ( + <> + + {children} + + ); } diff --git a/web/src/app/(app)/search/page.test.tsx b/web/src/app/(app)/search/page.test.tsx index 7bbd870..37fac3c 100644 --- a/web/src/app/(app)/search/page.test.tsx +++ b/web/src/app/(app)/search/page.test.tsx @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest"; import SearchPage from "./page"; const searchNoteSummariesMock = vi.fn(); +const listSavedViewsMock = vi.fn(); vi.mock("next/link", () => ({ default: ({ href, children, ...props }: React.ComponentProps<"a"> & { href: string }) => ( @@ -16,8 +17,21 @@ vi.mock("@/lib/notes-client", () => ({ searchNoteSummaries: () => searchNoteSummariesMock(), })); +vi.mock("@/lib/saved-views-client", () => ({ + listSavedViews: () => listSavedViewsMock(), + createSavedView: vi.fn(async () => ({})), + deleteSavedView: vi.fn(async () => undefined), +})); + +vi.mock("@/lib/use-debounce", () => ({ + useDebounce: (value: unknown) => value, +})); + describe("SearchPage", () => { it("renders an accessible search field, saved searches, and note links", async () => { + listSavedViewsMock.mockResolvedValue([ + { id: "sv-1", name: "Launch readiness", scope: "search", query: "tag:launch", createdAt: "", updatedAt: "", sortOrder: 0 }, + ]); searchNoteSummariesMock.mockResolvedValue([ { id: "note-prd-cutline", @@ -36,7 +50,7 @@ describe("SearchPage", () => { expect(screen.getByRole("heading", { level: 1, name: "Search" })).toBeInTheDocument(); expect(screen.getByRole("textbox", { name: "Search notes" })).toBeInTheDocument(); expect(screen.getByText("Saved searches")).toBeInTheDocument(); - expect(screen.getByText("Launch readiness")).toBeInTheDocument(); + expect(await screen.findByText("Launch readiness")).toBeInTheDocument(); expect(await screen.findByRole("link", { name: /MVP cut line for agentic notes launch/i })).toHaveAttribute( "href", "/notes/note-prd-cutline" diff --git a/web/src/app/(app)/search/page.tsx b/web/src/app/(app)/search/page.tsx index 50446d5..2d6fb62 100644 --- a/web/src/app/(app)/search/page.tsx +++ b/web/src/app/(app)/search/page.tsx @@ -2,15 +2,19 @@ import Link from "next/link"; import { useSearchParams } from "next/navigation"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { AppShell } from "@/components/AppShell"; import { searchNoteSummaries } from "@/lib/notes-client"; +import { listSavedViews, createSavedView, deleteSavedView, type SavedView } from "@/lib/saved-views-client"; +import { useDebounce } from "@/lib/use-debounce"; import type { NoteSummary } from "@/lib/types"; export default function SearchPage() { const searchParams = useSearchParams(); const [notes, setNotes] = useState([]); const [query, setQuery] = useState(() => searchParams?.get("q") ?? ""); + const debouncedQuery = useDebounce(query, 250); + const [savedViewsList, setSavedViewsList] = useState([]); const [error, setError] = useState(null); useEffect(() => { @@ -18,40 +22,56 @@ export default function SearchPage() { }, [searchParams]); useEffect(() => { - const timeout = window.setTimeout(() => { - void (async () => { - try { - setNotes(await searchNoteSummaries(query)); - setError(null); - } catch (err) { - setError(err instanceof Error ? err.message : "Unable to load notes"); - } - })(); - }, 150); + void (async () => { + try { + setNotes(await searchNoteSummaries(debouncedQuery)); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : "Unable to load notes"); + } + })(); + }, [debouncedQuery]); - return () => { - window.clearTimeout(timeout); - }; + useEffect(() => { + void (async () => { + try { + setSavedViewsList(await listSavedViews("search")); + } catch { + // Saved views are best-effort + } + })(); + }, []); + + const handleSaveCurrentSearch = useCallback(async () => { + if (!query.trim()) return; + try { + const view = await createSavedView({ + id: `sv-${Date.now()}`, + name: query.trim().slice(0, 60), + scope: "search", + query: query.trim(), + }); + setSavedViewsList((current) => [...current, view]); + } catch { + // Best-effort + } }, [query]); - const savedViews = [ - { - id: "search-launch-readiness", - name: "Launch readiness", - query: "tag:launch tag:mvp status:active", - resultCount: notes.filter( - (note) => - note.status === "active" && - note.tags.some((tag) => tag === "launch" || tag === "mvp") - ).length, - }, - { - id: "search-drafts", - name: "Draft notes", - query: "status:draft", - resultCount: notes.filter((note) => note.status === "draft").length, - }, - ]; + const handleDeleteSavedView = useCallback(async (id: string) => { + try { + await deleteSavedView(id); + setSavedViewsList((current) => current.filter((v) => v.id !== id)); + } catch { + // Best-effort + } + }, []); + + const savedViews = useMemo(() => savedViewsList.map((view) => ({ + id: view.id, + name: view.name, + query: view.query, + resultCount: 0, + })), [savedViewsList]); return (