Skip to content
144 changes: 90 additions & 54 deletions client/src/components/Chat/Input/Files/Table/DataTable.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { useState } from 'react';
import { useRef, useState, useEffect, useCallback } from 'react';
import { useSetRecoilState } from 'recoil';
import { FileContext } from 'librechat-data-provider';
import { useVirtualizer } from '@tanstack/react-virtual';
import {
flexRender,
useReactTable,
getCoreRowModel,
getSortedRowModel,
getFilteredRowModel,
getPaginationRowModel,
} from '@tanstack/react-table';
import {
Table,
Expand Down Expand Up @@ -40,6 +40,8 @@ interface DataTableProps<TData, TValue> {
data: TData[];
}

const ESTIMATED_ROW_HEIGHT = 52;

const contextMap: Record<string, TranslationKeys> = {
[FileContext.filename]: 'com_ui_name',
[FileContext.updatedAt]: 'com_ui_date',
Expand All @@ -66,6 +68,7 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
const isSmallScreen = useMediaQuery('(max-width: 768px)');
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
const scrollRef = useRef<HTMLDivElement>(null);

const table = useReactTable({
data,
Expand All @@ -81,7 +84,6 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
onColumnFiltersChange: setColumnFilters,
getFilteredRowModel: getFilteredRowModel(),
onColumnVisibilityChange: setColumnVisibility,
getPaginationRowModel: getPaginationRowModel(),
onRowSelectionChange: setRowSelection,
state: {
sorting,
Expand All @@ -91,6 +93,31 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
},
});

const { rows } = table.getRowModel();
const estimateSize = useCallback(() => ESTIMATED_ROW_HEIGHT, []);
const getItemKey = useCallback((index: number) => rows[index]?.id ?? index, [rows]);
const rowVirtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => scrollRef.current,
estimateSize,
getItemKey,
overscan: 8,
});

useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = 0;
}
}, [sorting, columnFilters]);

const virtualRows = rowVirtualizer.getVirtualItems();
const paddingTop = virtualRows[0]?.start ?? 0;
const paddingBottom =
virtualRows.length > 0
? rowVirtualizer.getTotalSize() - (virtualRows[virtualRows.length - 1]?.end ?? 0)
: 0;
const visibleColumnCount = table.getVisibleLeafColumns().length;

return (
<div className="flex h-full flex-col gap-4">
<div className="flex flex-wrap items-center gap-2 py-2 sm:gap-4 sm:py-4">
Expand Down Expand Up @@ -130,11 +157,20 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
/>
</div>
</div>
<div className="relative grid h-full max-h-[calc(100vh-20rem)] min-h-[calc(100vh-20rem)] w-full flex-1 overflow-hidden overflow-x-auto overflow-y-auto rounded-md">
<Table className="w-full min-w-[300px] border-separate border-spacing-0">
<div
ref={scrollRef}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Point virtualization at the actual table scroller

When the file list exceeds the viewport, the shared <Table> still creates its default overflow-auto wrapper (packages/client/src/components/Table.tsx), and as the sole child of this fixed-height grid that inner wrapper receives the scrolling. The virtualizer instead observes this outer scrollRef, whose scrollTop remains zero, so it never advances beyond the initially rendered rows; users scroll into the bottom spacer and cannot access later files. Render the table with unwrapped or attach the virtualizer to the inner scrolling element.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 2c3f1f0: the table renders unwrapped, so the virtualizer's own div is the scroller. Verified by scenario my-files-scroll-reaches-last-file (200 files, last row reached) on desktop light, dark and mobile.

className="relative grid h-full max-h-[calc(100vh-20rem)] min-h-[calc(100vh-20rem)] w-full flex-1 overflow-hidden overflow-x-auto overflow-y-auto rounded-md"
>
{/* Unwrapped: this div is the scroller the virtualizer observes, so the table
must not add its own scrolling wrapper inside it. */}
<Table
unwrapped
aria-rowcount={rows.length > 0 ? rows.length + 1 : undefined}
className="w-full min-w-[300px] border-separate border-spacing-0"
>
<TableHeader sticky>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
<TableRow key={headerGroup.id} aria-rowindex={1}>
{headerGroup.headers.map((header, _index) => {
const size = header.getSize();
const style: Style = {
Expand All @@ -158,31 +194,55 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
))}
</TableHeader>
<TableBody className="w-full">
{table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id} data-state={row.getIsSelected() && 'selected'}>
{row.getVisibleCells().map((cell, _index) => {
const size = cell.column.getSize();
const style: Style = {
width: size === Number.MAX_SAFE_INTEGER ? 'auto' : size,
};
{rows.length ? (
<>
{paddingTop > 0 && (
<tr aria-hidden="true">
<td colSpan={visibleColumnCount} style={{ height: paddingTop }} />
</tr>
)}
{virtualRows.map((virtualRow) => {
const row = rows[virtualRow.index];
if (!row) {
return null;
}
return (
<TableRow
key={virtualRow.key}
ref={rowVirtualizer.measureElement}
data-index={virtualRow.index}
Comment on lines +210 to +213

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Expose virtual row positions to assistive technology

For file lists longer than the rendered virtual window, the table now removes most rows from the accessibility tree but provides neither the total filtered row count nor each mounted row's logical index. Screen readers therefore announce only the small mounted subset and report later windows as though they were the first rows, making selection state and position misleading. Add aria-rowcount to the table and the corresponding aria-rowindex metadata to each virtual row.

AGENTS.md reference: AGENTS.md:L199-L200

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 2c3f1f0: aria-rowcount on the table, aria-rowindex on the header and each mounted row, spacer rows aria-hidden. Verified by scenario my-files-rows-report-position.

aria-rowindex={virtualRow.index + 2}
data-state={row.getIsSelected() && 'selected'}
>
{row.getVisibleCells().map((cell) => {
const size = cell.column.getSize();
const style: Style = {
width: size === Number.MAX_SAFE_INTEGER ? 'auto' : size,
};

return (
<TableCell
key={cell.id}
size="compact"
className={cn(
'align-start px-2 text-xs sm:px-4 sm:text-sm [tr[data-disabled=true]_&]:opacity-50',
cell.column.id === 'select' ? 'overflow-visible' : 'overflow-x-auto',
)}
style={style}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
);
})}
</TableRow>
))
return (
<TableCell
key={cell.id}
size="compact"
className={cn(
'align-start px-2 text-xs sm:px-4 sm:text-sm [tr[data-disabled=true]_&]:opacity-50',
cell.column.id === 'select' ? 'overflow-visible' : 'overflow-x-auto',
)}
style={style}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
);
})}
</TableRow>
);
})}
{paddingBottom > 0 && (
<tr aria-hidden="true">
<td colSpan={visibleColumnCount} style={{ height: paddingBottom }} />
</tr>
)}
</>
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
Expand All @@ -208,30 +268,6 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
}`}
</span>
</div>
<div className="text-text-primary flex items-center space-x-1 pr-2 text-xs font-bold sm:text-sm">
<span className="hidden sm:inline">{localize('com_ui_page')}</span>
<span>{table.getState().pagination.pageIndex + 1}</span>
<span>/</span>
<span>{Math.max(table.getPageCount(), 1)}</span>
</div>
<Button
className="select-none"
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
{localize('com_ui_prev')}
</Button>
<Button
className="select-none"
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
{localize('com_ui_next')}
</Button>
</div>
</div>
);
Expand Down
4 changes: 2 additions & 2 deletions client/src/components/Chat/Input/TokenUsage/CompactAction.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ function CompactAction({ compact, canCompact, isCompacting }: CompactActionProps
const description = localize('com_ui_context_compact_info');

return (
<>
<div>
<TooltipAnchor
side="bottom"
description={description}
Expand Down Expand Up @@ -50,7 +50,7 @@ function CompactAction({ compact, canCompact, isCompacting }: CompactActionProps
<span id={descriptionId} className="sr-only">
{description}
</span>
</>
</div>
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import parseJsonField, { areToolCallArgsComplete } from './parseJsonField';
import { useMessagePartsHost } from '~/Providers/MessagePartsHostContext';
import CopyButton from '~/components/Messages/Content/CopyButton';
import LangIcon from '~/components/Messages/Content/LangIcon';
import { PANE_COPY_REVEAL, TOOL_ROW_CLASSES } from '../rows';
import { toolPanelSpacingClassName } from '../disclosure';
import useToolCallState from './useToolCallState';
import useLazyHighlight from './useLazyHighlight';
Expand All @@ -16,7 +17,6 @@ import { ERROR_PATTERNS } from './ExecuteCode';
import { AttachmentGroup } from './Attachment';
import { parseCommandOutput } from './command';
import { useToolCallIntent } from './intent';
import { TOOL_ROW_CLASSES } from '../rows';
import PtcToolTrace from './PtcToolTrace';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
Expand Down Expand Up @@ -215,20 +215,20 @@ export default function BashCall({
// (`DiffView`, the user-turn code bars) instead of a palette shade a theme
// cannot reach: the previous dark-only gray-950 fill was Tailwind's blue-black,
// outside this palette entirely.
<div className="bg-surface-code relative">
<div className="bg-surface-code group/copy relative">
<CopyButton
iconOnly
isCopied={isCopied}
onClick={handleCopy}
className="absolute top-1 right-1.5"
className={cn('bg-surface-code absolute top-1 right-1.5 z-[1]', PANE_COPY_REVEAL)}
label={localize('com_ui_copy_code')}
/>
<div
ref={commandPaneRef}
onScroll={onCommandPaneScroll}
className="max-h-[300px] overflow-auto"
>
<pre className="px-3 py-2.5 pr-10 font-mono text-xs break-words whitespace-pre-wrap">
<pre className="px-3 py-2.5 font-mono text-xs break-words whitespace-pre-wrap">
<span className="text-text-tertiary select-none" aria-hidden="true">
{'$ '}
</span>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -517,16 +517,10 @@ describe('BashCall output pane', () => {
const numbered = (count: number) =>
Array.from({ length: count }, (_, i) => `line ${i + 1}`).join('\n');

it('collapses long output to its tail and expands to the full text', () => {
it('shows long output in full with no show more toggle', () => {
finished(numbered(40));
const pre = screen.getByText(/line 40/);
const shown = () => (pre.textContent ?? '').split('\n');
expect(shown()).toEqual(numbered(40).split('\n').slice(-15));

fireEvent.click(screen.getByRole('button', { name: 'Show more' }));
expect(shown()).toHaveLength(40);
expect(shown()[0]).toBe('line 1');
expect(screen.getByRole('button', { name: 'Show less' })).toBeInTheDocument();
expect(screen.getByText(/line 40/).textContent).toBe(numbered(40));
expect(screen.queryByRole('button', { name: 'Show more' })).not.toBeInTheDocument();
});

it('keeps leading whitespace and blank lines verbatim', () => {
Expand Down
9 changes: 2 additions & 7 deletions client/src/components/Chat/Messages/Content/ProgressText.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -202,13 +202,8 @@ export default function ProgressText({
<Wrapper popover={popover} failed={phase === 'failed'} rootRef={rootRef}>
<Button
type="button"
variant="ghost"
className={cn(
'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',
)}
variant="disclosure"
className="group/disclosure"
disabled={!hasInput}
tabIndex={hasInput ? 0 : -1}
onClick={hasInput ? onClick : undefined}
Expand Down
Loading
Loading