mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-21 17:44:11 +00:00
🫧 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
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:
parent
913540d00a
commit
87b3557f11
6 changed files with 223 additions and 25 deletions
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
13
client/src/store/steer.ts
Normal 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));
|
||||
Loading…
Add table
Add a link
Reference in a new issue