feat(memory): Notion doc-aware versioned memory tree + page-content ingest (#3378)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
sanil-23
2026-06-04 16:47:54 -04:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 1083c3112d
commit 07093e899b
31 changed files with 1881 additions and 147 deletions
@@ -0,0 +1,69 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { memoryTreeGraphExport } from '../../utils/tauriCommands';
import { MemoryWorkspace } from './MemoryWorkspace';
// Stub the i18n hook and the heavy child panels so the test renders just the
// MemoryWorkspace shell (graph fetch effect + the refresh control + poll).
vi.mock('../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) }));
vi.mock('./MemoryGraph', () => ({ MemoryGraph: () => null }));
vi.mock('./MemorySourcesRegistry', () => ({ MemorySourcesRegistry: () => null }));
vi.mock('./MemoryTreeStatusPanel', () => ({ MemoryTreeStatusPanel: () => null }));
vi.mock('./ObsidianVaultSection', () => ({ ObsidianVaultSection: () => null }));
vi.mock('./SyncAuditPanel', () => ({ SyncAuditPanel: () => null }));
vi.mock('./WhatsAppMemorySection', () => ({ WhatsAppMemorySection: () => null }));
vi.mock('../../utils/tauriCommands', () => ({
memoryTreeGraphExport: vi.fn().mockResolvedValue({ nodes: [], edges: [] }),
memoryTreeFlushNow: vi.fn().mockResolvedValue(undefined),
memoryTreeResetTree: vi.fn().mockResolvedValue(undefined),
memoryTreeWipeAll: vi.fn().mockResolvedValue(undefined),
}));
const graphExportMock = vi.mocked(memoryTreeGraphExport);
describe('<MemoryWorkspace />', () => {
beforeEach(() => {
graphExportMock.mockClear();
});
afterEach(() => {
vi.useRealTimers();
});
it('re-exports the graph when the refresh button is clicked', async () => {
render(<MemoryWorkspace />);
// Mount runs the initial graph load.
await waitFor(() => expect(graphExportMock).toHaveBeenCalledTimes(1));
fireEvent.click(screen.getByTestId('memory-graph-refresh'));
// Bumping graphVersion re-runs the load effect.
await waitFor(() => expect(graphExportMock).toHaveBeenCalledTimes(2));
});
it('polls the graph on a 30s tick, and skips the tick while the tab is hidden', async () => {
vi.useFakeTimers();
render(<MemoryWorkspace />);
// Flush the mount-time async load under fake timers.
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
const afterMount = graphExportMock.mock.calls.length;
// Visible tab → the 30s tick re-pulls the graph.
await act(async () => {
await vi.advanceTimersByTimeAsync(30_000);
});
expect(graphExportMock.mock.calls.length).toBeGreaterThan(afterMount);
// Backgrounded tab → the tick is skipped (no extra RPC churn).
const hiddenSpy = vi.spyOn(document, 'hidden', 'get').mockReturnValue(true);
const afterVisible = graphExportMock.mock.calls.length;
await act(async () => {
await vi.advanceTimersByTimeAsync(30_000);
});
expect(graphExportMock.mock.calls.length).toBe(afterVisible);
hiddenSpy.mockRestore();
});
});
@@ -111,6 +111,22 @@ export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) {
};
}, []);
// Live refresh: re-pull the graph every 30s while this tab is mounted so it
// reflects background tree growth (e.g. seal_document jobs draining as
// Notion syncs) without a manual refresh. The Memory tab unmounts this
// component when inactive, which clears the interval — so the poll only runs
// while the tab is actually open. Ticks are skipped while the window is
// backgrounded to avoid needless RPC churn; the next visible tick catches up.
useEffect(() => {
const GRAPH_POLL_MS = 30_000;
const id = setInterval(() => {
if (typeof document !== 'undefined' && document.hidden) return;
console.debug('[ui-flow][memory-workspace] graph poll tick → bump version');
setGraphVersion(v => v + 1);
}, GRAPH_POLL_MS);
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'));
@@ -236,6 +252,17 @@ export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) {
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}
@@ -41,10 +41,15 @@ describe('memoryGraphLayout', () => {
expect(nodeColor(contact())).toBe(CONTACT_COLOR);
});
it('nodeRadius grows with level and is fixed for chunk/contact', () => {
it('nodeRadius grows with level (capped) and is fixed for chunk/contact', () => {
expect(nodeRadius(summary({ level: 0 }))).toBe(5);
expect(nodeRadius(summary({ level: 3 }))).toBe(12.5);
expect(nodeRadius(summary({ level: 99 }))).toBe(252.5);
// Capped at 14: document merge-tier nodes live at a large synthetic level
// (MERGE_LEVEL_BASE = 1000+), so the raw `5 + level*2.5` is clamped to keep
// the d3 layout/collision sane instead of rendering giant discs.
expect(nodeRadius(summary({ level: 4 }))).toBe(14); // 5 + 4*2.5 = 15 → capped
expect(nodeRadius(summary({ level: 99 }))).toBe(14);
expect(nodeRadius(summary({ level: 1001 }))).toBe(14);
expect(nodeRadius(contact())).toBe(9);
expect(nodeRadius(chunk())).toBe(3);
});
@@ -40,7 +40,14 @@ export const SOURCE_COLOR = '#F97316'; // synthetic source root nodes
/** Layout is computed in this fixed coordinate space; the renderer pans/zooms it. */
export const VIEWPORT_W = 1100;
export const VIEWPORT_H = 640;
export const ZOOM_MIN = 0.3;
// Lower bound shared by auto-fit framing and manual wheel zoom-out. Kept very
// small (20× zoom-out) so large clouds — e.g. a Notion connection's hundreds
// of page-chunk leaves — can be framed in full. At 0.3 the auto-fit was
// clamped above the scale needed to show every node, so big graphs rendered
// "too zoomed in" with the outer nodes spilling off-screen. Using one shared
// floor (rather than a separate, lower auto-fit floor) avoids a zoom-snap
// where the first wheel tick would jump back up to the manual floor.
export const ZOOM_MIN = 0.05;
export const ZOOM_MAX = 4;
export function levelColor(level: number | null | undefined): string {
@@ -57,7 +64,16 @@ export function nodeColor(node: GraphNode): string {
export function nodeRadius(node: GraphNode): number {
if (node.kind === 'source') return 16;
if (node.kind === 'summary') return 5 + (node.level ?? 0) * 2.5;
if (node.kind === 'summary') {
// Higher levels render slightly larger, but the size MUST be capped:
// document source trees place their cross-document merge tier at a large
// synthetic level (MERGE_LEVEL_BASE = 1000+), so the raw `level * 2.5`
// would explode to thousands of px — rendering giant discs and, via the
// `forceCollide(nodeRadius + 2)` term, blowing the whole layout apart.
// The cap keeps merge nodes the largest summaries without distorting it.
const level = node.level ?? 0;
return Math.min(5 + level * 2.5, 14);
}
if (node.kind === 'contact') return 9;
return 3; // chunk / document leaf
}