Files
openhuman/app/src/components/intelligence/MemoryWorkspace.tsx
T

303 lines
12 KiB
TypeScript

/**
* 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';
interface MemoryWorkspaceProps {
onToast?: (toast: Omit<ToastNotification, 'id'>) => void;
}
export function MemoryWorkspace({ onToast: _onToast }: MemoryWorkspaceProps) {
const [allChunks, setAllChunks] = useState<Chunk[]>([]);
const [sources, setSources] = useState<Source[]>([]);
const [topPeople, setTopPeople] = useState<EntityRef[]>([]);
const [topTopics, setTopTopics] = useState<EntityRef[]>([]);
const [selectedChunkId, setSelectedChunkId] = useState<string | null>(null);
const [selection, setSelection] = useState<NavigatorSelection>({ 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<Set<string> | 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<string>();
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<Chunk[]>(() => {
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 (
<div className="flex min-h-[40vh] items-center justify-center">
<MemoryEmptyPlaceholder />
</div>
);
}
return (
<section
className="memory-workspace-root relative flex"
style={{ height: 'calc(100vh - 16rem)' }}
data-testid="memory-workspace">
{/* 2-pane base */}
<aside
className="w-60 shrink-0 overflow-y-auto border-r border-stone-100 bg-stone-50/60"
aria-label="Memory navigator">
<MemoryNavigator
chunks={allChunks}
sources={sources}
topPeople={topPeople}
topTopics={topTopics}
selection={selection}
onSelectionChange={handleSelectionChange}
searchQuery={searchQuery}
onSearchChange={handleSearchChange}
/>
</aside>
<main className="flex-1 overflow-y-auto bg-white" aria-label="Result list">
<MemoryResultList
chunks={filteredChunks}
selectedChunkId={selectedChunkId}
onSelectChunk={handleSelectChunk}
/>
</main>
{/* Detail overlay — fills the entire workspace card */}
{selectedChunk && (
<div
className="absolute inset-0 z-10 flex flex-col bg-canvas-50/95 backdrop-blur-sm
duration-150 motion-safe:animate-fade-in"
role="dialog"
aria-modal="true"
aria-label="Chunk detail">
<header
className="sticky top-0 z-10 flex items-center justify-between gap-4
border-b border-stone-100 bg-white/90 px-6 py-3 backdrop-blur">
<div className="min-w-0 flex-1">
<p className="truncate font-mono text-xs text-stone-500">
<span className="text-stone-400">{selectedChunk.source_kind}</span>
{' · '}
{selectedChunk.source_id}
</p>
</div>
<button
onClick={handleCloseDetail}
aria-label="Close detail (Esc)"
title="Close (Esc)"
className="rounded-lg p-1.5 text-stone-500 transition-colors
hover:bg-stone-100 hover:text-stone-900
focus:outline-none focus:ring-2 focus:ring-ocean-200">
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true">
<path d="M18 6L6 18" />
<path d="M6 6l12 12" />
</svg>
</button>
</header>
<div className="flex-1 overflow-y-auto">
<div className="mx-auto max-w-4xl px-8 py-6">
<MemoryChunkDetail chunk={selectedChunk} onSelectEntity={handleSelectEntity} />
</div>
</div>
</div>
)}
</section>
);
}