mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(brain): Brain memory surface — center bottom-bar button → full-page knowledge graph (#3578)
This commit is contained in:
@@ -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": []
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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': 'تأكيد',
|
||||
|
||||
@@ -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': 'নিশ্চিত করুন',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 l’instant — 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',
|
||||
|
||||
@@ -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': 'कन्फर्म करें',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '확인',
|
||||
|
||||
@@ -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ź',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Подтвердить',
|
||||
|
||||
@@ -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': '确认',
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user