diff --git a/app/src/components/intelligence/VaultPanel.test.tsx b/app/src/components/intelligence/VaultPanel.test.tsx index f000a767a..e4c72f5fb 100644 --- a/app/src/components/intelligence/VaultPanel.test.tsx +++ b/app/src/components/intelligence/VaultPanel.test.tsx @@ -11,12 +11,14 @@ import { VaultPanel } from './VaultPanel'; const mockList = vi.fn(); const mockCreate = vi.fn(); const mockSync = vi.fn(); +const mockSyncStatus = vi.fn(); const mockRemove = vi.fn(); vi.mock('../../utils/tauriCommands/vault', () => ({ openhumanVaultList: (...args: unknown[]) => mockList(...args), openhumanVaultCreate: (...args: unknown[]) => mockCreate(...args), openhumanVaultSync: (...args: unknown[]) => mockSync(...args), + openhumanVaultSyncStatus: (...args: unknown[]) => mockSyncStatus(...args), openhumanVaultRemove: (...args: unknown[]) => mockRemove(...args), })); @@ -35,11 +37,35 @@ function vault(overrides: Record = {}) { }; } +/** Build a completed `CoreVaultSyncState` payload for mockSyncStatus. */ +function syncState(overrides: Record = {}) { + return { + result: { + vault_id: 'v-1', + status: 'completed', + scanned: 4, + ingested: 3, + unchanged: 1, + removed: 0, + failed: 0, + skipped_unsupported: 0, + total: 4, + started_at_ms: 1_000, + finished_at_ms: 2_200, + duration_ms: 1_200, + errors: [], + ...overrides, + }, + logs: [], + }; +} + describe('', () => { beforeEach(() => { mockList.mockReset(); mockCreate.mockReset(); mockSync.mockReset(); + mockSyncStatus.mockReset(); mockRemove.mockReset(); }); @@ -133,20 +159,10 @@ describe('', () => { mockList .mockResolvedValueOnce({ result: [vault()], logs: [] }) .mockResolvedValueOnce({ result: [vault()], logs: [] }); - mockSync.mockResolvedValueOnce({ - result: { - vault_id: 'v-1', - scanned: 4, - ingested: 3, - unchanged: 1, - removed: 0, - failed: 0, - skipped_unsupported: 0, - duration_ms: 1200, - errors: [], - }, - logs: [], - }); + // vault_sync returns immediately with "started" + mockSync.mockResolvedValueOnce({ result: { status: 'started', vault_id: 'v-1' }, logs: [] }); + // vault_sync_status returns "completed" on first poll + mockSyncStatus.mockResolvedValueOnce(syncState()); const onToast = vi.fn(); render(); await waitFor(() => screen.getByTestId('vault-list')); @@ -168,20 +184,16 @@ describe('', () => { mockList .mockResolvedValueOnce({ result: [vault()], logs: [] }) .mockResolvedValueOnce({ result: [vault()], logs: [] }); - mockSync.mockResolvedValueOnce({ - result: { - vault_id: 'v-1', - scanned: 2, + mockSync.mockResolvedValueOnce({ result: { status: 'started', vault_id: 'v-1' }, logs: [] }); + mockSyncStatus.mockResolvedValueOnce( + syncState({ ingested: 1, unchanged: 0, - removed: 0, failed: 1, - skipped_unsupported: 0, duration_ms: 50, errors: ['x.md: read failed'], - }, - logs: [], - }); + }) + ); const onToast = vi.fn(); render(); await waitFor(() => screen.getByTestId('vault-list')); @@ -209,6 +221,110 @@ describe('', () => { ); }); + it('emits error toast when sync status returns failed', async () => { + mockList + .mockResolvedValueOnce({ result: [vault()], logs: [] }) + .mockResolvedValueOnce({ result: [vault()], logs: [] }); + mockSync.mockResolvedValueOnce({ result: { status: 'started', vault_id: 'v-1' }, logs: [] }); + mockSyncStatus.mockResolvedValueOnce( + syncState({ status: 'failed', failed: 0, errors: ['disk full'] }) + ); + const onToast = vi.fn(); + render(); + await waitFor(() => screen.getByTestId('vault-list')); + + fireEvent.click(screen.getByText('Sync')); + await waitFor(() => + expect(onToast).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'error', + title: expect.stringContaining('Sync failed'), + message: expect.stringContaining('disk full'), + }) + ) + ); + }); + + it('emits error toast when status poll RPC throws', async () => { + mockList.mockResolvedValueOnce({ result: [vault()], logs: [] }); + mockSync.mockResolvedValueOnce({ result: { status: 'started', vault_id: 'v-1' }, logs: [] }); + mockSyncStatus.mockRejectedValueOnce(new Error('poll error')); + const onToast = vi.fn(); + render(); + await waitFor(() => screen.getByTestId('vault-list')); + + fireEvent.click(screen.getByText('Sync')); + await waitFor(() => + expect(onToast).toHaveBeenCalledWith( + expect.objectContaining({ type: 'error', title: 'Sync failed', message: 'poll error' }) + ) + ); + }); + + it('uses fallback failed-file count message when errors array is empty', async () => { + mockList + .mockResolvedValueOnce({ result: [vault()], logs: [] }) + .mockResolvedValueOnce({ result: [vault()], logs: [] }); + mockSync.mockResolvedValueOnce({ result: { status: 'started', vault_id: 'v-1' }, logs: [] }); + mockSyncStatus.mockResolvedValueOnce(syncState({ status: 'failed', failed: 3, errors: [] })); + const onToast = vi.fn(); + render(); + await waitFor(() => screen.getByTestId('vault-list')); + + fireEvent.click(screen.getByText('Sync')); + await waitFor(() => + expect(onToast).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'error', + message: expect.stringContaining('Failed 3 file(s)'), + }) + ) + ); + }); + + it('includes skipped_unsupported count in completed toast message', async () => { + mockList + .mockResolvedValueOnce({ result: [vault()], logs: [] }) + .mockResolvedValueOnce({ result: [vault()], logs: [] }); + mockSync.mockResolvedValueOnce({ result: { status: 'started', vault_id: 'v-1' }, logs: [] }); + mockSyncStatus.mockResolvedValueOnce( + syncState({ ingested: 2, skipped_unsupported: 5, duration_ms: 0 }) + ); + const onToast = vi.fn(); + render(); + await waitFor(() => screen.getByTestId('vault-list')); + + fireEvent.click(screen.getByText('Sync')); + await waitFor(() => + expect(onToast).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining('skipped 5') }) + ) + ); + }); + + it('cancels pending poll timer on unmount', async () => { + mockList.mockResolvedValueOnce({ result: [vault()], logs: [] }); + mockSync.mockResolvedValueOnce({ result: { status: 'started', vault_id: 'v-1' }, logs: [] }); + // Always running — the 1 500 ms re-poll timer stays live after poll #1. + mockSyncStatus.mockResolvedValue(syncState({ status: 'running', ingested: 1, total: 4 })); + + const { unmount } = render(); + await waitFor(() => screen.getByTestId('vault-list')); + + fireEvent.click(screen.getByText('Sync')); + + // Wait until the first poll fires (0 ms timer) so the 1 500 ms next-poll + // timer is scheduled in pollTimers.current. + await waitFor(() => expect(mockSyncStatus).toHaveBeenCalledTimes(1)); + + const clearSpy = vi.spyOn(globalThis, 'clearTimeout'); + unmount(); + + // useEffect cleanup must have called clearTimeout for the pending timer. + expect(clearSpy).toHaveBeenCalled(); + clearSpy.mockRestore(); + }); + it('removes a vault with purge=true when both confirms accepted', async () => { mockList .mockResolvedValueOnce({ result: [vault()], logs: [] }) diff --git a/app/src/components/intelligence/VaultPanel.tsx b/app/src/components/intelligence/VaultPanel.tsx index c0fa760d9..488dc802c 100644 --- a/app/src/components/intelligence/VaultPanel.tsx +++ b/app/src/components/intelligence/VaultPanel.tsx @@ -3,18 +3,22 @@ * files mirrored into memory under namespace `vault:`. Sits inside * the Intelligence ▸ Memory tab. */ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import type { ToastNotification } from '../../types/intelligence'; import { type CoreVault, - type CoreVaultSyncReport, + type CoreVaultSyncState, openhumanVaultCreate, openhumanVaultList, openhumanVaultRemove, openhumanVaultSync, + openhumanVaultSyncStatus, } from '../../utils/tauriCommands/vault'; +/** How often the UI re-polls for sync progress while a sync is running (ms). */ +const SYNC_POLL_INTERVAL_MS = 1_500; + interface VaultPanelProps { onToast?: (toast: Omit) => void; } @@ -24,12 +28,28 @@ export function VaultPanel({ onToast }: VaultPanelProps) { const [loading, setLoading] = useState(true); const [loadError, setLoadError] = useState(null); const [busy, setBusy] = useState>({}); + const [syncProgress, setSyncProgress] = useState< + Record + >({}); const [creating, setCreating] = useState(false); const [showForm, setShowForm] = useState(false); const [newName, setNewName] = useState(''); const [newPath, setNewPath] = useState(''); const [newExcludes, setNewExcludes] = useState(''); + // Track active polling timers so we can cancel them on unmount. + const pollTimers = useRef>>({}); + + // Cancel all active poll timers on unmount. + useEffect(() => { + const timers = pollTimers.current; + return () => { + for (const t of Object.values(timers)) { + clearTimeout(t); + } + }; + }, []); + const reload = useCallback(async () => { setLoading(true); setLoadError(null); @@ -88,29 +108,99 @@ export function VaultPanel({ onToast }: VaultPanelProps) { const handleSync = useCallback( async (vault: CoreVault) => { setBusy(b => ({ ...b, [vault.id]: 'sync' })); + setSyncProgress(p => ({ ...p, [vault.id]: undefined })); + + // Start the background sync. try { - const resp = await openhumanVaultSync(vault.id); - const r: CoreVaultSyncReport = resp.result; - onToast?.({ - type: r.failed > 0 ? 'info' : 'success', - title: `Synced "${vault.name}"`, - message: - `Ingested ${r.ingested}, unchanged ${r.unchanged}, removed ${r.removed}` + - (r.failed > 0 ? `, failed ${r.failed}` : '') + - (r.skipped_unsupported > 0 ? `, skipped ${r.skipped_unsupported}` : '') + - ` · ${(r.duration_ms / 1000).toFixed(1)}s`, - }); - await reload(); + await openhumanVaultSync(vault.id); } catch (err) { - console.error('[ui-flow][vault-panel] sync failed', err); + console.error('[ui-flow][vault-panel] sync start failed', err); onToast?.({ type: 'error', title: 'Sync failed', message: err instanceof Error ? err.message : String(err), }); - } finally { setBusy(b => ({ ...b, [vault.id]: undefined })); + return; } + + console.debug('[ui-flow][vault-panel] sync started, polling for status', { + vaultId: vault.id, + }); + + // Poll until the background task finishes. + const vaultId = vault.id; + const vaultName = vault.name; + + const poll = async () => { + let st: CoreVaultSyncState; + try { + const resp = await openhumanVaultSyncStatus(vaultId); + st = resp.result; + } catch (err) { + console.error('[ui-flow][vault-panel] sync status poll failed', err); + setBusy(b => ({ ...b, [vaultId]: undefined })); + setSyncProgress(p => ({ ...p, [vaultId]: undefined })); + onToast?.({ + type: 'error', + title: 'Sync failed', + message: err instanceof Error ? err.message : String(err), + }); + return; + } + + // Update progress indicator while running. + if (st.total > 0) { + setSyncProgress(p => ({ ...p, [vaultId]: { ingested: st.ingested, total: st.total } })); + } + + console.debug('[ui-flow][vault-panel] sync poll', { + vaultId, + status: st.status, + ingested: st.ingested, + total: st.total, + }); + + if (st.status === 'completed' || st.status === 'failed') { + // Clear polling state and show final toast. + delete pollTimers.current[vaultId]; + setBusy(b => ({ ...b, [vaultId]: undefined })); + setSyncProgress(p => ({ ...p, [vaultId]: undefined })); + + if (st.status === 'failed') { + onToast?.({ + type: 'error', + title: `Sync failed for "${vaultName}"`, + message: + st.errors.length > 0 + ? st.errors.slice(0, 3).join('; ') + : `Failed ${st.failed} file(s)`, + }); + } else { + onToast?.({ + type: st.failed > 0 ? 'info' : 'success', + title: `Synced "${vaultName}"`, + message: + `Ingested ${st.ingested}, unchanged ${st.unchanged}, removed ${st.removed}` + + (st.failed > 0 ? `, failed ${st.failed}` : '') + + (st.skipped_unsupported > 0 ? `, skipped ${st.skipped_unsupported}` : '') + + (st.duration_ms > 0 ? ` · ${(st.duration_ms / 1000).toFixed(1)}s` : ''), + }); + } + await reload(); + return; + } + + // Still running — schedule the next poll. + pollTimers.current[vaultId] = setTimeout(() => { + void poll(); + }, SYNC_POLL_INTERVAL_MS); + }; + + // First poll fires immediately (0 ms delay) so tests don't need fake timers. + pollTimers.current[vaultId] = setTimeout(() => { + void poll(); + }, 0); }, [onToast, reload] ); @@ -272,7 +362,11 @@ export function VaultPanel({ onToast }: VaultPanelProps) { className="rounded-md border border-primary-300 bg-white dark:bg-neutral-900 px-3 py-1.5 text-xs font-semibold text-primary-700 dark:text-primary-300 shadow-sm transition-colors hover:bg-primary-50 dark:hover:bg-primary-500/15 disabled:cursor-not-allowed disabled:opacity-50"> - {state === 'sync' ? 'Syncing…' : 'Sync'} + {state === 'sync' + ? (syncProgress[v.id]?.total ?? 0) > 0 + ? `Syncing… ${syncProgress[v.id]!.ingested}/${syncProgress[v.id]!.total}` + : 'Syncing…' + : 'Sync'}