🧠 fix: Messages View Height Expansion from #11142 & improve Thinking/Code-Block UX (#11148)

This commit fixes the messages view height auto-expansion issue introduced
in PR #11142 and improves the UX of both Thinking and CodeBlock components.

## Bug Fix

The ThinkingFooter component added in PR #11142 was causing the messages
view height to automatically expand. The footer was placed inside the
CSS grid's overflow-hidden container, but its presence affected the
grid height calculation, causing layout issues during streaming.

## Solution: FloatingThinkingBar

Replaced ThinkingFooter with a new FloatingThinkingBar component that:
- Uses absolute positioning (bottom-right) like CodeBlock's FloatingCodeBar
- Only appears on hover/focus, not affecting layout
- Shows expand/collapse button with dynamic icon based on state
- Uses TooltipAnchor for accessible tooltips
- Supports keyboard navigation by showing when any element in the
  container is focused (top bar buttons or content)

## Changes

### Thinking.tsx
- Added FloatingThinkingBar component with hover/focus visibility
- Updated ThinkingContent with additional bottom padding (pb-10)
- Added containerRef and hover/focus event handlers on outer container
- Removed ThinkingFooter component (replaced by FloatingThinkingBar)

### Reasoning.tsx
- Integrated FloatingThinkingBar with same hover/focus pattern
- Added containerRef and event handlers on outer container
- Supports keyboard navigation through entire component

### CodeBlock.tsx
- Updated FloatingCodeBar to show icons only (removed text labels)
- Added TooltipAnchor wrapper for copy button with localized tooltips
- Improved accessibility with proper aria-label and aria-hidden

### Localization
- Added com_ui_expand_thoughts: "Expand Thoughts"

## Accessibility

- Full keyboard navigation support: tabbing through ThinkingButton,
  copy button, and FloatingThinkingBar
- TooltipAnchor provides hover tooltips for icon-only buttons
- Proper aria-label attributes on all interactive elements
- tabIndex management based on visibility state
This commit is contained in:
Danny Avila 2025-12-29 21:21:50 -05:00 committed by GitHub
parent 06ba025bd9
commit eb1a59d2fd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 148 additions and 53 deletions

View file

@ -1,8 +1,8 @@
import { memo, useMemo, useState, useCallback } from 'react';
import { memo, useMemo, useState, useCallback, useRef } from 'react';
import { useAtom } from 'jotai';
import type { MouseEvent } from 'react';
import type { MouseEvent, FocusEvent } from 'react';
import { ContentTypes } from 'librechat-data-provider';
import { ThinkingContent, ThinkingButton, ThinkingFooter } from './Thinking';
import { ThinkingContent, ThinkingButton, FloatingThinkingBar } from './Thinking';
import { showThinkingAtom } from '~/store/showThinking';
import { useMessageContext } from '~/Providers';
import { useLocalize } from '~/hooks';
@ -39,6 +39,8 @@ const Reasoning = memo(({ reasoning, isLast }: ReasoningProps) => {
const localize = useLocalize();
const [showThinking] = useAtom(showThinkingAtom);
const [isExpanded, setIsExpanded] = useState(showThinking);
const [isBarVisible, setIsBarVisible] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const { isSubmitting, isLatestMessage, nextType } = useMessageContext();
// Strip <think> tags from the reasoning content (modern format)
@ -54,6 +56,26 @@ const Reasoning = memo(({ reasoning, isLast }: ReasoningProps) => {
setIsExpanded((prev) => !prev);
}, []);
const handleFocus = useCallback(() => {
setIsBarVisible(true);
}, []);
const handleBlur = useCallback((e: FocusEvent) => {
if (!containerRef.current?.contains(e.relatedTarget as Node)) {
setIsBarVisible(false);
}
}, []);
const handleMouseEnter = useCallback(() => {
setIsBarVisible(true);
}, []);
const handleMouseLeave = useCallback(() => {
if (!containerRef.current?.contains(document.activeElement)) {
setIsBarVisible(false);
}
}, []);
const effectiveIsSubmitting = isLatestMessage ? isSubmitting : false;
const label = useMemo(
@ -67,7 +89,14 @@ const Reasoning = memo(({ reasoning, isLast }: ReasoningProps) => {
}
return (
<div className="group/reasoning">
<div
ref={containerRef}
className="group/reasoning"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onFocus={handleFocus}
onBlur={handleBlur}
>
<div className="group/thinking-container">
<div className="mb-2 pb-2 pt-2">
<ThinkingButton
@ -86,9 +115,13 @@ const Reasoning = memo(({ reasoning, isLast }: ReasoningProps) => {
gridTemplateRows: isExpanded ? '1fr' : '0fr',
}}
>
<div className="overflow-hidden">
<div className="relative overflow-hidden">
<ThinkingContent>{reasoningText}</ThinkingContent>
<ThinkingFooter onClick={handleClick} />
<FloatingThinkingBar
isVisible={isBarVisible && isExpanded}
isExpanded={isExpanded}
onClick={handleClick}
/>
</div>
</div>
</div>

View file

@ -1,8 +1,8 @@
import { useState, useMemo, memo, useCallback } from 'react';
import { useState, useMemo, memo, useCallback, useRef } from 'react';
import { useAtomValue } from 'jotai';
import { Clipboard, CheckMark } from '@librechat/client';
import { Clipboard, CheckMark, TooltipAnchor } from '@librechat/client';
import { Lightbulb, ChevronDown, ChevronUp } from 'lucide-react';
import type { MouseEvent, FC } from 'react';
import type { MouseEvent, FocusEvent, FC } from 'react';
import { showThinkingAtom } from '~/store/showThinking';
import { fontSizeAtom } from '~/store/fontSize';
import { useLocalize } from '~/hooks';
@ -18,7 +18,7 @@ export const ThinkingContent: FC<{
const fontSize = useAtomValue(fontSizeAtom);
return (
<div className="relative rounded-3xl border border-border-medium bg-surface-tertiary p-4 text-text-secondary">
<div className="relative rounded-3xl border border-border-medium bg-surface-tertiary p-4 pb-10 text-text-secondary">
<p className={cn('whitespace-pre-wrap leading-[26px]', fontSize)}>{children}</p>
</div>
);
@ -122,28 +122,54 @@ export const ThinkingButton = memo(
);
/**
* ThinkingFooter - Footer with collapse button shown at the bottom of expanded content
* Allows users to collapse without scrolling back to the top
* FloatingThinkingBar - Floating bar with expand/collapse button
* Shows on hover/focus, positioned at bottom right of thinking content
* Inspired by CodeBlock's FloatingCodeBar pattern
*/
export const ThinkingFooter = memo(
({ onClick }: { onClick: (e: MouseEvent<HTMLButtonElement>) => void }) => {
export const FloatingThinkingBar = memo(
({
isVisible,
isExpanded,
onClick,
}: {
isVisible: boolean;
isExpanded: boolean;
onClick: (e: MouseEvent<HTMLButtonElement>) => void;
}) => {
const localize = useLocalize();
const tooltipText = isExpanded
? localize('com_ui_collapse_thoughts')
: localize('com_ui_expand_thoughts');
return (
<div className="mt-3 flex items-center justify-end gap-2">
<button
type="button"
onClick={onClick}
aria-label={localize('com_ui_collapse_thoughts')}
className={cn(
'rounded-lg p-1.5 text-text-secondary-alt',
'hover:bg-surface-hover hover:text-text-primary',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-black dark:focus-visible:ring-white',
)}
>
<span className="sr-only">{localize('com_ui_collapse_thoughts')}</span>
<ChevronUp className="h-[18px] w-[18px]" aria-hidden="true" />
</button>
<div
className={cn(
'absolute bottom-3 right-3 flex items-center gap-2 transition-opacity duration-150',
isVisible ? 'opacity-100' : 'pointer-events-none opacity-0',
)}
>
<TooltipAnchor
description={tooltipText}
render={
<button
type="button"
tabIndex={isVisible ? 0 : -1}
onClick={onClick}
aria-label={tooltipText}
className={cn(
'flex items-center justify-center rounded-lg bg-surface-secondary p-1.5 text-text-secondary-alt shadow-sm',
'hover:bg-surface-hover hover:text-text-primary',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-heavy',
)}
>
{isExpanded ? (
<ChevronUp className="h-[18px] w-[18px]" aria-hidden="true" />
) : (
<ChevronDown className="h-[18px] w-[18px]" aria-hidden="true" />
)}
</button>
}
/>
</div>
);
},
@ -168,12 +194,34 @@ const Thinking: React.ElementType = memo(({ children }: { children: React.ReactN
const localize = useLocalize();
const showThinking = useAtomValue(showThinkingAtom);
const [isExpanded, setIsExpanded] = useState(showThinking);
const [isBarVisible, setIsBarVisible] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const handleClick = useCallback((e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
setIsExpanded((prev) => !prev);
}, []);
const handleFocus = useCallback(() => {
setIsBarVisible(true);
}, []);
const handleBlur = useCallback((e: FocusEvent) => {
if (!containerRef.current?.contains(e.relatedTarget as Node)) {
setIsBarVisible(false);
}
}, []);
const handleMouseEnter = useCallback(() => {
setIsBarVisible(true);
}, []);
const handleMouseLeave = useCallback(() => {
if (!containerRef.current?.contains(document.activeElement)) {
setIsBarVisible(false);
}
}, []);
const label = useMemo(() => localize('com_ui_thoughts'), [localize]);
// Extract text content for copy functionality
@ -189,7 +237,14 @@ const Thinking: React.ElementType = memo(({ children }: { children: React.ReactN
}
return (
<div className="group/thinking-container">
<div
ref={containerRef}
className="group/thinking-container"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onFocus={handleFocus}
onBlur={handleBlur}
>
<div className="mb-4 pb-2 pt-2">
<ThinkingButton
isExpanded={isExpanded}
@ -204,9 +259,13 @@ const Thinking: React.ElementType = memo(({ children }: { children: React.ReactN
gridTemplateRows: isExpanded ? '1fr' : '0fr',
}}
>
<div className="overflow-hidden">
<div className="relative overflow-hidden">
<ThinkingContent>{children}</ThinkingContent>
<ThinkingFooter onClick={handleClick} />
<FloatingThinkingBar
isVisible={isBarVisible && isExpanded}
isExpanded={isExpanded}
onClick={handleClick}
/>
</div>
</div>
</div>
@ -215,7 +274,7 @@ const Thinking: React.ElementType = memo(({ children }: { children: React.ReactN
ThinkingButton.displayName = 'ThinkingButton';
ThinkingContent.displayName = 'ThinkingContent';
ThinkingFooter.displayName = 'ThinkingFooter';
FloatingThinkingBar.displayName = 'FloatingThinkingBar';
Thinking.displayName = 'Thinking';
export default memo(Thinking);

View file

@ -2,7 +2,7 @@ import React, { useRef, useState, useMemo, useEffect, useCallback } from 'react'
import copy from 'copy-to-clipboard';
import { InfoIcon } from 'lucide-react';
import { Tools } from 'librechat-data-provider';
import { Clipboard, CheckMark } from '@librechat/client';
import { Clipboard, CheckMark, TooltipAnchor } from '@librechat/client';
import type { CodeBarProps } from '~/common';
import ResultSwitcher from '~/components/Messages/Content/ResultSwitcher';
import { useToolCallsMapContext, useMessageContext } from '~/Providers';
@ -114,26 +114,28 @@ const FloatingCodeBar: React.FC<FloatingCodeBarProps> = React.memo(
{allowExecution === true && (
<RunCode lang={lang} codeRef={codeRef} blockIndex={blockIndex} />
)}
<button
ref={copyButtonRef}
type="button"
tabIndex={isVisible ? 0 : -1}
className={cn(
'flex gap-2 rounded px-2 py-1 hover:bg-gray-700 focus:bg-gray-700 focus:outline focus:outline-white',
error === true ? 'h-4 w-4 items-start text-white/50' : '',
)}
onClick={handleCopy}
>
{isCopied ? <CheckMark className="h-[18px] w-[18px]" /> : <Clipboard />}
{error !== true && (
<span className="relative">
<span className="invisible">{localize('com_ui_copy_code')}</span>
<span className="absolute inset-0 flex items-center">
{isCopied ? localize('com_ui_copied') : localize('com_ui_copy_code')}
</span>
</span>
)}
</button>
<TooltipAnchor
description={isCopied ? localize('com_ui_copied') : localize('com_ui_copy_code')}
render={
<button
ref={copyButtonRef}
type="button"
tabIndex={isVisible ? 0 : -1}
aria-label={isCopied ? localize('com_ui_copied') : localize('com_ui_copy_code')}
className={cn(
'flex items-center justify-center rounded p-1.5 hover:bg-gray-700 focus:bg-gray-700 focus:outline focus:outline-white',
error === true ? 'h-4 w-4 text-white/50' : '',
)}
onClick={handleCopy}
>
{isCopied ? (
<CheckMark className="h-[18px] w-[18px]" aria-hidden="true" />
) : (
<Clipboard aria-hidden="true" />
)}
</button>
}
/>
</>
)}
</div>

View file

@ -799,6 +799,7 @@
"com_ui_code": "Code",
"com_ui_collapse": "Collapse",
"com_ui_collapse_thoughts": "Collapse Thoughts",
"com_ui_expand_thoughts": "Expand Thoughts",
"com_ui_collapse_chat": "Collapse Chat",
"com_ui_command_placeholder": "Optional: Enter a command for the prompt or name will be used",
"com_ui_command_usage_placeholder": "Select a Prompt by command or name",