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 ? (