mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-22 01:53:33 +00:00
refactor(client): unify tool row disclosure styling in a shared module
Extract the hover-reveal chevron and expanded panel spacing into disclosure.ts and align every tool activity row on the same layout: h-5 header, my-1 rhythm, full-width click target, and a 200ms chevron rotation. Rows rendered through ProgressText now stretch across the message column (the absolute content layer was shrink-wrapping to the text), and the copy buttons in thinking and output panels anchor to the row instead of the panel corner.
This commit is contained in:
parent
0cb2270ba0
commit
a498f0cc5f
17 changed files with 273 additions and 55 deletions
|
|
@ -5,6 +5,7 @@ import { Clipboard, CheckMark, TooltipAnchor } from '@librechat/client';
|
|||
import type { TMessage } from 'librechat-data-provider';
|
||||
import type { MouseEvent } from 'react';
|
||||
import MessageIcon from '~/components/Share/MessageIcon';
|
||||
import { toolPanelSpacingClassName } from './disclosure';
|
||||
import { useLocalize, useExpandCollapse } from '~/hooks';
|
||||
import { useAgentsMapContext } from '~/Providers';
|
||||
import { cn } from '~/utils';
|
||||
|
|
@ -64,7 +65,7 @@ const AgentHandoff: React.FC<AgentHandoffProps> = ({ name, args: _args = '' }) =
|
|||
: localize('com_ui_copy_to_clipboard');
|
||||
|
||||
return (
|
||||
<div className="my-1.5">
|
||||
<div className="my-2">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
|
|
@ -104,7 +105,12 @@ const AgentHandoff: React.FC<AgentHandoffProps> = ({ name, args: _args = '' }) =
|
|||
<div style={expandStyle}>
|
||||
<div className="overflow-hidden" ref={expandRef}>
|
||||
{hasInfo && (
|
||||
<div className="group/handoff my-2 ml-8 rounded-xl border border-border-light bg-surface-secondary p-4 text-xs">
|
||||
<div
|
||||
className={cn(
|
||||
toolPanelSpacingClassName,
|
||||
'group/handoff ml-8 rounded-xl border border-border-light bg-surface-secondary p-4 text-xs',
|
||||
)}
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<span className="text-[10px] font-medium uppercase tracking-wide text-text-secondary">
|
||||
{localize('com_ui_handoff_instructions')}
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ export default function CodeAnalyze({
|
|||
<span className="sr-only" aria-live="polite" aria-atomic="true">
|
||||
{phase === 'running' ? localize('com_ui_analyzing') : localize('com_ui_analyzing_finished')}
|
||||
</span>
|
||||
<div className="my-1 flex items-center gap-2.5">
|
||||
<div className="relative my-1 flex h-5 shrink-0 items-center gap-2.5">
|
||||
<ProgressText
|
||||
phase={phase}
|
||||
onClick={handleToggleCode}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import ProgressText from '~/components/Chat/Messages/Content/ProgressText';
|
|||
import parseJsonField, { areToolCallArgsComplete } from './parseJsonField';
|
||||
import CopyButton from '~/components/Messages/Content/CopyButton';
|
||||
import LangIcon from '~/components/Messages/Content/LangIcon';
|
||||
import { toolPanelSpacingClassName } from '../disclosure';
|
||||
import { sandboxStartingByToolCallId } from '~/store';
|
||||
import useToolCallState from './useToolCallState';
|
||||
import useLazyHighlight from './useLazyHighlight';
|
||||
|
|
@ -156,7 +157,12 @@ export default function BashCall({
|
|||
</div>
|
||||
<div style={expandStyle}>
|
||||
<div className="overflow-hidden" ref={expandRef}>
|
||||
<div className="my-2 overflow-hidden rounded-lg border border-border-light">
|
||||
<div
|
||||
className={cn(
|
||||
toolPanelSpacingClassName,
|
||||
'overflow-hidden rounded-lg border border-border-light',
|
||||
)}
|
||||
>
|
||||
{command && (
|
||||
<div
|
||||
ref={commandPaneRef}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { SquareTerminal } from 'lucide-react';
|
|||
import type { TAttachment, PartMetadata } from 'librechat-data-provider';
|
||||
import { parseBackgroundHandle, splitBackgroundAttachments } from './handle';
|
||||
import ProgressText from '~/components/Chat/Messages/Content/ProgressText';
|
||||
import { toolPanelSpacingClassName } from '../disclosure';
|
||||
import { sandboxStartingByToolCallId } from '~/store';
|
||||
import useLazyHighlight from './useLazyHighlight';
|
||||
import useToolCallState from './useToolCallState';
|
||||
|
|
@ -168,7 +169,12 @@ export default function ExecuteCode({
|
|||
</div>
|
||||
<div style={expandStyle}>
|
||||
<div className="overflow-hidden" ref={expandRef}>
|
||||
<div className="my-2 overflow-hidden rounded-lg border border-border-light bg-surface-secondary">
|
||||
<div
|
||||
className={cn(
|
||||
toolPanelSpacingClassName,
|
||||
'overflow-hidden rounded-lg border border-border-light bg-surface-secondary',
|
||||
)}
|
||||
>
|
||||
{code && <CodeWindowHeader language={lang} code={code} />}
|
||||
{code && (
|
||||
<pre
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ export default function PendingSkillCall({
|
|||
<div className={TOOL_ROW_CLASSES}>
|
||||
<div className="progress-text-wrapper text-token-text-secondary relative -mt-[0.75px] h-5 w-full leading-5">
|
||||
<div
|
||||
className="progress-text-content absolute left-0 top-0 max-w-full overflow-visible whitespace-nowrap"
|
||||
className="progress-text-content absolute left-0 right-0 top-0 max-w-full overflow-visible whitespace-nowrap"
|
||||
style={{ opacity: 1, transform: 'none' }}
|
||||
>
|
||||
<div className="inline-flex w-full items-center gap-2">
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { Lightbulb, ChevronDown } from 'lucide-react';
|
|||
import { ContentTypes } from 'librechat-data-provider';
|
||||
import type { MouseEvent, FocusEvent } from 'react';
|
||||
import { ThinkingContent, ThinkingButton, ThinkingLabel, FloatingThinkingBar, useInViewport } from './Thinking';
|
||||
import { disclosureChevronClassName } from '~/components/Chat/Messages/Content/disclosure';
|
||||
import CopyButton from '~/components/Messages/Content/CopyButton';
|
||||
import { useLocalize, useExpandCollapse, useLazyCollapseBody } from '~/hooks';
|
||||
import useSmoothStreaming from '~/hooks/Messages/useSmoothStreaming';
|
||||
|
|
@ -257,6 +258,7 @@ Reasoning.displayName = 'Reasoning';
|
|||
type ReasoningCompactProps = {
|
||||
reasoning: string;
|
||||
label: string;
|
||||
isAfterTool?: boolean;
|
||||
/** True while this is the reasoning part currently streaming. Drives the
|
||||
* collapsed grok-style live peek. */
|
||||
isStreaming?: boolean;
|
||||
|
|
@ -270,7 +272,7 @@ type ReasoningCompactProps = {
|
|||
* a floating collapse + copy bar inside the rounded content panel.
|
||||
*/
|
||||
export const ReasoningCompact = memo(
|
||||
({ reasoning, label, isStreaming = false }: ReasoningCompactProps) => {
|
||||
({ reasoning, label, isAfterTool = false, isStreaming = false }: ReasoningCompactProps) => {
|
||||
const contentId = useId();
|
||||
const localize = useLocalize();
|
||||
const fontSize = useAtomValue(fontSizeAtom);
|
||||
|
|
@ -314,25 +316,26 @@ export const ReasoningCompact = memo(
|
|||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="group/reasoning-compact"
|
||||
className={cn('group/reasoning-compact mb-1', isAfterTool ? 'mt-0' : 'mt-1')}
|
||||
onMouseEnter={revealBar}
|
||||
onMouseLeave={hideBar}
|
||||
onFocus={revealBar}
|
||||
onBlur={handleBlur}
|
||||
>
|
||||
<div ref={headerRef} className="relative my-1.5 flex h-5 shrink-0 items-center gap-1.5">
|
||||
<div ref={headerRef} className="relative flex h-5 shrink-0 items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggle}
|
||||
aria-expanded={isExpanded}
|
||||
aria-controls={contentId}
|
||||
className="inline-flex min-w-0 flex-1 items-center gap-2 text-text-secondary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-heavy"
|
||||
className="group/disclosure inline-flex min-w-0 flex-1 items-center gap-2 text-text-secondary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-heavy"
|
||||
>
|
||||
<Lightbulb className="size-4 shrink-0 text-text-secondary" aria-hidden="true" />
|
||||
<span className="tool-status-text font-medium">{label}</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'size-4 shrink-0 translate-y-[1px] text-text-secondary transition-transform duration-200 ease-out',
|
||||
disclosureChevronClassName,
|
||||
'size-4 translate-y-[1px]',
|
||||
isExpanded && 'rotate-180',
|
||||
)}
|
||||
aria-hidden="true"
|
||||
|
|
|
|||
|
|
@ -388,7 +388,7 @@ export default function SubagentCall({
|
|||
data-subagent-parent-message={parentMessageId}
|
||||
data-subagent-part-index={partIndex}
|
||||
className={cn(
|
||||
'my-1.5 flex w-full flex-col gap-1 rounded-lg border border-border-light bg-surface-secondary px-3 py-2 text-left transition',
|
||||
'my-2 flex w-full flex-col gap-1 rounded-lg border border-border-light bg-surface-secondary px-3 py-2 text-left transition',
|
||||
canOpenDetails ? 'group hover:bg-surface-tertiary' : 'cursor-default opacity-80',
|
||||
running && !detachedStatusUnknown && 'animate-pulse-slow',
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -110,14 +110,14 @@ export const ThinkingButton = memo(
|
|||
}, [content]);
|
||||
|
||||
return (
|
||||
<div className="group/thinking flex w-full items-center justify-between gap-2">
|
||||
<div className="group/thinking relative flex w-full items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-expanded={isExpanded}
|
||||
aria-controls={contentId}
|
||||
className={cn(
|
||||
'group/button flex flex-1 items-center justify-start rounded-lg leading-[18px]',
|
||||
'group/button flex flex-1 items-center justify-start rounded-lg pr-10 leading-[18px]',
|
||||
fontSize,
|
||||
)}
|
||||
>
|
||||
|
|
@ -177,7 +177,7 @@ export const ThinkingButton = memo(
|
|||
label={localize('com_ui_copy_thoughts_to_clipboard')}
|
||||
copiedLabel={localize('com_ui_copied_to_clipboard')}
|
||||
className={cn(
|
||||
'opacity-0 transition-opacity',
|
||||
'absolute right-0 top-1/2 -translate-y-1/2 opacity-0 transition-opacity',
|
||||
'group-focus-within/thinking-container:opacity-100 group-hover/thinking-container:opacity-100',
|
||||
'focus-visible:opacity-100',
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { ReasoningCompact } from '../Reasoning';
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
useLocalize: () => (key: string) => key,
|
||||
useExpandCollapse: (isExpanded: boolean) => ({
|
||||
style: { display: 'grid', gridTemplateRows: isExpanded ? '1fr' : '0fr' },
|
||||
ref: { current: null },
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../Thinking', () => ({
|
||||
ThinkingContent: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>,
|
||||
ThinkingButton: () => <button type="button">{'thinking'}</button>,
|
||||
FloatingThinkingBar: () => null,
|
||||
useInViewport: () => ({ ref: { current: null }, inViewport: true }),
|
||||
}));
|
||||
|
||||
jest.mock('~/components/Messages/Content/CopyButton', () => ({
|
||||
__esModule: true,
|
||||
default: () => <button type="button">{'copy'}</button>,
|
||||
}));
|
||||
|
||||
jest.mock('lucide-react', () => ({
|
||||
Lightbulb: () => <span data-testid="thoughts-icon" />,
|
||||
ChevronDown: ({ className }: { className?: string }) => (
|
||||
<span data-testid="thoughts-chevron" className={className} />
|
||||
),
|
||||
}));
|
||||
|
||||
describe('ReasoningCompact', () => {
|
||||
it('reveals its own Thoughts chevron instantly on row hover or focus', () => {
|
||||
render(<ReasoningCompact reasoning="A useful thought" label="Thoughts" />);
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Thoughts' });
|
||||
const chevron = screen.getByTestId('thoughts-chevron');
|
||||
const row = button.closest('.group\\/reasoning-compact');
|
||||
|
||||
expect(row).toHaveClass('mb-1', 'mt-1');
|
||||
expect(button.parentElement).not.toHaveClass('my-1.5');
|
||||
expect(button).toHaveClass('group/disclosure');
|
||||
expect(chevron).toHaveClass(
|
||||
'opacity-0',
|
||||
'group-hover/disclosure:opacity-100',
|
||||
'group-focus-within/disclosure:opacity-100',
|
||||
);
|
||||
expect(chevron).toHaveClass('transition-transform');
|
||||
expect(chevron.className).not.toContain('transition-opacity');
|
||||
});
|
||||
|
||||
it('removes the extra top margin when Thoughts follows a tool', () => {
|
||||
render(<ReasoningCompact reasoning="A useful thought" label="Thoughts" isAfterTool />);
|
||||
|
||||
const row = screen
|
||||
.getByRole('button', { name: 'Thoughts' })
|
||||
.closest('.group\\/reasoning-compact');
|
||||
|
||||
expect(row).toHaveClass('mb-1', 'mt-0');
|
||||
expect(row).not.toHaveClass('mt-1');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { ThinkingButton } from '../Thinking';
|
||||
|
||||
jest.mock('jotai', () => ({
|
||||
...jest.requireActual('jotai'),
|
||||
useAtomValue: () => 'text-base',
|
||||
}));
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
useLocalize: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
jest.mock('~/components/Messages/Content/CopyButton', () => ({
|
||||
__esModule: true,
|
||||
default: ({ className }: { className?: string }) => (
|
||||
<button type="button" data-testid="copy-thoughts" className={className} />
|
||||
),
|
||||
}));
|
||||
|
||||
describe('ThinkingButton', () => {
|
||||
const props = {
|
||||
onClick: jest.fn(),
|
||||
label: 'Thoughts',
|
||||
content: 'A useful thought',
|
||||
contentId: 'thoughts-content',
|
||||
};
|
||||
|
||||
it('keeps the expanded copy control out of the header layout flow', () => {
|
||||
const { rerender } = render(<ThinkingButton {...props} isExpanded={false} />);
|
||||
|
||||
const toggle = screen.getByRole('button', { name: 'Thoughts' });
|
||||
expect(toggle.parentElement).toHaveClass('relative');
|
||||
expect(screen.queryByTestId('copy-thoughts')).not.toBeInTheDocument();
|
||||
|
||||
rerender(<ThinkingButton {...props} isExpanded />);
|
||||
|
||||
expect(screen.getByTestId('copy-thoughts')).toHaveClass(
|
||||
'absolute',
|
||||
'right-0',
|
||||
'top-1/2',
|
||||
'-translate-y-1/2',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -5,6 +5,7 @@ import * as Popover from '@radix-ui/react-popover';
|
|||
import { isReportableRunStepDuration } from 'librechat-data-provider';
|
||||
import type { ToolCallPhase } from '~/utils/toolCallPhase';
|
||||
import { cn, getRunStepDurationLabels } from '~/utils';
|
||||
import { disclosureChevronClassName } from './disclosure';
|
||||
import CancelledIcon from './CancelledIcon';
|
||||
import { ROW_GLYPH_SLOT } from './rows';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
|
@ -12,11 +13,11 @@ import { useLocalize } from '~/hooks';
|
|||
const wrapperClass =
|
||||
'progress-text-wrapper text-token-text-secondary relative -mt-[0.75px] h-5 w-full leading-5';
|
||||
|
||||
/** `max-w-full` caps the absolutely-positioned line at the message column;
|
||||
* the label span truncates itself, so overflow stays visible for the
|
||||
/** `right-0` and `max-w-full` cap the absolutely-positioned line at the message
|
||||
* column; the label span truncates itself, so overflow stays visible for the
|
||||
* button's focus ring. */
|
||||
const contentClass =
|
||||
'progress-text-content absolute left-0 top-0 max-w-full overflow-visible whitespace-nowrap';
|
||||
'progress-text-content absolute left-0 right-0 top-0 max-w-full overflow-visible whitespace-nowrap';
|
||||
|
||||
const Wrapper = ({ popover, children }: { popover: boolean; children: React.ReactNode }) => {
|
||||
if (popover) {
|
||||
|
|
@ -104,7 +105,7 @@ export default function ProgressText({
|
|||
type="button"
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'h-auto w-full justify-start gap-2 rounded-none p-0 hover:bg-transparent hover:text-inherit disabled:opacity-100',
|
||||
'group/disclosure inline-flex h-auto w-full items-center justify-start gap-2 rounded-none p-0 hover:bg-transparent hover:text-inherit disabled:opacity-100',
|
||||
hasInput
|
||||
? 'focus-visible:ring-border-heavy focus-visible:ring-offset-0'
|
||||
: 'pointer-events-none',
|
||||
|
|
@ -151,7 +152,8 @@ export default function ProgressText({
|
|||
{hasInput && (
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'size-4 shrink-0 translate-y-[1px] transition-transform duration-200 ease-out',
|
||||
disclosureChevronClassName,
|
||||
'size-4 translate-y-[1px]',
|
||||
isExpanded && 'rotate-180',
|
||||
)}
|
||||
aria-hidden="true"
|
||||
|
|
|
|||
|
|
@ -114,32 +114,34 @@ export default function OutputRenderer({ text }: OutputRendererProps) {
|
|||
const structured = !isJson && isStructuredText(displayText);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{isJson ? (
|
||||
<pre className="max-h-[300px] overflow-auto rounded text-xs">
|
||||
<code className="hljs language-json !whitespace-pre-wrap !break-words">
|
||||
<div>
|
||||
<div className="relative pr-10">
|
||||
{isJson ? (
|
||||
<pre className="max-h-[300px] overflow-auto rounded text-xs">
|
||||
<code className="hljs language-json !whitespace-pre-wrap !break-words">
|
||||
{visibleText}
|
||||
</code>
|
||||
</pre>
|
||||
) : (
|
||||
<pre
|
||||
className={cn(
|
||||
'max-h-[300px] overflow-auto whitespace-pre-wrap break-words text-xs',
|
||||
error && 'font-mono text-status-error',
|
||||
!error && structured && 'font-mono text-text-secondary',
|
||||
!error && !structured && 'font-sans text-sm text-text-primary',
|
||||
)}
|
||||
>
|
||||
{visibleText}
|
||||
</code>
|
||||
</pre>
|
||||
) : (
|
||||
<pre
|
||||
className={cn(
|
||||
'max-h-[300px] overflow-auto whitespace-pre-wrap break-words text-xs',
|
||||
error && 'font-mono text-status-error',
|
||||
!error && structured && 'font-mono text-text-secondary',
|
||||
!error && !structured && 'font-sans text-sm text-text-primary',
|
||||
)}
|
||||
>
|
||||
{visibleText}
|
||||
</pre>
|
||||
)}
|
||||
<div className="absolute bottom-0 right-0">
|
||||
<CopyButton
|
||||
isCopied={isCopied}
|
||||
onClick={handleCopy}
|
||||
iconOnly
|
||||
label={localize('com_ui_copy')}
|
||||
/>
|
||||
</pre>
|
||||
)}
|
||||
<div className="absolute right-0 top-1/2 -translate-y-1/2">
|
||||
<CopyButton
|
||||
isCopied={isCopied}
|
||||
onClick={handleCopy}
|
||||
iconOnly
|
||||
label={localize('com_ui_copy')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{needsTruncation && (
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import OutputRenderer from '../OutputRenderer';
|
||||
|
||||
jest.mock('copy-to-clipboard', () => jest.fn());
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
useLocalize: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
jest.mock('~/components/Messages/Content/CopyButton', () => ({
|
||||
__esModule: true,
|
||||
default: () => <button type="button" data-testid="copy-output" />,
|
||||
}));
|
||||
|
||||
describe('OutputRenderer', () => {
|
||||
it('vertically centers the copy control beside the output', () => {
|
||||
render(<OutputRenderer text={'First line\nSecond line'} />);
|
||||
|
||||
const copyPositioner = screen.getByTestId('copy-output').parentElement;
|
||||
expect(copyPositioner).toHaveClass('absolute', 'right-0', 'top-1/2', '-translate-y-1/2');
|
||||
expect(copyPositioner?.parentElement).toHaveClass('relative', 'pr-10');
|
||||
});
|
||||
});
|
||||
|
|
@ -8,6 +8,7 @@ import type {
|
|||
SearchResultData,
|
||||
PartMetadata,
|
||||
} from 'librechat-data-provider';
|
||||
import { disclosureChevronClassName, toolPanelSpacingClassName } from './disclosure';
|
||||
import { FaviconImage, getCleanDomain } from '~/components/Web/SourceHovercard';
|
||||
import { useLocalize, useExpandCollapse, useLazyCollapseBody } from '~/hooks';
|
||||
import { StackedFavicons } from '~/components/Web/Sources';
|
||||
|
|
@ -237,14 +238,14 @@ export default function WebSearch({
|
|||
const completedText = intent ?? localize('com_ui_web_searched');
|
||||
|
||||
return (
|
||||
<div className="mb-2">
|
||||
<div className="my-1">
|
||||
<span className="sr-only" aria-live="polite" aria-atomic="true">
|
||||
{completedText}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'tool-status-text group flex items-center gap-2 rounded-full py-1 transition-colors',
|
||||
'tool-status-text group/disclosure flex h-5 items-center gap-2 rounded-full',
|
||||
hasSourceData
|
||||
? 'text-text-secondary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-heavy'
|
||||
: 'pointer-events-none text-text-secondary',
|
||||
|
|
@ -268,10 +269,7 @@ export default function WebSearch({
|
|||
<span className="min-w-0 truncate font-medium">{completedText}</span>
|
||||
{hasSourceData && (
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'size-3.5 shrink-0 text-text-secondary transition-transform duration-200',
|
||||
showSourceList && 'rotate-180',
|
||||
)}
|
||||
className={cn(disclosureChevronClassName, 'size-3.5', showSourceList && 'rotate-180')}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
|
@ -280,7 +278,12 @@ export default function WebSearch({
|
|||
<div style={sourceExpandStyle} onTransitionEnd={handleTransitionEnd}>
|
||||
<div className="overflow-hidden" ref={sourceExpandRef}>
|
||||
{shouldRenderBody && (
|
||||
<div className="my-2 max-h-[280px] overflow-y-auto rounded-lg border border-border-light">
|
||||
<div
|
||||
className={cn(
|
||||
toolPanelSpacingClassName,
|
||||
'max-h-[280px] overflow-y-auto rounded-lg border border-border-light',
|
||||
)}
|
||||
>
|
||||
{allSources.map((source, i) => {
|
||||
const domain = getCleanDomain(source.link);
|
||||
return (
|
||||
|
|
@ -312,7 +315,7 @@ export default function WebSearch({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="my-1 flex items-center gap-2">
|
||||
<div className="relative my-1 flex h-5 shrink-0 items-center gap-2.5">
|
||||
<span className="sr-only" aria-live="polite" aria-atomic="true">
|
||||
{genericProgressText}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import ProgressText from '../ProgressText';
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
|
|
@ -111,3 +111,35 @@ describe('ProgressText duration', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ProgressText disclosure', () => {
|
||||
it('shows an expandable chevron only on hover or focus', () => {
|
||||
const onClick = jest.fn();
|
||||
renderProgressText({ hasInput: true, onClick });
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Completed foo' });
|
||||
const chevron = button.querySelector('svg');
|
||||
|
||||
expect(button).toHaveClass('group/disclosure');
|
||||
expect(chevron).toHaveClass(
|
||||
'opacity-0',
|
||||
'group-hover/disclosure:opacity-100',
|
||||
'group-focus-within/disclosure:opacity-100',
|
||||
);
|
||||
expect(chevron).toHaveClass('transition-transform');
|
||||
expect(chevron?.getAttribute('class')).not.toContain('transition-opacity');
|
||||
|
||||
fireEvent.click(button);
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('renders no chevron or disclosure state without expandable content', () => {
|
||||
renderProgressText({ hasInput: false });
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Completed foo' });
|
||||
|
||||
expect(button).toBeDisabled();
|
||||
expect(button).not.toHaveAttribute('aria-expanded');
|
||||
expect(button.querySelector('svg')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -56,7 +56,9 @@ jest.mock('~/components/Web/Sources', () => ({
|
|||
|
||||
jest.mock('lucide-react', () => ({
|
||||
Globe: () => <span data-testid="globe-icon" />,
|
||||
ChevronDown: () => <span data-testid="chevron-icon" />,
|
||||
ChevronDown: ({ className }: { className?: string }) => (
|
||||
<span data-testid="chevron-icon" className={className} />
|
||||
),
|
||||
}));
|
||||
|
||||
function makeSource(link: string, title: string): ValidSource {
|
||||
|
|
@ -289,6 +291,27 @@ describe('WebSearch', () => {
|
|||
expect(matches.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('uses standard tool-row spacing and reveals its chevron on hover or focus', () => {
|
||||
const searchResults = makeSearchResults({
|
||||
0: { organic: [makeSource('https://example.com', 'Example')] },
|
||||
});
|
||||
|
||||
renderWebSearch({ searchResults });
|
||||
|
||||
const button = screen.getByRole('button', { name: /Searched the web/ });
|
||||
expect(button.parentElement).toHaveClass('my-1');
|
||||
expect(button).not.toHaveClass('py-1');
|
||||
expect(button).not.toHaveClass('transition-colors');
|
||||
const chevron = screen.getByTestId('chevron-icon');
|
||||
expect(chevron).toHaveClass(
|
||||
'opacity-0',
|
||||
'group-hover/disclosure:opacity-100',
|
||||
'group-focus-within/disclosure:opacity-100',
|
||||
);
|
||||
expect(chevron).toHaveClass('transition-transform');
|
||||
expect(chevron.className).not.toContain('transition-opacity');
|
||||
});
|
||||
|
||||
it('renders searching state during streaming', () => {
|
||||
renderWebSearch({
|
||||
isSubmitting: true,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,4 @@
|
|||
export const disclosureChevronClassName =
|
||||
'shrink-0 text-text-secondary opacity-0 transition-transform duration-200 ease-out group-focus-within/disclosure:opacity-100 group-hover/disclosure:opacity-100 motion-reduce:transition-none';
|
||||
|
||||
export const toolPanelSpacingClassName = 'mb-2 mt-0';
|
||||
Loading…
Add table
Add a link
Reference in a new issue