import React, { useState, useEffect, useRef } from 'react' import { useNavigate } from '@tanstack/react-router' import { useProjects } from '../features/projects/useProjects' import { useSessions } from '../features/home/useSessions' import { useSavedSearches } from '../features/settings/useSavedSearches' interface PaletteProps { open: boolean onClose: () => void } interface JumpItem { kind: 'jump' label: string hint: string path: string } interface ProjectItem { kind: 'project' label: string hint?: string cwd: string } interface SessionItem { kind: 'session' label: string hint?: string tool: string host: string sessionId: string } interface SavedItem { kind: 'saved' label: string hint?: string query: string } type PaletteItem = JumpItem | ProjectItem | SessionItem | SavedItem const JUMP_ITEMS: JumpItem[] = [ { kind: 'jump', label: 'Recent', hint: 'g h', path: '/' }, { kind: 'jump', label: 'Projects', hint: 'g p', path: '/projects' }, { kind: 'jump', label: 'Stats', hint: 'g s', path: '/stats' }, { kind: 'jump', label: 'Health', hint: 'g i', path: '/health' }, { kind: 'jump', label: 'Settings', hint: '', path: '/settings' }, ] export function Palette({ open, onClose }: PaletteProps): React.JSX.Element { const navigate = useNavigate() const [query, setQuery] = useState('') const [cursor, setCursor] = useState(0) const inputRef = useRef(null) // Autofocus when palette opens; reset state when it closes useEffect(() => { if (open) { setQuery('') setCursor(0) // Small defer so the element is visible before focus requestAnimationFrame(() => { inputRef.current?.focus() }) } }, [open]) // Reset cursor when query changes useEffect(() => { setCursor(0) }, [query]) // Always call hooks unconditionally (React rules); data is used only when open const { data: projects } = useProjects({ since: 'all' }) const { data: sessions } = useSessions({}) const { data: savedSearches } = useSavedSearches() if (!open) return <> const q = query.trim() const filteredJumps: JumpItem[] = q === '' ? JUMP_ITEMS : JUMP_ITEMS.filter(item => item.label.toLowerCase().includes(q.toLowerCase()), ) const filteredProjects: ProjectItem[] = (projects ?? []) .map(p => ({ kind: 'project' as const, label: p.cwd, cwd: p.cwd })) .filter(item => q === '' || item.label.toLowerCase().includes(q.toLowerCase())) const filteredSessions: SessionItem[] = (sessions ?? []) .slice(0, 50) .map(s => ({ kind: 'session' as const, label: `${s.tool} · ${s.host} · ${s.summary || s.sessionId}`, tool: s.tool, host: s.host, sessionId: s.sessionId, })) .filter(item => q === '' || item.label.toLowerCase().includes(q.toLowerCase())) const filteredSaved: SavedItem[] = (savedSearches ?? []) .map(ss => ({ kind: 'saved' as const, label: ss.name, hint: ss.query, query: ss.query })) .filter(item => q === '' || item.label.toLowerCase().includes(q.toLowerCase())) const filtered: PaletteItem[] = [ ...filteredJumps, ...filteredProjects, ...filteredSessions, ...filteredSaved, ] const showSearch = q !== '' && filtered.length === 0 const total = filtered.length + (showSearch ? 1 : 0) function fire(idx: number): void { if (showSearch && idx === 0) { void navigate({ to: '/search', search: { q: query } }) onClose() return } const item = filtered[showSearch ? idx - 1 : idx] if (item) { switch (item.kind) { case 'jump': void navigate({ to: item.path }) break case 'project': void navigate({ to: '/project/$', params: { _splat: item.cwd } }) break case 'session': void navigate({ to: '/session/$tool/$host/$id', params: { tool: item.tool, host: item.host, id: item.sessionId }, }) break case 'saved': void navigate({ to: '/search', search: { q: item.query } }) break } onClose() } } function onKeyDown(e: React.KeyboardEvent): void { if (e.key === 'Escape') { e.preventDefault() onClose() } else if (e.key === 'ArrowDown') { e.preventDefault() setCursor(c => Math.min(total - 1, c + 1)) } else if (e.key === 'ArrowUp') { e.preventDefault() setCursor(c => Math.max(0, c - 1)) } else if (e.key === 'Enter') { e.preventDefault() fire(cursor) } } // Build grouped render list with section headers interface Group { key: string head: string items: PaletteItem[] startIdx: number } const offset = showSearch ? 1 : 0 const groups: Group[] = [ { key: 'jump', head: 'jump', items: filteredJumps, startIdx: offset }, { key: 'projects', head: 'projects', items: filteredProjects, startIdx: offset + filteredJumps.length }, { key: 'sessions', head: 'sessions', items: filteredSessions, startIdx: offset + filteredJumps.length + filteredProjects.length }, { key: 'saved', head: 'saved', items: filteredSaved, startIdx: offset + filteredJumps.length + filteredProjects.length + filteredSessions.length }, ] return (
e.stopPropagation()}> {/* Input row */}
⌘K setQuery(e.target.value)} onKeyDown={onKeyDown} placeholder="search turns, jump to a page, pick a session…" />
{/* Item list */}
{showSearch && (
fire(0)} > search "{query}"
)} {groups.map(group => { if (group.items.length === 0) return null return (
{group.head}
{group.items.map((item, i) => { const idx = group.startIdx + i return (
fire(idx)} > {item.kind} {item.label} {item.hint !== undefined && item.hint !== '' && ( {item.hint} )}
) })}
) })} {filtered.length === 0 && !showSearch && (
no matches
)}
{/* Footer */}
↑↓ navigate ↵ open esc close g h · g p · g s · g i
) }