diff --git a/client/src/components/Chat/Messages/Content/MessageContent.tsx b/client/src/components/Chat/Messages/Content/MessageContent.tsx index f51f14c714..419625eb6e 100644 --- a/client/src/components/Chat/Messages/Content/MessageContent.tsx +++ b/client/src/components/Chat/Messages/Content/MessageContent.tsx @@ -5,6 +5,7 @@ import type { TMessage } from 'librechat-data-provider'; import type { TMessageContentProps, TDisplayProps } from '~/common'; import useSmoothStreaming from '~/hooks/Messages/useSmoothStreaming'; import Error from '~/components/Messages/Content/Error'; +import CollapsibleText from './Parts/CollapsibleText'; import { useMessageContext } from '~/Providers'; import EmptyText from './Parts/EmptyText'; import MarkdownLite from './MarkdownLite'; @@ -90,6 +91,7 @@ export const ErrorMessage = ({ const DisplayMessage = ({ text, isCreatedByUser, message, showCursor }: TDisplayProps) => { const { isSubmitting = false, isLatestMessage = false } = useMessageContext(); const enableUserMsgMarkdown = useRecoilValue(store.enableUserMsgMarkdown); + const collapseLongUserMessages = useRecoilValue(store.collapseLongUserMessages); const smoothStreaming = useSmoothStreaming(); // The word fade itself indicates streaming, so the trailing block cursor @@ -111,17 +113,19 @@ const DisplayMessage = ({ text, isCreatedByUser, message, showCursor }: TDisplay return ( -
0 && 'result-streaming', - isCreatedByUser && !enableUserMsgMarkdown && 'whitespace-pre-wrap', - 'text-text-primary', - )} - > - {content} -
+ +
0 && 'result-streaming', + isCreatedByUser && !enableUserMsgMarkdown && 'whitespace-pre-wrap', + 'text-text-primary', + )} + > + {content} +
+
); }; diff --git a/client/src/components/Chat/Messages/Content/Parts/CollapsibleText.tsx b/client/src/components/Chat/Messages/Content/Parts/CollapsibleText.tsx new file mode 100644 index 0000000000..7935ddccde --- /dev/null +++ b/client/src/components/Chat/Messages/Content/Parts/CollapsibleText.tsx @@ -0,0 +1,131 @@ +import { useLayoutEffect, memo, useId, useRef, useState } from 'react'; +import { Button } from '@librechat/client'; +import { ChevronDown, ChevronUp } from 'lucide-react'; +import type { ReactNode } from 'react'; +import { useLocalize } from '~/hooks'; +import { cn } from '~/utils'; + +/** Collapsed preview height (px) for a long message before "Show more". The + * tolerance below only decides whether the toggle appears: it absorbs the + * trailing markdown margin so content that fits but for its own bottom margin + * does not trip a pointless toggle. The clamp itself is applied only once the + * content actually overflows, so nothing is ever hidden without a toggle. */ +const COLLAPSED_MAX_HEIGHT = 256; +const OVERFLOW_TOLERANCE = 8; + +/** + * Clamps a long user message to a preview height with a fade and a + * "Show more" toggle, so a pasted wall of text or code cannot dominate the + * thread. The clamp is visual only: `overflow-hidden` keeps the full text in + * the DOM, so it stays readable by assistive tech, copyable, and findable by + * in-page search. Renders children untouched while `enabled` is false, keeping + * the DOM identical when the preference is off. + */ +const CollapsibleText = memo(function CollapsibleText({ + enabled, + children, +}: { + enabled: boolean; + children: ReactNode; +}) { + const localize = useLocalize(); + const contentRef = useRef(null); + const contentId = useId(); + const [expanded, setExpanded] = useState(false); + const [overflowing, setOverflowing] = useState(false); + const [wasEnabled, setWasEnabled] = useState(enabled); + + // Turning the preference off resets the reveal, so re-enabling always + // starts from the collapsed preview instead of a stale expanded state. + if (wasEnabled !== enabled) { + setWasEnabled(enabled); + if (!enabled) { + setExpanded(false); + } + } + + /** Measures the inner wrapper, which is never clamped: `scrollHeight` there + * is the natural content height, and its ResizeObserver fires when content + * grows or shrinks (font size change, a late image or diagram finishing + * layout) even while the outer region is clipped. A layout effect, so the + * first paint already carries the clamp instead of flashing the full wall + * of text on mount. */ + useLayoutEffect(() => { + const el = contentRef.current; + if (el == null) { + return; + } + const measure = () => + setOverflowing(el.scrollHeight - COLLAPSED_MAX_HEIGHT > OVERFLOW_TOLERANCE); + measure(); + if (typeof ResizeObserver === 'undefined') { + return; + } + const observer = new ResizeObserver(measure); + observer.observe(el); + return () => observer.disconnect(); + }, [enabled]); + + if (!enabled) { + return <>{children}; + } + + const clamped = !expanded && overflowing; + + /** Focus stays in the tab order across the whole message (the text is in the + * DOM and must remain reachable), but landing on a control that is actually + * clipped reveals it rather than leaving focus inside hidden content. A + * control that is already visible within the preview does not expand; any + * overhang past the boundary counts, since the overflow tolerance exists + * only to absorb trailing margins when deciding if the message overflows. */ + const revealIfClipped = (event: React.FocusEvent) => { + if (!clamped) { + return; + } + const target = event.target as HTMLElement; + const boundary = event.currentTarget.getBoundingClientRect().top + COLLAPSED_MAX_HEIGHT; + if (target.getBoundingClientRect().bottom > boundary) { + setExpanded(true); + } + }; + + return ( +
+
+
+ {children} +
+ {clamped && ( + + {overflowing && ( + + )} +
+ ); +}); + +export default CollapsibleText; diff --git a/client/src/components/Chat/Messages/Content/Parts/SteerPart.tsx b/client/src/components/Chat/Messages/Content/Parts/SteerPart.tsx index b2d553ea3c..237235d149 100644 --- a/client/src/components/Chat/Messages/Content/Parts/SteerPart.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/SteerPart.tsx @@ -7,6 +7,7 @@ import MessageTimestamp from '~/components/Chat/Messages/ui/MessageTimestamp'; import MarkdownLite from '~/components/Chat/Messages/Content/MarkdownLite'; import FileContainer from '~/components/Chat/Input/Files/FileContainer'; import Image from '~/components/Chat/Messages/Content/Image'; +import CollapsibleText from './CollapsibleText'; import { useShareContext } from '~/Providers'; import { useLocalize } from '~/hooks'; import { cn } from '~/utils'; @@ -40,6 +41,7 @@ const SteerPart = memo(function SteerPart({ const { isSharedConvo } = useShareContext(); const usernameDisplay = useRecoilValue(store.UsernameDisplay); const enableUserMsgMarkdown = useRecoilValue(store.enableUserMsgMarkdown); + const collapseLongUserMessages = useRecoilValue(store.collapseLongUserMessages); /** The share surface must never label the SHARER's steers with the * viewer's identity; always the generic user label there. */ @@ -101,15 +103,17 @@ const SteerPart = memo(function SteerPart({ ))}
)} -
- {enableUserMsgMarkdown ? : steer} -
+ +
+ {enableUserMsgMarkdown ? : steer} +
+
- {content} -
+ +
+ {content} +
+
); }); TextPart.displayName = 'TextPart'; diff --git a/client/src/components/Chat/Messages/Content/Parts/__tests__/CollapsibleText.test.tsx b/client/src/components/Chat/Messages/Content/Parts/__tests__/CollapsibleText.test.tsx new file mode 100644 index 0000000000..7cb8933eb8 --- /dev/null +++ b/client/src/components/Chat/Messages/Content/Parts/__tests__/CollapsibleText.test.tsx @@ -0,0 +1,217 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import CollapsibleText from '../CollapsibleText'; + +jest.mock('~/hooks', () => ({ + useLocalize: () => (key: string) => key, +})); + +const longMessage = 'line\n'.repeat(80); +const shortMessage = 'a short message'; +const plainMessage = 'a long message'; +const linkLabel = 'focusable link'; + +/** jsdom does no layout, so scrollHeight is 0 unless stubbed. Returning a set + * height above the collapse cap (256px) makes the content read as overflowing; + * the default 0 keeps it within the cap. */ +const stubScrollHeight = (height: number) => + jest.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockReturnValue(height); + +describe('CollapsibleText', () => { + it('renders children untouched while the preference is off', () => { + const scrollHeight = stubScrollHeight(5000); + try { + const { container } = render( + +

{plainMessage}

+
, + ); + // No clamp wrapper, no toggle: the DOM stays identical to before. + const paragraph = screen.getByText(plainMessage); + expect(paragraph.parentElement).toBe(container); + expect(screen.queryByRole('button')).toBeNull(); + } finally { + scrollHeight.mockRestore(); + } + }); + + it('offers no toggle for content that fits the preview', () => { + const scrollHeight = stubScrollHeight(100); + try { + render( + +

{shortMessage}

+
, + ); + expect(screen.getByText(shortMessage)).toBeInTheDocument(); + expect(screen.queryByRole('button')).toBeNull(); + } finally { + scrollHeight.mockRestore(); + } + }); + + it('does not clamp sub-tolerance content that barely exceeds the cap', () => { + // 260px is within the 8px tolerance, so no toggle appears; the clamp must + // not apply either, or the trailing sliver would be hidden irrecoverably. + const scrollHeight = stubScrollHeight(260); + try { + render( + +

{shortMessage}

+
, + ); + expect(screen.queryByRole('button')).toBeNull(); + const region = screen.getByText(shortMessage).closest('[id]'); + expect(region).not.toHaveStyle({ maxHeight: '256px' }); + expect(region?.className).not.toContain('overflow-hidden'); + } finally { + scrollHeight.mockRestore(); + } + }); + + it('clamps an overflowing message and keeps the full text in the page', () => { + const scrollHeight = stubScrollHeight(500); + try { + render( + +

{longMessage}

+
, + ); + const toggle = screen.getByRole('button', { name: 'com_ui_show_more' }); + expect(toggle).toHaveAttribute('aria-expanded', 'false'); + + // aria-controls must point at the clamped region holding the text. + const region = document.getElementById(toggle.getAttribute('aria-controls') ?? ''); + expect(region).not.toBeNull(); + expect(region?.textContent).toBe(longMessage); + + // The clamp is visual only: the full text stays in the DOM. + expect(region?.className).toContain('overflow-hidden'); + expect(region).toHaveStyle({ maxHeight: '256px' }); + } finally { + scrollHeight.mockRestore(); + } + }); + + it('reveals the message when focus reaches clipped content', () => { + // Links and code-block controls below the cutoff stay in the tab order; + // focusing one must not leave focus inside visually hidden content. jsdom + // has no layout, so place the link's box below the 256px cutoff by hand + // (the region's own rect stays all-zero, putting its boundary at 256). + const scrollHeight = stubScrollHeight(500); + try { + render( + +

+ {linkLabel} +

+
, + ); + const link = screen.getByRole('link'); + link.getBoundingClientRect = () => + ({ top: 300, bottom: 320, height: 20, width: 40, left: 0, right: 40 }) as DOMRect; + fireEvent(link, new FocusEvent('focusin', { bubbles: true })); + expect(screen.getByRole('button', { name: 'com_ui_show_less' })).toHaveAttribute( + 'aria-expanded', + 'true', + ); + } finally { + scrollHeight.mockRestore(); + } + }); + + it('keeps the message collapsed when a visible control gains focus', () => { + const scrollHeight = stubScrollHeight(500); + try { + render( + +

+ {linkLabel} +

+
, + ); + const link = screen.getByRole('link'); + link.getBoundingClientRect = () => + ({ top: 10, bottom: 30, height: 20, width: 40, left: 0, right: 40 }) as DOMRect; + fireEvent(link, new FocusEvent('focusin', { bubbles: true })); + expect(screen.getByRole('button', { name: 'com_ui_show_more' })).toHaveAttribute( + 'aria-expanded', + 'false', + ); + } finally { + scrollHeight.mockRestore(); + } + }); + + it('reveals a control clipped by even a pixel at the boundary', () => { + // The overflow tolerance absorbs trailing margins for the toggle decision + // only: a focused control hanging 2px past the cutoff is still hidden. + const scrollHeight = stubScrollHeight(500); + try { + render( + +

+ {linkLabel} +

+
, + ); + const link = screen.getByRole('link'); + link.getBoundingClientRect = () => + ({ top: 254, bottom: 258, height: 4, width: 40, left: 0, right: 40 }) as DOMRect; + fireEvent(link, new FocusEvent('focusin', { bubbles: true })); + expect(screen.getByRole('button', { name: 'com_ui_show_less' })).toHaveAttribute( + 'aria-expanded', + 'true', + ); + } finally { + scrollHeight.mockRestore(); + } + }); + + it('expands in place and offers show less', () => { + const scrollHeight = stubScrollHeight(500); + try { + render( + +

{plainMessage}

+
, + ); + fireEvent.click(screen.getByRole('button', { name: 'com_ui_show_more' })); + const collapse = screen.getByRole('button', { name: 'com_ui_show_less' }); + expect(collapse).toHaveAttribute('aria-expanded', 'true'); + expect(document.getElementById(collapse.getAttribute('aria-controls') ?? '')).toHaveStyle({ + maxHeight: '', + }); + } finally { + scrollHeight.mockRestore(); + } + }); + + it('starts collapsed again after the preference is turned off and back on', () => { + const scrollHeight = stubScrollHeight(500); + try { + const { rerender } = render( + +

{plainMessage}

+
, + ); + fireEvent.click(screen.getByRole('button', { name: 'com_ui_show_more' })); + rerender( + +

{plainMessage}

+
, + ); + rerender( + +

{plainMessage}

+
, + ); + expect(screen.getByRole('button', { name: 'com_ui_show_more' })).toHaveAttribute( + 'aria-expanded', + 'false', + ); + } finally { + scrollHeight.mockRestore(); + } + }); +}); diff --git a/client/src/components/Chat/Messages/Content/Parts/index.ts b/client/src/components/Chat/Messages/Content/Parts/index.ts index 527654a442..5c6b6ae9a4 100644 --- a/client/src/components/Chat/Messages/Content/Parts/index.ts +++ b/client/src/components/Chat/Messages/Content/Parts/index.ts @@ -2,6 +2,7 @@ export * from './Attachment'; export * from './OpenAIImageGen'; export { default as Text } from './Text'; +export { default as CollapsibleText } from './CollapsibleText'; export { default as Reasoning } from './Reasoning'; export { default as EmptyText } from './EmptyText'; export { default as LogContent } from './LogContent'; diff --git a/client/src/components/Nav/Settings/registry.tsx b/client/src/components/Nav/Settings/registry.tsx index 2c08d8fb74..0d6ef12b58 100644 --- a/client/src/components/Nav/Settings/registry.tsx +++ b/client/src/components/Nav/Settings/registry.tsx @@ -280,6 +280,19 @@ export const registry: SettingEntry[] = [ switchId: 'enableUserMsgMarkdown', }), }, + { + id: 'collapseLongUserMessages', + tab: CHAT, + section: 'messages', + labelKey: 'com_nav_collapse_user_messages', + keywords: ['collapse', 'expand', 'long', 'user', 'message', 'truncate', 'show', 'more'], + Component: toggleControl({ + stateAtom: store.collapseLongUserMessages, + localizationKey: 'com_nav_collapse_user_messages', + switchId: 'collapseLongUserMessages', + hoverCardText: 'com_nav_info_collapse_user_messages', + }), + }, { id: 'usernameDisplay', tab: CHAT, diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index 7a47979387..84f9b5e46b 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -486,6 +486,7 @@ "com_nav_clear_conversation": "Clear conversations", "com_nav_clear_conversation_confirm_message": "Are you sure you want to clear all conversations? This is irreversible.", "com_nav_client_image_resize": "Resize images before upload", + "com_nav_collapse_user_messages": "Collapse long user messages", "com_nav_close_sidebar": "Close sidebar", "com_nav_confirm_archive_all": "Confirm Archive", "com_nav_confirm_clear": "Confirm Clear", @@ -535,6 +536,7 @@ "com_nav_info_client_image_resize_unavailable": "Image resizing is unavailable because the file settings could not be loaded. Reload the page to try again.", "com_nav_info_client_image_resize_unsupported": "Image resizing is unavailable because this browser does not support the required image processing features.", "com_nav_info_code_background": "When enabled, the model can run code executions in the background: the conversation continues immediately while the code runs, and the model retrieves the result later with the background task tool. Output and generated files still attach to the original code run. Requires the app-level background tools capability.", + "com_nav_info_collapse_user_messages": "When enabled, long user messages render collapsed to a preview with a Show more option, so pasted text or code cannot dominate the conversation. The full text stays in the page, ready to copy or search.", "com_nav_info_default_temporary_chat": "When enabled, new chats will start with temporary chat mode activated by default. Temporary chats are not saved to your history.", "com_nav_info_during_run_action": "Choose what pressing Enter does while the assistant is still responding. \"Steer the response\" inserts your message into the current response at its next step; \"Queue for after\" sends it as a new turn once the response finishes. You can always override this per message from the send button.", "com_nav_info_enter_to_send": "When enabled, pressing `ENTER` will send your message. When disabled, pressing Enter will add a new line, and you'll need to press `CTRL + ENTER` / `⌘ + ENTER` to send your message.", diff --git a/client/src/store/settings.ts b/client/src/store/settings.ts index 109e63e447..4680e0dcbc 100644 --- a/client/src/store/settings.ts +++ b/client/src/store/settings.ts @@ -79,6 +79,12 @@ const localStorageAtoms = { chatDirection: atomWithLocalStorage('chatDirection', 'LTR'), autoExpandTools: atomWithLocalStorage(LocalStorageKeys.AUTO_EXPAND_TOOLS, false), saveDrafts: atomWithLocalStorage('saveDrafts', true), + /** + * Whether long user messages render collapsed to a preview with a + * "Show more" toggle. The clamp is visual only; the full text stays in + * the DOM, copyable and searchable. + */ + collapseLongUserMessages: atomWithLocalStorage('collapseLongUserMessages', false), /** * Whether pasting a large block of text attaches it as a `.txt` file instead of * flooding the composer. The text still reaches the model in full.