Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions src/renderer/chat/ConversationChatPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Window['herdr']['conversation']['read']>()
.mockResolvedValueOnce(page([item(1)], 'newer-1', 'old-1'))
.mockImplementationOnce(
() =>
new Promise<ConversationReadResult>((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(<ConversationChatPanel pane={pane('w-older:p1')} />);
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<Window['herdr']['conversation']['read']>().mockResolvedValue(
page(
Expand Down
16 changes: 14 additions & 2 deletions src/renderer/chat/ConversationChatPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();
const [planHydrationRetry, setPlanHydrationRetry] = useState(0);
Expand All @@ -278,6 +279,7 @@ function ConversationChatPanelForPane({
);
const planHydrationPromiseRef = useRef<Promise<boolean> | undefined>(undefined);
const pollInFlightRef = useRef(false);
const loadingOlderRef = useRef(false);
const pendingRefreshRevisionRef = useRef<number | undefined>(undefined);
const pendingPayloadDrainRef = useRef(false);
const planHydrationRetryRef = useRef(0);
Expand Down Expand Up @@ -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 = {
Expand All @@ -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]);

Expand Down Expand Up @@ -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'}
</Button>
) : null}
{loading && items.length === 0 ? (
Expand Down