"use client"; import * as DialogPrimitive from "@radix-ui/react-dialog"; import { generateText } from "ai"; import { ArrowRight, CheckSquare, Command, FileText, FolderKanban, LayoutGrid, Loader2, Search, Settings, Sparkles, SquarePen, } from "lucide-react"; import { useParams, useRouter } from "next/navigation"; import * as React from "react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Separator } from "@/components/ui/separator"; import { cn } from "@/lib/utils"; import { createOpenAIClient, GENERAL_SYSTEM_PROMPT, selectOpenAIModel, } from "@tasks/ai"; const RECENT_KEY = "tasks-command-palette-recent-v1"; type PaletteMode = "default" | "ai" | "nav"; export type ResultKind = "object" | "action" | "page" | "ai"; export type PaletteResult = { id: string; kind: ResultKind; title: string; subtitle?: string; icon: React.ComponentType<{ className?: string }>; href?: string; onSelect?: () => void; group: string; }; function loadRecent(): string[] { if (typeof window === "undefined") return []; try { const raw = window.localStorage.getItem(RECENT_KEY); if (!raw) return []; const parsed = JSON.parse(raw) as unknown; return Array.isArray(parsed) ? parsed.filter((x) => typeof x === "string") : []; } catch { return []; } } function saveRecent(ids: string[]) { try { window.localStorage.setItem(RECENT_KEY, JSON.stringify(ids.slice(0, 12))); } catch { /* ignore */ } } function fuzzyScore(text: string, query: string): number { const t = text.toLowerCase(); const q = query.toLowerCase().trim(); if (!q) return 1; let qi = 0; let bonus = 0; for (let i = 0; i < t.length && qi < q.length; i++) { if (t[i] === q[qi]) { bonus += i === 0 ? 2 : 1; qi++; } } if (qi < q.length) return 0; return bonus + 1 / t.length; } function parseQuery(raw: string): { mode: PaletteMode; body: string } { const s = raw.trimStart(); if (s.startsWith(">")) return { mode: "ai", body: s.slice(1).trim() }; if (s.startsWith("/")) return { mode: "nav", body: s.slice(1).trim() }; return { mode: "default", body: raw.trim() }; } export function CommandPalette() { const router = useRouter(); const params = useParams<{ workspaceSlug?: string }>(); const workspaceSlug = params?.workspaceSlug ?? "workspace"; const base = `/${workspaceSlug}`; const [open, setOpen] = React.useState(false); const [query, setQuery] = React.useState(""); const [active, setActive] = React.useState(0); const [recentIds, setRecentIds] = React.useState([]); const [aiLoading, setAiLoading] = React.useState(false); const [aiReply, setAiReply] = React.useState(null); const [aiError, setAiError] = React.useState(null); const inputRef = React.useRef(null); React.useEffect(() => { setRecentIds(loadRecent()); }, [open]); React.useEffect(() => { const onKey = (e: KeyboardEvent) => { const isK = e.key === "k" || e.key === "K"; if (!isK || !(e.metaKey || e.ctrlKey)) return; const t = e.target as HTMLElement | null; if (t?.closest?.("[data-command-palette-ignore-shortcut]")) return; e.preventDefault(); setOpen((o) => !o); }; document.addEventListener("keydown", onKey, true); return () => document.removeEventListener("keydown", onKey, true); }, []); React.useEffect(() => { if (!open) return; const id = window.requestAnimationFrame(() => inputRef.current?.focus()); return () => window.cancelAnimationFrame(id); }, [open]); React.useEffect(() => { if (!open) { setAiReply(null); setAiError(null); setAiLoading(false); } }, [open]); const { mode, body } = parseQuery(query); const catalog = React.useMemo((): PaletteResult[] => { const objects: PaletteResult[] = [ { id: "obj-1", kind: "object", title: "Sprint planning", subtitle: "Task · Due Friday", icon: CheckSquare, href: `${base}/lists/sprint`, group: "Objects", }, { id: "obj-2", kind: "object", title: "Q1 roadmap", subtitle: "Document · Edited 3d ago", icon: FileText, href: `${base}/docs/roadmap`, group: "Objects", }, { id: "obj-3", kind: "object", title: "Product launch", subtitle: "Project · 12 members", icon: FolderKanban, href: `${base}/launch`, group: "Objects", }, ]; const actions: PaletteResult[] = [ { id: "act-create-task", kind: "action", title: "Create task", subtitle: "Add a new task to the current workspace", icon: SquarePen, group: "Actions", onSelect: () => router.push(`${base}?create=task`), }, { id: "act-create-doc", kind: "action", title: "Create document", subtitle: "Start a new doc from the template gallery", icon: FileText, group: "Actions", onSelect: () => router.push(`${base}/docs?new=1`), }, { id: "act-settings", kind: "action", title: "Open settings", subtitle: "Workspace preferences and integrations", icon: Settings, group: "Actions", onSelect: () => router.push(`${base}/settings`), }, ]; const pages: PaletteResult[] = [ { id: "page-home", kind: "page", title: "Workspace home", subtitle: "Dashboard", icon: LayoutGrid, href: base, group: "Pages", }, { id: "page-docs", kind: "page", title: "Documents", subtitle: "All docs in this workspace", icon: FileText, href: `${base}/docs`, group: "Pages", }, { id: "page-boards", kind: "page", title: "Whiteboards", subtitle: "Visual boards", icon: FolderKanban, href: `${base}/whiteboards`, group: "Pages", }, ]; return [...objects, ...actions, ...pages]; }, [base, router]); const recentResults = React.useMemo(() => { if (recentIds.length === 0) return []; const map = new Map(catalog.map((c) => [c.id, c])); return recentIds .map((id) => map.get(id)) .filter((x): x is PaletteResult => Boolean(x)); }, [catalog, recentIds]); const flatRows = React.useMemo(() => { if (mode === "ai") { const row: PaletteResult = { id: "ai-run", kind: "ai", title: body ? `Ask AI: ${body}` : "Ask AI (type after >)", subtitle: body ? "Press Enter to run" : "Example: > summarize my week", icon: Sparkles, group: "AI", }; return [row]; } if (mode === "nav") { const pages = catalog.filter((c) => c.kind === "page"); if (!body) return pages; return pages .map((p) => ({ p, s: Math.max( fuzzyScore(p.title, body), fuzzyScore(p.subtitle ?? "", body), ), })) .filter((x) => x.s > 0) .sort((a, b) => b.s - a.s) .map((x) => x.p); } const q = body; if (!q) { if (recentResults.length > 0) { const seen = new Set(recentResults.map((r) => r.id)); const rest = catalog.filter((c) => !seen.has(c.id)); return [ ...recentResults.map((r) => ({ ...r, group: "Recent" })), ...rest, ]; } return catalog; } const filtered = catalog .map((c) => ({ c, s: Math.max( fuzzyScore(c.title, q), fuzzyScore(c.subtitle ?? "", q), fuzzyScore(c.group, q), ), })) .filter((x) => x.s > 0) .sort((a, b) => b.s - a.s) .map((x) => x.c); const ask: PaletteResult = { id: "ask-ai", kind: "ai", title: `Ask AI: ${q}`, subtitle: "Answer in the palette", icon: Sparkles, group: "AI", }; return [...filtered, ask]; }, [body, catalog, mode, recentResults]); React.useEffect(() => { setActive(0); }, [query, flatRows.length, mode]); const pushRecent = React.useCallback((id: string) => { setRecentIds((prev) => { const next = [id, ...prev.filter((x) => x !== id)]; saveRecent(next); return next; }); }, []); const runAi = React.useCallback(async (text: string) => { const trimmed = text.trim(); if (!trimmed) return; setAiLoading(true); setAiError(null); setAiReply(null); try { const client = createOpenAIClient(); if (!client.ok) { setAiError( "Add OPENAI_API_KEY or NEXT_PUBLIC_OPENAI_API_KEY to use AI in the command palette.", ); return; } const model = selectOpenAIModel(client.provider, "gpt-4o-mini"); const { text: out } = await generateText({ model, system: GENERAL_SYSTEM_PROMPT, prompt: trimmed, }); setAiReply(out); } catch (e) { setAiError((e as Error).message ?? "AI request failed."); } finally { setAiLoading(false); } }, []); const execute = React.useCallback( (row: PaletteResult) => { if (row.kind === "ai" && row.id === "ask-ai") { void runAi(body); pushRecent(row.id); return; } if (row.kind === "ai" && row.id === "ai-run") { void runAi(body); return; } if (row.href) { router.push(row.href); pushRecent(row.id); setOpen(false); setQuery(""); return; } if (row.onSelect) { row.onSelect(); pushRecent(row.id); setOpen(false); setQuery(""); } }, [body, pushRecent, router, runAi], ); const onKeyDown = (e: React.KeyboardEvent) => { if (e.key === "ArrowDown") { e.preventDefault(); setActive((i) => (flatRows.length ? (i + 1) % flatRows.length : 0)); } else if (e.key === "ArrowUp") { e.preventDefault(); setActive((i) => flatRows.length ? (i - 1 + flatRows.length) % flatRows.length : 0, ); } else if (e.key === "Enter") { e.preventDefault(); const row = flatRows[active]; if (row) execute(row); } }; return ( ev.preventDefault()} >
setQuery(e.target.value)} placeholder={ mode === "ai" ? "AI command…" : mode === "nav" ? "Jump to page…" : "Search or run a command…" } className="h-11 border-0 bg-transparent px-0 text-base shadow-none focus-visible:ring-0" /> K
Modes:{" "} > {" "} AI ·{" "} / {" "} pages
{flatRows.length === 0 ? (
No matches. Try a shorter query or switch mode with{" "} >{" "} or{" "} /.
) : ( flatRows.map((row, idx) => { const showHeader = idx === 0 || flatRows[idx - 1]!.group !== row.group; const Icon = row.icon; const isActive = idx === active; return (
{showHeader ? ( <> {idx > 0 ? ( ) : null}
{row.group}
) : null}
); }) )}
{(aiLoading || aiReply || aiError) && (
{aiLoading ? (
Thinking…
) : null} {aiError ? (

{aiError}

) : null} {aiReply ? (
{aiReply}
) : null} {aiReply || aiError ? ( ) : null}
)}
↑↓ {" "} navigate ·{" "} {" "} run ·{" "} esc {" "} close
Command palette Search workspace objects, run actions, or ask AI.
); }