From 87b3557f11c4af8345f90c680d008d159400038e Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Tue, 21 Jul 2026 11:43:06 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=AB=A7=20feat:=20Float=20In-Flight=20Stee?= =?UTF-8?q?rs=20Over=20the=20Thread=20with=20Collapsible=20Content=20(#143?= =?UTF-8?q?65)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- client/src/components/Chat/Input/ChatForm.tsx | 4 +- .../components/Chat/Input/InFlightSteers.tsx | 145 +++++++++++++++--- .../Input/__tests__/InFlightSteers.test.tsx | 66 ++++++++ .../components/Chat/Messages/MessagesView.tsx | 19 ++- client/src/store/index.ts | 1 + client/src/store/steer.ts | 13 ++ 6 files changed, 223 insertions(+), 25 deletions(-) create mode 100644 client/src/store/steer.ts diff --git a/client/src/components/Chat/Input/ChatForm.tsx b/client/src/components/Chat/Input/ChatForm.tsx index c738ef8451..12581ac6e8 100644 --- a/client/src/components/Chat/Input/ChatForm.tsx +++ b/client/src/components/Chat/Input/ChatForm.tsx @@ -489,7 +489,9 @@ const ChatForm = memo(function ChatForm({
{/* 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) && (
@@ -222,7 +259,8 @@ const InFlightSteer = memo(function InFlightSteer({ ))}
)} -
+ {/* items-start so the sticky controls have room to travel — see below. */} +