From daee829cd4cab18d992c688e4dc96dfb8eb19ea0 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Thu, 1 Oct 2026 18:04:13 +0200 Subject: [PATCH 1/8] perf: Virtualize the My Files Table --- .../Chat/Input/Files/Table/DataTable.tsx | 133 +++++++++++------- client/src/locales/en/translation.json | 1 - 2 files changed, 81 insertions(+), 53 deletions(-) diff --git a/client/src/components/Chat/Input/Files/Table/DataTable.tsx b/client/src/components/Chat/Input/Files/Table/DataTable.tsx index 797ee3ec767..341c86c2c08 100644 --- a/client/src/components/Chat/Input/Files/Table/DataTable.tsx +++ b/client/src/components/Chat/Input/Files/Table/DataTable.tsx @@ -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, @@ -40,6 +40,8 @@ interface DataTableProps { data: TData[]; } +const ESTIMATED_ROW_HEIGHT = 52; + const contextMap: Record = { [FileContext.filename]: 'com_ui_name', [FileContext.updatedAt]: 'com_ui_date', @@ -66,6 +68,7 @@ export default function DataTable({ columns, data }: DataTablePro const isSmallScreen = useMediaQuery('(max-width: 768px)'); const [columnFilters, setColumnFilters] = useState([]); const [columnVisibility, setColumnVisibility] = useState({}); + const scrollRef = useRef(null); const table = useReactTable({ data, @@ -81,7 +84,6 @@ export default function DataTable({ columns, data }: DataTablePro onColumnFiltersChange: setColumnFilters, getFilteredRowModel: getFilteredRowModel(), onColumnVisibilityChange: setColumnVisibility, - getPaginationRowModel: getPaginationRowModel(), onRowSelectionChange: setRowSelection, state: { sorting, @@ -91,6 +93,31 @@ export default function DataTable({ 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 (
@@ -130,7 +157,10 @@ export default function DataTable({ columns, data }: DataTablePro />
-
+
{table.getHeaderGroups().map((headerGroup) => ( @@ -158,31 +188,54 @@ export default function DataTable({ columns, data }: DataTablePro ))} - {table.getRowModel().rows.length ? ( - table.getRowModel().rows.map((row) => ( - - {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 && ( + + + )} + {virtualRows.map((virtualRow) => { + const row = rows[virtualRow.index]; + if (!row) { + return null; + } + return ( + + {row.getVisibleCells().map((cell) => { + const size = cell.column.getSize(); + const style: Style = { + width: size === Number.MAX_SAFE_INTEGER ? 'auto' : size, + }; - return ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ); - })} - - )) + return ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ); + })} + + ); + })} + {paddingBottom > 0 && ( + + + )} + ) : ( @@ -208,30 +261,6 @@ export default function DataTable({ columns, data }: DataTablePro }`} -
- {localize('com_ui_page')} - {table.getState().pagination.pageIndex + 1} - / - {Math.max(table.getPageCount(), 1)} -
- - ); diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index d5c24812b64..bddddf6ea46 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -2097,7 +2097,6 @@ "com_ui_optional": "(optional)", "com_ui_options": "options", "com_ui_output": "Output", - "com_ui_page": "Page", "com_ui_parameters": "Parameters", "com_ui_params_changed_count": "{{count}} changed settings", "com_ui_params_changed_count_one": "{{count}} changed setting", From abb4f224a54cf79e8f16c85e6bbbf94272b0979e Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Thu, 1 Oct 2026 18:12:28 +0200 Subject: [PATCH 2/8] fix: Even Bottom Spacing Under the Compact Context Button --- client/src/components/Chat/Input/TokenUsage/CompactAction.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/src/components/Chat/Input/TokenUsage/CompactAction.tsx b/client/src/components/Chat/Input/TokenUsage/CompactAction.tsx index df07a5b215a..47a7be2a623 100644 --- a/client/src/components/Chat/Input/TokenUsage/CompactAction.tsx +++ b/client/src/components/Chat/Input/TokenUsage/CompactAction.tsx @@ -20,7 +20,7 @@ function CompactAction({ compact, canCompact, isCompacting }: CompactActionProps const description = localize('com_ui_context_compact_info'); return ( - <> +
{description} - +
); } From 2100ae5686666e8f1a02cb4359270121bf6956c6 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Thu, 1 Oct 2026 18:27:29 +0200 Subject: [PATCH 3/8] feat: Overlay Tool Output Copy Actions and Show Full Output --- .../Chat/Messages/Content/Parts/BashCall.tsx | 8 +- .../Content/Parts/__tests__/BashCall.test.tsx | 12 +-- .../Chat/Messages/Content/ProgressText.tsx | 9 +-- .../Content/ToolOutput/OutputRenderer.tsx | 75 ++++--------------- .../Content/ToolOutput/StackedToolIcons.tsx | 2 +- .../__tests__/OutputRenderer.test.tsx | 53 +++++-------- .../components/Chat/Messages/Content/rows.ts | 8 ++ eslint-suppressions.json | 5 +- .../client/src/components/Button.spec.tsx | 13 ++++ packages/client/src/components/Button.tsx | 15 ++++ 10 files changed, 78 insertions(+), 122 deletions(-) diff --git a/client/src/components/Chat/Messages/Content/Parts/BashCall.tsx b/client/src/components/Chat/Messages/Content/Parts/BashCall.tsx index 69d0e721cca..860d97480ab 100644 --- a/client/src/components/Chat/Messages/Content/Parts/BashCall.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/BashCall.tsx @@ -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'; @@ -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'; @@ -215,12 +215,12 @@ 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. -
+
-
+                  
                     
diff --git a/client/src/components/Chat/Messages/Content/Parts/__tests__/BashCall.test.tsx b/client/src/components/Chat/Messages/Content/Parts/__tests__/BashCall.test.tsx
index 6ea6cac9397..f8f6635d046 100644
--- a/client/src/components/Chat/Messages/Content/Parts/__tests__/BashCall.test.tsx
+++ b/client/src/components/Chat/Messages/Content/Parts/__tests__/BashCall.test.tsx
@@ -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', () => {
diff --git a/client/src/components/Chat/Messages/Content/ProgressText.tsx b/client/src/components/Chat/Messages/Content/ProgressText.tsx
index 8f605a06b8a..4e35713fe03 100644
--- a/client/src/components/Chat/Messages/Content/ProgressText.tsx
+++ b/client/src/components/Chat/Messages/Content/ProgressText.tsx
@@ -202,13 +202,8 @@ export default function ProgressText({
     
       
)} -
- -
+
- {needsTruncation && ( - - )} {error && rawError && rawError !== displayText && (
+ {/* Unwrapped: this div is the scroller the virtualizer observes, so the table + must not add its own scrolling wrapper inside it. */} +
0 ? rows.length + 1 : undefined} + className="w-full min-w-[300px] border-separate border-spacing-0" + > {table.getHeaderGroups().map((headerGroup) => ( - + {headerGroup.headers.map((header, _index) => { const size = header.getSize(); const style: Style = { @@ -205,6 +211,7 @@ export default function DataTable({ columns, data }: DataTablePro key={virtualRow.key} ref={rowVirtualizer.measureElement} data-index={virtualRow.index} + aria-rowindex={virtualRow.index + 2} data-state={row.getIsSelected() && 'selected'} > {row.getVisibleCells().map((cell) => { diff --git a/e2e/specs/mock/scenarios/my-files-virtual.spec.ts b/e2e/specs/mock/scenarios/my-files-virtual.spec.ts new file mode 100644 index 00000000000..df960b5a276 --- /dev/null +++ b/e2e/specs/mock/scenarios/my-files-virtual.spec.ts @@ -0,0 +1,83 @@ +import { expect, test } from '@playwright/test'; +import { FileSources } from 'librechat-data-provider'; +import type { TFile } from 'librechat-data-provider'; +import type { Page } from '@playwright/test'; + +/** + * The My Files table renders only the rows near its scroll position. With a list + * far longer than the dialog, scrolling has to keep mounting rows down to the + * last file, and each mounted row has to report its place in the whole list. + * The account menu sits behind the mobile drawer on a phone-width viewport, so + * this scenario uses a desktop viewport. + */ +test.describe.configure({ timeout: 120_000 }); +test.use({ viewport: { width: 1280, height: 800 } }); + +const FILE_COUNT = 200; + +const files: TFile[] = Array.from({ length: FILE_COUNT }, (_, index) => ({ + file_id: `virtual-file-${index}`, + filename: `Virtual fixture ${String(index).padStart(3, '0')}.txt`, + filepath: `/files/virtual-file-${index}.txt`, + user: 'virtual-files-user', + bytes: 100, + object: 'file', + source: FileSources.local, + type: 'text/plain', + usage: 0, + embedded: false, + createdAt: '2026-09-01T00:00:00.000Z', + updatedAt: '2026-09-01T00:00:00.000Z', +})); + +async function openMyFiles(page: Page) { + await page.route('**/api/files', (route) => route.fulfill({ json: files })); + await page.goto('/c/new', { timeout: 15000 }); + await page.getByTestId('nav-user').click(); + await page.getByRole('menu').getByRole('menuitem', { name: 'My Files', exact: true }).click(); + const dialog = page.getByRole('dialog', { name: 'My Files', exact: true }); + await expect(dialog).toBeVisible({ timeout: 15000 }); + const table = dialog.getByRole('table'); + await expect(table.locator('tbody tr[aria-rowindex="2"]')).toBeVisible(); + return { dialog, table }; +} + +/** Wheels over the table until the last row is mounted, as a user scrolling down would. */ +async function scrollToLastRow(page: Page, table: ReturnType) { + const lastRow = table.locator(`tbody tr[aria-rowindex="${FILE_COUNT + 1}"]`); + await table.hover(); + for (let attempt = 0; attempt < 80 && (await lastRow.count()) === 0; attempt++) { + await page.mouse.wheel(0, 1200); + } + return lastRow; +} + +test.describe('my files virtual table', () => { + test('scrolling the files table reaches the last file @scenario:my-files-scroll-reaches-last-file', async ({ + page, + }) => { + const { table } = await openMyFiles(page); + const mounted = await table.locator('tbody tr[aria-rowindex]').count(); + expect(mounted).toBeLessThan(FILE_COUNT); + + const lastRow = await scrollToLastRow(page, table); + await expect(lastRow).toBeInViewport(); + await expect(lastRow).toContainText('Virtual fixture'); + }); + + test('a mounted file row reports its position in the whole list @scenario:my-files-rows-report-position', async ({ + page, + }) => { + const { table } = await openMyFiles(page); + await expect(table).toHaveAttribute('aria-rowcount', String(FILE_COUNT + 1)); + await expect(table.locator('thead tr')).toHaveAttribute('aria-rowindex', '1'); + + const lastRow = await scrollToLastRow(page, table); + await expect(lastRow).toBeInViewport(); + /* The spacer rows that stand in for unmounted files stay out of the tree. */ + const spacers = table.locator('tbody tr:not([aria-rowindex])'); + for (const spacer of await spacers.all()) { + await expect(spacer).toHaveAttribute('aria-hidden', 'true'); + } + }); +}); From 3f92756febdd8545724b6e3538d2e9779defbea6 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:26:42 +0200 Subject: [PATCH 5/8] test: Scenarios for Tool Output Copy Reveal and Full Output --- .../mock/scenarios/tool-output-polish.spec.ts | 171 ++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 e2e/specs/mock/scenarios/tool-output-polish.spec.ts diff --git a/e2e/specs/mock/scenarios/tool-output-polish.spec.ts b/e2e/specs/mock/scenarios/tool-output-polish.spec.ts new file mode 100644 index 00000000000..7a273801089 --- /dev/null +++ b/e2e/specs/mock/scenarios/tool-output-polish.spec.ts @@ -0,0 +1,171 @@ +import { expect, test } from '@playwright/test'; +import type { Locator, Page, Route } from '@playwright/test'; +import { messagesView } from '../helpers'; + +/** + * Tool output cards: the copy button overlays the output and reveals on hover or keyboard focus, + * the full output sits in the fixed max-height scroll box, and the row header shows no pressed fill. + */ + +const NO_PARENT = '00000000-0000-0000-0000-000000000000'; +const unique = (prefix: string) => `${prefix}-${Date.now()}-${Math.floor(Math.random() * 1e6)}`; +const escapeRe = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +const LINE_COUNT = 60; +const lineLabel = (n: number) => `line-${String(n).padStart(3, '0')}`; + +async function seedToolConversation(page: Page): Promise { + const conversationId = unique('e2e-tool-output'); + const now = new Date(0).toISOString(); + const output = Array.from({ length: LINE_COUNT }, (_, i) => lineLabel(i + 1)).join('\n'); + const message = { + messageId: `${conversationId}-msg`, + conversationId, + parentMessageId: NO_PARENT, + isCreatedByUser: false, + sender: 'Assistant', + endpoint: 'Mock Provider A', + model: 'mock-model-a', + text: '', + content: [ + { + type: 'tool_call', + tool_call: { + id: `${conversationId}-tool`, + name: 'lookup_records', + args: JSON.stringify({ query: 'numbered lines' }), + output, + progress: 1, + }, + }, + ], + createdAt: now, + updatedAt: now, + }; + const conversation = { + conversationId, + title: 'Tool output polish', + endpoint: 'Mock Provider A', + endpointType: 'custom', + model: 'mock-model-a', + createdAt: now, + updatedAt: now, + }; + const convoIdRe = escapeRe(conversationId); + await page.route(new RegExp(`/api/convos/${convoIdRe}(?:\\?.*)?$`), (route: Route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(conversation), + }), + ); + await page.route(new RegExp(`/api/messages/${convoIdRe}(?:\\?.*)?$`), (route: Route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([message]), + }), + ); + await page.goto(`/c/${conversationId}`, { timeout: 30000 }); +} + +/** The tool row header: the disclosure button inside the `tool-call` row (ProgressText.tsx). */ +const rowHeader = (page: Page): Locator => + messagesView(page).getByTestId('tool-call').getByRole('button').first(); + +/** Seeds the conversation and makes sure the row is expanded, whatever the auto-expand preference. */ +async function openToolRow(page: Page): Promise<{ header: Locator; output: Locator }> { + await seedToolConversation(page); + const header = rowHeader(page); + await expect(header).toBeVisible({ timeout: 15000 }); + if ((await header.getAttribute('aria-expanded')) !== 'true') { + await header.click(); + } + await expect(header).toHaveAttribute('aria-expanded', 'true'); + const output = messagesView(page) + .locator('pre', { hasText: lineLabel(1) }) + .first(); + await expect(output).toBeVisible({ timeout: 15000 }); + return { header, output }; +} + +const opacityOf = (locator: Locator) => + locator.evaluate((el) => Number.parseFloat(getComputedStyle(el).opacity)); + +test.describe('tool output polish', () => { + test.afterEach(async ({ page }) => { + await page.unrouteAll(); + }); + + test('copy reveals on hover or focus @scenario:tool-output-copy-reveals-on-hover-or-focus', async ({ + page, + }) => { + test.setTimeout(60000); + const { header, output } = await openToolRow(page); + const copy = messagesView(page) + .getByTestId('tool-call') + .locator('xpath=following-sibling::*') + .getByRole('button', { name: 'Copy', exact: true }); + await expect(copy).toHaveCount(1); + const canHover = await page.evaluate(() => matchMedia('(hover: hover)').matches); + + if (!canHover) { + await expect.poll(() => opacityOf(copy)).toBe(1); + await copy.focus(); + await expect.poll(() => opacityOf(copy)).toBe(1); + return; + } + + await page.mouse.move(1, 1); + await expect.poll(() => opacityOf(copy)).toBe(0); + + await output.hover(); + await expect.poll(() => opacityOf(copy)).toBe(1); + + await page.mouse.move(1, 1); + await expect.poll(() => opacityOf(copy)).toBe(0); + + /* Tab from the row header reaches the copy button first; focus-visible reveals it. */ + await header.focus(); + await page.keyboard.press('Tab'); + await expect(copy).toBeFocused(); + await expect.poll(() => opacityOf(copy)).toBe(1); + }); + + test('output renders in full inside the scroll box @scenario:tool-output-shows-full-output', async ({ + page, + }) => { + test.setTimeout(60000); + const { output } = await openToolRow(page); + await expect(output).toContainText(lineLabel(1)); + await expect(output).toContainText(lineLabel(LINE_COUNT)); + await expect(messagesView(page).getByRole('button', { name: 'Show more' })).toHaveCount(0); + const { scrollHeight, clientHeight } = await output.evaluate((el) => ({ + scrollHeight: el.scrollHeight, + clientHeight: el.clientHeight, + })); + expect(scrollHeight).toBeGreaterThan(clientHeight); + }); + + test('pressing the row header shows no fill @scenario:tool-row-header-has-no-pressed-fill', async ({ + page, + }) => { + test.setTimeout(60000); + const { header } = await openToolRow(page); + const background = () => header.evaluate((el) => getComputedStyle(el).backgroundColor); + await page.mouse.move(1, 1); + const resting = await background(); + + const box = await header.boundingBox(); + expect(box).not.toBeNull(); + if (box == null) { + return; + } + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + try { + await expect.poll(background).toBe(resting); + } finally { + await page.mouse.up(); + } + }); +}); From 8aecd2e4010bb0a5ac0f4ffd2d622e49b493414f Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:30:12 +0200 Subject: [PATCH 6/8] test: Tab Through the Output Box to Reach the Copy Action --- e2e/specs/mock/scenarios/tool-output-polish.spec.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/e2e/specs/mock/scenarios/tool-output-polish.spec.ts b/e2e/specs/mock/scenarios/tool-output-polish.spec.ts index 7a273801089..bd088a006e2 100644 --- a/e2e/specs/mock/scenarios/tool-output-polish.spec.ts +++ b/e2e/specs/mock/scenarios/tool-output-polish.spec.ts @@ -124,9 +124,15 @@ test.describe('tool output polish', () => { await page.mouse.move(1, 1); await expect.poll(() => opacityOf(copy)).toBe(0); - /* Tab from the row header reaches the copy button first; focus-visible reveals it. */ + /* Tabbing on from the row header reaches the copy button (the scrollable output box can + take a stop first); focus-visible reveals it. */ await header.focus(); - await page.keyboard.press('Tab'); + for (let presses = 0; presses < 5; presses++) { + await page.keyboard.press('Tab'); + if (await copy.evaluate((el) => el === document.activeElement)) { + break; + } + } await expect(copy).toBeFocused(); await expect.poll(() => opacityOf(copy)).toBe(1); }); From bf9b54a622f3384263b4156aaf4002178219ade2 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Fri, 2 Oct 2026 15:43:30 +0200 Subject: [PATCH 7/8] fix: Open Terminal Output on Its Last Lines --- .../Content/ToolOutput/OutputRenderer.tsx | 13 ++++++++++++- .../__tests__/OutputRenderer.test.tsx | 17 +++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/client/src/components/Chat/Messages/Content/ToolOutput/OutputRenderer.tsx b/client/src/components/Chat/Messages/Content/ToolOutput/OutputRenderer.tsx index 85441185715..d50e921400e 100644 --- a/client/src/components/Chat/Messages/Content/ToolOutput/OutputRenderer.tsx +++ b/client/src/components/Chat/Messages/Content/ToolOutput/OutputRenderer.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo, useCallback } from 'react'; +import { useRef, useState, useMemo, useCallback, useLayoutEffect } from 'react'; import copy from 'copy-to-clipboard'; import { Button } from '@librechat/client'; import { hasToolCallErrorPrefix, stripToolCallErrorPrefix } from 'librechat-data-provider'; @@ -132,6 +132,16 @@ export default function OutputRenderer({ const error = extracted.error || forceError; const [showErrorDetails, setShowErrorDetails] = useState(false); const [isCopied, setIsCopied] = useState(false); + const outputRef = useRef(null); + + /* Terminal output opens on its last lines, where failures, stack traces and the + exit trailer land; the rest stays a scroll away inside the box. */ + useLayoutEffect(() => { + const node = outputRef.current; + if (terminal && node != null) { + node.scrollTop = node.scrollHeight; + } + }, [terminal, displayText, segments]); const handleCopy = useCallback(() => { setIsCopied(true); @@ -157,6 +167,7 @@ export default function OutputRenderer({ ) : (
 {
     expect(screen.queryByText('com_ui_show_more')).not.toBeInTheDocument();
   });
 
+  it('opens terminal output on its last lines and default output on its first', () => {
+    const text = Array.from({ length: 30 }, (_, i) => `line ${i + 1}`).join('\n');
+    const scrollHeight = jest
+      .spyOn(HTMLElement.prototype, 'scrollHeight', 'get')
+      .mockReturnValue(900);
+    try {
+      const { unmount } = render();
+      expect(screen.getByText(/line 30/).scrollTop).toBe(900);
+      unmount();
+
+      render();
+      expect(screen.getByText(/line 30/).scrollTop).toBe(0);
+    } finally {
+      scrollHeight.mockRestore();
+    }
+  });
+
   it('keeps whitespace-only terminal output', () => {
     const { container } = render();
     expect(container.querySelector('pre')?.textContent).toBe('\n\n');

From 6f6c230dbca919f6257311623a0b7553e93b584a Mon Sep 17 00:00:00 2001
From: Marco Beretta <81851188+berry-13@users.noreply.github.com>
Date: Fri, 2 Oct 2026 16:02:19 +0200
Subject: [PATCH 8/8] fix: Follow Streamed Terminal Output Only While the
 Reader Is at the Bottom

---
 .../Content/ToolOutput/OutputRenderer.tsx     | 13 +++++++--
 .../__tests__/OutputRenderer.test.tsx         | 29 +++++++++++++++++++
 2 files changed, 40 insertions(+), 2 deletions(-)

diff --git a/client/src/components/Chat/Messages/Content/ToolOutput/OutputRenderer.tsx b/client/src/components/Chat/Messages/Content/ToolOutput/OutputRenderer.tsx
index d50e921400e..bd7df546594 100644
--- a/client/src/components/Chat/Messages/Content/ToolOutput/OutputRenderer.tsx
+++ b/client/src/components/Chat/Messages/Content/ToolOutput/OutputRenderer.tsx
@@ -2,6 +2,7 @@ import { useRef, useState, useMemo, useCallback, useLayoutEffect } from 'react';
 import copy from 'copy-to-clipboard';
 import { Button } from '@librechat/client';
 import { hasToolCallErrorPrefix, stripToolCallErrorPrefix } from 'librechat-data-provider';
+import type { UIEvent } from 'react';
 import CopyButton from '~/components/Messages/Content/CopyButton';
 import { PANE_COPY_REVEAL } from '../rows';
 import { useLocalize } from '~/hooks';
@@ -133,16 +134,23 @@ export default function OutputRenderer({
   const [showErrorDetails, setShowErrorDetails] = useState(false);
   const [isCopied, setIsCopied] = useState(false);
   const outputRef = useRef(null);
+  /** Whether the reader is at the bottom of the box; scrolling up stops the follow. */
+  const followRef = useRef(true);
 
   /* Terminal output opens on its last lines, where failures, stack traces and the
-     exit trailer land; the rest stays a scroll away inside the box. */
+     exit trailer land, and keeps following new output until the reader scrolls up. */
   useLayoutEffect(() => {
     const node = outputRef.current;
-    if (terminal && node != null) {
+    if (terminal && node != null && followRef.current) {
       node.scrollTop = node.scrollHeight;
     }
   }, [terminal, displayText, segments]);
 
+  const handleOutputScroll = useCallback((event: UIEvent) => {
+    const node = event.currentTarget;
+    followRef.current = node.scrollHeight - node.scrollTop - node.clientHeight <= 8;
+  }, []);
+
   const handleCopy = useCallback(() => {
     setIsCopied(true);
     copy(copyText ?? displayText, { format: 'text/plain' });
@@ -168,6 +176,7 @@ export default function OutputRenderer({
         ) : (
           
 {
     }
   });
 
+  it('stops following streamed terminal output once the reader scrolls up', () => {
+    const lines = (count: number) =>
+      Array.from({ length: count }, (_, i) => `line ${i + 1}`).join('\n');
+    const scrollHeight = jest
+      .spyOn(HTMLElement.prototype, 'scrollHeight', 'get')
+      .mockReturnValue(900);
+    const clientHeight = jest
+      .spyOn(HTMLElement.prototype, 'clientHeight', 'get')
+      .mockReturnValue(300);
+    try {
+      const { rerender } = render();
+      const pre = screen.getByText(/line 30/);
+      expect(pre.scrollTop).toBe(900);
+
+      pre.scrollTop = 100;
+      fireEvent.scroll(pre);
+      rerender();
+      expect(pre.scrollTop).toBe(100);
+
+      pre.scrollTop = 600;
+      fireEvent.scroll(pre);
+      rerender();
+      expect(pre.scrollTop).toBe(900);
+    } finally {
+      scrollHeight.mockRestore();
+      clientHeight.mockRestore();
+    }
+  });
+
   it('keeps whitespace-only terminal output', () => {
     const { container } = render();
     expect(container.querySelector('pre')?.textContent).toBe('\n\n');