mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
fix(memory): add click feedback to the Trees refresh button (#4331)
This commit is contained in:
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user