/** * CommandMenu — reusable command palette with agent chat fallback. * * Features: * - Anchored to top of viewport (not centered) * - Falls back to agent chat when no command matches * - Opens agent sidebar automatically when sending prompts * - Customizable commands via children * * Usage: * * * doThing()}>Do thing * * */ import { Command as CommandPrimitive, CommandGroup as CommandGroupPrimitive, CommandInput as CommandInputPrimitive, CommandItem as CommandItemPrimitive, CommandList as CommandListPrimitive, CommandSeparator as CommandSeparatorPrimitive, CommandShortcut as CommandShortcutPrimitive, } from "@agent-native/toolkit/ui/command"; import { IconBook2, IconExternalLink, IconMessage, IconHistory, } from "@tabler/icons-react"; import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode, } from "react"; import { parseChangelog } from "../changelog/parse.js"; import { sendToAgentChat } from "./agent-chat.js"; import { ChangelogDialog, useChangelogSeen } from "./changelog/Changelog.js"; import { Dialog, DialogContent, DialogTitle } from "./components/ui/dialog.js"; import { cn } from "./utils.js"; // ─── Context ──────────────────────────────────────────────────────────────── interface CommandMenuContextValue { search: string; onOpenChange: (open: boolean) => void; } const CommandMenuContext = createContext(null); function useCommandMenuContext() { const ctx = useContext(CommandMenuContext); if (!ctx) throw new Error("CommandMenu.* must be used inside "); return ctx; } // ─── Hooks ────────────────────────────────────────────────────────────────── /** * Opens the agent sidebar (dispatches event that AgentSidebar listens for) */ export function openAgentSidebar() { window.dispatchEvent(new Event("agent-panel:open")); } export function openAgentSettings( section?: string | { section?: string | null }, ) { if (typeof window === "undefined") return; const normalizedSection = typeof section === "string" ? section : section?.section; const secretHash = normalizedSection?.replace(/^#/, ""); if (secretHash?.toLowerCase().startsWith("secrets:")) { window.location.hash = `#${secretHash}`; } openAgentSidebar(); // Voice mode unmounts the chat surface while its dock is collapsed, so its // settings listener does not exist until opening the sidebar remounts it. // Deliver after the open-state render can commit instead of racing React's // concurrent remount. Non-visual runtimes fall back to the next task. const dispatchSettings = () => { window.dispatchEvent( new CustomEvent("agent-panel:open-settings", { detail: normalizedSection ? { section: normalizedSection } : undefined, }), ); }; if (typeof window.requestAnimationFrame === "function") { window.requestAnimationFrame(dispatchSettings); } else { window.setTimeout(dispatchSettings, 0); } } export function focusAgentChat() { window.dispatchEvent( new CustomEvent("agent-panel:set-mode", { detail: { mode: "chat" }, }), ); openAgentSidebar(); } /** * Sends a prompt to the agent and opens the sidebar */ export function submitToAgent(message: string) { focusAgentChat(); sendToAgentChat({ message, submit: true }); } // ─── Sub-components ───────────────────────────────────────────────────────── interface CommandGroupProps { heading?: string; children: ReactNode; } function CommandGroup({ heading, children }: CommandGroupProps) { return ( {children} ); } interface CommandItemProps { onSelect: () => void; children: ReactNode; keywords?: string[]; className?: string; deferSelect?: boolean; } function CommandItem({ onSelect, children, keywords: _keywords, className, deferSelect = true, }: CommandItemProps) { const { onOpenChange } = useCommandMenuContext(); const handleSelect = () => { if (!deferSelect) { onSelect(); onOpenChange(false); return; } onOpenChange(false); // Small delay to let dialog close animation start setTimeout(onSelect, 50); }; return ( {children} ); } interface CommandShortcutProps { children: ReactNode; className?: string; } function CommandShortcut({ children, className }: CommandShortcutProps) { return ( {children} ); } function CommandSeparator({ className }: { className?: string }) { return ; } export interface CommandMenuDoc { title: string; href: string; description?: string; keywords?: string[]; } interface CommandDocsGroupProps { docs: CommandMenuDoc[]; heading?: string; } function commandDocSearchText(doc: CommandMenuDoc): string { return [doc.title, doc.description, ...(doc.keywords ?? [])] .filter(Boolean) .join(" ") .toLowerCase(); } function filterCommandDocs(docs: CommandMenuDoc[], search: string) { const searchLower = search.trim().toLowerCase(); if (!searchLower) return docs; return docs.filter((doc) => commandDocSearchText(doc).includes(searchLower)); } function openDocsHref(href: string) { if (/^https?:\/\//i.test(href)) { window.open(href, "_blank", "noopener,noreferrer"); return; } window.location.assign(href); } function CommandDocsGroup({ docs, heading = "Docs" }: CommandDocsGroupProps) { if (docs.length === 0) return null; return ( {docs.map((doc) => ( openDocsHref(doc.href)} keywords={[doc.title, doc.description ?? "", ...(doc.keywords ?? [])]} deferSelect={false} className="items-start py-2" > {doc.title} {doc.description ? ( {doc.description} ) : null} ))} ); } // ─── Main Component ───────────────────────────────────────────────────────── export interface CommandMenuProps { open: boolean; onOpenChange: (open: boolean) => void; children: ReactNode; /** Render app-specific dynamic results from the current search value. */ renderResults?: (search: string) => ReactNode; /** Placeholder text for the search input */ placeholder?: string; /** Text shown when no results match (before showing agent fallback) */ emptyText?: string; /** Whether to show the "Ask AI" fallback when no commands match. Default: true */ showAgentFallback?: boolean; /** Custom class for the dialog content */ className?: string; /** * Raw CHANGELOG.md contents. When provided, the menu shows a built-in * "What's new" entry that opens an in-app changelog dialog (with an unseen * dot for new releases). Pass your app's own file: * import changelog from "../CHANGELOG.md?raw"; * */ changelog?: string; /** Label for the built-in changelog entry. Default: "What's new". */ changelogLabel?: string; /** * Stable key used to remember which release a user has already seen (for the * unseen dot). Defaults to the document title's host app; set explicitly when * multiple apps share an origin. */ changelogKey?: string; } export function CommandMenu({ open, onOpenChange, children, renderResults, placeholder = "Type a command or ask AI...", emptyText: _emptyText = "No commands found.", showAgentFallback = true, className, changelog, changelogLabel = "What's new", changelogKey, }: CommandMenuProps) { const [search, setSearch] = useState(""); const inputRef = useRef(null); const containerRef = useRef(null); // Built-in "What's new" changelog surface (only active when `changelog` is // passed). The dialog is rendered alongside the menu so it survives the menu // closing; the unseen dot persists per browser via localStorage. const [changelogOpen, setChangelogOpen] = useState(false); const hasChangelog = typeof changelog === "string" && changelog.trim().length > 0; const changelogEntries = useMemo( () => (hasChangelog ? parseChangelog(changelog as string) : []), [hasChangelog, changelog], ); const latestChangelogId = changelogEntries[0]?.id; const { unseen: changelogUnseen, markSeen: markChangelogSeen } = useChangelogSeen(changelogKey ?? "app", latestChangelogId); const openChangelog = useCallback(() => { onOpenChange(false); markChangelogSeen(); // Let the menu close before the dialog opens (avoids overlay flicker). setTimeout(() => setChangelogOpen(true), 50); }, [onOpenChange, markChangelogSeen]); // Focus input when opening; clear search while closed so reopen never renders // dynamic results for the previous query. useEffect(() => { if (!open) { setSearch(""); return; } if (open) { setSearch(""); // Wait for render then focus requestAnimationFrame(() => { inputRef.current?.focus(); }); } }, [open]); const handleSubmitToAgent = useCallback(() => { onOpenChange(false); if (!search.trim()) { focusAgentChat(); return; } submitToAgent(search.trim()); }, [search, onOpenChange]); // The built-in "What's new" row matches changelog-ish search terms. const changelogRowMatches = !search || [ changelogLabel, "changelog", "what's new", "whats new", "updates", "release notes", "changes", ] .join(" ") .toLowerCase() .includes(search.toLowerCase()); const showChangelogRow = hasChangelog && changelogRowMatches; // Filter children based on search const filterChildren = (nodes: ReactNode): ReactNode => { return React.Children.map(nodes, (child) => { if (!React.isValidElement(child)) return child; const props = child.props as Record; // If it's a CommandGroup, filter its children if (child.type === CommandGroup) { const groupChildren = filterChildren(props.children as ReactNode); const hasChildren = React.Children.count(groupChildren) > 0; if (!hasChildren) return null; return React.cloneElement(child, { ...props, children: groupChildren, } as Record); } if (child.type === CommandDocsGroup) { const docs = Array.isArray(props.docs) ? (props.docs as CommandMenuDoc[]) : []; const filteredDocs = filterCommandDocs(docs, search); if (filteredDocs.length === 0) return null; return React.cloneElement(child, { ...props, docs: filteredDocs, } as Record); } // If it's a CommandItem, check if it matches search if (child.type === CommandItem) { if (!search) return child; const text = getTextContent(props.children as ReactNode).toLowerCase(); const keywords = ((props.keywords as string[]) || []) .join(" ") .toLowerCase(); const searchLower = search.toLowerCase(); if (text.includes(searchLower) || keywords.includes(searchLower)) { return child; } return null; } // If it's a separator, keep it (will be cleaned up later if needed) if (child.type === CommandSeparator) { return search ? null : child; // Hide separators when searching } return child; }); }; const filteredChildren = filterChildren(children); const hasResults = React.Children.toArray(filteredChildren).some( (child) => React.isValidElement(child) && (child.type === CommandGroup || child.type === CommandDocsGroup), ); const dynamicResults = open ? renderResults?.(search) : null; const hasDynamicResults = Boolean(dynamicResults); return ( <> {placeholder} {/* Search input */} {/* Command list */} {dynamicResults} {hasResults && filteredChildren} {/* What's new — built-in changelog entry */} {showChangelogRow && ( <> {hasResults && }
{changelogLabel} {changelogUnseen && ( )}
)} {/* Ask AI — always visible at the bottom */} {showAgentFallback && ( <> {(hasResults || showChangelogRow || hasDynamicResults) && ( )}
{search.trim() ? ( <> Ask AI:{" "} "{search}" ) : ( Ask AI anything... )} {search.trim() && ( )}
)}
{hasChangelog && ( )} ); } // Helper to extract text content from React children function getTextContent(children: ReactNode): string { if (typeof children === "string") return children; if (typeof children === "number") return String(children); if (!children) return ""; if (Array.isArray(children)) { return children.map(getTextContent).join(" "); } if ( React.isValidElement(children) && (children.props as Record).children ) { return getTextContent( (children.props as Record).children as ReactNode, ); } return ""; } // Attach sub-components CommandMenu.Group = CommandGroup; CommandMenu.Item = CommandItem; CommandMenu.DocsGroup = CommandDocsGroup; CommandMenu.Shortcut = CommandShortcut; CommandMenu.Separator = CommandSeparator; // ─── Keyboard Hook ────────────────────────────────────────────────────────── export const COMMAND_MENU_OPEN_EVENT = "agent-native:open-command-menu"; export function openCommandMenu() { if (typeof window !== "undefined") { window.dispatchEvent(new Event(COMMAND_MENU_OPEN_EVENT)); } } /** * Hook to handle Cmd+K (or Ctrl+K) to open the command menu */ export function useCommandMenuShortcut( onOpen: () => void, options: { allowContentEditable?: boolean } = {}, ) { useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { // Don't trigger if user is typing in a native form control. const target = e.target instanceof HTMLElement ? e.target : null; const isContentEditable = target?.isContentEditable; if ( target?.tagName === "INPUT" || target?.tagName === "TEXTAREA" || target?.tagName === "SELECT" || (!options.allowContentEditable && isContentEditable) ) { return; } e.preventDefault(); onOpen(); } }; const handleOpenRequest = () => onOpen(); const useCapture = Boolean(options.allowContentEditable); document.addEventListener("keydown", handleKeyDown, useCapture); window.addEventListener(COMMAND_MENU_OPEN_EVENT, handleOpenRequest); return () => { document.removeEventListener("keydown", handleKeyDown, useCapture); window.removeEventListener(COMMAND_MENU_OPEN_EVENT, handleOpenRequest); }; }, [onOpen, options.allowContentEditable]); } export type { CommandDocsGroupProps, CommandGroupProps, CommandItemProps, CommandShortcutProps, };