{/* Primary composer owns the selection popup so split-view doesn't double it. */}
{index === 0 && quotesEnabled && }
-
+ {/* `relative` anchors the in-flight steer overlay, which floats above
+ the composer (`bottom-full`) over the bottom of the thread. */}
+
{/* Run-scoped: `enabled` alone is any primary composer on a steerable
endpoint, so a chip that outlives the run would strand a bubble. */}
{steering.enabled && isSubmitting && (
diff --git a/client/src/components/Chat/Input/InFlightSteers.tsx b/client/src/components/Chat/Input/InFlightSteers.tsx
index 289e458431..00227e4755 100644
--- a/client/src/components/Chat/Input/InFlightSteers.tsx
+++ b/client/src/components/Chat/Input/InFlightSteers.tsx
@@ -1,7 +1,8 @@
-import { memo, useRef, useMemo, useState, useEffect, useCallback } from 'react';
+import { memo, useId, useRef, useMemo, useState, useEffect, useCallback } from 'react';
+import { useSetAtom } from 'jotai';
import { useToastContext } from '@librechat/client';
-import { X, Zap, Clock, Pencil } from 'lucide-react';
import { useRecoilValue, useRecoilCallback } from 'recoil';
+import { X, Zap, Clock, Pencil, ChevronUp, ChevronDown } from 'lucide-react';
import type { TFile, TMessage } from 'librechat-data-provider';
import type { SteeringControls, QueuedMessageContext } from '~/hooks/Chat/useSteering';
import type { PendingSteer } from '~/store/families';
@@ -12,6 +13,7 @@ import FileContainer from '~/components/Chat/Input/Files/FileContainer';
import { useSteerCancel, useSteerReclaim, useLocalize } from '~/hooks';
import ImagePreview from '~/components/Chat/Input/Files/ImagePreview';
import { RowMenu, useDefaultToggleEntry } from './SteerMenu';
+import { steerOverlayHeightFamily } from '~/store/steer';
import { carriedSteerContext, cn } from '~/utils';
import store from '~/store';
@@ -34,6 +36,13 @@ const splitFiles = (files?: TMessage['files']) => {
return { images, others };
};
+/** Collapsed preview height (px) for a long steer before "Show more". Matched
+ * to the JS overflow check below so the toggle appears exactly when clipped;
+ * the tolerance absorbs the trailing markdown margin so content that fits but
+ * for its own bottom margin does not trip a pointless toggle. */
+const STEER_COLLAPSED_MAX_HEIGHT = 128;
+const STEER_OVERFLOW_TOLERANCE = 8;
+
/**
* One steer on its way into the run, anchored above the composer as a message
* bubble rather than a control chip — the words are already part of the
@@ -78,6 +87,31 @@ const InFlightSteer = memo(function InFlightSteer({
const { images, others } = useMemo(() => splitFiles(steer.files), [steer.files]);
const sending = steer.status === 'sending';
+ /** Long steers (several paragraphs) collapse to a preview so the stack stays
+ * scannable; the toggle is offered only once the content actually overflows
+ * the cap. `scrollHeight` reports the full height even while clamped, so the
+ * same check holds whether expanded or not, and the observer re-measures on
+ * the width reflows that change wrapped-line count. */
+ const contentRef = useRef(null);
+ const contentId = useId();
+ const [expanded, setExpanded] = useState(false);
+ const [overflowing, setOverflowing] = useState(false);
+ useEffect(() => {
+ const el = contentRef.current;
+ if (el == null) {
+ return;
+ }
+ const measure = () =>
+ setOverflowing(el.scrollHeight - STEER_COLLAPSED_MAX_HEIGHT > STEER_OVERFLOW_TOLERANCE);
+ measure();
+ if (typeof ResizeObserver === 'undefined') {
+ return;
+ }
+ const observer = new ResizeObserver(measure);
+ observer.observe(el);
+ return () => observer.disconnect();
+ }, []);
+
/** Whether the words have already been re-homed by a terminal conversion (a
* run that ended/errored mid-reclaim queues the still-present chip). The
* queue action is safe either way — the conversion dedupes by id — but a
@@ -198,7 +232,10 @@ const InFlightSteer = memo(function InFlightSteer({
role="listitem"
data-testid="in-flight-steer"
data-steer-status={steer.status}
- className="group flex flex-col items-start gap-1.5"
+ /* pointer-events-auto: the overlay container disables events so wheeling
+ * over the gaps reaches the messages behind; each bubble re-enables them
+ * for its own controls and internal scroll. */
+ className="group pointer-events-auto flex flex-col items-start gap-1.5"
>
{(images.length > 0 || others.length > 0) && (
+ {/* items-start so the sticky controls have room to travel — see below. */}
+
{localize('com_ui_steer_in_flight')}
-
- {/* No code execution: this bubble sits outside MessageContext, so
- * Run Code would fire with no message/part to target. */}
- {enableUserMsgMarkdown ? (
-
- ) : (
- steer.text
+
+
+
+ {/* No code execution: this bubble sits outside MessageContext, so
+ * Run Code would fire with no message/part to target. */}
+ {enableUserMsgMarkdown ? (
+
+ ) : (
+ steer.text
+ )}
+
+ {!expanded && overflowing && (
+
+ )}
+
+ {overflowing && (
+
)}
@@ -254,8 +323,10 @@ const InFlightSteer = memo(function InFlightSteer({
/* One always-visible affordance: a label-less menu hidden until hover
* is undiscoverable, and edit/queue/cancel all live inside it now, so
* the menu shows at rest on every pointer (matching the always-on
- * controls on the queued rows). */
-
+ * controls on the queued rows). `sticky` keeps it in view while the
+ * user scrolls through a tall, expanded steer (the stack scrolls once
+ * it passes 35vh). */
+
)}
@@ -293,6 +364,7 @@ const InFlightSteers = memo(function InFlightSteers({
const localize = useLocalize();
const steers = useRecoilValue(store.pendingSteersByConvoId(conversationId));
const inFlight = useMemo(() => steers.filter((steer) => steer.status !== 'failed'), [steers]);
+ const setOverlayHeight = useSetAtom(steerOverlayHeightFamily(conversationId));
/** 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
@@ -306,6 +378,31 @@ const InFlightSteers = memo(function InFlightSteers({
}
}, [newestId]);
+ /** The overlay is pulled out of flow (absolute), so the messages no longer
+ * shrink to fit it. Publish its rendered height so the message scroll area
+ * can reserve an equal band of bottom padding — keeping the newest message
+ * clear of the overlay at rest while older ones scroll behind it. */
+ useEffect(() => {
+ const list = listRef.current;
+ if (list == null) {
+ setOverlayHeight(0);
+ return;
+ }
+ const publish = () => setOverlayHeight(list.offsetHeight);
+ publish();
+ if (typeof ResizeObserver === 'undefined') {
+ return;
+ }
+ const observer = new ResizeObserver(publish);
+ observer.observe(list);
+ return () => observer.disconnect();
+ }, [setOverlayHeight, inFlight.length]);
+
+ /** Drop the reserved band when the overlay leaves (run ends while steers are
+ * still in flight, or conversation switch) — the measure effect above only
+ * resets when it re-runs, which unmount does not do. */
+ useEffect(() => () => setOverlayHeight(0), [setOverlayHeight]);
+
if (inFlight.length === 0) {
return null;
}
@@ -316,10 +413,12 @@ const InFlightSteers = memo(function InFlightSteers({
role="list"
aria-label={localize('com_ui_steer_in_flight')}
data-testid="in-flight-steers"
- /* Capped: a steer runs to 16k chars and a run takes up to 10 of them.
- * Unbounded, the stack would push the composer off-screen — the old
- * in-thread slot could grow freely because it scrolled with the thread. */
- className="flex max-h-[35vh] flex-col items-start gap-2 overflow-y-auto px-2 pb-2"
+ /* Floats above the composer over the bottom of the thread instead of
+ * displacing it, so scrolling up reveals the messages behind. Capped: a
+ * steer runs to 16k chars and a run takes up to 10 of them; unbounded it
+ * would cover the whole thread. pointer-events-none lets wheeling over
+ * the gaps reach those messages (each bubble opts back in). */
+ className="pointer-events-none absolute inset-x-0 bottom-full flex max-h-[35vh] flex-col items-start gap-2 overflow-y-auto px-2 pb-2"
>
{inFlight.map((steer) => (
{
expect(screen.queryByTestId('steer-markdown')).toBeNull();
expect(screen.getByText('**bold** steer')).toBeInTheDocument();
});
+
+ it('floats the stack over the thread instead of displacing it', () => {
+ // Anchored above the composer and pulled out of flow so the messages keep
+ // their full height and slide behind it when the user scrolls up.
+ renderSteers([{ steerId: 's1', text: 'scroll behind me', status: 'pending', createdAt: 1 }]);
+ const stack = screen.getByTestId('in-flight-steers');
+ expect(stack.className).toContain('absolute');
+ expect(stack.className).toContain('bottom-full');
+ // Wheeling over the gaps must reach the messages behind; bubbles opt back in.
+ expect(stack.className).toContain('pointer-events-none');
+ expect(screen.getByTestId('in-flight-steer').className).toContain('pointer-events-auto');
+ });
+
+ it('offers show more for a long steer and expands it in place', () => {
+ // jsdom does no layout, so stub scrollHeight above the collapse cap (128px)
+ // to make the content read as overflowing.
+ const scrollHeight = jest
+ .spyOn(HTMLElement.prototype, 'scrollHeight', 'get')
+ .mockReturnValue(500);
+ try {
+ renderSteers([
+ { steerId: 's1', text: 'paragraph\n\n'.repeat(20), status: 'pending', createdAt: 1 },
+ ]);
+ const toggle = screen.getByRole('button', { name: 'com_ui_show_more' });
+ expect(toggle).toHaveAttribute('aria-expanded', 'false');
+ fireEvent.click(toggle);
+ const collapse = screen.getByRole('button', { name: 'com_ui_show_less' });
+ expect(collapse).toHaveAttribute('aria-expanded', 'true');
+ } finally {
+ scrollHeight.mockRestore();
+ }
+ });
+
+ it('does not offer a toggle for a steer that fits the preview', () => {
+ // Left unstubbed, jsdom scrollHeight is 0, i.e. never overflows.
+ renderSteers([{ steerId: 's1', text: 'thank you', status: 'pending', createdAt: 1 }]);
+ expect(screen.queryByRole('button', { name: 'com_ui_show_more' })).toBeNull();
+ expect(screen.queryByRole('button', { name: 'com_ui_show_less' })).toBeNull();
+ });
+
+ it('sticks the options menu so it stays reachable while scrolling long content', () => {
+ renderSteers([{ steerId: 's1', text: 'x'.repeat(4000), status: 'pending', createdAt: 1 }]);
+ expect(screen.getByTestId('steer-controls').className).toContain('sticky');
+ });
+
+ it('publishes its height for the messages to reserve, and clears it on unmount', () => {
+ // The overlay no longer takes layout space, so it hands its measured height
+ // to `steerOverlayHeightFamily`; `MessagesView` reserves an equal band of
+ // bottom padding so the newest message rests clear of it.
+ const jotaiStore = getDefaultStore();
+ const offsetHeight = jest
+ .spyOn(HTMLElement.prototype, 'offsetHeight', 'get')
+ .mockReturnValue(96);
+ try {
+ const { unmount } = renderSteers([
+ { steerId: 's1', text: 'reserve for me', status: 'pending', createdAt: 1 },
+ ]);
+ expect(jotaiStore.get(steerOverlayHeightFamily(CONVO_ID))).toBe(96);
+ unmount();
+ expect(jotaiStore.get(steerOverlayHeightFamily(CONVO_ID))).toBe(0);
+ } finally {
+ offsetHeight.mockRestore();
+ }
+ });
});
diff --git a/client/src/components/Chat/Messages/MessagesView.tsx b/client/src/components/Chat/Messages/MessagesView.tsx
index 477bd14752..671ca71d81 100644
--- a/client/src/components/Chat/Messages/MessagesView.tsx
+++ b/client/src/components/Chat/Messages/MessagesView.tsx
@@ -1,10 +1,12 @@
import { memo, useState, useRef, useEffect } from 'react';
import { useAtomValue } from 'jotai';
import { useRecoilValue } from 'recoil';
+import { Constants } from 'librechat-data-provider';
import { CSSTransition } from 'react-transition-group';
import type { TMessage } from 'librechat-data-provider';
import { useScreenshot, useMessageScrolling, useLocalize } from '~/hooks';
import ScrollToBottom from '~/components/Messages/ScrollToBottom';
+import { steerOverlayHeightFamily } from '~/store/steer';
import { MessagesViewProvider } from '~/Providers';
import { fontSizeAtom } from '~/store/fontSize';
import MultiMessage from './MultiMessage';
@@ -100,6 +102,13 @@ function MessagesViewContent({
const { conversationId } = conversation ?? {};
+ /** The in-flight steer overlay floats above the composer over the bottom of
+ * the thread (see `InFlightSteers`); reserve an equal band here so the
+ * newest message rests above it and older ones scroll behind. */
+ const steerOverlayHeight = useAtomValue(
+ steerOverlayHeightFamily(conversationId ?? Constants.NEW_CONVO),
+ );
+
return (
<>