🫧 feat: Float In-Flight Steers Over the Thread with Collapsible Content (#14365)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions

- Float the in-flight steer stack as a bottom-anchored overlay (`absolute
  bottom-full`) so it no longer shrinks the message viewport and older messages
  scroll behind it; a per-conversation Jotai height atom reserves an equal band
  of bottom padding on the message scroll area so the newest message rests clear
- Collapse long steers to a ~128px preview with a bottom fade and a Show
  more/Show less toggle, shown only once the content overflows the cap
- Sticky the per-steer options menu (`sticky top-2`) so it stays reachable while
  scrolling through a tall, expanded steer
This commit is contained in:
Danny Avila 2026-07-21 11:43:06 -04:00 • committed by GitHub
parent 913540d00a
commit 87b3557f11
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 223 additions and 25 deletions

View file

@ -489,7 +489,9 @@ const ChatForm = memo(function ChatForm({
<div className="relative flex h-full flex-1 items-stretch md:flex-col">
{/* Primary composer owns the selection popup so split-view doesn't double it. */}
{index === 0 && quotesEnabled && <QuoteButton conversationId={conversationId} />}
<div className="flex w-full flex-col">
{/* `relative` anchors the in-flight steer overlay, which floats above
the composer (`bottom-full`) over the bottom of the thread. */}
<div className="relative flex w-full flex-col">
{/* 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 && (

View file

@ -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<HTMLDivElement>(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) && (
<div className="flex flex-wrap items-center gap-2">
@ -222,7 +259,8 @@ const InFlightSteer = memo(function InFlightSteer({
))}
</div>
)}
<div className="flex max-w-full items-center gap-1.5">
{/* items-start so the sticky controls have room to travel — see below. */}
<div className="flex max-w-full items-start gap-1.5">
<div
className={cn(
/* Outlined, not just filled: an in-flight steer is provisional —
@ -234,19 +272,50 @@ const InFlightSteer = memo(function InFlightSteer({
>
<Zap className="mt-1 h-3.5 w-3.5 shrink-0 text-amber-500" aria-hidden="true" />
<span className="sr-only">{localize('com_ui_steer_in_flight')}</span>
<div
className={cn(
'markdown prose message-content dark:prose-invert light min-w-0 break-words',
'dark:text-gray-20',
!enableUserMsgMarkdown && 'whitespace-pre-wrap',
)}
>
{/* No code execution: this bubble sits outside MessageContext, so
* Run Code would fire with no message/part to target. */}
{enableUserMsgMarkdown ? (
<MarkdownLite content={steer.text} codeExecution={false} />
) : (
steer.text
<div className="flex min-w-0 flex-col items-start gap-1">
<div
ref={contentRef}
id={contentId}
className={cn('relative w-full', !expanded && 'overflow-hidden')}
style={!expanded ? { maxHeight: STEER_COLLAPSED_MAX_HEIGHT } : undefined}
>
<div
className={cn(
'markdown prose message-content dark:prose-invert light min-w-0 break-words',
'dark:text-gray-20',
!enableUserMsgMarkdown && 'whitespace-pre-wrap',
)}
>
{/* No code execution: this bubble sits outside MessageContext, so
* Run Code would fire with no message/part to target. */}
{enableUserMsgMarkdown ? (
<MarkdownLite content={steer.text} codeExecution={false} />
) : (
steer.text
)}
</div>
{!expanded && overflowing && (
<div
className="pointer-events-none absolute inset-x-0 bottom-0 h-10 bg-gradient-to-t from-surface-secondary to-transparent"
aria-hidden="true"
/>
)}
</div>
{overflowing && (
<button
type="button"
onClick={() => setExpanded((prev) => !prev)}
aria-expanded={expanded}
aria-controls={contentId}
className="inline-flex items-center gap-1 rounded text-xs font-medium text-text-secondary hover:text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-xheavy"
>
{expanded ? (
<ChevronUp className="h-3.5 w-3.5" aria-hidden="true" />
) : (
<ChevronDown className="h-3.5 w-3.5" aria-hidden="true" />
)}
{expanded ? localize('com_ui_show_less') : localize('com_ui_show_more')}
</button>
)}
</div>
</div>
@ -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). */
<div data-testid="steer-controls" className="flex shrink-0 items-center">
* 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). */
<div data-testid="steer-controls" className="sticky top-2 flex shrink-0 items-center">
<RowMenu label={localize('com_ui_more_options')} entries={entries} />
</div>
)}
@ -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) => (
<InFlightSteer

View file

@ -1,8 +1,10 @@
import React from 'react';
import { RecoilRoot } from 'recoil';
import { getDefaultStore } from 'jotai';
import { render, screen, fireEvent, act } from '@testing-library/react';
import type { SteeringControls } from '~/hooks/Chat/useSteering';
import type { PendingSteer } from '~/store/families';
import { steerOverlayHeightFamily } from '~/store/steer';
import InFlightSteers from '../InFlightSteers';
import store from '~/store';
@ -509,4 +511,68 @@ describe('InFlightSteers', () => {
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();
}
});
});

View file

@ -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 (
<>
<div className="relative flex-1 overflow-hidden overflow-y-auto">
@ -114,7 +123,15 @@ function MessagesViewContent({
width: '100%',
}}
>
<div ref={contentRef} className="flex flex-col pb-9 pt-14 dark:bg-transparent">
<div
ref={contentRef}
className="flex flex-col pb-9 pt-14 dark:bg-transparent"
style={
steerOverlayHeight > 0
? { paddingBottom: `calc(2.25rem + ${steerOverlayHeight}px)` }
: undefined
}
>
{(_messagesTree && _messagesTree.length == 0) || _messagesTree === null ? (
<div
className={cn(

View file

@ -18,6 +18,7 @@ export * from './favorites';
export * from './subagents';
export * from './sandbox';
export * from './usage';
export * from './steer';
export default {
...artifacts,

13
client/src/store/steer.ts Normal file
View file

@ -0,0 +1,13 @@
import { atom } from 'jotai';
import { atomFamily } from 'jotai/utils';
/**
* Measured pixel height of the in-flight steer overlay for a conversation.
* The overlay floats above the composer over the bottom of the message scroll
* area; the messages reserve an equal band of bottom padding (see
* `MessagesView`) so the newest message clears it at rest and older messages
* scroll behind it. `InFlightSteers` publishes its height here and resets it to
* 0 on unmount, so the entry is never stale — the atomFamily is not GC'd, but
* each holds a single number per visited conversation.
*/
export const steerOverlayHeightFamily = atomFamily((_conversationId: string) => atom<number>(0));