feat: Full project management application scaffold
Complete architecture for a ClickUp/Notion/Miro-class project management app:
- Turborepo monorepo with Next.js 15, TypeScript, PostgreSQL (Drizzle ORM)
- Object-centered database schema (everything is an Object: tasks, projects, docs, whiteboards)
- NextAuth v5 authentication with credentials + OAuth providers
- tRPC v11 API layer with full CRUD for objects, properties, relations, templates, search
- Three-panel UI: collapsible sidebar, center content area, push-in right panel
- Purple/teal theme with light/dark mode via Shadcn/ui + Tailwind CSS
- Multiple views: List, Kanban board (dnd-kit), Table (spreadsheet), Embedded iframe
- TipTap rich text editor with slash commands, custom blocks (callout, toggle, mention, embed, divider), AI block
- Real-time collaboration via Yjs + Hocuspocus with presence/cursors
- tldraw whiteboard with custom shape cards (task, document, project)
- MCP server exposing all app data/tools for AI agents
- AI chat panel, editor AI slash commands, Cmd+K command palette
- Template system with built-in templates (Bug Report, Meeting Notes, Sprint)
- Full-text search with result highlighting
- Docker Compose for full-stack deployment (web + collab + postgres + redis)
Made-with: Cursor
2026-03-26 23:39:16 -04:00
|
|
|
"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,
|
2026-04-27 13:57:27 -04:00
|
|
|
} from "@tasks/ai";
|
feat: Full project management application scaffold
Complete architecture for a ClickUp/Notion/Miro-class project management app:
- Turborepo monorepo with Next.js 15, TypeScript, PostgreSQL (Drizzle ORM)
- Object-centered database schema (everything is an Object: tasks, projects, docs, whiteboards)
- NextAuth v5 authentication with credentials + OAuth providers
- tRPC v11 API layer with full CRUD for objects, properties, relations, templates, search
- Three-panel UI: collapsible sidebar, center content area, push-in right panel
- Purple/teal theme with light/dark mode via Shadcn/ui + Tailwind CSS
- Multiple views: List, Kanban board (dnd-kit), Table (spreadsheet), Embedded iframe
- TipTap rich text editor with slash commands, custom blocks (callout, toggle, mention, embed, divider), AI block
- Real-time collaboration via Yjs + Hocuspocus with presence/cursors
- tldraw whiteboard with custom shape cards (task, document, project)
- MCP server exposing all app data/tools for AI agents
- AI chat panel, editor AI slash commands, Cmd+K command palette
- Template system with built-in templates (Bug Report, Meeting Notes, Sprint)
- Full-text search with result highlighting
- Docker Compose for full-stack deployment (web + collab + postgres + redis)
Made-with: Cursor
2026-03-26 23:39:16 -04:00
|
|
|
|
|
|
|
|
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]">
|
|
|
|
|
>
|
|
|
|
|
</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">></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>
|
|
|
|
|
);
|
|
|
|
|
}
|