/** * Two-pane memory_tree browser with overlay detail. * * ┌─────────────┬──────────────────────────────────────┐ * │ NAVIGATOR │ RESULT LIST │ ← default 2-pane * │ 240px │ flex │ * └─────────────┴──────────────────────────────────────┘ * * ┌──────────────────────────────────────────────────[✕]┐ * │ CHUNK DETAIL (full card width) │ ← when chunk selected * │ Subject · sender · entities · body · score │ * └─────────────────────────────────────────────────────┘ * * ResultList finally gets ~970px to breathe (multi-line rows with * sender, time, entity chips, preview). When a chunk is selected the * detail layer absolute-positions over the 2-pane base so list scroll * state is preserved on close. Esc + close button + (later) backdrop * click all dismiss. * * Talks to the real `openhuman.memory_tree_*` JSON-RPC surface via the * `utils/tauriCommands/memoryTree` wrappers. */ import { useCallback, useEffect, useMemo, useState } from 'react'; import type { ToastNotification } from '../../types/intelligence'; import { type Chunk, type ChunkFilter, type EntityRef, memoryTreeChunksForEntity, memoryTreeListChunks, memoryTreeListSources, memoryTreeTopEntities, type Source, } from '../../utils/tauriCommands'; import './memory-workspace.css'; import { MemoryChunkDetail } from './MemoryChunkDetail'; import { MemoryEmptyPlaceholder } from './MemoryEmptyPlaceholder'; import { MemoryNavigator, type NavigatorSelection } from './MemoryNavigator'; import { MemoryResultList } from './MemoryResultList'; import { MemorySyncConnections } from './MemorySyncConnections'; interface MemoryWorkspaceProps { onToast?: (toast: Omit) => void; } export function MemoryWorkspace({ onToast: _onToast }: MemoryWorkspaceProps) { const [allChunks, setAllChunks] = useState([]); const [sources, setSources] = useState([]); const [topPeople, setTopPeople] = useState([]); const [topTopics, setTopTopics] = useState([]); const [selectedChunkId, setSelectedChunkId] = useState(null); const [selection, setSelection] = useState({ sourceIds: [], entityIds: [] }); const [searchQuery, setSearchQuery] = useState(''); // Initial data load. useEffect(() => { console.debug('[ui-flow][memory-workspace] initial load (2-pane + overlay)'); let cancelled = false; const run = async () => { try { const [chunkResult, srcs, people, anyEntities] = await Promise.all([ memoryTreeListChunks({ limit: 500 }), memoryTreeListSources(), memoryTreeTopEntities('person', 12), memoryTreeTopEntities(undefined, 40), ]); if (cancelled) return; const topicKinds = new Set(['technology', 'product', 'event']); const topics = anyEntities.filter(e => topicKinds.has(e.kind)).slice(0, 12); setAllChunks(chunkResult.chunks); setSources(srcs); setTopPeople(people); setTopTopics(topics); } catch (err) { if (cancelled) return; // Initial-load failure leaves the panes empty rather than // half-populated with stale state. The console line lets us // diagnose without blocking the user behind a modal — they can // still navigate the tab and retry by reloading. console.error('[ui-flow][memory-workspace] initial load failed', err); } }; void run(); return () => { cancelled = true; }; }, []); // Resolve entity selection → set of chunk ids via the dedicated // `memory_tree_chunks_for_entity` RPC. The chunks' `tags` column only // stores high-level category tags (`["gmail", "ingested"]`), NOT // per-chunk entity refs — those live in `mem_tree_entity_index`. // Calling the inverse-index RPC gives us the real chunk ids that // mention each selected entity. Union across the selection (chunk // mentions ANY of the selected entities is enough — same semantics // as a multi-select OR filter in Mail.app's people sidebar). const [entityChunkIds, setEntityChunkIds] = useState | null>(null); useEffect(() => { console.debug( '[ui-flow][memory-workspace] entity-effect fire entityIds=%o', selection.entityIds ); let cancelled = false; const run = async () => { if (selection.entityIds.length === 0) { setEntityChunkIds(null); return; } try { const results = await Promise.all( selection.entityIds.map(id => memoryTreeChunksForEntity(id)) ); if (cancelled) { console.debug('[ui-flow][memory-workspace] entity-effect cancelled before commit'); return; } const union = new Set(); for (const ids of results) for (const id of ids) union.add(id); console.debug( '[ui-flow][memory-workspace] entity-effect commit set_size=%d sample=%o', union.size, [...union].slice(0, 3) ); setEntityChunkIds(union); } catch (err) { if (cancelled) return; // If the inverse-index lookup rejects, do NOT keep filtering by // the previously-resolved set — that would leave the result list // showing chunks tied to the old selection while the user thinks // they've moved on. Reset to "no entity filter" so they at least // see the unfiltered timeline; the navigator selection is left // alone so they can retry by reselecting. console.error('[ui-flow][memory-workspace] entity-effect lookup failed', err); setEntityChunkIds(null); } }; void run(); return () => { cancelled = true; }; }, [selection.entityIds]); // Apply navigator selection + search. const filteredChunks = useMemo(() => { const filter: ChunkFilter = { source_ids: selection.sourceIds.length > 0 ? selection.sourceIds : undefined, query: searchQuery.trim() || undefined, }; const out = allChunks.filter(c => { if (filter.source_ids && !filter.source_ids.includes(c.source_id)) return false; if (entityChunkIds && !entityChunkIds.has(c.id)) return false; if (filter.query) { const needle = filter.query.toLowerCase(); const hay = `${c.content_preview ?? ''} ${c.tags.join(' ')}`.toLowerCase(); if (!hay.includes(needle)) return false; } return true; }); console.debug( '[ui-flow][memory-workspace] filteredChunks recompute all=%d entitySet=%s out=%d', allChunks.length, entityChunkIds ? `Set(${entityChunkIds.size})` : 'null', out.length ); return out; }, [allChunks, selection.sourceIds, entityChunkIds, searchQuery]); const selectedChunk = useMemo( () => allChunks.find(c => c.id === selectedChunkId) ?? null, [allChunks, selectedChunkId] ); const handleSelectChunk = useCallback((id: string) => { console.debug('[ui-flow][memory-workspace] open chunk overlay', id); setSelectedChunkId(id); }, []); const handleCloseDetail = useCallback(() => { setSelectedChunkId(null); }, []); const handleSelectionChange = useCallback((next: NavigatorSelection) => { setSelection(next); }, []); const handleSearchChange = useCallback((q: string) => { setSearchQuery(q); }, []); const handleSelectEntity = useCallback((entity: EntityRef) => { console.debug('[ui-flow][memory-workspace] entity click → activate lens', entity.entity_id); setSelection(prev => { if (prev.entityIds.includes(entity.entity_id)) return prev; return { ...prev, entityIds: [...prev.entityIds, entity.entity_id] }; }); // Closing detail surfaces the filtered list immediately. setSelectedChunkId(null); }, []); // Esc key dismisses the detail overlay. useEffect(() => { if (!selectedChunkId) return; const handleKey = (e: KeyboardEvent) => { if (e.key === 'Escape') { e.stopPropagation(); handleCloseDetail(); } }; window.addEventListener('keydown', handleKey); return () => window.removeEventListener('keydown', handleKey); }, [selectedChunkId, handleCloseDetail]); const isEmpty = allChunks.length === 0; if (isEmpty) { return (
); } return (
{/* 2-pane base */}
{/* Detail overlay — fills the entire workspace card */} {selectedChunk && (

{selectedChunk.source_kind} {' · '} {selectedChunk.source_id}

)}
); }