feat(brain): Brain memory surface — center bottom-bar button → full-page knowledge graph (#3578)

This commit is contained in:
Cyrus Gray
2026-06-10 16:46:09 +05:30
committed by GitHub
parent 55522ad48b
commit 23b3830623
30 changed files with 1246 additions and 378 deletions
+202
View File
@@ -0,0 +1,202 @@
{
"v": "5.7.4",
"fr": 30,
"ip": 0,
"op": 90,
"w": 220,
"h": 220,
"nm": "brain-collecting",
"ddd": 0,
"assets": [],
"layers": [
{
"ddd": 0,
"ind": 1,
"ty": 4,
"nm": "ping",
"sr": 1,
"ks": {
"o": {
"a": 1,
"k": [
{ "t": 0, "s": [55], "i": { "x": [0.4], "y": [1] }, "o": { "x": [0.6], "y": [0] } },
{ "t": 90, "s": [0] }
]
},
"r": { "a": 0, "k": 0 },
"p": { "a": 0, "k": [110, 110, 0] },
"a": { "a": 0, "k": [0, 0, 0] },
"s": {
"a": 1,
"k": [
{
"t": 0,
"s": [40, 40],
"i": { "x": [0.4, 0.4], "y": [1, 1] },
"o": { "x": [0.6, 0.6], "y": [0, 0] }
},
{ "t": 90, "s": [150, 150] }
]
}
},
"ao": 0,
"shapes": [
{
"ty": "gr",
"it": [
{ "ty": "el", "p": { "a": 0, "k": [0, 0] }, "s": { "a": 0, "k": [120, 120] }, "d": 1 },
{
"ty": "st",
"c": { "a": 0, "k": [0.29, 0.514, 0.867, 1] },
"o": { "a": 0, "k": 100 },
"w": { "a": 0, "k": 3 },
"lc": 2,
"lj": 1
},
{
"ty": "tr",
"p": { "a": 0, "k": [0, 0] },
"a": { "a": 0, "k": [0, 0] },
"s": { "a": 0, "k": [100, 100] },
"r": { "a": 0, "k": 0 },
"o": { "a": 0, "k": 100 }
}
]
}
],
"ip": 0,
"op": 90,
"st": 0,
"bm": 0
},
{
"ddd": 0,
"ind": 2,
"ty": 4,
"nm": "nodes-ring",
"sr": 1,
"ks": {
"o": { "a": 0, "k": 100 },
"r": {
"a": 1,
"k": [
{ "t": 0, "s": [0], "i": { "x": [0.5], "y": [0.5] }, "o": { "x": [0.5], "y": [0.5] } },
{ "t": 90, "s": [360] }
]
},
"p": { "a": 0, "k": [110, 110, 0] },
"a": { "a": 0, "k": [0, 0, 0] },
"s": { "a": 0, "k": [100, 100] }
},
"ao": 0,
"shapes": [
{
"ty": "gr",
"it": [
{ "ty": "el", "p": { "a": 0, "k": [68, 0] }, "s": { "a": 0, "k": [16, 16] }, "d": 1 },
{
"ty": "el",
"p": { "a": 0, "k": [34, 58.89] },
"s": { "a": 0, "k": [12, 12] },
"d": 1
},
{
"ty": "el",
"p": { "a": 0, "k": [-34, 58.89] },
"s": { "a": 0, "k": [16, 16] },
"d": 1
},
{ "ty": "el", "p": { "a": 0, "k": [-68, 0] }, "s": { "a": 0, "k": [12, 12] }, "d": 1 },
{
"ty": "el",
"p": { "a": 0, "k": [-34, -58.89] },
"s": { "a": 0, "k": [16, 16] },
"d": 1
},
{
"ty": "el",
"p": { "a": 0, "k": [34, -58.89] },
"s": { "a": 0, "k": [12, 12] },
"d": 1
},
{
"ty": "fl",
"c": { "a": 0, "k": [0.435, 0.627, 0.902, 1] },
"o": { "a": 0, "k": 100 }
},
{
"ty": "tr",
"p": { "a": 0, "k": [0, 0] },
"a": { "a": 0, "k": [0, 0] },
"s": { "a": 0, "k": [100, 100] },
"r": { "a": 0, "k": 0 },
"o": { "a": 0, "k": 100 }
}
]
}
],
"ip": 0,
"op": 90,
"st": 0,
"bm": 0
},
{
"ddd": 0,
"ind": 3,
"ty": 4,
"nm": "core",
"sr": 1,
"ks": {
"o": { "a": 0, "k": 100 },
"r": { "a": 0, "k": 0 },
"p": { "a": 0, "k": [110, 110, 0] },
"a": { "a": 0, "k": [0, 0, 0] },
"s": {
"a": 1,
"k": [
{
"t": 0,
"s": [78, 78],
"i": { "x": [0.4, 0.4], "y": [1, 1] },
"o": { "x": [0.6, 0.6], "y": [0, 0] }
},
{
"t": 45,
"s": [112, 112],
"i": { "x": [0.4, 0.4], "y": [1, 1] },
"o": { "x": [0.6, 0.6], "y": [0, 0] }
},
{ "t": 90, "s": [78, 78] }
]
}
},
"ao": 0,
"shapes": [
{
"ty": "gr",
"it": [
{ "ty": "el", "p": { "a": 0, "k": [0, 0] }, "s": { "a": 0, "k": [56, 56] }, "d": 1 },
{
"ty": "fl",
"c": { "a": 0, "k": [0.29, 0.514, 0.867, 1] },
"o": { "a": 0, "k": 100 }
},
{
"ty": "tr",
"p": { "a": 0, "k": [0, 0] },
"a": { "a": 0, "k": [0, 0] },
"s": { "a": 0, "k": [100, 100] },
"r": { "a": 0, "k": 0 },
"o": { "a": 0, "k": 100 }
}
]
}
],
"ip": 0,
"op": 90,
"st": 0,
"bm": 0
}
],
"markers": []
}
+12
View File
@@ -7,6 +7,7 @@ import PublicRoute from './components/PublicRoute';
import { getIsMobile } from './lib/platform';
import Accounts from './pages/Accounts';
import Activity from './pages/Activity';
import Brain from './pages/Brain';
import Home from './pages/Home';
import Invites from './pages/Invites';
import Notifications from './pages/Notifications';
@@ -67,6 +68,17 @@ const AppRoutes = () => {
iOS AppRoutesIOS still serves /human natively — only desktop redirects. */}
<Route path="/human" element={<Navigate to="/chat" replace />} />
{/* Brain — the centerpiece memory knowledge-graph surface, reached from
the raised center button in the bottom bar. Full-page, graph-only. */}
<Route
path="/brain"
element={
<ProtectedRoute requireAuth={true}>
<Brain />
</ProtectedRoute>
}
/>
{/* Primary Activity surface — replaces /intelligence (Phase 3). */}
<Route
path="/activity"
+116 -42
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { AVATAR_MENU_ITEMS, NAV_TABS } from '../config/navConfig';
import { AVATAR_MENU_ITEMS, BRAIN_TAB, NAV_TABS } from '../config/navConfig';
import { useT } from '../lib/i18n/I18nContext';
import { useCoreState } from '../providers/CoreStateProvider';
import { trackEvent } from '../services/analytics';
@@ -91,6 +91,25 @@ function TabIcon({ id }: { id: string }) {
/>
</svg>
);
case 'brain':
// Two symmetric lobes — reads clearly as a brain. Rendered larger and
// white inside the raised center circle.
return (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M9.5 2A2.5 2.5 0 0112 4.5v15a2.5 2.5 0 01-4.96.44 2.5 2.5 0 01-2.96-3.08 3 3 0 01-.34-5.58 2.5 2.5 0 011.32-4.24 2.5 2.5 0 011.98-3A2.5 2.5 0 019.5 2z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M14.5 2A2.5 2.5 0 0012 4.5v15a2.5 2.5 0 004.96.44 2.5 2.5 0 002.96-3.08 3 3 0 00.34-5.58 2.5 2.5 0 00-1.32-4.24 2.5 2.5 0 00-1.98-3A2.5 2.5 0 0014.5 2z"
/>
</svg>
);
default:
return null;
}
@@ -209,6 +228,96 @@ const BottomTabBar = () => {
trackEvent('avatar_menu_item_click', { item_id: itemId });
};
// One regular pill tab. `iconOnly` renders just the glyph (no label) — used
// for the pinned Home button so it reads as a fixed icon, like the avatar.
//
// When labels are always visible (theme setting), every labelled tab is given
// the SAME fixed width so the row stays symmetric. In the default hover mode
// the label still expands on hover (no fixed width) — unchanged behaviour.
const renderTab = (tab: (typeof tabs)[number], iconOnly = false) => {
const active = isActive(tab.path);
const showBadge = tab.id === 'notifications' && unreadCount > 0;
const showCompanionDot = tab.id === 'settings' && companionActive;
const fixedWidth = !iconOnly && labelsAlwaysVisible;
return (
<button
key={tab.id}
data-walkthrough={tab.walkthroughAttr}
onClick={() => handleTabClick(tab, active)}
title={tab.label}
className={`group relative flex items-center rounded-sm text-sm transition-colors duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] cursor-pointer ${
iconOnly
? 'h-9 w-9 justify-center'
: fixedWidth
? 'w-32 justify-center px-2 py-2'
: 'px-2 py-2'
} ${
active
? 'bg-white dark:bg-neutral-800 text-stone-900 dark:text-neutral-100 font-semibold shadow-sm'
: 'bg-transparent text-stone-500 dark:text-neutral-400 hover:bg-stone-300/50 dark:hover:bg-neutral-800/60 hover:text-stone-700 dark:hover:text-neutral-200'
}`}
aria-label={
tab.id === 'notifications' && unreadCount > 0
? `${tab.label} (${unreadCount} ${t('alerts.unread')})`
: tab.label
}>
<span className="relative inline-flex flex-shrink-0">
<TabIcon id={tab.id} />
{showBadge && (
<span className="absolute -top-1 -right-1 min-w-[14px] h-[14px] px-1 rounded-full bg-coral-500 text-[9px] font-bold text-white flex items-center justify-center leading-none">
{unreadCount > 9 ? '9+' : unreadCount}
</span>
)}
{showCompanionDot && (
<span className="absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full bg-blue-500 animate-pulse" />
)}
</span>
{!iconOnly && (
<span
className={`min-w-0 overflow-hidden whitespace-nowrap transition-[max-width,margin-left,opacity] duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] ${
active || labelsAlwaysVisible
? `${fixedWidth ? 'truncate ' : ''}max-w-[160px] ml-2 opacity-100`
: 'max-w-0 ml-0 opacity-0 group-hover:max-w-[160px] group-hover:ml-2 group-hover:opacity-100 group-focus-visible:max-w-[160px] group-focus-visible:ml-2 group-focus-visible:opacity-100'
}`}>
{tab.label}
</span>
)}
</button>
);
};
// The Brain — a raised circular button rising out of the center of the bar.
// The bg-colored ring fakes a notch cut into the pill's top edge. `brain-fab`
// is targeted by the reduced-motion gate in index.css to silence the glow.
const renderBrainButton = () => {
const active = isActive(BRAIN_TAB.path);
const brainTab = { ...BRAIN_TAB, label: t(BRAIN_TAB.labelKey) };
return (
<button
key="brain"
type="button"
data-walkthrough={BRAIN_TAB.walkthroughAttr}
onClick={() => handleTabClick(brainTab, active)}
aria-label={brainTab.label}
title={brainTab.label}
className={`brain-fab group relative mx-1 flex h-12 w-12 -translate-y-5 items-center justify-center rounded-full text-white shadow-soft ring-4 ring-stone-200 transition-all duration-300 ease-[cubic-bezier(0.22,1,0.36,1)] cursor-pointer dark:ring-neutral-900 ${
active
? 'bg-primary-600 animate-glow-pulse shadow-[0_0_16px_rgba(74,131,221,0.55)] scale-105'
: 'bg-primary-500 hover:bg-primary-600 hover:scale-105'
}`}>
<TabIcon id="brain" />
</button>
);
};
// Home is pinned to the far-left of the pill behind a divider — mirroring the
// avatar pinned to the far-right behind its own divider. The rest of the row
// splits evenly around the centered Brain button:
// [ home ] | assistant · connections ( 🧠 ) activity · settings | [ avatar ]
const homeTab = tabs[0];
const leftTabs = tabs.slice(1, 3);
const rightTabs = tabs.slice(3);
return (
// pointer-events-none on the full-width shell so transparent areas (e.g.
// beside the centered nav pill) do not steal clicks from sticky footers
@@ -233,47 +342,12 @@ const BottomTabBar = () => {
if (!e.currentTarget.contains(e.relatedTarget as Node)) setRevealed(false);
}}>
<nav className="pointer-events-auto inline-flex items-center gap-1 rounded-sm border border-stone-300 dark:border-neutral-700 bg-stone-200 dark:bg-neutral-900 shadow-soft px-1 py-1">
{tabs.map(tab => {
const active = isActive(tab.path);
const showBadge = tab.id === 'notifications' && unreadCount > 0;
const showCompanionDot = tab.id === 'settings' && companionActive;
return (
<button
key={tab.id}
data-walkthrough={tab.walkthroughAttr}
onClick={() => handleTabClick(tab, active)}
className={`group relative flex items-center px-2 py-2 rounded-sm text-sm transition-colors duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] cursor-pointer ${
active
? 'bg-white dark:bg-neutral-800 text-stone-900 dark:text-neutral-100 font-semibold shadow-sm'
: 'bg-transparent text-stone-500 dark:text-neutral-400 hover:bg-stone-300/50 dark:hover:bg-neutral-800/60 hover:text-stone-700 dark:hover:text-neutral-200'
}`}
aria-label={
tab.id === 'notifications' && unreadCount > 0
? `${tab.label} (${unreadCount} ${t('alerts.unread')})`
: tab.label
}>
<span className="relative inline-flex flex-shrink-0">
<TabIcon id={tab.id} />
{showBadge && (
<span className="absolute -top-1 -right-1 min-w-[14px] h-[14px] px-1 rounded-full bg-coral-500 text-[9px] font-bold text-white flex items-center justify-center leading-none">
{unreadCount > 9 ? '9+' : unreadCount}
</span>
)}
{showCompanionDot && (
<span className="absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full bg-blue-500 animate-pulse" />
)}
</span>
<span
className={`overflow-hidden whitespace-nowrap transition-[max-width,margin-left,opacity] duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] ${
active || labelsAlwaysVisible
? 'max-w-[160px] ml-2 opacity-100'
: 'max-w-0 ml-0 opacity-0 group-hover:max-w-[160px] group-hover:ml-2 group-hover:opacity-100 group-focus-visible:max-w-[160px] group-focus-visible:ml-2 group-focus-visible:opacity-100'
}`}>
{tab.label}
</span>
</button>
);
})}
<div className="relative mr-1 border-r border-stone-300 pr-1 dark:border-neutral-700">
{renderTab(homeTab, true)}
</div>
{leftTabs.map(tab => renderTab(tab))}
{renderBrainButton()}
{rightTabs.map(tab => renderTab(tab))}
<div
className="relative ml-1 border-l border-stone-300 pl-1 dark:border-neutral-700"
ref={profileMenuRef}>
@@ -156,14 +156,39 @@ describe('BottomTabBar', () => {
agentProfilesApiMock.select.mockResolvedValue(testProfiles);
});
it('renders exactly 5 tab buttons (Phase 6: Human merged into Assistant)', async () => {
it('renders exactly 5 regular tab buttons (Brain is rendered separately)', async () => {
await renderBottomTabBar('/home');
// Query only buttons inside <nav> to exclude the avatar button
// Query only the regular pill tabs inside <nav>: exclude the avatar button
// (aria-haspopup) and the special raised Brain button (tab-brain).
const nav = document.querySelector('nav');
const navButtons = nav?.querySelectorAll('button:not([aria-haspopup])');
const navButtons = nav?.querySelectorAll(
'button:not([aria-haspopup]):not([data-walkthrough="tab-brain"])'
);
expect(navButtons).toHaveLength(5);
});
it('renders the raised Brain button with data-walkthrough="tab-brain"', async () => {
await renderBottomTabBar('/home');
const brainBtn = screen.getByRole('button', { name: 'Brain' });
expect(brainBtn).toBeInTheDocument();
expect(brainBtn).toHaveAttribute('data-walkthrough', 'tab-brain');
expect(brainBtn).toHaveClass('brain-fab');
});
it('navigates to /brain and tracks the change when the Brain button is clicked', async () => {
const { trackEvent } = await import('../../services/analytics');
await renderBottomTabBar('/home');
fireEvent.click(screen.getByRole('button', { name: 'Brain' }));
expect(trackEvent).toHaveBeenCalledWith('tab_bar_change', {
from_tab: 'home',
to_tab: 'brain',
from_path: '/home',
to_path: '/brain',
});
});
it('does NOT render a Rewards tab', async () => {
await renderBottomTabBar('/home');
expect(screen.queryByRole('button', { name: 'Rewards' })).toBeNull();
@@ -0,0 +1,324 @@
/**
* MemoryControls — the unified action toolbar for the memory surface.
*
* Extracted from `MemoryWorkspace` so the Brain page and Settings → Memory
* share one elegant, consistent control bar instead of a row of mismatched,
* differently-coloured buttons.
*
* Visual language (deliberate hierarchy, not a rainbow):
* - Trees / Contacts — a segmented graph-mode toggle on the left.
* - Build summary trees — the single primary (filled) action.
* - Refresh · View vault — quiet ghost buttons.
* - Reset memory · Reset memory tree — destructive, intentionally muted and
* set apart behind a divider; they only reveal their warning tint on hover.
*
* The parent owns the graph fetch: every mutation (and Refresh) calls
* `onRefresh()` so the caller re-pulls the graph on its own cadence.
*/
import { useCallback, useState } from 'react';
import { useT } from '../../lib/i18n/I18nContext';
import type { ToastNotification } from '../../types/intelligence';
import {
type GraphMode,
memoryTreeFlushNow,
memoryTreeResetTree,
memoryTreeWipeAll,
} from '../../utils/tauriCommands';
import { ObsidianVaultSection } from './ObsidianVaultSection';
interface MemoryControlsProps {
mode: GraphMode;
onModeChange: (next: GraphMode) => void;
/** Re-pull the graph — parent owns the fetch. Called after every mutation. */
onRefresh: () => void;
onToast?: (toast: Omit<ToastNotification, 'id'>) => void;
/** Absolute content root (from graph export); enables the View vault button. */
contentRootAbs?: string | null;
}
// ── Shared button system ──────────────────────────────────────────────────────
const BTN_BASE =
'inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-50 focus:outline-none focus:ring-2';
const BTN_PRIMARY = `${BTN_BASE} bg-primary-500 text-white shadow-sm hover:bg-primary-600 focus:ring-primary-200`;
const BTN_GHOST = `${BTN_BASE} border border-stone-200 bg-white text-stone-700 shadow-sm hover:bg-stone-50 focus:ring-stone-200 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-200 dark:hover:bg-neutral-800 dark:focus:ring-neutral-700`;
// Destructive actions read as proper (bordered) buttons but stay muted until
// hover, when they reveal their warning tint.
const BTN_MUTED = `${BTN_BASE} border border-stone-200 bg-white text-stone-500 shadow-sm focus:ring-stone-200 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-400 dark:focus:ring-neutral-700`;
export function MemoryControls({
mode,
onModeChange,
onRefresh,
onToast,
contentRootAbs,
}: MemoryControlsProps) {
const { t } = useT();
const [building, setBuilding] = useState(false);
const [wiping, setWiping] = useState(false);
const [resetting, setResetting] = useState(false);
const busy = building || wiping || resetting;
const handleWipe = useCallback(async () => {
// Two-step confirm so accidental clicks can't nuke a workspace.
if (!window.confirm(t('workspace.wipeConfirm'))) return;
setWiping(true);
try {
const resp = await memoryTreeWipeAll();
onToast?.({
type: 'success',
title: 'Memory wiped',
message:
`Removed ${resp.rows_deleted.toLocaleString()} row(s) and ` +
`${resp.dirs_removed.length} folder(s); cleared ` +
`${resp.sync_state_cleared.toLocaleString()} sync-state cursor(s). ` +
`Click Sync on a connected source to repopulate.`,
});
// Re-pull immediately so the canvas reflects the wipe.
onRefresh();
} catch (err) {
console.error('[ui-flow][memory-controls] wipe_all failed', err);
onToast?.({
type: 'error',
title: 'Reset failed',
message: err instanceof Error ? err.message : String(err),
});
} finally {
setWiping(false);
}
}, [onToast, onRefresh, t]);
const handleResetTree = useCallback(async () => {
if (!window.confirm(t('workspace.resetTreeConfirm'))) return;
setResetting(true);
try {
const resp = await memoryTreeResetTree();
onToast?.({
type: 'success',
title: 'Memory tree rebuilding',
message:
`Cleared ${resp.tree_rows_deleted.toLocaleString()} tree row(s); ` +
`requeued ${resp.chunks_requeued.toLocaleString()} chunk(s) ` +
`(${resp.jobs_enqueued.toLocaleString()} extract jobs). ` +
`The graph will fill back in as the worker drains.`,
});
// reset_tree restarts from extract jobs (slower than seal-only) — give the
// worker a longer head start than build does before re-pulling.
setTimeout(() => onRefresh(), 8000);
} catch (err) {
console.error('[ui-flow][memory-controls] reset_tree failed', err);
onToast?.({
type: 'error',
title: 'Could not reset memory tree',
message: err instanceof Error ? err.message : String(err),
});
} finally {
setResetting(false);
}
}, [onToast, onRefresh, t]);
const handleBuildTrees = useCallback(async () => {
setBuilding(true);
try {
const resp = await memoryTreeFlushNow();
onToast?.({
type: resp.enqueued ? 'success' : 'info',
title: resp.enqueued
? `Building summary trees · ${resp.stale_buffers} buffer(s)`
: 'Build already in progress',
message: resp.enqueued
? 'Force-sealing every L0 buffer through the configured AI summariser. The graph will refresh once the worker drains.'
: 'A flush job for today is already queued — no new work needed.',
});
// The seal cascade runs async on the worker pool; 4s covers the typical
// case without making the UI feel stuck.
setTimeout(() => onRefresh(), 4000);
} catch (err) {
console.error('[ui-flow][memory-controls] flush_now failed', err);
onToast?.({
type: 'error',
title: 'Could not build summary trees',
message: err instanceof Error ? err.message : String(err),
});
} finally {
setBuilding(false);
}
}, [onToast, onRefresh]);
return (
<div className="flex flex-wrap items-center justify-between gap-3" data-testid="memory-actions">
<ModeToggle mode={mode} onChange={onModeChange} />
<div className="flex flex-wrap items-center gap-2">
{/* Destructive actions — muted, set apart behind a divider. */}
<button
type="button"
onClick={handleWipe}
disabled={busy}
data-testid="memory-wipe-all"
className={`${BTN_MUTED} hover:border-coral-300 hover:bg-coral-50 hover:text-coral-600 dark:hover:border-coral-500/30 dark:hover:bg-coral-500/10 dark:hover:text-coral-300`}
title={t('workspace.wipeTitle')}>
{wiping ? <Spinner /> : <TrashIcon />}
{wiping ? t('workspace.resetting') : t('workspace.resetMemory')}
</button>
<button
type="button"
onClick={handleResetTree}
disabled={busy}
data-testid="memory-reset-tree"
className={`${BTN_MUTED} hover:border-amber-300 hover:bg-amber-50 hover:text-amber-700 dark:hover:border-amber-500/30 dark:hover:bg-amber-500/10 dark:hover:text-amber-300`}
title={t('workspace.resetTreeTitle')}>
{resetting ? <Spinner /> : <RefreshIcon />}
{resetting ? t('workspace.rebuilding') : t('workspace.resetMemoryTree')}
</button>
<span aria-hidden className="mx-1 h-5 w-px self-center bg-stone-200 dark:bg-neutral-700" />
{/* Secondary actions — quiet ghost buttons. */}
<button
type="button"
onClick={onRefresh}
data-testid="memory-graph-refresh"
className={BTN_GHOST}
title={t('common.refresh')}>
<RefreshIcon /> {t('common.refresh')}
</button>
{contentRootAbs ? (
<ObsidianVaultSection contentRootAbs={contentRootAbs} onToast={onToast} />
) : null}
{/* Primary action. */}
<button
type="button"
onClick={handleBuildTrees}
disabled={building}
data-testid="memory-build-trees"
className={BTN_PRIMARY}>
{building ? <Spinner /> : <BrainIcon />}
{building ? t('workspace.building') : t('workspace.buildSummaryTrees')}
</button>
</div>
</div>
);
}
// ── Mode toggle ───────────────────────────────────────────────────────────────
interface ModeToggleProps {
mode: GraphMode;
onChange: (next: GraphMode) => void;
}
function ModeToggle({ mode, onChange }: ModeToggleProps) {
const { t } = useT();
const baseBtn =
'px-3.5 py-1.5 text-xs font-semibold rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary-200';
const active = 'bg-primary-500 text-white shadow-sm';
const idle =
'text-stone-600 hover:text-stone-900 dark:text-neutral-300 dark:hover:text-neutral-100';
return (
<div
className="inline-flex items-center gap-1 rounded-full border border-stone-200 bg-stone-50 p-1 dark:border-neutral-800 dark:bg-neutral-800/60"
role="tablist"
aria-label={t('workspace.graphViewMode')}
data-testid="memory-graph-mode-toggle">
<button
type="button"
onClick={() => onChange('tree')}
className={`${baseBtn} ${mode === 'tree' ? active : idle}`}
role="tab"
aria-selected={mode === 'tree'}
data-testid="memory-graph-mode-tree">
{t('workspace.trees')}
</button>
<button
type="button"
onClick={() => onChange('contacts')}
className={`${baseBtn} ${mode === 'contacts' ? active : idle}`}
role="tab"
aria-selected={mode === 'contacts'}
data-testid="memory-graph-mode-contacts">
{t('workspace.contacts')}
</button>
</div>
);
}
// ── Tiny inline icons (no extra dep) ──────────────────────────────────────────
function RefreshIcon() {
return (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true">
<path d="M21 12a9 9 0 11-3-6.7" />
<path d="M21 4v5h-5" />
<path d="M3 12a9 9 0 003 6.7" />
<path d="M3 20v-5h5" />
</svg>
);
}
function TrashIcon() {
return (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true">
<path d="M3 6h18" />
<path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2" />
<path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6" />
<path d="M10 11v6" />
<path d="M14 11v6" />
</svg>
);
}
function BrainIcon() {
return (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true">
<path d="M9.5 2A2.5 2.5 0 0112 4.5v15a2.5 2.5 0 01-4.96.44 2.5 2.5 0 01-2.96-3.08 3 3 0 01-.34-5.58 2.5 2.5 0 011.32-4.24 2.5 2.5 0 011.98-3A2.5 2.5 0 019.5 2z" />
<path d="M14.5 2A2.5 2.5 0 0012 4.5v15a2.5 2.5 0 004.96.44 2.5 2.5 0 002.96-3.08 3 3 0 00.34-5.58 2.5 2.5 0 00-1.32-4.24 2.5 2.5 0 00-1.98-3A2.5 2.5 0 0014.5 2z" />
</svg>
);
}
function Spinner() {
return (
<svg
className="animate-spin"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
aria-hidden="true">
<circle cx="12" cy="12" r="9" opacity="0.25" />
<path d="M21 12a9 9 0 00-9-9" />
</svg>
);
}
@@ -68,6 +68,22 @@ describe('<MemoryGraph />', () => {
expect(screen.getByTestId('memory-graph-empty')).toBeInTheDocument();
});
it('fires onReady once the layout settles (synchronous SVG path under jsdom)', async () => {
const onReady = vi.fn();
const nodes = [
makeSummaryNode({ id: 'root', level: 0, parent_id: null }),
makeSummaryNode({ id: 'child', level: 1, parent_id: 'root' }),
];
render(<MemoryGraph nodes={nodes} edges={[]} mode="tree" onReady={onReady} />);
await waitFor(() => expect(onReady).toHaveBeenCalledTimes(1));
});
it('does not fire onReady for an empty graph (nothing to lay out)', () => {
const onReady = vi.fn();
render(<MemoryGraph nodes={[]} edges={[]} mode="tree" onReady={onReady} />);
expect(onReady).not.toHaveBeenCalled();
});
it('renders an SVG with one circle per node in tree mode', () => {
const nodes = [
makeSummaryNode({ id: 'root', level: 0, parent_id: null }),
@@ -101,6 +101,12 @@ interface MemoryGraphProps {
mode: GraphMode;
/** Optional override for the empty-state message. */
emptyHint?: string;
/**
* Fired exactly once when the graph's force layout first settles (SVG
* worker `end`, the synchronous relax fallback, or the Pixi sim cooling).
* A loading overlay (e.g. the Brain page) waits on this to reveal the graph.
*/
onReady?: () => void;
}
interface SummaryPreviewState {
@@ -186,9 +192,22 @@ function relaxLayout(nodes: SimNode[], edges: Array<[number, number]>, iteration
}
}
export function MemoryGraph({ nodes, edges, mode, emptyHint }: MemoryGraphProps) {
export function MemoryGraph({ nodes, edges, mode, emptyHint, onReady }: MemoryGraphProps) {
const { t } = useT();
const [hovered, setHovered] = useState<GraphNode | null>(null);
// Fire `onReady` at most once across this component's lifetime. The latest
// callback is held in a ref so `fireReady` stays stable (the SVG layout hook
// depends on a stable `onSettled`, and the guard prevents refires on reheat).
const onReadyRef = useRef(onReady);
onReadyRef.current = onReady;
const readyFiredRef = useRef(false);
const fireReady = useCallback(() => {
if (readyFiredRef.current) return;
readyFiredRef.current = true;
console.debug('[memory-graph] layout settled → onReady');
onReadyRef.current?.();
}, []);
const [preview, setPreview] = useState<SummaryPreviewState | null>(null);
const [previewingPath, setPreviewingPath] = useState<string | null>(null);
const svgRef = useRef<SVGSVGElement | null>(null);
@@ -476,6 +495,12 @@ export function MemoryGraph({ nodes, edges, mode, emptyHint }: MemoryGraphProps)
else run();
}, [applyPositions]);
// Frame the graph then signal readiness once the worker layout cools.
const onSvgSettled = useCallback(() => {
fitToView();
fireReady();
}, [fitToView, fireReady]);
// SVG fallback layout runs in a worker (off the main thread); positions
// stream back and are applied imperatively. No-op on WebGL and where workers
// are unavailable (the synchronous relaxLayout above covers that case).
@@ -487,10 +512,18 @@ export function MemoryGraph({ nodes, edges, mode, emptyHint }: MemoryGraphProps)
SVG_CENTER,
sim?.alpha ?? 1,
scheduleApply,
fitToView
onSvgSettled
);
stopLayoutRef.current = svgLayout.stop;
// Synchronous-layout path (no WebGL, no Worker — e.g. jsdom under test):
// `relaxLayout` already ran inside the `sim` memo, so the graph is laid out
// as soon as `sim` exists. Signal readiness on the next tick.
useEffect(() => {
if (useWebGL || WORKER_SUPPORTED || !sim) return;
fireReady();
}, [useWebGL, sim, fireReady]);
// Ramp the rest in per-frame batches (setState only inside the rAF callback).
useEffect(() => {
if (useWebGL || !sim) return;
@@ -593,6 +626,7 @@ export function MemoryGraph({ nodes, edges, mode, emptyHint }: MemoryGraphProps)
if (n.kind === 'summary') void openSummary(n);
}}
onError={() => setPixiFailed(true)}
onReady={fireReady}
/>
) : (
<svg
@@ -32,22 +32,19 @@
* configured cloud or local LLM and the new summary nodes appear in
* the graph after the worker drains.
*/
import { useCallback, useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import { useT } from '../../lib/i18n/I18nContext';
import type { ToastNotification } from '../../types/intelligence';
import {
type GraphExportResponse,
type GraphMode,
memoryTreeFlushNow,
memoryTreeGraphExport,
memoryTreeResetTree,
memoryTreeWipeAll,
} from '../../utils/tauriCommands';
import { MemoryControls } from './MemoryControls';
import { MemoryGraph } from './MemoryGraph';
import { MemorySourcesRegistry } from './MemorySourcesRegistry';
import { MemoryTreeStatusPanel } from './MemoryTreeStatusPanel';
import { ObsidianVaultSection } from './ObsidianVaultSection';
import { SyncAuditPanel } from './SyncAuditPanel';
import { WhatsAppMemorySection } from './WhatsAppMemorySection';
@@ -59,9 +56,6 @@ export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) {
const { t } = useT();
const [graph, setGraph] = useState<GraphExportResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const [building, setBuilding] = useState(false);
const [wiping, setWiping] = useState(false);
const [resetting, setResetting] = useState(false);
const [mode, setMode] = useState<GraphMode>('tree');
const [graphVersion, setGraphVersion] = useState(0);
@@ -127,209 +121,19 @@ export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) {
return () => clearInterval(id);
}, []);
const handleWipe = useCallback(async () => {
// Two-step confirm so accidental clicks can't nuke a workspace.
const ok = window.confirm(t('workspace.wipeConfirm'));
if (!ok) return;
setWiping(true);
try {
const resp = await memoryTreeWipeAll();
onToast?.({
type: 'success',
title: 'Memory wiped',
message:
`Removed ${resp.rows_deleted.toLocaleString()} row(s) and ` +
`${resp.dirs_removed.length} folder(s); cleared ` +
`${resp.sync_state_cleared.toLocaleString()} sync-state cursor(s). ` +
`Click Sync on a connected source to repopulate.`,
});
// Re-fetch the (now empty) graph immediately so the canvas
// reflects the wipe instead of staying frozen on stale data.
try {
const next = await memoryTreeGraphExport(mode);
setGraph(next);
} catch (err) {
console.warn('[ui-flow][memory-workspace] post-wipe graph refresh failed', err);
}
} catch (err) {
console.error('[ui-flow][memory-workspace] wipe_all failed', err);
onToast?.({
type: 'error',
title: 'Reset failed',
message: err instanceof Error ? err.message : String(err),
});
} finally {
setWiping(false);
}
}, [onToast, mode]);
const handleResetTree = useCallback(async () => {
const ok = window.confirm(t('workspace.resetTreeConfirm'));
if (!ok) return;
setResetting(true);
try {
const resp = await memoryTreeResetTree();
onToast?.({
type: 'success',
title: 'Memory tree rebuilding',
message:
`Cleared ${resp.tree_rows_deleted.toLocaleString()} tree row(s); ` +
`requeued ${resp.chunks_requeued.toLocaleString()} chunk(s) ` +
`(${resp.jobs_enqueued.toLocaleString()} extract jobs). ` +
`The graph will fill back in as the worker drains.`,
});
// Stagger the graph re-fetch a bit longer than build_trees does —
// reset_tree starts from extract jobs (slower than seal-only).
setTimeout(() => {
void (async () => {
try {
const next = await memoryTreeGraphExport(mode);
setGraph(next);
} catch (err) {
console.warn('[ui-flow][memory-workspace] post-reset graph refresh failed', err);
}
})();
}, 8000);
} catch (err) {
console.error('[ui-flow][memory-workspace] reset_tree failed', err);
onToast?.({
type: 'error',
title: 'Could not reset memory tree',
message: err instanceof Error ? err.message : String(err),
});
} finally {
setResetting(false);
}
}, [onToast, mode]);
const handleBuildTrees = useCallback(async () => {
setBuilding(true);
try {
const resp = await memoryTreeFlushNow();
onToast?.({
type: resp.enqueued ? 'success' : 'info',
title: resp.enqueued
? `Building summary trees · ${resp.stale_buffers} buffer(s)`
: 'Build already in progress',
message: resp.enqueued
? 'Force-sealing every L0 buffer through the configured AI summariser. The graph will refresh once the worker drains.'
: 'A flush job for today is already queued — no new work needed.',
});
// Re-fetch the graph after a short delay so newly-sealed
// summaries appear in the view. The seal cascade runs async on
// the worker pool; 4s is enough for the typical case without
// making the UI feel stuck.
setTimeout(() => {
void (async () => {
try {
const next = await memoryTreeGraphExport(mode);
setGraph(next);
} catch (err) {
console.warn('[ui-flow][memory-workspace] post-build graph refresh failed', err);
}
})();
}, 4000);
} catch (err) {
console.error('[ui-flow][memory-workspace] flush_now failed', err);
onToast?.({
type: 'error',
title: 'Could not build summary trees',
message: err instanceof Error ? err.message : String(err),
});
} finally {
setBuilding(false);
}
}, [onToast, mode]);
return (
<div className="space-y-4" data-testid="memory-workspace">
<MemoryTreeStatusPanel onToast={onToast} />
<MemorySourcesRegistry onToast={onToast} />
<WhatsAppMemorySection />
<div
className="flex flex-wrap items-center justify-between gap-3"
data-testid="memory-actions">
<ModeToggle mode={mode} onChange={setMode} />
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => setGraphVersion(v => v + 1)}
data-testid="memory-graph-refresh"
className="inline-flex items-center gap-2 rounded-lg
border border-stone-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-4 py-2 text-sm font-semibold
text-stone-700 dark:text-neutral-200 shadow-sm transition-colors hover:bg-stone-50 dark:hover:bg-neutral-800
focus:outline-none focus:ring-2 focus:ring-stone-200"
title={t('common.refresh')}>
<RefreshIcon /> {t('common.refresh')}
</button>
<button
type="button"
onClick={handleWipe}
disabled={wiping || building}
data-testid="memory-wipe-all"
className="inline-flex items-center gap-2 rounded-lg
border border-coral-200 dark:border-coral-500/30 bg-white dark:bg-neutral-900 px-4 py-2 text-sm font-semibold
text-coral-700 dark:text-coral-300 shadow-sm transition-colors hover:bg-coral-50 dark:hover:bg-coral-500/10
disabled:cursor-not-allowed disabled:opacity-50
focus:outline-none focus:ring-2 focus:ring-coral-200"
title={t('workspace.wipeTitle')}>
{wiping ? (
<>
<Spinner /> {t('workspace.resetting')}
</>
) : (
<>
<TrashIcon /> {t('workspace.resetMemory')}
</>
)}
</button>
<button
type="button"
onClick={handleResetTree}
disabled={resetting || wiping || building}
data-testid="memory-reset-tree"
className="inline-flex items-center gap-2 rounded-lg
border border-amber-300 dark:border-amber-500/30 bg-white dark:bg-neutral-900 px-4 py-2 text-sm font-semibold
text-amber-800 dark:text-amber-300 shadow-sm transition-colors hover:bg-amber-50 dark:hover:bg-amber-500/10
disabled:cursor-not-allowed disabled:opacity-50
focus:outline-none focus:ring-2 focus:ring-amber-200"
title={t('workspace.resetTreeTitle')}>
{resetting ? (
<>
<Spinner /> {t('workspace.rebuilding')}
</>
) : (
<>
<RefreshIcon /> {t('workspace.resetMemoryTree')}
</>
)}
</button>
<button
type="button"
onClick={handleBuildTrees}
disabled={building}
data-testid="memory-build-trees"
className="inline-flex items-center gap-2 rounded-lg
bg-primary-500 px-4 py-2 text-sm font-semibold text-white
shadow-sm transition-colors hover:bg-primary-600
disabled:cursor-not-allowed disabled:opacity-50
focus:outline-none focus:ring-2 focus:ring-primary-200">
{building ? (
<>
<Spinner /> {t('workspace.building')}
</>
) : (
<>
<BrainIcon /> {t('workspace.buildSummaryTrees')}
</>
)}
</button>
{graph && (
<ObsidianVaultSection contentRootAbs={graph.content_root_abs} onToast={onToast} />
)}
</div>
</div>
<MemoryControls
mode={mode}
onModeChange={setMode}
onRefresh={() => setGraphVersion(v => v + 1)}
onToast={onToast}
contentRootAbs={graph?.content_root_abs}
/>
{error ? (
<div className="rounded-lg border border-coral-200 dark:border-coral-500/30 bg-coral-50 dark:bg-coral-500/10 px-4 py-3 text-sm text-coral-800">
@@ -352,122 +156,3 @@ export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) {
</div>
);
}
interface ModeToggleProps {
mode: GraphMode;
onChange: (next: GraphMode) => void;
}
function ModeToggle({ mode, onChange }: ModeToggleProps) {
const { t } = useT();
const baseBtn =
'px-3 py-1.5 text-xs font-medium rounded-md transition-colors focus:outline-none focus:ring-2 focus:ring-primary-200';
const active = 'bg-primary-500 text-white shadow-sm';
const idle =
'bg-white dark:bg-neutral-900 text-stone-600 dark:text-neutral-300 hover:bg-stone-50 dark:hover:bg-neutral-800/60';
return (
<div
className="inline-flex items-center gap-1 rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-1"
role="tablist"
aria-label={t('workspace.graphViewMode')}
data-testid="memory-graph-mode-toggle">
<button
type="button"
onClick={() => onChange('tree')}
className={`${baseBtn} ${mode === 'tree' ? active : idle}`}
role="tab"
aria-selected={mode === 'tree'}
data-testid="memory-graph-mode-tree">
{t('workspace.trees')}
</button>
<button
type="button"
onClick={() => onChange('contacts')}
className={`${baseBtn} ${mode === 'contacts' ? active : idle}`}
role="tab"
aria-selected={mode === 'contacts'}
data-testid="memory-graph-mode-contacts">
{t('workspace.contacts')}
</button>
</div>
);
}
// ── Tiny inline icons (no extra dep) ────────────────────────────────────
function RefreshIcon() {
return (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true">
<path d="M21 12a9 9 0 11-3-6.7" />
<path d="M21 4v5h-5" />
<path d="M3 12a9 9 0 003 6.7" />
<path d="M3 20v-5h5" />
</svg>
);
}
function TrashIcon() {
return (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true">
<path d="M3 6h18" />
<path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2" />
<path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6" />
<path d="M10 11v6" />
<path d="M14 11v6" />
</svg>
);
}
function BrainIcon() {
return (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true">
<path d="M9 4.5a2.5 2.5 0 015 0v15a2.5 2.5 0 01-5 0" />
<path d="M9 4.5A2.5 2.5 0 116.5 7M9 19.5A2.5 2.5 0 116.5 17" />
<path d="M14 4.5A2.5 2.5 0 1117.5 7M14 19.5A2.5 2.5 0 1017.5 17" />
</svg>
);
}
function Spinner() {
return (
<svg
className="animate-spin"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
aria-hidden="true">
<circle cx="12" cy="12" r="9" opacity="0.25" />
<path d="M21 12a9 9 0 00-9-9" />
</svg>
);
}
@@ -198,11 +198,13 @@ export function ObsidianVaultSection({ contentRootAbs, onToast }: ObsidianVaultS
onClick={handleViewVault}
disabled={checking}
data-testid="memory-open-in-obsidian"
className="inline-flex items-center gap-2 rounded-lg
bg-violet-500 px-4 py-2 text-sm font-semibold text-white
shadow-sm transition-colors hover:bg-violet-600
className="inline-flex items-center gap-1.5 rounded-lg
border border-stone-200 bg-white px-3 py-1.5 text-xs font-semibold
text-stone-700 shadow-sm transition-colors hover:bg-stone-50
disabled:cursor-not-allowed disabled:opacity-50
focus:outline-none focus:ring-2 focus:ring-violet-300"
focus:outline-none focus:ring-2 focus:ring-stone-200
dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-200
dark:hover:bg-neutral-800 dark:focus:ring-neutral-700"
title={`obsidian://open?path=${contentRootAbs}`}>
<ExternalLinkIcon />
{checking ? t('workspace.checkingVault') : t('workspace.viewVault')}
@@ -304,8 +306,8 @@ export function ObsidianVaultSection({ contentRootAbs, onToast }: ObsidianVaultS
function ExternalLinkIcon() {
return (
<svg
width="16"
height="16"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
@@ -24,6 +24,8 @@ interface PixiGraphProps {
/** Called if Pixi fails to initialise at runtime so the parent can
* fall back to the SVG renderer. */
onError?: () => void;
/** Fired once the layout settles (graph is ready to reveal). */
onReady?: () => void;
}
export function PixiGraph({
@@ -35,16 +37,19 @@ export function PixiGraph({
onHover,
onOpen,
onError,
onReady,
}: PixiGraphProps) {
const hostRef = useRef<HTMLDivElement | null>(null);
const handleRef = useRef<PixiGraphHandle | null>(null);
const onHoverRef = useRef(onHover);
const onOpenRef = useRef(onOpen);
const onErrorRef = useRef(onError);
const onReadyRef = useRef(onReady);
const darkRef = useRef(dark);
onHoverRef.current = onHover;
onOpenRef.current = onOpen;
onErrorRef.current = onError;
onReadyRef.current = onReady;
darkRef.current = dark;
// Mount the renderer once; update in-place when graph data changes.
@@ -72,6 +77,7 @@ export function PixiGraph({
dark: darkRef.current,
onHover: n => onHoverRef.current(n),
onOpen: n => onOpenRef.current(n),
onReady: () => onReadyRef.current?.(),
})
.then(handle => {
if (cancelled) {
@@ -33,6 +33,8 @@ export interface PixiGraphOptions {
dark: boolean;
onHover: (node: SimNode | null) => void;
onOpen: (node: SimNode) => void;
/** Fired once the force simulation first cools (graph is laid out). */
onReady?: () => void;
}
export interface PixiGraphHandle {
@@ -86,6 +88,9 @@ export async function mountPixiGraph(
let dark = opts.dark;
let dirty = true;
let hoveredId: string | null = null;
// Fires `onReady` exactly once when the sim first cools — the signal a
// loading overlay (e.g. the Brain page) waits on before revealing the graph.
let readyFired = false;
// Auto-fit the whole graph into view until the user pans/zooms/drags,
// so the initial frame is zoomed out to show as much as possible.
let userInteracted = false;
@@ -150,6 +155,10 @@ export async function mountPixiGraph(
if (sim.alpha() > sim.alphaMin()) {
sim.tick();
changed = true;
} else if (!readyFired) {
// Simulation has cooled → the layout has settled. Signal readiness once.
readyFired = true;
opts.onReady?.();
}
if (changed) {
// Keep the whole graph framed while it settles, until the user
+16 -1
View File
@@ -8,7 +8,7 @@
*/
import { describe, expect, it } from 'vitest';
import { AVATAR_MENU_ITEMS, NAV_TABS } from '../navConfig';
import { AVATAR_MENU_ITEMS, BRAIN_TAB, NAV_TABS } from '../navConfig';
describe('NAV_TABS', () => {
it('has exactly 5 entries (Phase 6: Human merged into Assistant)', () => {
@@ -68,6 +68,10 @@ describe('NAV_TABS', () => {
expect(NAV_TABS.find(t => t.id === 'skills')).toBeUndefined();
});
it('does not contain the Brain tab (rendered specially, not in the regular row)', () => {
expect(NAV_TABS.find(t => t.id === 'brain')).toBeUndefined();
});
it('Assistant tab uses nav.assistant label key and tab-chat walkthrough attr', () => {
const assistantTab = NAV_TABS.find(t => t.id === 'chat');
expect(assistantTab).toBeDefined();
@@ -77,6 +81,17 @@ describe('NAV_TABS', () => {
});
});
describe('BRAIN_TAB', () => {
it('has the expected shape (id, path, labelKey, walkthroughAttr)', () => {
expect(BRAIN_TAB).toEqual({
id: 'brain',
labelKey: 'nav.brain',
path: '/brain',
walkthroughAttr: 'tab-brain',
});
});
});
describe('AVATAR_MENU_ITEMS', () => {
it('has exactly 5 entries', () => {
expect(AVATAR_MENU_ITEMS).toHaveLength(5);
+14
View File
@@ -41,6 +41,20 @@ export const NAV_TABS: NavTab[] = [
{ id: 'settings', labelKey: 'nav.settings', path: '/settings', walkthroughAttr: 'tab-settings' },
];
/**
* The "Brain" — the app's centerpiece memory-graph surface. Rendered
* specially by BottomTabBar.tsx as a raised circular button in the dead
* center of the bar (a notch the button rises out of), NOT as part of the
* regular `NAV_TABS` row. Kept separate so its special, elevated nature is
* explicit and the 5-tab invariants for the regular row stay intact.
*/
export const BRAIN_TAB: NavTab = {
id: 'brain',
labelKey: 'nav.brain',
path: '/brain',
walkthroughAttr: 'tab-brain',
};
// ── Avatar / account menu ─────────────────────────────────────────────────────
/**
+6
View File
@@ -549,4 +549,10 @@
animation: none !important;
transition: none !important;
}
/* Brain center button — silence the active glow-pulse. The Brain page's
loading Lottie is already skipped in JS via prefers-reduced-motion. */
.brain-fab {
animation: none !important;
}
}
+7
View File
@@ -21,11 +21,18 @@ const messages: TranslationMap = {
'nav.defaultAgentProfile': 'الوكيل الافتراضي',
'nav.noAgentProfiles': 'لم يتم العثور على ملفات وكلاء',
'nav.activity': 'النشاط',
'nav.brain': 'الدماغ',
'nav.avatarMenu.account': 'الحساب',
'nav.avatarMenu.billing': 'الفواتير',
'nav.avatarMenu.rewards': 'المكافآت',
'nav.avatarMenu.invites': 'دعوة صديق',
'nav.avatarMenu.wallet': 'المحفظة',
// Brain — full-page memory knowledge-graph surface
'brain.subtitle': 'خريطة معرفتك ومصادر الذاكرة وعناصر التحكم.',
'brain.loading': 'يتم جمع ذكرياتك…',
'brain.empty': 'دماغك فارغ في الوقت الحالي — قم بربط مصدر لبدء بناء الذاكرة.',
'brain.error': 'تعذّر تحميل دماغك. يرجى المحاولة مرة أخرى.',
'common.cancel': 'إلغاء',
'common.save': 'حفظ',
'common.confirm': 'تأكيد',
+7
View File
@@ -21,11 +21,18 @@ const messages: TranslationMap = {
'nav.defaultAgentProfile': 'ডিফল্ট এজেন্ট',
'nav.noAgentProfiles': 'কোনো এজেন্ট প্রোফাইল পাওয়া যায়নি',
'nav.activity': 'কার্যকলাপ',
'nav.brain': 'ব্রেইন',
'nav.avatarMenu.account': 'অ্যাকাউন্ট',
'nav.avatarMenu.billing': 'বিলিং',
'nav.avatarMenu.rewards': 'পুরস্কার',
'nav.avatarMenu.invites': 'বন্ধুকে আমন্ত্রণ জানান',
'nav.avatarMenu.wallet': 'ওয়ালেট',
// Brain — full-page memory knowledge-graph surface
'brain.subtitle': 'আপনার জ্ঞান গ্রাফ, মেমরি উৎস এবং নিয়ন্ত্রণ।',
'brain.loading': 'আপনার স্মৃতি সংগ্রহ করা হচ্ছে…',
'brain.empty': 'আপনার ব্রেইন এখন খালি — মেমরি তৈরি শুরু করতে একটি উৎস সংযুক্ত করুন।',
'brain.error': 'আপনার ব্রেইন লোড করা যায়নি। অনুগ্রহ করে আবার চেষ্টা করুন।',
'common.cancel': 'বাতিল',
'common.save': 'সংরক্ষণ',
'common.confirm': 'নিশ্চিত করুন',
+7
View File
@@ -21,11 +21,18 @@ const messages: TranslationMap = {
'nav.defaultAgentProfile': 'Standardagent',
'nav.noAgentProfiles': 'Keine Agentenprofile gefunden',
'nav.activity': 'Aktivität',
'nav.brain': 'Gehirn',
'nav.avatarMenu.account': 'Konto',
'nav.avatarMenu.billing': 'Abrechnung',
'nav.avatarMenu.rewards': 'Prämien',
'nav.avatarMenu.invites': 'Freund einladen',
'nav.avatarMenu.wallet': 'Wallet',
// Brain — full-page memory knowledge-graph surface
'brain.subtitle': 'Dein Wissensgraph, deine Speicherquellen und Steuerelemente.',
'brain.loading': 'Deine Erinnerungen werden gesammelt…',
'brain.empty': 'Dein Gehirn ist noch leer verbinde eine Quelle, um Speicher aufzubauen.',
'brain.error': 'Dein Gehirn konnte nicht geladen werden. Bitte versuche es erneut.',
'common.cancel': 'Abbrechen',
'common.save': 'Speichern',
'common.confirm': 'Bestätigen',
+7
View File
@@ -22,12 +22,19 @@ const en: TranslationMap = {
'nav.defaultAgentProfile': 'Default agent',
'nav.noAgentProfiles': 'No agent profiles found',
'nav.activity': 'Activity',
'nav.brain': 'Brain',
'nav.avatarMenu.account': 'Account',
'nav.avatarMenu.billing': 'Billing',
'nav.avatarMenu.rewards': 'Rewards',
'nav.avatarMenu.invites': 'Invite a friend',
'nav.avatarMenu.wallet': 'Wallet',
// Brain — full-page memory knowledge-graph surface
'brain.subtitle': 'Your knowledge graph, memory sources, and controls.',
'brain.loading': 'Gathering your memories…',
'brain.empty': 'Your brain is empty for now — connect a source to start building memory.',
'brain.error': "Couldn't load your brain. Please try again.",
// Common
'common.cancel': 'Cancel',
'common.save': 'Save',
+8
View File
@@ -21,11 +21,19 @@ const messages: TranslationMap = {
'nav.defaultAgentProfile': 'Agente predeterminado',
'nav.noAgentProfiles': 'No se encontraron perfiles de agente',
'nav.activity': 'Actividad',
'nav.brain': 'Cerebro',
'nav.avatarMenu.account': 'Cuenta',
'nav.avatarMenu.billing': 'Facturación',
'nav.avatarMenu.rewards': 'Recompensas',
'nav.avatarMenu.invites': 'Invitar a un amigo',
'nav.avatarMenu.wallet': 'Cartera',
// Brain — full-page memory knowledge-graph surface
'brain.subtitle': 'Tu grafo de conocimiento, fuentes de memoria y controles.',
'brain.loading': 'Recopilando tus recuerdos…',
'brain.empty':
'Tu cerebro está vacío por ahora: conecta una fuente para empezar a construir tu memoria.',
'brain.error': 'No se pudo cargar tu cerebro. Inténtalo de nuevo.',
'common.cancel': 'Cancelar',
'common.save': 'Guardar',
'common.confirm': 'Confirmar',
+8
View File
@@ -21,11 +21,19 @@ const messages: TranslationMap = {
'nav.defaultAgentProfile': 'Agent par défaut',
'nav.noAgentProfiles': "Aucun profil d'agent trouvé",
'nav.activity': 'Activité',
'nav.brain': 'Cerveau',
'nav.avatarMenu.account': 'Compte',
'nav.avatarMenu.billing': 'Facturation',
'nav.avatarMenu.rewards': 'Récompenses',
'nav.avatarMenu.invites': 'Inviter un ami',
'nav.avatarMenu.wallet': 'Portefeuille',
// Brain — full-page memory knowledge-graph surface
'brain.subtitle': 'Votre graphe de connaissances, vos sources de mémoire et vos commandes.',
'brain.loading': 'Collecte de vos souvenirs…',
'brain.empty':
'Votre cerveau est vide pour linstant — connectez une source pour commencer à constituer votre mémoire.',
'brain.error': 'Impossible de charger votre cerveau. Veuillez réessayer.',
'common.cancel': 'Annuler',
'common.save': 'Enregistrer',
'common.confirm': 'Confirmer',
+7
View File
@@ -21,11 +21,18 @@ const messages: TranslationMap = {
'nav.defaultAgentProfile': 'डिफॉल्ट एजेंट',
'nav.noAgentProfiles': 'कोई एजेंट प्रोफाइल नहीं मिला',
'nav.activity': 'गतिविधि',
'nav.brain': 'ब्रेन',
'nav.avatarMenu.account': 'खाता',
'nav.avatarMenu.billing': 'बिलिंग',
'nav.avatarMenu.rewards': 'रिवॉर्ड',
'nav.avatarMenu.invites': 'दोस्त को आमंत्रित करें',
'nav.avatarMenu.wallet': 'वॉलेट',
// Brain — full-page memory knowledge-graph surface
'brain.subtitle': 'आपका नॉलेज ग्राफ, मेमोरी स्रोत और नियंत्रण।',
'brain.loading': 'आपकी यादें इकट्ठा की जा रही हैं…',
'brain.empty': 'आपका ब्रेन अभी खाली है — मेमोरी बनाना शुरू करने के लिए कोई स्रोत कनेक्ट करें।',
'brain.error': 'आपका ब्रेन लोड नहीं हो सका। कृपया फिर से प्रयास करें।',
'common.cancel': 'रद्द करें',
'common.save': 'सेव करें',
'common.confirm': 'कन्फर्म करें',
+7
View File
@@ -21,11 +21,18 @@ const messages: TranslationMap = {
'nav.defaultAgentProfile': 'Agen default',
'nav.noAgentProfiles': 'Profil agen tidak ditemukan',
'nav.activity': 'Aktivitas',
'nav.brain': 'Otak',
'nav.avatarMenu.account': 'Akun',
'nav.avatarMenu.billing': 'Tagihan',
'nav.avatarMenu.rewards': 'Hadiah',
'nav.avatarMenu.invites': 'Undang teman',
'nav.avatarMenu.wallet': 'Dompet',
// Brain — full-page memory knowledge-graph surface
'brain.subtitle': 'Grafik pengetahuan, sumber memori, dan kontrol Anda.',
'brain.loading': 'Mengumpulkan memori Anda…',
'brain.empty': 'Otak Anda masih kosong — hubungkan sumber untuk mulai membangun memori.',
'brain.error': 'Tidak dapat memuat otak Anda. Silakan coba lagi.',
'common.cancel': 'Batal',
'common.save': 'Simpan',
'common.confirm': 'Konfirmasi',
+8
View File
@@ -21,11 +21,19 @@ const messages: TranslationMap = {
'nav.defaultAgentProfile': 'Agente predefinito',
'nav.noAgentProfiles': 'Nessun profilo agente trovato',
'nav.activity': 'Attività',
'nav.brain': 'Cervello',
'nav.avatarMenu.account': 'Account',
'nav.avatarMenu.billing': 'Fatturazione',
'nav.avatarMenu.rewards': 'Premi',
'nav.avatarMenu.invites': 'Invita un amico',
'nav.avatarMenu.wallet': 'Portafoglio',
// Brain — full-page memory knowledge-graph surface
'brain.subtitle': 'Il tuo grafo della conoscenza, le fonti di memoria e i controlli.',
'brain.loading': 'Raccolta dei tuoi ricordi…',
'brain.empty':
'Il tuo cervello è ancora vuoto: collega una fonte per iniziare a costruire la memoria.',
'brain.error': 'Impossibile caricare il tuo cervello. Riprova.',
'common.cancel': 'Annulla',
'common.save': 'Salva',
'common.confirm': 'Conferma',
+7
View File
@@ -21,11 +21,18 @@ const messages: TranslationMap = {
'nav.defaultAgentProfile': '기본 에이전트',
'nav.noAgentProfiles': '에이전트 프로필을 찾을 수 없습니다',
'nav.activity': '활동',
'nav.brain': '브레인',
'nav.avatarMenu.account': '계정',
'nav.avatarMenu.billing': '결제',
'nav.avatarMenu.rewards': '보상',
'nav.avatarMenu.invites': '친구 초대',
'nav.avatarMenu.wallet': '지갑',
// Brain — full-page memory knowledge-graph surface
'brain.subtitle': '지식 그래프, 메모리 소스 및 컨트롤.',
'brain.loading': '기억을 모으는 중…',
'brain.empty': '아직 브레인이 비어 있습니다 — 소스를 연결하여 메모리를 만들어 보세요.',
'brain.error': '브레인을 불러올 수 없습니다. 다시 시도해 주세요.',
'common.cancel': '취소',
'common.save': '저장',
'common.confirm': '확인',
+7
View File
@@ -21,11 +21,18 @@ const messages: TranslationMap = {
'nav.defaultAgentProfile': 'Domyślny agent',
'nav.noAgentProfiles': 'Nie znaleziono profili agentów',
'nav.activity': 'Aktywność',
'nav.brain': 'Mózg',
'nav.avatarMenu.account': 'Konto',
'nav.avatarMenu.billing': 'Rozliczenia',
'nav.avatarMenu.rewards': 'Nagrody',
'nav.avatarMenu.invites': 'Zaproś znajomego',
'nav.avatarMenu.wallet': 'Portfel',
// Brain — full-page memory knowledge-graph surface
'brain.subtitle': 'Twój graf wiedzy, źródła pamięci i ustawienia.',
'brain.loading': 'Zbieranie Twoich wspomnień…',
'brain.empty': 'Twój mózg jest na razie pusty — połącz źródło, aby zacząć budować pamięć.',
'brain.error': 'Nie udało się załadować Twojego mózgu. Spróbuj ponownie.',
'common.cancel': 'Anuluj',
'common.save': 'Zapisz',
'common.confirm': 'Potwierdź',
+8
View File
@@ -21,11 +21,19 @@ const messages: TranslationMap = {
'nav.defaultAgentProfile': 'Agente padrão',
'nav.noAgentProfiles': 'Nenhum perfil de agente encontrado',
'nav.activity': 'Atividade',
'nav.brain': 'Cérebro',
'nav.avatarMenu.account': 'Conta',
'nav.avatarMenu.billing': 'Faturamento',
'nav.avatarMenu.rewards': 'Recompensas',
'nav.avatarMenu.invites': 'Convidar um amigo',
'nav.avatarMenu.wallet': 'Carteira',
// Brain — full-page memory knowledge-graph surface
'brain.subtitle': 'Seu grafo de conhecimento, fontes de memória e controles.',
'brain.loading': 'Reunindo suas memórias…',
'brain.empty':
'Seu cérebro está vazio por enquanto — conecte uma fonte para começar a construir a memória.',
'brain.error': 'Não foi possível carregar seu cérebro. Tente novamente.',
'common.cancel': 'Cancelar',
'common.save': 'Salvar',
'common.confirm': 'Confirmar',
+7
View File
@@ -21,11 +21,18 @@ const messages: TranslationMap = {
'nav.defaultAgentProfile': 'Агент по умолчанию',
'nav.noAgentProfiles': 'Профили агентов не найдены',
'nav.activity': 'Активность',
'nav.brain': 'Мозг',
'nav.avatarMenu.account': 'Аккаунт',
'nav.avatarMenu.billing': 'Оплата',
'nav.avatarMenu.rewards': 'Награды',
'nav.avatarMenu.invites': 'Пригласить друга',
'nav.avatarMenu.wallet': 'Кошелёк',
// Brain — full-page memory knowledge-graph surface
'brain.subtitle': 'Ваш граф знаний, источники памяти и элементы управления.',
'brain.loading': 'Собираем ваши воспоминания…',
'brain.empty': 'Ваш мозг пока пуст — подключите источник, чтобы начать формировать память.',
'brain.error': 'Не удалось загрузить ваш мозг. Пожалуйста, попробуйте ещё раз.',
'common.cancel': 'Отмена',
'common.save': 'Сохранить',
'common.confirm': 'Подтвердить',
+7
View File
@@ -21,11 +21,18 @@ const messages: TranslationMap = {
'nav.defaultAgentProfile': '默认代理',
'nav.noAgentProfiles': '未找到代理档案',
'nav.activity': '动态',
'nav.brain': '大脑',
'nav.avatarMenu.account': '账户',
'nav.avatarMenu.billing': '账单',
'nav.avatarMenu.rewards': '奖励',
'nav.avatarMenu.invites': '邀请好友',
'nav.avatarMenu.wallet': '钱包',
// Brain — full-page memory knowledge-graph surface
'brain.subtitle': '你的知识图谱、记忆来源与控制项。',
'brain.loading': '正在收集你的记忆…',
'brain.empty': '你的大脑暂时是空的——连接一个来源即可开始构建记忆。',
'brain.error': '无法加载你的大脑,请重试。',
'common.cancel': '取消',
'common.save': '保存',
'common.confirm': '确认',
+226
View File
@@ -0,0 +1,226 @@
/**
* Brain — the centerpiece memory surface.
*
* Reached from the raised center button in the bottom bar. On open it plays a
* short "brain collecting information" Lottie flourish (a minimum display
* window so it always reads as intentional), then cross-fades into the live
* knowledge graph once its force layout has settled. Below the graph it
* surfaces the full memory workspace — controls, tree status, and connected
* sources — framed as a clean, single-column dashboard.
*
* Readiness gate — the loading overlay leaves only when ALL of:
* 1. the minimum animation window has elapsed (skipped under reduced motion),
* 2. the graph data has been fetched, and
* 3. the graph layout has settled (`MemoryGraph` `onReady`, or there's nothing
* to lay out) — OR a hard max-timeout fires so a stuck layout can never
* trap the user behind the overlay, OR the fetch errored.
*
* Everything here is light: one existing RPC for the graph (the status/sources
* panels own their polling), the existing Pixi/worker graph pipeline, and a
* runtime-fetched (cached) Lottie asset.
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import { MemoryControls } from '../components/intelligence/MemoryControls';
import { MemoryGraph } from '../components/intelligence/MemoryGraph';
import { MemorySourcesRegistry } from '../components/intelligence/MemorySourcesRegistry';
import { MemoryTreeStatusPanel } from '../components/intelligence/MemoryTreeStatusPanel';
import { ToastContainer } from '../components/intelligence/Toast';
import LottieAnimation from '../components/LottieAnimation';
import { useT } from '../lib/i18n/I18nContext';
import type { ToastNotification } from '../types/intelligence';
import {
type GraphExportResponse,
type GraphMode,
memoryTreeGraphExport,
} from '../utils/tauriCommands';
/** Minimum time the loading flourish stays up so it never just flashes. */
const MIN_ANIMATION_MS = 1200;
/** Hard ceiling: reveal the graph regardless after this, even if not settled. */
const MAX_WAIT_MS = 8000;
const BRAIN_LOTTIE_SRC = '/lottie/brain-collecting.json';
/** Honour the OS reduced-motion preference — skip the flourish entirely. */
const prefersReducedMotion = (): boolean =>
typeof window !== 'undefined' &&
typeof window.matchMedia === 'function' &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
export default function Brain() {
const { t } = useT();
const [graph, setGraph] = useState<GraphExportResponse | null>(null);
const [error, setError] = useState<string | null>(null);
// Graph layout has settled (or there is nothing to lay out).
const [graphReady, setGraphReady] = useState(false);
// Minimum animation window has elapsed.
const [minElapsed, setMinElapsed] = useState(false);
// Hard ceiling reached — reveal regardless.
const [timedOut, setTimedOut] = useState(false);
// Graph view mode — driven by the toolbar's Trees / Contacts toggle.
const [mode, setMode] = useState<GraphMode>('tree');
// Bumped to force a graph re-pull (Refresh + post-mutation refresh).
const [refreshKey, setRefreshKey] = useState(0);
const [toasts, setToasts] = useState<ToastNotification[]>([]);
const reduceMotion = useRef(prefersReducedMotion());
const addToast = useCallback((toast: Omit<ToastNotification, 'id'>) => {
setToasts(prev => [...prev, { ...toast, id: `toast-${Date.now()}-${Math.random()}` }]);
}, []);
const removeToast = useCallback((id: string) => {
setToasts(prev => prev.filter(toast => toast.id !== id));
}, []);
const refresh = useCallback(() => setRefreshKey(k => k + 1), []);
// ── Fetch the graph on mount / mode change / refresh, and refresh when the
// tree finishes building.
useEffect(() => {
let cancelled = false;
const load = async () => {
console.debug('[brain] graph fetch: entry mode=%s', mode);
// Clear any prior error so a successful retry isn't masked by a stale one.
setError(null);
try {
const resp = await memoryTreeGraphExport(mode);
if (cancelled) return;
console.debug(
'[brain] graph fetch: exit n=%d edges=%d',
resp.nodes.length,
resp.edges.length
);
setGraph(resp);
// Nothing to lay out → ready immediately (MemoryGraph won't fire onReady
// for an empty graph since it short-circuits to the empty placeholder).
if (resp.nodes.length === 0) setGraphReady(true);
} catch (err) {
if (cancelled) return;
console.error('[brain] graph fetch failed', err);
setError(err instanceof Error ? err.message : String(err));
}
};
void load();
const onTreeDone = () => {
console.debug('[brain] memory-tree-completed → refetch');
void load();
};
window.addEventListener('openhuman:memory-tree-completed', onTreeDone);
return () => {
cancelled = true;
window.removeEventListener('openhuman:memory-tree-completed', onTreeDone);
};
}, [mode, refreshKey]);
// ── Minimum-display timer (skipped under reduced motion).
useEffect(() => {
if (reduceMotion.current) {
setMinElapsed(true);
return;
}
const id = window.setTimeout(() => setMinElapsed(true), MIN_ANIMATION_MS);
return () => window.clearTimeout(id);
}, []);
// ── Safety ceiling so the overlay can't trap the user behind a stuck layout.
useEffect(() => {
const id = window.setTimeout(() => {
console.debug('[brain] readiness max-timeout reached');
setTimedOut(true);
}, MAX_WAIT_MS);
return () => window.clearTimeout(id);
}, []);
const handleGraphReady = useCallback(() => {
console.debug('[brain] graph onReady');
setGraphReady(true);
}, []);
const dataLoaded = graph !== null || error !== null;
const overlayDone = (minElapsed && dataLoaded && graphReady) || error !== null || timedOut;
// Matches the MemorySourcesRegistry card so the dashboard reads as one set.
const cardClass =
'rounded-lg border border-stone-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-900';
return (
// Standard page shell — matches Activity/other pages. `relative` anchors the
// loading overlay and the toast stack.
<div className="relative min-h-full p-4 pt-6">
<div className="mx-auto max-w-4xl space-y-5">
{/* Page header */}
<header className="min-w-0">
<h1 className="text-xl font-bold text-stone-900 dark:text-neutral-100">
{t('nav.brain')}
</h1>
<p className="mt-1 text-sm text-stone-500 dark:text-neutral-400">{t('brain.subtitle')}</p>
</header>
{/* Graph toolbar — mode toggle + actions. */}
<MemoryControls
mode={mode}
onModeChange={setMode}
onRefresh={refresh}
onToast={addToast}
contentRootAbs={graph?.content_root_abs}
/>
{/* Hero graph — mounted as soon as data arrives (even while the overlay
covers it) so its force layout settles and fires onReady underneath. */}
<div
className={`transition-opacity duration-700 ${overlayDone ? 'opacity-100' : 'opacity-0'}`}
aria-hidden={!overlayDone}>
{/* A loaded graph wins over a transient error so a failed background
refetch never clobbers an already-rendered graph. */}
{graph ? (
<div className="animate-fade-in">
<MemoryGraph
nodes={graph.nodes}
edges={graph.edges}
mode={mode}
emptyHint={t('brain.empty')}
onReady={handleGraphReady}
/>
</div>
) : error ? (
<div className={`${cardClass} text-sm text-coral-600 dark:text-coral-400`} role="alert">
{t('brain.error')}
</div>
) : null}
</div>
{/* Memory workspace — revealed once the graph is in. The panels own
their own polling, so deferring their mount keeps the load light. */}
{overlayDone ? (
<div className="animate-fade-in space-y-5">
<div className={cardClass}>
<MemoryTreeStatusPanel onToast={addToast} />
</div>
<MemorySourcesRegistry onToast={addToast} />
</div>
) : null}
</div>
{/* Loading overlay — the "brain collecting information" flourish. Opaque
and viewport-filling so the whole page (header, toolbar, graph, and the
panels below) stays hidden until the graph is ready. `fixed` keeps it
centered in the viewport regardless of page height; z-40 sits below the
bottom nav bar (z-50) so navigation stays available. */}
{!overlayDone && (
<div
className="fixed inset-0 z-40 flex flex-col items-center justify-center gap-4 bg-stone-50 animate-fade-in dark:bg-neutral-950"
data-testid="brain-loading"
role="status"
aria-live="polite">
{!reduceMotion.current && (
<LottieAnimation src={BRAIN_LOTTIE_SRC} height={220} width={220} />
)}
<p className="text-sm font-medium text-stone-500 dark:text-neutral-400">
{t('brain.loading')}
</p>
</div>
)}
<ToastContainer notifications={toasts} onRemove={removeToast} />
</div>
);
}
+116
View File
@@ -0,0 +1,116 @@
/**
* Tests for the Brain page orchestration: it shows the loading overlay, then
* reveals the memory graph once data is fetched, the layout has settled
* (mocked MemoryGraph fires onReady), and the minimum animation window passes.
* Heavy children (the Pixi/SVG graph and the Lottie player) and the RPC are
* mocked so this stays a fast unit test of the gate logic.
*/
import { act, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import Brain from '../Brain';
const graphExportMock = vi.hoisted(() => vi.fn());
vi.mock('../../utils/tauriCommands', () => ({ memoryTreeGraphExport: graphExportMock }));
// Mocked graph: fires onReady on mount to simulate the layout settling.
vi.mock('../../components/intelligence/MemoryGraph', async () => {
const React = await import('react');
return {
MemoryGraph: ({ nodes, onReady }: { nodes: unknown[]; onReady?: () => void }) => {
React.useEffect(() => {
onReady?.();
}, [onReady]);
return React.createElement('div', { 'data-testid': 'memory-graph' }, `nodes:${nodes.length}`);
},
};
});
vi.mock('../../components/LottieAnimation', async () => {
const React = await import('react');
return {
default: ({ src }: { src: string }) =>
React.createElement('div', { 'data-testid': 'brain-lottie', 'data-src': src }),
};
});
// The memory workspace panels own their own polling/RPCs — stub them so this
// stays an isolated test of the Brain page's loading/reveal orchestration.
vi.mock('../../components/intelligence/MemoryControls', () => ({ MemoryControls: () => null }));
vi.mock('../../components/intelligence/MemoryTreeStatusPanel', () => ({
MemoryTreeStatusPanel: () => null,
}));
vi.mock('../../components/intelligence/MemorySourcesRegistry', () => ({
MemorySourcesRegistry: () => null,
}));
vi.mock('../../components/intelligence/Toast', () => ({ ToastContainer: () => null }));
const makeGraph = (n: number) => ({
nodes: Array.from({ length: n }, (_, i) => ({ id: `n${i}`, kind: 'summary', label: `N${i}` })),
edges: [],
content_root_abs: '/tmp/content',
});
describe('Brain page', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
// jsdom has no matchMedia — default to "motion allowed".
window.matchMedia = vi
.fn()
.mockReturnValue({
matches: false,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
}) as unknown as typeof window.matchMedia;
});
afterEach(() => {
vi.useRealTimers();
});
it('shows the loading overlay first, then reveals the graph once ready', async () => {
graphExportMock.mockResolvedValue(makeGraph(3));
render(<Brain />);
// Overlay (with the Lottie flourish) is visible immediately.
expect(screen.getByTestId('brain-loading')).toBeInTheDocument();
expect(screen.getByTestId('brain-lottie')).toHaveAttribute(
'data-src',
'/lottie/brain-collecting.json'
);
// Flush the fetch + the minimum-display timer.
await act(async () => {
await vi.advanceTimersByTimeAsync(1300);
});
expect(screen.queryByTestId('brain-loading')).toBeNull();
expect(screen.getByTestId('memory-graph')).toHaveTextContent('nodes:3');
});
it('reveals immediately (empty state) when there is no memory yet', async () => {
graphExportMock.mockResolvedValue(makeGraph(0));
render(<Brain />);
await act(async () => {
await vi.advanceTimersByTimeAsync(1300);
});
expect(screen.queryByTestId('brain-loading')).toBeNull();
expect(screen.getByTestId('memory-graph')).toHaveTextContent('nodes:0');
});
it('surfaces an error and dismisses the overlay when the fetch fails', async () => {
graphExportMock.mockRejectedValue(new Error('boom'));
render(<Brain />);
await act(async () => {
await vi.advanceTimersByTimeAsync(50);
});
expect(screen.queryByTestId('brain-loading')).toBeNull();
expect(screen.getByRole('alert')).toBeInTheDocument();
});
});