mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-28 21:44:38 +00:00
286 lines
9.3 KiB
TypeScript
286 lines
9.3 KiB
TypeScript
/**
|
||
* Left pane of MemoryWorkspace — search box, slim heatmap header, and four
|
||
* collapsible lens sections (recent / sources / people / topics).
|
||
*
|
||
* Selections are NOT mutually exclusive: multiple selected items intersect
|
||
* the result list filter. Sections may be collapsed independently.
|
||
*/
|
||
import { useEffect, useMemo, useState } from 'react';
|
||
|
||
import { useT } from '../../lib/i18n/I18nContext';
|
||
import type { Chunk, EntityRef, Source } from '../../utils/tauriCommands';
|
||
import { MemoryHeatmap } from './MemoryHeatmap';
|
||
|
||
export interface NavigatorSelection {
|
||
sourceIds: string[];
|
||
entityIds: string[];
|
||
}
|
||
|
||
interface MemoryNavigatorProps {
|
||
chunks: Chunk[];
|
||
sources: Source[];
|
||
topPeople: EntityRef[];
|
||
topTopics: EntityRef[];
|
||
selection: NavigatorSelection;
|
||
onSelectionChange: (next: NavigatorSelection) => void;
|
||
searchQuery: string;
|
||
onSearchChange: (next: string) => void;
|
||
}
|
||
|
||
const HOUR_MS = 60 * 60 * 1000;
|
||
const DAY_MS = 24 * HOUR_MS;
|
||
|
||
function dotClassFor(status: string | undefined): string {
|
||
switch (status) {
|
||
case 'admitted':
|
||
return 'mw-dot dot-admitted';
|
||
case 'pending_extraction':
|
||
return 'mw-dot dot-pending';
|
||
case 'buffered':
|
||
return 'mw-dot dot-buffered';
|
||
case 'dropped':
|
||
return 'mw-dot dot-dropped';
|
||
default:
|
||
return 'mw-dot';
|
||
}
|
||
}
|
||
|
||
interface SectionProps {
|
||
label: string;
|
||
defaultOpen?: boolean;
|
||
countSummary?: string;
|
||
children: React.ReactNode;
|
||
}
|
||
|
||
function NavSection({ label, defaultOpen = true, countSummary, children }: SectionProps) {
|
||
const [open, setOpen] = useState(defaultOpen);
|
||
return (
|
||
<div className="mw-section">
|
||
<button
|
||
type="button"
|
||
className="mw-section-heading"
|
||
onClick={() => setOpen(o => !o)}
|
||
aria-expanded={open}>
|
||
<span>{label}</span>
|
||
{countSummary && (
|
||
<span
|
||
style={{
|
||
fontFamily: "'JetBrains Mono', monospace",
|
||
fontSize: 10,
|
||
letterSpacing: 0,
|
||
color: 'var(--ink-whisper)',
|
||
marginLeft: 8,
|
||
textTransform: 'none',
|
||
}}>
|
||
{countSummary}
|
||
</span>
|
||
)}
|
||
<span className={`mw-section-chev${open ? ' open' : ''}`} aria-hidden>
|
||
›
|
||
</span>
|
||
</button>
|
||
{open && children}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function MemoryNavigator({
|
||
chunks,
|
||
sources,
|
||
topPeople,
|
||
topTopics,
|
||
selection,
|
||
onSelectionChange,
|
||
searchQuery,
|
||
onSearchChange,
|
||
}: MemoryNavigatorProps) {
|
||
const { t } = useT();
|
||
const heatmapTimestamps = useMemo(
|
||
() => chunks.map(c => Math.floor(c.timestamp_ms / 1000)),
|
||
[chunks]
|
||
);
|
||
|
||
// Wall-clock-derived counts. Computed in an effect to keep render pure
|
||
// (the `react-hooks/components-and-hooks-must-be-pure` rule rejects a
|
||
// raw `Date.now()` call inside a `useMemo` body, since two equivalent
|
||
// renders could produce different values).
|
||
const [recentCounts, setRecentCounts] = useState<{ today: number; week: number }>({
|
||
today: 0,
|
||
week: 0,
|
||
});
|
||
useEffect(() => {
|
||
const now = Date.now();
|
||
const startOfDay = new Date(now);
|
||
startOfDay.setHours(0, 0, 0, 0);
|
||
const startOfDayMs = startOfDay.getTime();
|
||
const startOfWeekMs = now - 7 * DAY_MS;
|
||
let today = 0;
|
||
let week = 0;
|
||
for (const c of chunks) {
|
||
if (c.timestamp_ms >= startOfDayMs) today++;
|
||
if (c.timestamp_ms >= startOfWeekMs) week++;
|
||
}
|
||
setRecentCounts({ today, week });
|
||
}, [chunks]);
|
||
const { today: todayCount, week: weekCount } = recentCounts;
|
||
|
||
const toggleSource = (id: string) => {
|
||
const has = selection.sourceIds.includes(id);
|
||
onSelectionChange({
|
||
...selection,
|
||
sourceIds: has ? selection.sourceIds.filter(s => s !== id) : [...selection.sourceIds, id],
|
||
});
|
||
};
|
||
|
||
const toggleEntity = (id: string) => {
|
||
const has = selection.entityIds.includes(id);
|
||
const next = has ? selection.entityIds.filter(s => s !== id) : [...selection.entityIds, id];
|
||
console.debug(
|
||
'[ui-flow][memory-navigator] toggleEntity id=%s wasActive=%o next=%o',
|
||
id,
|
||
has,
|
||
next
|
||
);
|
||
onSelectionChange({ ...selection, entityIds: next });
|
||
};
|
||
|
||
const renderEntityList = (refs: EntityRef[]) => (
|
||
<ul className="mw-list">
|
||
{refs.map(ref => {
|
||
const tag = `${ref.kind}/${ref.surface.replace(/\s+/g, '-')}`;
|
||
// For tag-based selection we use the raw tag string; for entity_id we
|
||
// compare against tags on chunks. Match either form to be lenient.
|
||
const isActive =
|
||
selection.entityIds.includes(tag) || selection.entityIds.includes(ref.entity_id);
|
||
return (
|
||
<li key={ref.entity_id}>
|
||
<button
|
||
type="button"
|
||
className={`mw-list-item${isActive ? ' is-active' : ''}`}
|
||
onClick={() => toggleEntity(ref.entity_id)}
|
||
aria-pressed={isActive}>
|
||
<span className="mw-dot" aria-hidden />
|
||
<span className="mw-list-name" title={ref.surface}>
|
||
{ref.surface}
|
||
</span>
|
||
<span className="mw-list-count">{ref.count}</span>
|
||
</button>
|
||
</li>
|
||
);
|
||
})}
|
||
{refs.length === 0 && (
|
||
<li style={{ padding: '6px 16px', fontSize: 12, color: 'var(--ink-whisper)' }}>—</li>
|
||
)}
|
||
</ul>
|
||
);
|
||
|
||
return (
|
||
<aside className="mw-pane-navigator" data-testid="memory-navigator">
|
||
<div className="mw-search-row">
|
||
<input
|
||
type="text"
|
||
className="mw-search-input"
|
||
placeholder={t('memory.searchPlaceholder')}
|
||
value={searchQuery}
|
||
onChange={e => onSearchChange(e.target.value)}
|
||
aria-label={t('memory.search')}
|
||
/>
|
||
</div>
|
||
|
||
<div className="mw-heatmap-host" data-testid="memory-navigator-heatmap">
|
||
<MemoryHeatmap timestamps={heatmapTimestamps} />
|
||
</div>
|
||
|
||
<div className="mw-pane-scroll">
|
||
<NavSection label={t('navigator.recent')} defaultOpen>
|
||
<div className="mw-recent-summary">
|
||
<span>
|
||
{t('navigator.today')} {todayCount}
|
||
</span>
|
||
<span>
|
||
{t('navigator.thisWeek')} {weekCount}
|
||
</span>
|
||
</div>
|
||
</NavSection>
|
||
|
||
<NavSection
|
||
label={t('navigator.sources')}
|
||
defaultOpen
|
||
countSummary={String(sources.length)}>
|
||
{sources.length === 0 ? (
|
||
<ul className="mw-list">
|
||
<li style={{ padding: '6px 16px', fontSize: 12, color: 'var(--ink-whisper)' }}>—</li>
|
||
</ul>
|
||
) : (
|
||
(() => {
|
||
// Group sources by their source_kind ('email', 'slack', 'chat', …)
|
||
// and render each kind as its own nested collapsible. Lets the
|
||
// user filter at the kind level (drill into Email vs Slack) and
|
||
// then by individual sender within each.
|
||
const byKind = new Map<string, Source[]>();
|
||
for (const s of sources) {
|
||
const arr = byKind.get(s.source_kind) ?? [];
|
||
arr.push(s);
|
||
byKind.set(s.source_kind, arr);
|
||
}
|
||
const kindLabel: Record<string, string> = {
|
||
email: t('navigator.email'),
|
||
slack: t('navigator.slack'),
|
||
chat: t('navigator.chat'),
|
||
document: t('navigator.documents'),
|
||
};
|
||
const kinds = Array.from(byKind.entries()).sort((a, b) => b[1].length - a[1].length);
|
||
return (
|
||
<div>
|
||
{kinds.map(([kind, kindSources]) => (
|
||
<NavSection
|
||
key={kind}
|
||
label={kindLabel[kind] ?? kind}
|
||
defaultOpen={false}
|
||
countSummary={String(kindSources.length)}>
|
||
<ul className="mw-list">
|
||
{kindSources.map(src => {
|
||
const isActive = selection.sourceIds.includes(src.source_id);
|
||
return (
|
||
<li key={src.source_id}>
|
||
<button
|
||
type="button"
|
||
className={`mw-list-item${isActive ? ' is-active' : ''}`}
|
||
onClick={() => toggleSource(src.source_id)}
|
||
aria-pressed={isActive}>
|
||
<span className={dotClassFor(src.lifecycle_status)} aria-hidden />
|
||
<span className="mw-list-name" title={src.display_name}>
|
||
{src.display_name}
|
||
</span>
|
||
<span className="mw-list-count">{src.chunk_count}</span>
|
||
</button>
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
</NavSection>
|
||
))}
|
||
</div>
|
||
);
|
||
})()
|
||
)}
|
||
</NavSection>
|
||
|
||
<NavSection
|
||
label={t('navigator.people')}
|
||
defaultOpen
|
||
countSummary={String(topPeople.length)}>
|
||
{renderEntityList(topPeople)}
|
||
</NavSection>
|
||
|
||
<NavSection
|
||
label={t('navigator.topics')}
|
||
defaultOpen
|
||
countSummary={String(topTopics.length)}>
|
||
{renderEntityList(topTopics)}
|
||
</NavSection>
|
||
</div>
|
||
</aside>
|
||
);
|
||
}
|