ubiquitous-invention/apps/web/components/ai/command-palette.tsx

566 lines
18 KiB
TypeScript
Raw Normal View History

"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 "../../../../packages/ai/src";
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<string[]>([]);
const [aiLoading, setAiLoading] = React.useState(false);
const [aiReply, setAiReply] = React.useState<string | null>(null);
const [aiError, setAiError] = React.useState<string | null>(null);
const inputRef = React.useRef<HTMLInputElement>(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 (
<DialogPrimitive.Root open={open} onOpenChange={setOpen}>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="fixed inset-0 z-[200] bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
<DialogPrimitive.Content
className={cn(
"fixed left-1/2 top-[12vh] z-[201] w-[min(720px,calc(100vw-2rem))] -translate-x-1/2 rounded-2xl border border-border bg-popover p-0 shadow-2xl outline-none",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
)}
onKeyDown={onKeyDown}
onOpenAutoFocus={(ev) => ev.preventDefault()}
>
<div className="flex items-center gap-3 border-b border-border px-4 py-3">
<Search className="size-5 shrink-0 text-muted-foreground" />
<Input
ref={inputRef}
value={query}
onChange={(e) => 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"
/>
<Badge variant="outline" className="hidden shrink-0 gap-1 sm:inline-flex">
<Command className="size-3" />
K
</Badge>
</div>
<div className="px-4 pb-2 pt-1 text-[11px] text-muted-foreground">
<span className="font-medium text-foreground/80">Modes:</span>{" "}
<kbd className="rounded border border-border bg-muted px-1 py-0.5 font-mono text-[10px]">
&gt;
</kbd>{" "}
AI ·{" "}
<kbd className="rounded border border-border bg-muted px-1 py-0.5 font-mono text-[10px]">
/
</kbd>{" "}
pages
</div>
<ScrollArea className="max-h-[min(420px,60vh)]">
<div className="px-2 pb-3 pt-1">
{flatRows.length === 0 ? (
<div className="px-3 py-10 text-center text-sm text-muted-foreground">
No matches. Try a shorter query or switch mode with{" "}
<kbd className="rounded border px-1 font-mono text-xs">&gt;</kbd>{" "}
or{" "}
<kbd className="rounded border px-1 font-mono text-xs">/</kbd>.
</div>
) : (
flatRows.map((row, idx) => {
const showHeader =
idx === 0 ||
flatRows[idx - 1]!.group !== row.group;
const Icon = row.icon;
const isActive = idx === active;
return (
<div key={`${row.group}-${row.id}-${idx}`}>
{showHeader ? (
<>
{idx > 0 ? (
<Separator className="my-2 opacity-50" />
) : null}
<div className="px-2 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
{row.group}
</div>
</>
) : null}
<button
type="button"
className={cn(
"mb-0.5 flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left text-sm transition-colors",
isActive
? "bg-primary/12 text-foreground"
: "hover:bg-muted/80",
)}
onClick={() => execute(row)}
onMouseEnter={() => setActive(idx)}
>
<span className="flex size-9 shrink-0 items-center justify-center rounded-md border border-border bg-muted/50">
<Icon className="size-4" />
</span>
<span className="min-w-0 flex-1">
<span className="block font-medium leading-tight">
{row.title}
</span>
{row.subtitle ? (
<span className="text-xs text-muted-foreground">
{row.subtitle}
</span>
) : null}
</span>
{"href" in row && row.href ? (
<ArrowRight className="size-4 shrink-0 text-muted-foreground opacity-60" />
) : null}
</button>
</div>
);
})
)}
</div>
</ScrollArea>
{(aiLoading || aiReply || aiError) && (
<div className="border-t border-border px-4 py-3">
{aiLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin text-primary" />
Thinking
</div>
) : null}
{aiError ? (
<p className="text-sm text-destructive">{aiError}</p>
) : null}
{aiReply ? (
<div className="max-h-40 overflow-y-auto rounded-lg border border-border bg-muted/30 p-3 text-sm leading-relaxed text-foreground">
{aiReply}
</div>
) : null}
{aiReply || aiError ? (
<Button
type="button"
variant="ghost"
size="sm"
className="mt-2 h-8"
onClick={() => {
setAiReply(null);
setAiError(null);
}}
>
Clear
</Button>
) : null}
</div>
)}
<div className="flex items-center justify-between border-t border-border px-4 py-2 text-[11px] text-muted-foreground">
<span>
<kbd className="rounded border border-border bg-muted px-1.5 py-0.5 font-mono">
</kbd>{" "}
navigate ·{" "}
<kbd className="rounded border border-border bg-muted px-1.5 py-0.5 font-mono">
</kbd>{" "}
run ·{" "}
<kbd className="rounded border border-border bg-muted px-1.5 py-0.5 font-mono">
esc
</kbd>{" "}
close
</span>
</div>
<DialogPrimitive.Title className="sr-only">
Command palette
</DialogPrimitive.Title>
<DialogPrimitive.Description className="sr-only">
Search workspace objects, run actions, or ask AI.
</DialogPrimitive.Description>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
);
}