"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
ArrowLeft,
Check,
CornerDownLeft,
Copy,
CreditCard,
FilePlus2,
FolderKanban,
Moon,
Search,
Settings,
Sparkles,
Square,
UserPlus,
Download,
type LucideIcon,
} from "lucide-react";
import { cn } from "@/lib/utils";
export interface BarCommand {
id: string;
label: string;
group?: string;
icon?: LucideIcon;
shortcut?: string[];
keywords?: string[];
onRun?: () => void;
}
export type AskHandler = (query: string, ctx: { signal: AbortSignal }) => AsyncIterable<string> | Promise<string>;
export interface CommandBarAIProps {
commands?: BarCommand[];
/** Answers questions. Return a string or stream chunks. Defaults to a local canned engine. */
onAsk?: AskHandler;
onCommand?: (command: BarCommand) => void;
/** Example questions shown when the input is empty. */
suggestions?: string[];
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
/** Key used with ⌘/Ctrl to toggle. Set null to disable. */
hotkey?: string | null;
placeholder?: string;
assistantName?: string;
className?: string;
}
/* ---------------- canned engine ---------------- */
export interface CannedAnswer {
match: RegExp;
answer: string;
}
/** A tiny offline "AI" that streams pre-written answers by keyword. Swap for a real model via `onAsk`. */
export function createCannedEngine(answers: CannedAnswer[], fallback: (q: string) => string): AskHandler {
return async function* (query, { signal }) {
const hit = answers.find((a) => a.match.test(query));
const text = hit ? hit.answer : fallback(query);
await wait(420, signal);
const tokens = text.match(/\S+\s*|\s+/g) ?? [];
for (let i = 0; i < tokens.length; i++) {
if (signal.aborted) return;
yield tokens[i];
await wait(18 + (i % 5) * 7, signal);
}
};
}
function wait(ms: number, signal: AbortSignal) {
return new Promise<void>((res) => {
const t = setTimeout(res, ms);
signal.addEventListener("abort", () => (clearTimeout(t), res()), { once: true });
});
}
const DEFAULT_ENGINE = createCannedEngine(
[
{
match: /invite|team|member|collaborat/i,
answer:
"To invite teammates, open **Settings → Members** and choose **Invite people**.\n\n- Paste emails separated by commas\n- Pick a role: Viewer, Editor or Admin\n- Invites expire after 7 days\n\nTip: run the **Invite teammates** command below to jump straight there.",
},
{
match: /shortcut|keyboard|hotkey/i,
answer:
"The shortcuts people use most:\n\n- **⌘K** opens this bar from anywhere\n- **C** creates a new task, **P** a new project\n- **⌘⇧D** toggles dark mode\n- **G then S** goes to settings",
},
{
match: /export|csv|download|backup/i,
answer:
"You can export any project as CSV or JSON. Open the project, press **⋯ → Export**, then choose the format.\n\nWorkspace-wide backups run nightly and are kept for 30 days on paid plans.",
},
{
match: /price|pricing|plan|billing|cost|invoice/i,
answer:
"Orbit has three plans:\n\n- **Free** — up to 3 projects and 5 members\n- **Team** — $12 per member / month, unlimited projects\n- **Scale** — SSO, audit logs and priority support\n\nYou can switch plans any time from **Billing**; changes are prorated.",
},
{
match: /dark|theme|light mode/i,
answer: "Toggle the theme with **⌘⇧D** or the **Toggle dark mode** command. Orbit also follows your system setting by default.",
},
],
(q) =>
`I couldn’t find “${q.slice(0, 60)}” in the Orbit docs, but here’s where I’d start:\n\n- Search your projects for related tasks\n- Check **Settings** for workspace options\n- Ask a teammate in **#help**\n\nConnect a real model with the \`onAsk\` prop for open-ended answers.`,
);
const DEFAULT_COMMANDS: BarCommand[] = [
{ id: "new-task", label: "Create task", group: "Actions", icon: FilePlus2, shortcut: ["C"], keywords: ["add", "todo", "new"] },
{ id: "new-project", label: "New project", group: "Actions", icon: FolderKanban, shortcut: ["P"], keywords: ["create", "board"] },
{ id: "invite", label: "Invite teammates", group: "Actions", icon: UserPlus, keywords: ["member", "team", "people", "share"] },
{ id: "export", label: "Export workspace", group: "Actions", icon: Download, keywords: ["csv", "backup", "download"] },
{ id: "theme", label: "Toggle dark mode", group: "Preferences", icon: Moon, shortcut: ["⌘", "⇧", "D"], keywords: ["theme", "light"] },
{ id: "settings", label: "Open settings", group: "Navigation", icon: Settings, shortcut: ["G", "S"], keywords: ["preferences", "config"] },
{ id: "billing", label: "Billing & plans", group: "Navigation", icon: CreditCard, keywords: ["price", "invoice", "upgrade", "plan"] },
];
const DEFAULT_SUGGESTIONS = ["How do I invite my team?", "What keyboard shortcuts are there?", "How much does the Team plan cost?"];
/* ---------------- fuzzy scoring ---------------- */
function score(cmd: BarCommand, q: string) {
const query = q.toLowerCase().trim();
if (!query) return 1;
const hay = [cmd.label, ...(cmd.keywords ?? [])].join(" ").toLowerCase();
if (cmd.label.toLowerCase().startsWith(query)) return 100;
if (hay.includes(query)) return 60;
const words = query.split(/\s+/).filter((w) => w.length > 2);
const wordHits = words.filter((w) => hay.includes(w)).length;
if (wordHits) return 20 + wordHits * 10;
// Subsequence match on the label.
let i = 0;
const label = cmd.label.toLowerCase();
for (const ch of label) if (ch === query[i]) i++;
return i === query.length ? 10 : 0;
}
/* ---------------- component ---------------- */
type Row = { kind: "cmd"; cmd: BarCommand } | { kind: "ask"; query: string };
export function CommandBarAI({
commands = DEFAULT_COMMANDS,
onAsk = DEFAULT_ENGINE,
onCommand,
suggestions = DEFAULT_SUGGESTIONS,
open: openProp,
defaultOpen = false,
onOpenChange,
hotkey = "k",
placeholder = "Search commands or ask anything…",
assistantName = "Orbit AI",
className,
}: CommandBarAIProps) {
const reduce = useReducedMotion();
const id = React.useId();
const [inner, setInner] = React.useState(defaultOpen);
const open = openProp ?? inner;
const setOpen = React.useCallback(
(v: boolean) => {
if (openProp === undefined) setInner(v);
onOpenChange?.(v);
},
[openProp, onOpenChange],
);
const [query, setQuery] = React.useState("");
const [active, setActive] = React.useState(0);
const [answer, setAnswer] = React.useState<{ q: string; text: string; status: "thinking" | "streaming" | "done" | "error" } | null>(null);
const [copied, setCopied] = React.useState(false);
const inputRef = React.useRef<HTMLInputElement>(null);
const panelRef = React.useRef<HTMLDivElement>(null);
const listRef = React.useRef<HTMLDivElement>(null);
const abortRef = React.useRef<AbortController | null>(null);
const returnFocus = React.useRef<HTMLElement | null>(null);
// Global hotkey.
React.useEffect(() => {
if (!hotkey) return;
const onKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === hotkey) {
e.preventDefault();
setOpen(!open);
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [hotkey, open, setOpen]);
React.useEffect(() => {
if (open) {
returnFocus.current = document.activeElement as HTMLElement | null;
requestAnimationFrame(() => inputRef.current?.focus());
} else {
abortRef.current?.abort();
returnFocus.current?.focus?.();
}
}, [open]);
React.useEffect(() => () => abortRef.current?.abort(), []);
const rows = React.useMemo<Row[]>(() => {
const q = query.trim();
if (!q) return [...suggestions.map<Row>((s) => ({ kind: "ask", query: s })), ...commands.map<Row>((c) => ({ kind: "cmd", cmd: c }))];
const matched = commands
.map((c) => ({ c, s: score(c, q) }))
.filter((x) => x.s > 0)
.sort((a, b) => b.s - a.s)
.map<Row>((x) => ({ kind: "cmd", cmd: x.c }));
const ask: Row = { kind: "ask", query: q };
const looksLikeQuestion = /\?$|^(how|what|why|when|where|can|does|is|should|who)\b/i.test(q);
return matched.length && !looksLikeQuestion ? [...matched, ask] : [ask, ...matched];
}, [query, commands, suggestions]);
const [prevQuery, setPrevQuery] = React.useState(query);
if (prevQuery !== query) {
setPrevQuery(query);
setActive(0);
}
React.useEffect(() => {
listRef.current?.querySelector<HTMLElement>(`[data-index="${active}"]`)?.scrollIntoView({ block: "nearest" });
}, [active]);
const ask = async (q: string) => {
abortRef.current?.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
setAnswer({ q, text: "", status: "thinking" });
setQuery("");
requestAnimationFrame(() => panelRef.current?.focus());
try {
const res = onAsk(q, { signal: ctrl.signal });
if (typeof (res as Promise<string>).then === "function") {
const text = await (res as Promise<string>);
if (!ctrl.signal.aborted) setAnswer({ q, text, status: "done" });
return;
}
let acc = "";
for await (const chunk of res as AsyncIterable<string>) {
if (ctrl.signal.aborted) break;
acc += chunk;
setAnswer({ q, text: acc, status: "streaming" });
}
if (!ctrl.signal.aborted) setAnswer({ q, text: acc, status: "done" });
} catch {
if (!ctrl.signal.aborted) setAnswer((a) => (a ? { ...a, status: "error" } : a));
}
};
const stop = () => {
abortRef.current?.abort();
setAnswer((a) => (a ? { ...a, status: "done" } : a));
};
const run = (row: Row) => {
if (row.kind === "ask") return ask(row.query);
row.cmd.onRun?.();
onCommand?.(row.cmd);
setOpen(false);
setQuery("");
setAnswer(null);
};
const back = () => {
abortRef.current?.abort();
setAnswer(null);
requestAnimationFrame(() => inputRef.current?.focus());
};
const related = React.useMemo(() => {
if (!answer || answer.status !== "done") return [];
return commands
.map((c) => ({ c, s: score(c, answer.q) }))
.filter((x) => x.s >= 20)
.sort((a, b) => b.s - a.s)
.slice(0, 2)
.map((x) => x.c);
}, [answer, commands]);
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
if (answer) back();
else if (query) setQuery("");
else setOpen(false);
return;
}
if (e.key === "Tab") {
// Focus trap within the panel.
const els = panelRef.current?.querySelectorAll<HTMLElement>("input, button:not([disabled])");
if (!els?.length) return;
if (!answer && e.target === inputRef.current && !e.shiftKey && query.trim()) {
e.preventDefault();
ask(query.trim());
return;
}
const list = Array.from(els);
const i = list.indexOf(document.activeElement as HTMLElement);
e.preventDefault();
list[(i + (e.shiftKey ? -1 : 1) + list.length) % list.length]?.focus();
return;
}
if (e.target !== inputRef.current || answer) return;
if (e.key === "ArrowDown") {
e.preventDefault();
setActive((a) => Math.min(rows.length - 1, a + 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActive((a) => Math.max(0, a - 1));
} else if (e.key === "Enter") {
e.preventDefault();
const row = rows[active];
if (row) run(row);
}
};
// Group rows for rendering.
const groups: { name: string; rows: { row: Row; index: number }[] }[] = [];
rows.forEach((row, index) => {
const name = row.kind === "ask" ? (query.trim() ? assistantName : "Ask AI") : (row.cmd.group ?? "Commands");
let g = groups.find((x) => x.name === name);
if (!g) groups.push((g = { name, rows: [] }));
g.rows.push({ row, index });
});
const listId = `${id}-list`;
const optId = (i: number) => `${id}-opt-${i}`;
return (
<AnimatePresence>
{open && (
<motion.div
className={cn("fixed inset-0 z-50 flex items-start justify-center p-3 pt-[10vh] sm:p-6 sm:pt-[12vh]", className)}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
<div className="absolute inset-0 bg-background/60 backdrop-blur-sm" onClick={() => setOpen(false)} aria-hidden />
<motion.div
ref={panelRef}
role="dialog"
tabIndex={-1}
aria-modal="true"
aria-label="Command bar"
onKeyDown={onKeyDown}
initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.97, y: -8 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.97, y: -8 }}
transition={{ type: "spring", stiffness: 420, damping: 32 }}
className="relative w-full max-w-xl overflow-hidden rounded-2xl border bg-popover outline-none text-popover-foreground shadow-2xl ring-1 ring-black/5"
>
{/* AI glow line */}
<div aria-hidden className={cn("absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-primary to-transparent transition-opacity", answer ? "opacity-100" : "opacity-0")} />
<div className="flex items-center gap-2 border-b px-4">
{answer ? (
<button
type="button"
onClick={back}
aria-label="Back to commands"
className="-ml-1 grid size-7 place-items-center rounded-md text-muted-foreground transition hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
>
<ArrowLeft className="size-4" />
</button>
) : (
<Search className="size-4 shrink-0 text-muted-foreground" aria-hidden />
)}
{answer ? (
<p className="flex min-w-0 flex-1 items-center gap-2 py-3.5 text-[15px]">
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
<Sparkles className="size-3" /> Ask AI
</span>
<span className="truncate">{answer.q}</span>
</p>
) : (
<input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={placeholder}
role="combobox"
aria-expanded
aria-controls={listId}
aria-activedescendant={rows.length ? optId(active) : undefined}
aria-autocomplete="list"
aria-label="Search commands or ask AI"
className="h-12 min-w-0 flex-1 bg-transparent text-[15px] outline-none placeholder:text-muted-foreground"
/>
)}
<kbd className="hidden rounded border bg-muted px-1.5 py-0.5 font-sans text-[10px] font-medium text-muted-foreground sm:inline">esc</kbd>
</div>
<div className="max-h-[min(380px,55vh)] overflow-y-auto">
{answer ? (
<div className="p-4" aria-live="polite" aria-busy={answer.status !== "done"}>
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground">
<span className="grid size-6 place-items-center rounded-full bg-gradient-to-br from-violet-500 to-cyan-400 text-white">
<Sparkles className="size-3" />
</span>
{assistantName}
{answer.status === "thinking" && <ThinkingDots />}
</div>
{answer.status === "error" ? (
<p className="mt-3 text-sm text-destructive">Something went wrong. Try again.</p>
) : (
<div className="mt-3 space-y-2.5 text-sm leading-6">
<Markdown text={answer.text} />
{answer.status === "streaming" && <span aria-hidden className="inline-block h-4 w-1.5 translate-y-0.5 animate-pulse rounded-sm bg-primary/70" />}
</div>
)}
{related.length > 0 && (
<motion.div initial={{ opacity: 0, y: 4 }} animate={{ opacity: 1, y: 0 }} className="mt-4">
<p className="mb-1.5 text-[11px] font-medium tracking-wide text-muted-foreground uppercase">Related actions</p>
<div className="flex flex-wrap gap-2">
{related.map((c) => {
const Icon = c.icon ?? Sparkles;
return (
<button
key={c.id}
type="button"
onClick={() => run({ kind: "cmd", cmd: c })}
className="inline-flex h-8 items-center gap-1.5 rounded-lg border bg-background px-2.5 text-xs font-medium transition hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
>
<Icon className="size-3.5 text-muted-foreground" /> {c.label}
</button>
);
})}
</div>
</motion.div>
)}
<div className="mt-4 flex items-center gap-2 border-t pt-3">
{answer.status === "done" || answer.status === "error" ? (
<>
<SmallButton
onClick={() => {
navigator.clipboard?.writeText(answer.text.replace(/\*\*/g, "")).catch(() => {});
setCopied(true);
setTimeout(() => setCopied(false), 1400);
}}
>
{copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />} {copied ? "Copied" : "Copy"}
</SmallButton>
<SmallButton onClick={back}>
<Sparkles className="size-3.5" /> Ask something else
</SmallButton>
</>
) : (
<SmallButton onClick={stop}>
<Square className="size-3 fill-current" /> Stop
</SmallButton>
)}
</div>
</div>
) : (
<div ref={listRef} id={listId} role="listbox" aria-label="Results" className="p-2">
{groups.map((g) => (
<div key={g.name} role="group" aria-label={g.name} className="mb-1 last:mb-0">
<p className="px-2 pt-2 pb-1 text-[11px] font-medium tracking-wide text-muted-foreground uppercase" aria-hidden>
{g.name}
</p>
{g.rows.map(({ row, index }) => {
const selected = index === active;
const Icon = row.kind === "ask" ? Sparkles : (row.cmd.icon ?? Search);
return (
<div
key={row.kind === "ask" ? `ask-${row.query}` : row.cmd.id}
id={optId(index)}
data-index={index}
role="option"
aria-selected={selected}
onPointerMove={() => setActive(index)}
onClick={() => run(row)}
className="relative flex cursor-pointer items-center gap-3 rounded-lg px-2 py-2 text-sm"
>
{selected && (
<motion.span
layoutId={`${id}-hl`}
transition={{ type: "spring", stiffness: 500, damping: 38 }}
className="absolute inset-0 rounded-lg bg-accent"
/>
)}
<span
className={cn(
"relative grid size-7 shrink-0 place-items-center rounded-md border",
row.kind === "ask" ? "border-primary/30 bg-primary/10 text-primary" : "bg-background text-muted-foreground",
)}
>
<Icon className="size-3.5" />
</span>
<span className={cn("relative min-w-0 flex-1 truncate", selected && "text-accent-foreground")}>
{row.kind === "ask" ? (
query.trim() ? (
<>
Ask AI: <span className="font-medium">“{row.query}”</span>
</>
) : (
row.query
)
) : (
row.cmd.label
)}
</span>
{row.kind === "cmd" && row.cmd.shortcut && (
<span className="relative hidden gap-1 sm:flex">
{row.cmd.shortcut.map((k) => (
<kbd key={k} className="min-w-5 rounded border bg-muted px-1 text-center font-sans text-[10px] font-medium text-muted-foreground">
{k}
</kbd>
))}
</span>
)}
{row.kind === "ask" && query.trim() && (
<kbd className="relative hidden rounded border bg-muted px-1 font-sans text-[10px] font-medium text-muted-foreground sm:inline">Tab</kbd>
)}
{selected && <CornerDownLeft className="relative size-3.5 text-muted-foreground" aria-hidden />}
</div>
);
})}
</div>
))}
</div>
)}
</div>
<div className="hidden items-center gap-4 border-t bg-muted/40 px-4 py-2 text-[11px] text-muted-foreground sm:flex">
{answer ? (
<span>Esc back to commands</span>
) : (
<>
<span>↑↓ navigate</span>
<span>↵ select</span>
<span>Tab ask AI</span>
</>
)}
<span className="ml-auto inline-flex items-center gap-1">
<Sparkles className="size-3" /> {assistantName}
</span>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}
function SmallButton({ children, onClick }: { children: React.ReactNode; onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
className="inline-flex h-7 items-center gap-1.5 rounded-full border px-2.5 text-xs font-medium text-muted-foreground transition hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
>
{children}
</button>
);
}
function ThinkingDots() {
return (
<span className="inline-flex gap-0.5" aria-label="Thinking">
{[0, 1, 2].map((i) => (
<motion.span
key={i}
className="size-1 rounded-full bg-current"
animate={{ opacity: [0.2, 1, 0.2] }}
transition={{ duration: 1, repeat: Infinity, delay: i * 0.15 }}
/>
))}
</span>
);
}
function inline(text: string, keyBase: string) {
return text.split(/(\*\*[^*]+\*\*|`[^`]+`)/g).map((part, i) => {
if (part.startsWith("**") && part.endsWith("**") && part.length > 4)
return (
<strong key={`${keyBase}-${i}`} className="font-semibold">
{part.slice(2, -2)}
</strong>
);
if (part.startsWith("`") && part.endsWith("`") && part.length > 2)
return (
<code key={`${keyBase}-${i}`} className="rounded bg-muted px-1 font-mono text-[0.85em]">
{part.slice(1, -1)}
</code>
);
return <React.Fragment key={`${keyBase}-${i}`}>{part}</React.Fragment>;
});
}
function Markdown({ text }: { text: string }) {
const blocks = text.split(/\n{2,}/);
return (
<>
{blocks.map((b, i) => {
const lines = b.split("\n").filter(Boolean);
if (lines.length && lines.every((l) => l.startsWith("- ")))
return (
<ul key={i} className="list-disc space-y-1 pl-5 marker:text-muted-foreground">
{lines.map((l, j) => (
<li key={j}>{inline(l.slice(2), `${i}-${j}`)}</li>
))}
</ul>
);
return <p key={i}>{inline(b, `${i}`)}</p>;
})}
</>
);
}