Fazekit

Code

"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>;
      })}
    </>
  );
}

More in Navigation

View all →