fix(memory): add click feedback to the Trees refresh button (#4331)

This commit is contained in:
Cyrus Gray
2026-06-30 16:53:36 +05:30
committed by GitHub
parent 124fdc9910
commit d1d3cd6562
2 changed files with 94 additions and 2 deletions
@@ -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('<MemoryControls /> 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(<MemoryControls mode="tree" onModeChange={noop} onRefresh={onRefresh} />);
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(<MemoryControls mode="tree" onModeChange={noop} onRefresh={onRefresh} />);
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(<MemoryControls mode="tree" onModeChange={noop} onRefresh={onRefresh} />);
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);
});
});
@@ -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 (
<div className="flex flex-wrap items-center justify-between gap-3" data-testid="memory-actions">
<ModeToggle mode={mode} onChange={onModeChange} />
@@ -179,11 +201,13 @@ export function MemoryControls({
{/* Secondary actions — quiet ghost buttons. */}
<button
type="button"
onClick={onRefresh}
onClick={handleRefresh}
disabled={refreshing}
aria-busy={refreshing}
data-testid="memory-graph-refresh"
className={BTN_GHOST}
title={t('common.refresh')}>
<RefreshIcon /> {t('common.refresh')}
{refreshing ? <Spinner /> : <RefreshIcon />} {t('common.refresh')}
</button>
{contentRootAbs ? (
<ObsidianVaultSection contentRootAbs={contentRootAbs} onToast={onToast} />