diff --git a/src/renderer/chat/ConversationChatPanel.test.tsx b/src/renderer/chat/ConversationChatPanel.test.tsx index cd41b73..52e7915 100644 --- a/src/renderer/chat/ConversationChatPanel.test.tsx +++ b/src/renderer/chat/ConversationChatPanel.test.tsx @@ -812,6 +812,73 @@ describe('ConversationChatPanel onboarding', () => { }); describe('ConversationChatPanel live state', () => { + it('deduplicates rapid clicks of "Load older history" to one in-flight read', async () => { + let finishOlder: ((page: ConversationReadResult) => void) | undefined; + const read = vi + .fn() + .mockResolvedValueOnce(page([item(1)], 'newer-1', 'old-1')) + .mockImplementationOnce( + () => + new Promise((resolve) => { + finishOlder = resolve; + }), + ) + .mockImplementationOnce(() => Promise.resolve(page([item(0)], 'newer-1', 'old-2'))); + window.herdr = { + conversation: { + read, + prompt: vi.fn(), + respond: vi.fn(), + subscribe: vi.fn(async () => undefined), + unsubscribe: vi.fn(async () => undefined), + }, + onSessionEvent: vi.fn(() => () => undefined), + } as unknown as Window['herdr']; + + render(); + const button = await screen.findByRole('button', { name: 'Load older history' }); + expect(button).toBeEnabled(); + + // Burst of rapid clicks while the older read is still pending. + for (let click = 0; click < 20; click += 1) { + fireEvent.click(button); + } + + // Only the single in-flight older read is enqueued. + await waitFor(() => expect(read).toHaveBeenCalledTimes(2)); + expect(read).toHaveBeenNthCalledWith(2, { + target: 'w-older:p1', + direction: 'older', + limit: 256, + cursor: 'old-1', + }); + + // Button is disabled and shows the loading affordance while pending. + const loadingButton = await screen.findByRole('button', { + name: 'Loading older history…', + }); + expect(loadingButton).toBeDisabled(); + expect(screen.queryByRole('button', { name: 'Load older history' })).not.toBeInTheDocument(); + + // Resolve the older page; the button re-enables with the normal label. + act(() => finishOlder?.(page([item(0)], 'newer-1', 'old-2'))); + const reenabled = await screen.findByRole('button', { name: 'Load older history' }); + expect(reenabled).toBeEnabled(); + expect( + screen.queryByRole('button', { name: 'Loading older history…' }), + ).not.toBeInTheDocument(); + + // A subsequent single click still works and uses the updated older cursor. + fireEvent.click(reenabled); + await waitFor(() => expect(read).toHaveBeenCalledTimes(3)); + expect(read).toHaveBeenNthCalledWith(3, { + target: 'w-older:p1', + direction: 'older', + limit: 256, + cursor: 'old-2', + }); + }); + it('renders a prominent working indicator for an in-progress turn', async () => { const read = vi.fn().mockResolvedValue( page( diff --git a/src/renderer/chat/ConversationChatPanel.tsx b/src/renderer/chat/ConversationChatPanel.tsx index 7f383ca..8d63d8c 100644 --- a/src/renderer/chat/ConversationChatPanel.tsx +++ b/src/renderer/chat/ConversationChatPanel.tsx @@ -255,6 +255,7 @@ function ConversationChatPanelForPane({ const [slashMenuSelectedIndex, setSlashMenuSelectedIndex] = useState(0); const [slashMenuDismissed, setSlashMenuDismissed] = useState(false); const [loading, setLoading] = useState(savedStore === undefined); + const [loadingOlder, setLoadingOlder] = useState(false); const [sending, setSending] = useState(false); const [error, setError] = useState(); const [planHydrationRetry, setPlanHydrationRetry] = useState(0); @@ -278,6 +279,7 @@ function ConversationChatPanelForPane({ ); const planHydrationPromiseRef = useRef | undefined>(undefined); const pollInFlightRef = useRef(false); + const loadingOlderRef = useRef(false); const pendingRefreshRevisionRef = useRef(undefined); const pendingPayloadDrainRef = useRef(false); const planHydrationRetryRef = useRef(0); @@ -999,6 +1001,13 @@ function ConversationChatPanelForPane({ }, [items, store.pending]); const loadOlder = useCallback(async () => { + // Ignore duplicate clicks while an older page is already in flight and + // when there is no recorded older page to fetch. + if (loadingOlderRef.current || storeRef.current.olderCursor === undefined) { + return; + } + loadingOlderRef.current = true; + setLoadingOlder(true); const viewport = scrollViewportRef.current; if (viewport) { olderAnchorRef.current = { @@ -1012,6 +1021,9 @@ function ConversationChatPanelForPane({ } catch (reason) { olderAnchorRef.current = undefined; setError(reason instanceof Error ? reason.message : 'Could not load older history.'); + } finally { + loadingOlderRef.current = false; + setLoadingOlder(false); } }, [read]); @@ -1077,10 +1089,10 @@ function ConversationChatPanelForPane({ type="button" className="w-full" variant="neutral" - disabled={loading} + disabled={loading || loadingOlder} onClick={() => void loadOlder()} > - Load older history + {loadingOlder ? 'Loading older history…' : 'Load older history'} ) : null} {loading && items.length === 0 ? (