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<HTMLInputElement>(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); enabled+staleTime gate
// network activity so closed-palette renders don't trigger refetches.
const { data: projects } = useProjects({ since: 'all' }, { enabled: open, staleTime: 30_000 })
const { data: sessions } = useSessions({}, { enabled: open, staleTime: 30_000 })
const { data: savedSearches } = useSavedSearches({ enabled: open, staleTime: 30_000 })
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<HTMLInputElement>): 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 (
<div className="scrim" onClick={onClose}>
<div className="palette" onClick={e => e.stopPropagation()}>
{/* Input row */}
<div className="palette-input-row">
<span className="palette-hint-badge mono muted">⌘K</span>
<input
ref={inputRef}
className="palette-input"
value={query}
onChange={e => setQuery(e.target.value)}
onKeyDown={onKeyDown}
placeholder="search turns, jump to a page, pick a session…"
/>
</div>
{/* Item list */}
<div className="palette-list">
{showSearch && (
<div
className={'palette-row' + (cursor === 0 ? ' cursor' : '')}
onClick={() => fire(0)}
>
<span className="kind accent-c">search</span>
<span className="label">"{query}"</span>
<span className="row-hint mono muted">↵</span>
</div>
)}
{groups.map(group => {
if (group.items.length === 0) return null
return (
<React.Fragment key={group.key}>
<div className="palette-group-head muted mono">{group.head}</div>
{group.items.map((item, i) => {
const idx = group.startIdx + i
return (
<div
key={`${item.kind}:${item.label}:${i}`}
className={'palette-row' + (idx === cursor ? ' cursor' : '')}
onClick={() => fire(idx)}
>
<span className="kind">{item.kind}</span>
<span className="label">{item.label}</span>
{item.hint !== undefined && item.hint !== '' && (
<span className="row-hint mono muted">{item.hint}</span>
)}
</div>
)
})}
</React.Fragment>
)
})}
{filtered.length === 0 && !showSearch && (
<div className="palette-empty muted">no matches</div>
)}
</div>
{/* Footer */}
<div className="palette-hint">
<span>↑↓ navigate</span>
<span>↵ open</span>
<span>esc close</span>
<span className="spacer" />
<span>g h · g p · g s · g i</span>
</div>
</div>
</div>
)
}