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">
+