"use client"; import { useRouter } from "next/navigation"; import { useEffect, useId, useMemo, useRef, useState } from "react"; import { Search } from "lucide-react"; import { openDay } from "@/components/day/day-layer-controller"; import { useTodayKey } from "@/components/day/use-today-key"; import { openCapturePicker, openShortcutsHelp, } from "@/components/layout/global-shortcuts"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogTitle, } from "@/components/ui/dialog"; import { Sheet, SheetContent, SheetDescription, SheetTitle, } from "@/components/ui/sheet"; import { useAuth } from "@/hooks/use-auth"; import { useIsMobile } from "@/hooks/use-is-mobile"; import { useModuleToggle } from "@/hooks/use-module-toggle"; import { EMPTY_STATE_ACTIONS, type PaletteEntry, type PaletteGroup, } from "@/components/command-palette/build-index"; import { rankEntries } from "@/lib/command-palette/rank"; import { pushRecent, readRecent } from "@/lib/command-palette/recent"; import { scrollToAnchorWhenReady } from "@/lib/dom/scroll-to-anchor"; import { useCoachLaunch } from "@/lib/insights/coach-launch-context"; import { useTranslations } from "@/lib/i18n/context"; import type { SearchHit } from "@/lib/search/contract"; import { cn } from "@/lib/utils"; import { setCommandPaletteOpen, useCommandPaletteOpen } from "./palette-store"; import { RecordHitContent } from "./record-hit"; import { usePaletteIndex } from "./use-palette-index"; import { useRecordSearch } from "./use-record-search"; /** * The command palette: one field, and below it the places, settings, module * switches and actions that match, grouped. Arrow keys move, Enter opens, * Escape closes. A module's entry is a switch: Enter flips it and the palette * stays open. * * After the local results comes "In your records": the server's search over * the record (`useRecordSearch`, `POST /api/search`), once the typing pauses. * Its hits are options like any other, after the local ones, so the arrows * and Enter work across both; each opens the page that shows the record. * * Accessibility follows the combobox pattern: the field is the * `role="combobox"` that keeps focus, the results are a `listbox` of grouped * `option`s, and `aria-activedescendant` names the highlighted one. * * A centred dialog from 768 px; on a phone a full-height sheet with the field * at the top. That one is a raw `Sheet` rather than `ResponsiveSheet` on * purpose: the responsive primitive's phone branch is a 90 % bottom sheet * that keeps the keyboard down on open, and a search wants the opposite, the * whole height and the keyboard up at once. * * Loaded on first use (`command-palette.lazy.tsx`), so no page pays for it. */ /** At most this many hits per group, so one group cannot bury the others. */ const PER_GROUP = 7; interface Section { group: PaletteGroup; entries: PaletteEntry[]; } function hashOf(href: string): { pathname: string; hash: string } { const at = href.indexOf("#"); return at === -1 ? { pathname: href, hash: "" } : { pathname: href.slice(0, at), hash: href.slice(at + 1) }; } export function arrangeResults( query: string, index: ReadonlyArray, recentIds: ReadonlyArray, ): Section[] { if (query.trim() === "") { const byId = new Map(index.map((entry) => [entry.id, entry])); const recent = recentIds .map((id) => byId.get(id)) .filter((entry): entry is PaletteEntry => entry !== undefined); const actions = EMPTY_STATE_ACTIONS.map((id) => byId.get(id)).filter( (entry): entry is PaletteEntry => entry !== undefined && !recentIds.includes(entry.id), ); return [ { group: "recent" as const, entries: recent }, { group: "actions" as const, entries: actions }, ].filter((section) => section.entries.length > 0); } const ranked = rankEntries(query, index); // Groups appear in the order of their best hit, so the best match is // always the first option. const order: PaletteGroup[] = []; const grouped = new Map(); for (const entry of ranked) { const list = grouped.get(entry.group); if (!list) { order.push(entry.group); grouped.set(entry.group, [entry]); } else if (list.length < PER_GROUP) { list.push(entry); } } return order.map((group) => ({ group, entries: grouped.get(group) ?? [] })); } export default function CommandPalette() { const open = useCommandPaletteOpen(); const isMobile = useIsMobile(); const { t } = useTranslations(); const title = t("palette.title"); const description = t("palette.description"); const body = open ? ( ) : null; if (isMobile) { return ( {title} {description} {body} ); } return ( {title} {description} {body} ); } export function PaletteBody({ isMobile }: { isMobile: boolean }) { const { t } = useTranslations(); const router = useRouter(); const { user } = useAuth(); const today = useTodayKey(); const coachLaunch = useCoachLaunch(); const index = usePaletteIndex(); const toggle = useModuleToggle(); const [query, setQuery] = useState(""); const [active, setActive] = useState(0); const [recentIds] = useState(() => readRecent(user?.id)); const listId = useId(); const inputRef = useRef(null); const sections = useMemo( () => arrangeResults(query, index, recentIds), [query, index, recentIds], ); const flat = useMemo( () => sections.flatMap((section) => section.entries), [sections], ); const records = useRecordSearch(query); const recordHits = useMemo( () => records.data?.groups.flatMap((group) => group.hits) ?? [], [records.data], ); const showRecords = records.query !== null; const shownHits = showRecords ? recordHits : []; const total = flat.length + shownHits.length; const activeIndex = total === 0 ? -1 : Math.min(active, total - 1); const optionId = (i: number) => `${listId}-option-${i}`; // Keep the highlighted option in view as the arrows move through a list // taller than the panel. useEffect(() => { if (activeIndex < 0) return; document .getElementById(optionId(activeIndex)) ?.scrollIntoView({ block: "nearest" }); // `optionId` is derived from `listId`, which is stable. // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeIndex]); function run(entry: PaletteEntry) { const target = entry.run; if (target.kind === "module") { // A switch: flip it where it is, and stay. toggle.mutate({ key: target.module, enabled: !target.enabled }); return; } setCommandPaletteOpen(false); if (target.kind === "href") { pushRecent(user?.id, entry.id); router.push(target.href); const { pathname, hash } = hashOf(target.href); if (hash) scrollToAnchorWhenReady(hash, { pathname }); return; } switch (target.action) { case "capture": openCapturePicker(); return; case "coach": if (coachLaunch) coachLaunch.askCoach(); else router.push("/coach"); return; case "backup": { router.push("/settings/export#full-backup"); scrollToAnchorWhenReady("full-backup", { pathname: "/settings/export", }); return; } case "today": openDay(today); return; case "shortcuts": openShortcutsHelp(); return; } } function openRecord(path: string) { setCommandPaletteOpen(false); router.push(path); } function runAt(index: number) { if (index < 0) return; if (index < flat.length) { run(flat[index]); return; } const hit = shownHits[index - flat.length]; if (hit) openRecord(hit.path); } function onKeyDown(event: React.KeyboardEvent) { if (event.nativeEvent.isComposing) return; if (event.key === "ArrowDown" || event.key === "ArrowUp") { event.preventDefault(); if (total === 0) return; const step = event.key === "ArrowDown" ? 1 : -1; setActive((activeIndex + step + total) % total); return; } if (event.key === "Enter") { event.preventDefault(); runAt(activeIndex); return; } if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") { event.preventDefault(); setCommandPaletteOpen(false); } } // Each section's first position in the flat option list. const starts = sections.reduce( (acc, section, i) => [ ...acc, i === 0 ? 0 : acc[i - 1] + sections[i - 1].entries.length, ], [], ); return (
{sections.length === 0 && (!showRecords || (!records.loading && !records.error && shownHits.length === 0)) ? (

{t("palette.noResults", { query: query.trim() })}

) : ( sections.map((section, sectionIndex) => { const headingId = `${listId}-group-${section.group}`; return (
{t(`palette.groups.${section.group}`)}
{section.entries.map((entry, entryIndex) => { const i = starts[sectionIndex] + entryIndex; const selected = i === activeIndex; const Icon = entry.icon; const isModule = entry.run.kind === "module"; const on = entry.run.kind === "module" && entry.run.enabled; return (
{ if (!selected) setActive(i); }} onMouseDown={(event) => { // Keep focus in the field; the click runs the entry. event.preventDefault(); }} onClick={() => run(entry)} className={cn( "flex min-h-11 cursor-pointer items-center gap-3 rounded-md px-3 py-2 text-sm", selected && "bg-accent text-accent-foreground", )} >
); })}
); }) )} {showRecords && !( sections.length === 0 && !records.loading && !records.error && shownHits.length === 0 ) ? ( ) : null}
{isMobile ? null : ( )}
); } /** * "In your records": the server's hits after the local results. A status line * (searching, nothing found, failed, older entries not read) is plain text, * not an option, so the arrows skip it. */ function RecordSection({ headingId, hits, firstIndex, activeIndex, optionId, loading, error, truncated, query, onHover, onOpen, }: { headingId: string; hits: ReadonlyArray; firstIndex: number; activeIndex: number; optionId: (i: number) => string; loading: boolean; error: boolean; truncated: boolean; query: string; onHover: (i: number) => void; onOpen: (path: string) => void; }) { const { t } = useTranslations(); const status = error ? t("palette.records.error") : hits.length === 0 ? loading ? t("palette.records.loading") : t("palette.records.empty", { query }) : null; return (
{t("palette.groups.records")}
{hits.map((hit, n) => { const i = firstIndex + n; const selected = i === activeIndex; return (
{ if (!selected) onHover(i); }} onMouseDown={(event) => { // Keep focus in the field; the click opens the record. event.preventDefault(); }} onClick={() => onOpen(hit.path)} className={cn( "group flex min-h-11 cursor-pointer items-start gap-3 rounded-md px-3 py-2 text-sm", selected && "bg-accent text-accent-foreground", )} >
); })} {status ? (

{status}

) : null} {truncated && !error ? (

{t("palette.records.truncated")}

) : null}
); }