From d1d3cd656224c28a28dd0ad34701a13fb749e174 Mon Sep 17 00:00:00 2001 From: Cyrus Gray <144336577+graycyrus@users.noreply.github.com> Date: Tue, 30 Jun 2026 16:53:36 +0530 Subject: [PATCH] fix(memory): add click feedback to the Trees refresh button (#4331) --- .../intelligence/MemoryControls.test.tsx | 68 +++++++++++++++++++ .../intelligence/MemoryControls.tsx | 28 +++++++- 2 files changed, 94 insertions(+), 2 deletions(-) create mode 100644 app/src/components/intelligence/MemoryControls.test.tsx diff --git a/app/src/components/intelligence/MemoryControls.test.tsx b/app/src/components/intelligence/MemoryControls.test.tsx new file mode 100644 index 000000000..c91a49e94 --- /dev/null +++ b/app/src/components/intelligence/MemoryControls.test.tsx @@ -0,0 +1,68 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { MemoryControls } from './MemoryControls'; + +// Stub i18n and the vault sub-section so we render just the control bar. +vi.mock('../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) })); +vi.mock('./ObsidianVaultSection', () => ({ ObsidianVaultSection: () => null })); +vi.mock('../../utils/tauriCommands', () => ({ + memoryTreeFlushNow: vi.fn().mockResolvedValue({ enqueued: true, stale_buffers: 0 }), + memoryTreeResetTree: vi.fn().mockResolvedValue(undefined), + memoryTreeWipeAll: vi.fn().mockResolvedValue(undefined), +})); + +const noop = () => {}; + +describe(' refresh feedback', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('calls onRefresh and shows a busy spinner immediately on click', async () => { + const onRefresh = vi.fn(); + render(); + + const btn = screen.getByTestId('memory-graph-refresh'); + expect(btn).not.toBeDisabled(); + + fireEvent.click(btn); + + // The parent re-pull fires right away… + expect(onRefresh).toHaveBeenCalledTimes(1); + // …and the button reports a busy state so the click is visibly acknowledged. + expect(btn).toBeDisabled(); + expect(btn.getAttribute('aria-busy')).toBe('true'); + expect(btn.querySelector('.animate-spin')).not.toBeNull(); + }); + + it('clears the busy state after the minimum spin window', async () => { + const onRefresh = vi.fn(); + render(); + + const btn = screen.getByTestId('memory-graph-refresh'); + fireEvent.click(btn); + expect(btn).toBeDisabled(); + + // Advance past the 600ms minimum spin window; the button re-enables. + await act(async () => { + await vi.advanceTimersByTimeAsync(600); + }); + expect(btn).not.toBeDisabled(); + expect(btn.getAttribute('aria-busy')).toBe('false'); + }); + + it('ignores re-clicks while already refreshing', async () => { + const onRefresh = vi.fn(); + render(); + + const btn = screen.getByTestId('memory-graph-refresh'); + fireEvent.click(btn); + // A disabled button shouldn't re-fire, but guard against programmatic clicks too. + fireEvent.click(btn); + expect(onRefresh).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/src/components/intelligence/MemoryControls.tsx b/app/src/components/intelligence/MemoryControls.tsx index 468077fd1..0ef025611 100644 --- a/app/src/components/intelligence/MemoryControls.tsx +++ b/app/src/components/intelligence/MemoryControls.tsx @@ -59,6 +59,7 @@ export function MemoryControls({ const [building, setBuilding] = useState(false); const [wiping, setWiping] = useState(false); const [resetting, setResetting] = useState(false); + const [refreshing, setRefreshing] = useState(false); const busy = building || wiping || resetting; const handleWipe = useCallback(async () => { @@ -147,6 +148,27 @@ export function MemoryControls({ } }, [onToast, onRefresh]); + const handleRefresh = useCallback(async () => { + if (refreshing) return; + console.debug('[ui-flow][memory-controls] manual refresh: entry'); + setRefreshing(true); + try { + // `onRefresh` re-pulls the graph; the parent owns the fetch and may + // resolve synchronously (it just bumps a version key today). Run it + // alongside a short minimum spin window so the click always produces + // perceptible feedback instead of an instant, invisible no-op. + await Promise.all([ + Promise.resolve(onRefresh()), + new Promise(resolve => setTimeout(resolve, 600)), + ]); + } catch (err) { + console.error('[ui-flow][memory-controls] manual refresh failed', err); + } finally { + setRefreshing(false); + console.debug('[ui-flow][memory-controls] manual refresh: exit'); + } + }, [onRefresh, refreshing]); + return (
@@ -179,11 +201,13 @@ export function MemoryControls({ {/* Secondary actions — quiet ghost buttons. */} {contentRootAbs ? (