oxios 1.10.1

Oxios Agent OS — Agent Operating System powered by oxi-sdk
import { Brain, Search } from 'lucide-react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { EmptyState } from '@/components/shared/empty-state'
import { ErrorState } from '@/components/shared/error-state'
import { LoadingCards } from '@/components/shared/loading'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { useMemoryDetail, useMemoryMap } from '@/hooks/use-memory'
import type { MemoryDetail as MemDetail, MemoryMapEntry } from '@/types/memory'
import { ClusterLegend } from './cluster-legend'
import { EmbeddingCanvas } from './embedding-canvas'
import { MemoryDetail } from './memory-detail'
import { SelectionPanel } from './selection-panel'

/**
 * Memory Embedding Map (RFC-T1-B).
 *
 * Top-level component for the "Map" tab on the Memory page. Owns:
 *  - filter / search state
 *  - the data fetch (`useMemoryMap`)
 *  - selection + detail modal state
 *  - Cmd+F focus shortcut
 *
 * Renders a two-column grid: canvas + legend on the left, selection
 * panel on the right. The detail modal is shared with the Browse tab.
 */
export function MemoryMap() {
  const { t } = useTranslation()
  const [tier, setTier] = useState<string>('all')
  const [type, setType] = useState<string>('all')
  const [animate, setAnimate] = useState<boolean>(true)
  const [query, setQuery] = useState<string>('')
  const [activeQuery, setActiveQuery] = useState<string>('')
  const [selectedId, setSelectedId] = useState<string | null>(null)
  const [hoveredId, setHoveredId] = useState<string | null>(null)
  const [flyToId, setFlyToId] = useState<string | null>(null)
  const [detailId, setDetailId] = useState<string | null>(null)
  const [detailOpen, setDetailOpen] = useState<boolean>(false)

  const { data, isLoading, isError, refetch } = useMemoryMap({
    tier: tier === 'all' ? undefined : tier,
    mem_type: type === 'all' ? undefined : type,
    limit: 500,
  })

  const entries = useMemo<MemoryMapEntry[]>(() => data?.entries ?? [], [data])

  // Find the currently selected entry (or fall back to the hovered one)
  // for the side panel.
  const panelEntry = useMemo(() => {
    const id = selectedId ?? hoveredId
    if (!id) return null
    return entries.find((e) => e.id === id) ?? null
  }, [selectedId, hoveredId, entries])

  // Cmd+F (or Ctrl+F) focuses the search input.
  const searchInputRef = useRef<HTMLInputElement | null>(null)
  useEffect(() => {
    const onKey = (e: KeyboardEvent) => {
      const isFind = (e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'f'
      if (isFind && searchInputRef.current) {
        e.preventDefault()
        searchInputRef.current.focus()
        searchInputRef.current.select()
      } else if (
        e.key === 'Escape' &&
        searchInputRef.current &&
        document.activeElement === searchInputRef.current
      ) {
        setQuery('')
        setActiveQuery('')
        setFlyToId(null)
        searchInputRef.current.blur()
      }
    }
    window.addEventListener('keydown', onKey)
    return () => window.removeEventListener('keydown', onKey)
  }, [])

  // Submitting the search finds the first matching node, highlights it,
  // and flies the camera to it.
  const runSearch = useCallback(() => {
    const q = query.trim().toLowerCase()
    setActiveQuery(q)
    if (!q) {
      setFlyToId(null)
      return
    }
    const hit = entries.find(
      (e) =>
        e.id.toLowerCase().includes(q) ||
        e.content_preview.toLowerCase().includes(q) ||
        e.mem_type.toLowerCase().includes(q),
    )
    if (hit) {
      setSelectedId(hit.id)
      setFlyToId(hit.id)
      // Clear the fly-to flag after the camera transition completes so
      // a follow-up identical search re-runs.
      setTimeout(() => setFlyToId((prev) => (prev === hit.id ? null : prev)), 700)
    }
  }, [query, entries])

  // Detail modal: load the full memory entry when a user opens one.
  const detailQuery = useMemoryDetail(detailId)
  const detailEntry: MemDetail | null = useMemo(() => {
    if (!detailQuery.data) return null
    const e = detailQuery.data
    return {
      id: e.id,
      key: e.key,
      tier: e.tier,
      memory_type: e.memory_type,
      content: e.content,
      summary: e.summary,
      project_ids: e.project_ids,
      created_at: e.created_at,
      updated_at: e.updated_at,
      last_accessed: e.last_accessed,
      access_count: e.access_count,
      pinned: e.pinned,
      protected: e.protected,
      protection_reason: e.protection_reason,
      tags: e.tags,
      metadata: e.metadata,
    }
  }, [detailQuery.data])

  const handleOpenDetail = useCallback((id: string) => {
    setDetailId(id)
    setDetailOpen(true)
  }, [])

  if (isError) {
    return <ErrorState onRetry={() => refetch()} />
  }

  const tierOptions = [
    { label: t('common.all'), value: 'all' },
    { label: t('memory.hot'), value: 'hot' },
    { label: t('memory.warm'), value: 'warm' },
    { label: t('memory.cold'), value: 'cold' },
  ]
  const typeOptions = [
    { label: t('common.all'), value: 'all' },
    { label: t('memory.fact'), value: 'fact' },
    { label: t('memory.episode'), value: 'episode' },
    { label: t('memory.knowledge'), value: 'knowledge' },
    { label: t('memory.decision'), value: 'decision' },
    { label: t('memory.skill'), value: 'skill' },
    { label: t('memory.preference'), value: 'preference' },
    { label: t('memory.conversation'), value: 'conversation' },
    { label: t('memory.session'), value: 'session' },
  ]

  return (
    <div className="space-y-3" data-testid="memory-map">
      <div className="flex flex-wrap items-center gap-2">
        <Select
          value={tier}
          onValueChange={setTier}
          options={tierOptions}
          placeholder={t('memory.filterByTier')}
          className="w-full sm:w-32"
          data-testid="map-tier-filter"
        />
        <Select
          value={type}
          onValueChange={setType}
          options={typeOptions}
          placeholder={t('memory.filterByType')}
          className="w-full sm:w-40"
          data-testid="map-type-filter"
        />
        <div className="flex items-center gap-1.5">
          <Switch
            checked={animate}
            onCheckedChange={setAnimate}
            data-testid="map-animate-toggle"
            aria-label={t('memory.mapAnimate')}
          />
          <span className="text-xs text-muted-foreground">{t('memory.mapAnimate')}</span>
        </div>
        <form
          className="ml-auto flex items-center gap-1.5"
          onSubmit={(e) => {
            e.preventDefault()
            runSearch()
          }}
        >
          <div className="relative">
            <Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
            <Input
              ref={searchInputRef}
              value={query}
              onChange={(e) => setQuery(e.target.value)}
              placeholder={t('memory.mapSearchPlaceholder')}
              className="h-8 w-48 pl-7 text-xs"
              data-testid="map-search-input"
            />
          </div>
        </form>
      </div>

      <div className="grid grid-cols-1 gap-3 lg:grid-cols-[1fr_320px]">
        <div className="space-y-2">
          <EmbeddingCanvas
            entries={entries}
            selectedId={selectedId ?? hoveredId}
            onHover={setHoveredId}
            onSelect={(id) => setSelectedId(id)}
            flyToId={flyToId}
            animate={animate}
          />
          <div className="flex flex-wrap items-center gap-2">
            <ClusterLegend />
            <p className="text-xs text-muted-foreground">
              {t('memory.mapNodeCount', { count: entries.length })}
              {activeQuery ? ` · ${t('memory.mapSearchActive', { query: activeQuery })}` : ''}
            </p>
          </div>
        </div>
        <div className="min-h-[360px]">
          {isLoading ? (
            <LoadingCards count={1} />
          ) : entries.length === 0 ? (
            <EmptyState
              icon={<Brain className="h-10 w-10" />}
              title={t('memory.mapEmpty')}
              description={t('memory.mapEmptyDescription')}
            />
          ) : (
            <SelectionPanel
              selected={panelEntry}
              allEntries={entries}
              onOpenDetail={handleOpenDetail}
              onHoverNeighbour={setHoveredId}
            />
          )}
        </div>
      </div>

      <MemoryDetail memory={detailEntry} open={detailOpen} onClose={() => setDetailOpen(false)} />
    </div>
  )
}