~bigbes/lethe

ac7e06ffe46e035bfbe2988dfc631c50fc99340f — Eugene Blikh a month ago dae9e25
web: palette items — projects, sessions, saved searches

Extend the command palette with three new item kinds (project, session,
saved) backed by unconditional TanStack Query hooks. Items are grouped in
fixed order (jump → projects → sessions → saved) with section headers;
fire() dispatches kind-aware navigation; .palette-group-head CSS added.
3 files changed, 127 insertions(+), 18 deletions(-)

M internal/server/web/dist/index.html
M web/src/shell/Palette.tsx
M web/src/styles/palette.css
M internal/server/web/dist/index.html => internal/server/web/dist/index.html +2 -2
@@ 13,8 13,8 @@
      href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500;700&display=swap"
      rel="stylesheet"
    />
    <script type="module" crossorigin src="/assets/index-CObHBPUh.js"></script>
    <link rel="stylesheet" crossorigin href="/assets/index-BqeYZ54s.css">
    <script type="module" crossorigin src="/assets/index-KEoqigJf.js"></script>
    <link rel="stylesheet" crossorigin href="/assets/index-D-7MmxJh.css">
  </head>
  <body class="density-compact">
    <div id="root"></div>

M web/src/shell/Palette.tsx => web/src/shell/Palette.tsx +116 -16
@@ 1,5 1,8 @@
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


@@ 13,6 16,31 @@ interface JumpItem {
  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' },


@@ 44,15 72,47 @@ export function Palette({ open, onClose }: PaletteProps): React.JSX.Element {
    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 filtered: JumpItem[] = q === ''

  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)



@@ 62,9 122,25 @@ export function Palette({ open, onClose }: PaletteProps): React.JSX.Element {
      onClose()
      return
    }
    const item = filtered[idx]
    const item = filtered[showSearch ? idx - 1 : idx]
    if (item) {
      void navigate({ to: item.path })
      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()
    }
  }


@@ 85,6 161,22 @@ export function Palette({ open, onClose }: PaletteProps): React.JSX.Element {
    }
  }

  // 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()}>


@@ 113,20 205,28 @@ export function Palette({ open, onClose }: PaletteProps): React.JSX.Element {
              <span className="row-hint mono muted">↵</span>
            </div>
          )}
          {filtered.map((item, i) => {
            const idx = showSearch ? i + 1 : i
          {groups.map(group => {
            if (group.items.length === 0) return null
            return (
              <div
                key={item.label}
                className={'palette-row' + (idx === cursor ? ' cursor' : '')}
                onClick={() => fire(idx)}
              >
                <span className="kind">{item.kind}</span>
                <span className="label">{item.label}</span>
                {item.hint !== '' && (
                  <span className="row-hint mono muted">{item.hint}</span>
                )}
              </div>
              <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 && (

M web/src/styles/palette.css => web/src/styles/palette.css +9 -0
@@ 118,6 118,15 @@
}
.palette-hint .spacer { flex: 1; }

/* ─── Group headers ─── */
.palette-group-head {
  padding: 8px 12px 4px;
  font-size: 11px;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--ink-3);
}

/* ─── No matches ─── */
.palette-empty {
  padding: 14px;