"use client"; import { useEffect, useMemo, useRef, useState } from "react"; import { useParams, useRouter } from "next/navigation"; import { Bell, FilePlus2, LayoutGrid, LogOut, Search, Settings, Sparkles, Table2, UserPlus } from "lucide-react"; import { searchApi } from "@/lib/api"; import { paletteSearchResult } from "@/lib/search"; import { useAuth } from "@/hooks/use-auth"; import { useCreatePage } from "@/hooks/use-pages"; import { useWorkspace } from "@/hooks/use-workspace"; import { useUiStore, toast } from "@/stores/ui-store"; import { cn, fuzzyMatch } from "@/lib/utils"; import type { SearchResult } from "@/lib/types"; interface PaletteItem { id: string; label: string; hint?: string; icon: React.ComponentType<{ className?: string }>; perform: () => void | Promise; keywords: string; } /** * Command palette (Cmd/Ctrl+K, PRODUCT-SPEC §27): fuzzy page search (debounced * /search/query) + workspace actions (new page, new database, switch * workspace, settings, notifications, invite, logout). */ export function CommandPalette() { const open = useUiStore((s) => s.commandPaletteOpen); const setOpen = useUiStore((s) => s.setCommandPalette); const router = useRouter(); const params = useParams<{ workspace: string }>(); const workspaceSlug = params?.workspace ?? "default"; const { data: workspace } = useWorkspace(workspaceSlug); const createPage = useCreatePage(workspace?.id ?? ""); // TASK-0126 — the "Generate page with AI" action needs EDIT; hide it for // GUEST (the server still returns 403 — this is the UI gate, AC item 6). const canGenerate = (workspace?.role ?? "").toLowerCase() !== "guest"; const { user } = useAuth(); const setAiPanel = useUiStore((s) => s.setAiPanel); const setGenerate = useUiStore((s) => s.setGenerate); const setAssistant = useUiStore((s) => s.setAssistant); const [query, setQuery] = useState(""); const [results, setResults] = useState([]); const [active, setActive] = useState(0); const inputRef = useRef(null); const listRef = useRef(null); useEffect(() => { if (open) { setQuery(""); setResults([]); setActive(0); // Focus after the modal mounts. requestAnimationFrame(() => inputRef.current?.focus()); } }, [open]); // Debounced page search. (Phase-8: the wire item is the shared ResultItem — // `paletteSearchResult` maps it back to the palette's read shape, which is // unchanged: r.id, r.title, r.type, r.path, r.snippet.) useEffect(() => { if (!open || query.trim().length < 2) { setResults([]); return; } const t = setTimeout(() => { searchApi .query(query.trim(), { workspaceId: workspace?.id, limit: 8 }) .then((items) => setResults(items.map(paletteSearchResult))) .catch(() => setResults([])); }, 200); return () => clearTimeout(t); }, [query, open, workspace?.id]); const close = () => setOpen(false); const actions = useMemo(() => { const go = (href: string) => () => { router.push(href); close(); }; const items: PaletteItem[] = [ { id: "new-page", label: "New page", hint: "Create an empty page here", icon: FilePlus2, keywords: "create add page blank", perform: () => createPage.mutateAsync({ parentId: null, title: "" }).then((p) => { router.push(`/w/${workspaceSlug}/page/${p.id}`); }) }, { id: "new-database", label: "New database", hint: "Create a database page", icon: Table2, keywords: "create table database", perform: () => createPage.mutateAsync({ parentId: null, type: "database", title: "" }).then((p) => { router.push(`/w/${workspaceSlug}/page/${p.id}`); }) }, { id: "ask-ai", label: "Ask AI", hint: "Open the AI assistant", icon: Sparkles, keywords: "ai ask assistant chat sparkle", perform: () => { setAiPanel(true, null); close(); } }, { id: "generate-ai", label: "Generate page with AI", hint: "Describe a page and let AI build it", icon: Sparkles, keywords: "ai generate page create build sparkle", perform: () => { setGenerate(true, null); close(); } }, { id: "assistant-extract", label: "Extract action items (AI)", hint: "Turn notes into tasks", icon: Sparkles, keywords: "ai assistant extract tasks actions todo sparkle", perform: () => { setAssistant(true, { mode: "extract-actions" }); close(); } }, { id: "assistant-duplicates", label: "Find duplicate pages (AI)", hint: "Likely-duplicate pages in this workspace", icon: Sparkles, keywords: "ai assistant duplicates find similar sparkle", perform: () => { setAssistant(true, { mode: "find-duplicates" }); close(); } }, { id: "assistant-stale", label: "Find stale pages (AI)", hint: "Pages not updated recently", icon: Sparkles, keywords: "ai assistant stale old outdated cleanup sparkle", perform: () => { setAssistant(true, { mode: "find-stale" }); close(); } }, { id: "ask-ai-workspace", label: "Ask AI about this workspace", hint: "Search this workspace with AI (RAG)", icon: Sparkles, keywords: "ai ask workspace search rag retrieve", perform: go(`/w/${workspaceSlug}/ask?open=1`) }, { id: "go-home", label: "Go to Home", icon: LayoutGrid, keywords: "home start", perform: go(`/w/${workspaceSlug}`) }, { id: "invite", label: "Invite people", icon: UserPlus, keywords: "invite member team people", perform: go(`/w/${workspaceSlug}/settings/members`) }, { id: "notifications", label: "Notifications", icon: Bell, keywords: "inbox bell alerts", perform: go(`/w/${workspaceSlug}/notifications`) }, { id: "settings", label: "Workspace settings", icon: Settings, keywords: "preferences configure", perform: go(`/w/${workspaceSlug}/settings`) }, { id: "logout", label: "Log out", hint: user?.email, icon: LogOut, keywords: "sign out exit", perform: async () => { const { authApi } = await import("@/lib/api"); const { authStore } = await import("@/lib/auth-store"); try { await authApi.logout(); } catch { /* best effort */ } authStore.clear(); router.replace("/login"); } } ]; // TASK-0126 — hide the generate action for GUEST (server still enforces // 403). T-172 — hide extract-actions (EDIT-gated) for GUEST; the other // four assistant capabilities are VIEW-level. return canGenerate ? items : items.filter((a) => a.id !== "generate-ai" && a.id !== "assistant-extract"); // eslint-disable-next-line react-hooks/exhaustive-deps }, [workspaceSlug, createPage, router, user?.email, setAiPanel, setGenerate, setAssistant, canGenerate]); const items = useMemo(() => { const q = query.trim().toLowerCase(); const searchItems: PaletteItem[] = results.map((r) => ({ id: `result-${r.id}`, label: r.title || "Untitled", hint: r.path?.join(" / ") || r.type, icon: r.type === "database" ? Table2 : FilePlus2, keywords: `${r.title} ${r.snippet ?? ""}`, perform: () => { // Page/block results open their page; database/record results open // the /databases/:id route (a record's mapped id IS its database). // (Phase-8 — both branches used to push the page route, which 404'd // for a database id.) router.push( r.type === "database" || r.type === "record" ? `/w/${workspaceSlug}/databases/${r.id}` : `/w/${workspaceSlug}/page/${r.id}` ); close(); } })); const filteredActions = actions.filter((a) => fuzzyMatch(q, a.label + " " + (a.keywords ?? ""))); // Actions first (when the query matches them), then search results. return q ? [...filteredActions, ...searchItems] : actions; }, [actions, query, results, router, workspaceSlug]); useEffect(() => { if (active >= items.length) setActive(Math.max(0, items.length - 1)); }, [items.length, active]); // Keyboard navigation. const onKeyDown = (e: React.KeyboardEvent) => { if (e.key === "ArrowDown") { e.preventDefault(); setActive((a) => Math.min(a + 1, items.length - 1)); } else if (e.key === "ArrowUp") { e.preventDefault(); setActive((a) => Math.max(a - 1, 0)); } else if (e.key === "Enter") { e.preventDefault(); const item = items[active]; // perform() is `() => void | Promise` — wrap in Promise.resolve so // the .catch below is safe for the sync (void) actions too (a direct // `.catch` on undefined threw on every non-async palette action). if (item) void Promise.resolve(item.perform()).catch((err: unknown) => { toast("error", "Action failed", err instanceof Error ? err.message : undefined); }); } else if (e.key === "Escape") { close(); } }; // Scroll the active item into view. useEffect(() => { const el = listRef.current?.querySelector(`[data-index="${active}"]`); el?.scrollIntoView({ block: "nearest" }); }, [active]); if (!open) return null; return (
{ setQuery(e.target.value); setActive(0); }} onKeyDown={onKeyDown} placeholder="Search pages or type a command…" className="h-11 w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground" role="combobox" aria-expanded="true" aria-controls="command-palette-list" /> esc
    {items.length === 0 ? (
  • No results — try a different search.
  • ) : ( items.map((item, i) => (
  • )) )}
); }