learning_ai_notes/web/src/app/(app)/search/page.tsx
Saravana Achu Mac a697752d15 feat: implement WEB_AI_FAST_ROADMAP (web + backend + docs)
Phase 1: Command palette (⌘K), editor autosave with quiet auto-saves, dashboard
saved views from API + quick links + onboarding seed CTA, explicit task scan panel.

Phase 2: Context pack formatter with YAML frontmatter, copy on note + workspace .md export.

Phase 3: ADR for hybrid search without embeddings; POST /notes/search (lexical +
ranked hybrid); search UI mode toggle.

Phase 4: POST copilot + suggest-title; in-editor copilot actions; /chat retrieval
answers with citations (backend chat.rag_enabled).

Phase 5: Settings MCP snippet, offline queue note, API token deferral; DEEP_LINKS.md.

Phase 6: Note shares + public GET; share page; POST onboarding-seed.

Phase 7: note_versions on PATCH; version panel; create-note templates; PWA manifest.

Flags: search.hybrid_enabled, copilot.enabled, chat.rag_enabled, onboarding.seed_enabled.
Made-with: Cursor
2026-03-31 13:00:36 -07:00

194 lines
7.4 KiB
TypeScript

"use client";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { Suspense, useCallback, useEffect, useMemo, useState } from "react";
import { AppShell } from "@/components/AppShell";
import { searchNotesRanked, type SearchRankedHit } from "@/lib/notes-client";
import { listSavedViews, createSavedView, deleteSavedView, type SavedView } from "@/lib/saved-views-client";
import { useDebounce } from "@/lib/use-debounce";
export default function SearchPage() {
return (
<Suspense fallback={<AppShell title="Search" description="Search notes"><p>Loading...</p></AppShell>}>
<SearchPageInner />
</Suspense>
);
}
function SearchPageInner() {
const searchParams = useSearchParams();
const [hits, setHits] = useState<SearchRankedHit[]>([]);
const [query, setQuery] = useState(() => searchParams?.get("q") ?? "");
const debouncedQuery = useDebounce(query, 250);
const [mode, setMode] = useState<"lexical" | "hybrid">("hybrid");
const [savedViewsList, setSavedViewsList] = useState<SavedView[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setQuery(searchParams?.get("q") ?? "");
}, [searchParams]);
useEffect(() => {
void (async () => {
try {
const res = await searchNotesRanked(debouncedQuery, mode);
setHits(res.items);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Unable to load notes");
}
})();
}, [debouncedQuery, mode]);
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: crypto.randomUUID(),
name: query.trim().slice(0, 60),
scope: "search",
query: query.trim(),
});
setSavedViewsList((current) => [...current, view]);
} catch {
// Best-effort
}
}, [query]);
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 (
<AppShell
title="Search"
description="Lexical and hybrid ranked search with match hints (title, body, tag). Toggle modes to compare behavior."
actions={<div className="badge">POST /notes/search</div>}
>
<section style={{ display: "grid", gridTemplateColumns: "minmax(260px, 320px) minmax(0, 1fr)", gap: "var(--nl-space-4)" }}>
<aside className="surface-card" style={{ padding: "var(--nl-space-5)", display: "grid", gap: "var(--nl-space-4)" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<div style={{ fontWeight: 700 }}>Saved searches</div>
{query.trim() ? (
<button
type="button"
className="badge"
onClick={() => void handleSaveCurrentSearch()}
style={{ cursor: "pointer" }}
>
+ Save current
</button>
) : null}
</div>
<div style={{ display: "grid", gap: "var(--nl-space-3)" }}>
{savedViews.map((view) => (
<div
key={view.id}
className="surface-muted"
style={{ padding: "var(--nl-space-4)", display: "grid", gap: "var(--nl-space-2)" }}
>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "start" }}>
<Link href={`/search?q=${encodeURIComponent(view.query)}`} style={{ display: "grid", gap: "var(--nl-space-2)" }}>
<strong>{view.name}</strong>
<span style={{ color: "var(--nl-text-secondary)" }}>{view.query}</span>
</Link>
<button
type="button"
onClick={() => {
if (window.confirm("Remove this saved view?")) void handleDeleteSavedView(view.id);
}}
style={{
color: "var(--nl-text-secondary)",
background: "none",
border: "none",
cursor: "pointer",
fontSize: "0.75rem",
}}
>
Remove
</button>
</div>
</div>
))}
{savedViews.length === 0 ? (
<span style={{ color: "var(--nl-text-secondary)", fontSize: "0.875rem" }}>No saved searches yet.</span>
) : null}
</div>
<div style={{ display: "grid", gap: "var(--nl-space-2)" }}>
<strong>Search mode</strong>
<label style={{ display: "flex", alignItems: "center", gap: 8 }}>
<input type="radio" name="smode" checked={mode === "hybrid"} onChange={() => setMode("hybrid")} />
Hybrid ranked (default)
</label>
<label style={{ display: "flex", alignItems: "center", gap: 8 }}>
<input type="radio" name="smode" checked={mode === "lexical"} onChange={() => setMode("lexical")} />
Lexical only
</label>
</div>
</aside>
<section className="surface-card" style={{ padding: "var(--nl-space-6)", display: "grid", gap: "var(--nl-space-4)" }}>
<input
aria-label="Search notes"
className="input-shell"
placeholder="Search notes, tags, and body text"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
<div style={{ display: "flex", gap: "var(--nl-space-2)", flexWrap: "wrap" }}>
<span className="badge">mode:{mode}</span>
<span className="badge">{hits.length} hits</span>
</div>
{error ? <div style={{ color: "var(--nl-text-secondary)" }}>{error}</div> : null}
<div style={{ display: "grid", gap: "var(--nl-space-3)" }}>
{hits.map((hit) => (
<div key={hit.noteId} className="surface-muted" style={{ padding: "var(--nl-space-4)", display: "grid", gap: "var(--nl-space-2)" }}>
<div style={{ display: "flex", justifyContent: "space-between", gap: "var(--nl-space-3)", flexWrap: "wrap" }}>
<Link href={`/notes/${hit.noteId}`} style={{ fontWeight: 700 }}>
{hit.title}
</Link>
<span className="badge">
{hit.matchKind} · score {hit.score}
</span>
</div>
<div style={{ color: "var(--nl-text-secondary)", fontSize: "var(--nl-fs-sm)" }}>{hit.snippet}</div>
<Link href={`/search?q=${encodeURIComponent(hit.workspaceId)}`} style={{ color: "var(--nl-text-secondary)", fontSize: "var(--nl-fs-sm)" }}>
workspace: {hit.workspaceId}
</Link>
</div>
))}
</div>
</section>
</section>
</AppShell>
);
}