Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 23 additions & 114 deletions client/src/components/Chat/Messages/PendingTurn.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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,
Expand All @@ -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<HTMLDivElement | null>(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 ?? '')
Expand All @@ -111,44 +38,26 @@ export default function PendingTurn({
};

return (
<div
ref={rowRef}
className="text-text-primary w-full border-0 bg-transparent"
data-testid="pending-turn"
>
<div className="m-auto justify-center px-4 py-3 sm:px-0">
<MessageRow
message={message}
icon={<MessageIcon iconData={iconData} />}
label={label}
timestamp={reveal.revealedAt}
ariaLabel={getMessageAriaLabel(message, localize)}
headerPrefix={getHeaderPrefixForScreenReader(message, localize)}
isCreatedByUser
fullWidth={maximizeChatSpace}
footer={
<SubRow
classes={cn(messageFooterClasses, 'flex-wrap items-center justify-end gap-1.5')}
>
<span className="text-text-secondary text-xs" role="status">
{localize('com_ui_queued_turn_starting')}
</span>
<span ref={setActionsTarget} className="flex items-center gap-1" />
</SubRow>
}
>
<Container message={message}>
<div
className={cn(
'markdown prose message-content light text-text-primary w-full break-words',
!enableUserMsgMarkdown && 'whitespace-pre-wrap',
)}
>
{enableUserMsgMarkdown ? <MarkdownLite content={reveal.text} /> : reveal.text}
</div>
</Container>
</MessageRow>
</div>
</div>
<PackagePendingTurn
scrollableRef={scrollableRef}
messages={messages}
maximizeChatSpace={maximizeChatSpace}
conversationId={conversation?.conversationId ?? ''}
latestMessageId={latestMessageId}
label={label}
icon={<MessageIcon iconData={iconData} />}
renderBody={(message, text) => (
<Container message={message}>
<div
className={cn(
'markdown prose message-content light text-text-primary w-full break-words',
!enableUserMsgMarkdown && 'whitespace-pre-wrap',
)}
>
{enableUserMsgMarkdown ? <MarkdownLite content={text} /> : text}
</div>
</Container>
)}
/>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand Down
2 changes: 1 addition & 1 deletion e2e/specs/mock/queued-reveal.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions packages/chat/src/components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
134 changes: 134 additions & 0 deletions packages/chat/src/components/messages/PendingTurn.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement | null>;
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<HTMLDivElement | null>(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 (
<div
ref={rowRef}
className="text-text-primary w-full border-0 bg-transparent"
data-testid="pending-turn"
>
<div className="m-auto justify-center px-4 py-3 sm:px-0">
<MessageRow
message={message}
icon={icon}
label={label}
timestamp={reveal.revealedAt}
ariaLabel={getMessageAriaLabel(message, localize)}
headerPrefix={getHeaderPrefixForScreenReader(message, localize)}
isCreatedByUser
fullWidth={maximizeChatSpace}
footer={
<SubRow
classes={[messageFooterClasses, 'flex-wrap items-center justify-end gap-1.5'].join(
' ',
)}
>
<span className="text-text-secondary text-xs" role="status">
{localize('com_ui_queued_turn_starting')}
</span>
<span ref={setActionsTarget} className="flex items-center gap-1" />
</SubRow>
}
>
{renderBody(message, reveal.text)}
</MessageRow>
</div>
</div>
);
}
Loading
Loading