From 87bda66545ffec11df06d29fd866d694c037c629 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Wed, 4 Nov 2026 15:38:22 +0100 Subject: [PATCH 1/2] refactor: Move the Queued Follow-Up Row Into @librechat/chat The package owns which queued turn is drawn, when it follows the reader to the end of the thread, and where the composer's queued actions attach. The app keeps who sent it, their icon and how the text reads, passed in as props. --- .../components/Chat/Messages/PendingTurn.tsx | 137 +++--------------- .../Messages/__tests__/PendingTurn.spec.tsx | 2 +- packages/chat/src/components.ts | 2 + .../src/components/messages/PendingTurn.tsx | 134 +++++++++++++++++ .../messages/__tests__/PendingTurn.spec.tsx | 103 +++++++++++++ 5 files changed, 263 insertions(+), 115 deletions(-) create mode 100644 packages/chat/src/components/messages/PendingTurn.tsx create mode 100644 packages/chat/src/components/messages/__tests__/PendingTurn.spec.tsx diff --git a/client/src/components/Chat/Messages/PendingTurn.tsx b/client/src/components/Chat/Messages/PendingTurn.tsx index 35c55e6093f..2a178bc55d5 100644 --- a/client/src/components/Chat/Messages/PendingTurn.tsx +++ b/client/src/components/Chat/Messages/PendingTurn.tsx @@ -1,22 +1,8 @@ -import { useCallback, useEffect, useMemo, useRef } from 'react'; -import { useAtomValue } from 'jotai'; import { useRecoilValue } from 'recoil'; -import { - hasRevealSuccessor, - buildRevealedMessage, - revealedQueuedTurnFamily, -} from '@librechat/chat/react'; -import { - SubRow, - messageFooterClasses, - useQueuedTurnPortal, - MessageRow, - Container, -} from '@librechat/chat/components'; +import { PendingTurn as PackagePendingTurn, Container } from '@librechat/chat/components'; import type { TMessage } from 'librechat-data-provider'; import type { RefObject } from 'react'; import type { TMessageIcon } from '~/common'; -import { getHeaderPrefixForScreenReader, getMessageAriaLabel } from '~/utils'; import MessageIcon from '~/components/Chat/Messages/MessageIcon'; import { useAuthContext } from '~/hooks/AuthContext'; import MarkdownLite from './Content/MarkdownLite'; @@ -25,16 +11,7 @@ import { useLocalize } from '~/hooks'; import { cn } from '~/utils'; import store from '~/store'; -/** How far above the end of the thread a reader can be and still be handed - * the next turn, matching the scroll button's own near-bottom band. */ -const FOLLOW_THRESHOLD_PX = 160; - -/** - * The queued follow-up the server is about to admit, drawn as the user turn - * after the completed response it follows. It is not persisted: its actions - * come from the composer that owns the queue, and it leaves as soon as the - * thread gains a turn after that response. - */ +/** The app's side of the queued follow-up row: who sent it, their icon, and how the text reads. */ export default function PendingTurn({ scrollableRef, messages, @@ -47,58 +24,8 @@ export default function PendingTurn({ const localize = useLocalize(); const { user } = useAuthContext(); const { conversation, latestMessageId } = useChatContext(); - const conversationId = conversation?.conversationId ?? ''; - const reveal = useAtomValue(revealedQueuedTurnFamily(conversationId)); - const setPortalTarget = useQueuedTurnPortal()?.setTarget; - const clientRequestId = reveal?.clientRequestId; const usernameDisplay = useRecoilValue(store.UsernameDisplay); const enableUserMsgMarkdown = useRecoilValue(store.enableUserMsgMarkdown); - const rowRef = useRef(null); - const message = useMemo( - () => (reveal == null ? null : buildRevealedMessage(reveal, conversationId)), - [reveal, conversationId], - ); - const successorSeen = useMemo( - () => reveal != null && hasRevealSuccessor(messages ?? [], reveal), - [messages, reveal], - ); - const shown = - message != null && - reveal != null && - reveal.parentMessageId === latestMessageId && - !successorSeen; - const setActionsTarget = useCallback( - (element: HTMLSpanElement | null) => { - setPortalTarget?.( - element == null || clientRequestId == null - ? null - : { element, conversationId, clientRequestId }, - ); - }, - [setPortalTarget, conversationId, clientRequestId], - ); - - /** A reader resting at the end of the thread was following the response; - * bring the turn that replaces it into view the way its streaming did. - * Anyone reading further up is left where they are. */ - useEffect(() => { - const row = rowRef.current; - const scrollEl = scrollableRef?.current; - if (!shown || row == null || scrollEl == null) { - return; - } - const distanceFromEnd = - scrollEl.scrollHeight - scrollEl.scrollTop - scrollEl.clientHeight - row.offsetHeight; - if (distanceFromEnd > FOLLOW_THRESHOLD_PX) { - return; - } - const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches === true; - row.scrollIntoView({ block: 'end', behavior: reduceMotion ? 'auto' : 'smooth' }); - }, [shown, scrollableRef]); - - if (!shown) { - return null; - } const label = usernameDisplay ? (user?.name ?? '') || (user?.username ?? '') @@ -111,44 +38,26 @@ export default function PendingTurn({ }; return ( -
-
- } - label={label} - timestamp={reveal.revealedAt} - ariaLabel={getMessageAriaLabel(message, localize)} - headerPrefix={getHeaderPrefixForScreenReader(message, localize)} - isCreatedByUser - fullWidth={maximizeChatSpace} - footer={ - - - {localize('com_ui_queued_turn_starting')} - - - - } - > - -
- {enableUserMsgMarkdown ? : reveal.text} -
-
-
-
-
+ } + renderBody={(message, text) => ( + +
+ {enableUserMsgMarkdown ? : text} +
+
+ )} + /> ); } diff --git a/client/src/components/Chat/Messages/__tests__/PendingTurn.spec.tsx b/client/src/components/Chat/Messages/__tests__/PendingTurn.spec.tsx index 59ce83da2f9..59d4c3ac5ef 100644 --- a/client/src/components/Chat/Messages/__tests__/PendingTurn.spec.tsx +++ b/client/src/components/Chat/Messages/__tests__/PendingTurn.spec.tsx @@ -84,7 +84,7 @@ describe('PendingTurn', () => { expect(row.querySelector('.message-render')).not.toBeNull(); expect(row.querySelector('.user-turn')).not.toBeNull(); expect(row).toHaveTextContent('queued follow-up'); - expect(screen.getByRole('status')).toHaveTextContent('com_ui_queued_turn_starting'); + expect(screen.getByRole('status')).toHaveTextContent('Starting as the next turn'); expect(container.querySelectorAll('button')).toHaveLength(0); }); diff --git a/packages/chat/src/components.ts b/packages/chat/src/components.ts index 9285199996d..f639ced0542 100644 --- a/packages/chat/src/components.ts +++ b/packages/chat/src/components.ts @@ -89,6 +89,8 @@ export { getRunStepDurationLabels, getElapsedDurationLabels } from './components export type { RunStepDurationLabels } from './components/messages/duration'; export { default as MessageRow } from './components/messages/MessageRow'; export { default as WakeupRow } from './components/messages/WakeupRow'; +export { default as PendingTurn } from './components/messages/PendingTurn'; +export type { PendingTurnProps } from './components/messages/PendingTurn'; export { default as MessageTimestamp } from './components/messages/MessageTimestamp'; export { getMessageRowWidthClass } from './components/messages/width'; export { isValidTimestamp, getMessageTimestamp } from './components/messages/time'; diff --git a/packages/chat/src/components/messages/PendingTurn.tsx b/packages/chat/src/components/messages/PendingTurn.tsx new file mode 100644 index 00000000000..918379e6597 --- /dev/null +++ b/packages/chat/src/components/messages/PendingTurn.tsx @@ -0,0 +1,134 @@ +import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { useAtomValue } from 'jotai'; +import { useLocalize } from '@librechat/client'; +import type { ReactElement, ReactNode, RefObject } from 'react'; +import type { TMessage } from 'librechat-data-provider'; +import { hasRevealSuccessor, buildRevealedMessage } from '../../react/steering/useQueuedTurnReveal'; +import { getHeaderPrefixForScreenReader, getMessageAriaLabel } from './labels'; +import { useQueuedTurnPortal } from '../composer/steering/QueuedTurnPortal'; +import { revealedQueuedTurnFamily } from '../../react/steer/rows'; +import { messageFooterClasses } from './styles'; +import MessageRow from './MessageRow'; +import SubRow from './SubRow'; + +/** How far above the end of the thread a reader can be and still be handed + * the next turn, matching the scroll button's own near-bottom band. */ +const FOLLOW_THRESHOLD_PX = 160; + +export type PendingTurnProps = { + scrollableRef?: RefObject; + messages?: TMessage[] | null; + maximizeChatSpace?: boolean; + /** The routed conversation: its reveal is the turn drawn. */ + conversationId: string; + /** The thread's current tail; the turn is drawn only after the response it follows. */ + latestMessageId?: string; + /** Who the turn is attributed to. */ + label: string; + /** The author's icon. */ + icon: ReactNode; + /** The turn's text, drawn inside the row (the host decides markdown and wrappers). */ + renderBody: (message: TMessage, text: string) => ReactNode; +}; + +/** + * The queued follow-up the server is about to admit, drawn as the user turn after the completed + * response it follows. It is not persisted: its actions come from the composer that owns the + * queue, and it leaves as soon as the thread gains a turn after that response. + */ +export default function PendingTurn({ + scrollableRef, + messages, + maximizeChatSpace = false, + conversationId, + latestMessageId, + label, + icon, + renderBody, +}: PendingTurnProps): ReactElement | null { + const localize = useLocalize(); + const reveal = useAtomValue(revealedQueuedTurnFamily(conversationId)); + const setPortalTarget = useQueuedTurnPortal()?.setTarget; + const clientRequestId = reveal?.clientRequestId; + const rowRef = useRef(null); + const message = useMemo( + () => (reveal == null ? null : buildRevealedMessage(reveal, conversationId)), + [reveal, conversationId], + ); + const successorSeen = useMemo( + () => reveal != null && hasRevealSuccessor(messages ?? [], reveal), + [messages, reveal], + ); + const shown = + message != null && + reveal != null && + reveal.parentMessageId === latestMessageId && + !successorSeen; + const setActionsTarget = useCallback( + (element: HTMLSpanElement | null) => { + setPortalTarget?.( + element == null || clientRequestId == null + ? null + : { element, conversationId, clientRequestId }, + ); + }, + [setPortalTarget, conversationId, clientRequestId], + ); + + /** A reader resting at the end of the thread was following the response; + * bring the turn that replaces it into view the way its streaming did. + * Anyone reading further up is left where they are. */ + useEffect(() => { + const row = rowRef.current; + const scrollEl = scrollableRef?.current; + if (!shown || row == null || scrollEl == null) { + return; + } + const distanceFromEnd = + scrollEl.scrollHeight - scrollEl.scrollTop - scrollEl.clientHeight - row.offsetHeight; + if (distanceFromEnd > FOLLOW_THRESHOLD_PX) { + return; + } + const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches === true; + row.scrollIntoView({ block: 'end', behavior: reduceMotion ? 'auto' : 'smooth' }); + }, [shown, scrollableRef]); + + if (!shown) { + return null; + } + + return ( +
+
+ + + {localize('com_ui_queued_turn_starting')} + + + + } + > + {renderBody(message, reveal.text)} + +
+
+ ); +} diff --git a/packages/chat/src/components/messages/__tests__/PendingTurn.spec.tsx b/packages/chat/src/components/messages/__tests__/PendingTurn.spec.tsx new file mode 100644 index 00000000000..74d033092ac --- /dev/null +++ b/packages/chat/src/components/messages/__tests__/PendingTurn.spec.tsx @@ -0,0 +1,103 @@ +/** + * @jest-environment jsdom + */ +import { act, render, screen } from '@testing-library/react'; +import { Provider as JotaiProvider, createStore } from 'jotai'; +import type { TMessage } from 'librechat-data-provider'; +import type { RevealedQueuedTurn } from '../../../react'; +import { revealedQueuedTurnFamily } from '../../../react/steer/rows'; +import PendingTurn from '../PendingTurn'; + +jest.mock('@librechat/client', () => ({ + ...jest.requireActual('@librechat/client'), + useLocalize: () => (key: string) => key, +})); + +const CONVO_ID = 'convo-pending'; +const RESPONSE_ID = 'response-1'; + +const reveal = (overrides: Partial = {}): RevealedQueuedTurn => ({ + clientRequestId: 'client-request-1', + parentMessageId: RESPONSE_ID, + text: 'queued follow-up', + revealedAt: '2026-09-14T00:00:00.000Z', + ...overrides, +}); + +function renderPendingTurn({ + revealed, + latestMessageId = RESPONSE_ID, + messages, +}: { + revealed?: RevealedQueuedTurn | null; + latestMessageId?: string; + messages?: TMessage[]; +}) { + const store = createStore(); + if (revealed != null) { + store.set(revealedQueuedTurnFamily(CONVO_ID), revealed); + } + const rendered = render( + + } + renderBody={(_message, text) => {text}} + /> + , + ); + return { ...rendered, store }; +} + +describe('PendingTurn', () => { + it('draws the revealed queued turn as a user turn after the response it follows', () => { + const { container } = renderPendingTurn({ revealed: reveal() }); + + const row = screen.getByTestId('pending-turn'); + expect(row.querySelector('.message-render')).not.toBeNull(); + expect(row.querySelector('.user-turn')).not.toBeNull(); + expect(screen.getByTestId('body')).toHaveTextContent('queued follow-up'); + expect(screen.getByRole('status')).toHaveTextContent('com_ui_queued_turn_starting'); + expect(container.querySelectorAll('button')).toHaveLength(0); + }); + + it('renders nothing without a reveal', () => { + renderPendingTurn({ revealed: null }); + expect(screen.queryByTestId('pending-turn')).toBeNull(); + }); + + it('renders nothing while the thread shows a different tail than the response it follows', () => { + renderPendingTurn({ revealed: reveal(), latestMessageId: 'another-branch' }); + expect(screen.queryByTestId('pending-turn')).toBeNull(); + }); + + it('leaves the thread the moment the reveal ends', () => { + const { store } = renderPendingTurn({ revealed: reveal() }); + expect(screen.getByTestId('pending-turn')).toBeInTheDocument(); + + act(() => { + store.set(revealedQueuedTurnFamily(CONVO_ID), null); + }); + + expect(screen.queryByTestId('pending-turn')).toBeNull(); + }); + + it('hides the drawing once history has a successor while the handoff guard remains', () => { + const { store } = renderPendingTurn({ + revealed: reveal(), + messages: [ + { + messageId: 'real-successor', + parentMessageId: RESPONSE_ID, + isCreatedByUser: true, + } as TMessage, + ], + }); + + expect(screen.queryByTestId('pending-turn')).toBeNull(); + expect(store.get(revealedQueuedTurnFamily(CONVO_ID))).not.toBeNull(); + }); +}); From 57bcc26ef10de23830089d65744c8285c5a36a9b Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Fri, 6 Nov 2026 17:49:03 +0100 Subject: [PATCH 2/2] test: Tag the Queued Follow-Up Row Scenario The spec already drives the queued turn through its reveal and handoff in the browser; the tag lets the gate record it as evidence for the row the package now draws. --- e2e/specs/mock/queued-reveal.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/specs/mock/queued-reveal.spec.ts b/e2e/specs/mock/queued-reveal.spec.ts index 7401fe4b062..b7fc736335b 100644 --- a/e2e/specs/mock/queued-reveal.spec.ts +++ b/e2e/specs/mock/queued-reveal.spec.ts @@ -124,7 +124,7 @@ async function typeDuringRun(page: Page, text: string) { } test.describe('server-queued follow-up reveal', () => { - test('shows the queued follow-up as the next user turn the moment the run completes', async ({ + test('shows the queued follow-up as the next user turn the moment the run completes @scenario:queued-follow-up-shows-as-the-next-turn', async ({ page, }) => { test.setTimeout(120000);