mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-30 07:08:50 +01:00
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:
parent
06ba025bd9
commit
eb1a59d2fd
4 changed files with 148 additions and 53 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue