diff --git a/app/src/components/intelligence/MemoryWorkspace.test.tsx b/app/src/components/intelligence/MemoryWorkspace.test.tsx new file mode 100644 index 000000000..0273088df --- /dev/null +++ b/app/src/components/intelligence/MemoryWorkspace.test.tsx @@ -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('', () => { + beforeEach(() => { + graphExportMock.mockClear(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('re-exports the graph when the refresh button is clicked', async () => { + render(); + // 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(); + // 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(); + }); +}); diff --git a/app/src/components/intelligence/MemoryWorkspace.tsx b/app/src/components/intelligence/MemoryWorkspace.tsx index 511103f4b..e15ff42f9 100644 --- a/app/src/components/intelligence/MemoryWorkspace.tsx +++ b/app/src/components/intelligence/MemoryWorkspace.tsx @@ -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">
+