📜 fix: Keep the Newest In-Flight Steer in View

Codex review on de9ede2aad. Valid, and a regression from the 35vh cap in
the previous commit: steers append newest-last, so once the stack
overflows it sits scrolled to the OLDEST entry. The steer just submitted
— and its cancel control — lands below the fold and reads as dropped.

The cap traded "composer pushed off-screen" for "newest steer hidden".
Sticks the stack to the bottom, keyed on the newest steer id so it fires
when one is appended rather than on every render.
This commit is contained in:
Danny Avila 2026-07-16 10:52:06 -04:00
parent de9ede2aad
commit 09c93987a8
2 changed files with 44 additions and 1 deletions

View file

@ -1,4 +1,4 @@
import { memo, useMemo, useState, useCallback } from 'react';
import { memo, useRef, useMemo, useState, useEffect, useCallback } from 'react';
import { X, Zap } from 'lucide-react';
import { useRecoilValue } from 'recoil';
import type { TFile, TMessage } from 'librechat-data-provider';
@ -156,12 +156,25 @@ const InFlightSteers = memo(function InFlightSteers({
const steers = useRecoilValue(store.pendingSteersByConvoId(conversationId));
const inFlight = useMemo(() => steers.filter((steer) => steer.status !== 'failed'), [steers]);
/** Steers append newest-last, so an overflowing stack would sit scrolled to
* the oldest the steer just submitted (and its cancel) would be below the
* fold and read as dropped. Keyed on the newest id, not every render. */
const listRef = useRef<HTMLDivElement>(null);
const newestId = inFlight[inFlight.length - 1]?.steerId;
useEffect(() => {
const list = listRef.current;
if (list != null) {
list.scrollTop = list.scrollHeight;
}
}, [newestId]);
if (inFlight.length === 0) {
return null;
}
return (
<div
ref={listRef}
role="list"
aria-label={localize('com_ui_steer_in_flight')}
data-testid="in-flight-steers"

View file

@ -204,6 +204,36 @@ describe('InFlightSteers', () => {
expect(screen.getByTestId('steer-markdown')).toHaveAttribute('data-code-execution', 'false');
});
it('keeps the newest steer in view when the capped stack overflows', () => {
// jsdom does no layout, so scrollHeight is 0 unless stubbed — without it
// the assertion would pass vacuously against a scrollTop of 0.
const scrollHeight = jest
.spyOn(HTMLElement.prototype, 'scrollHeight', 'get')
.mockReturnValue(600);
try {
const { rerender } = renderSteers([
{ steerId: 's1', text: 'first', status: 'pending', createdAt: 1 },
]);
// A newly submitted steer appends BELOW the existing ones, so a stack
// left scrolled to the top would hide it and its cancel control.
rerender(
<RecoilRoot
initializeState={({ set }) => {
set(store.pendingSteersByConvoId(CONVO_ID), [
{ steerId: 's1', text: 'first', status: 'pending', createdAt: 1 },
{ steerId: 's2', text: 'just submitted', status: 'pending', createdAt: 2 },
]);
}}
>
<InFlightSteers conversationId={CONVO_ID} />
</RecoilRoot>,
);
expect(screen.getByTestId('in-flight-steers').scrollTop).toBe(600);
} finally {
scrollHeight.mockRestore();
}
});
it('caps the stack so a long steer cannot push the composer off-screen', () => {
renderSteers([{ steerId: 's1', text: 'x'.repeat(4000), status: 'pending', createdAt: 1 }]);
// A steer runs to 16k chars, and a run takes up to 10 of them.