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/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); 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(); + }); +});