diff --git a/client/src/components/Chat/Messages/Content/Parts/BashCall.tsx b/client/src/components/Chat/Messages/Content/Parts/BashCall.tsx index d87dc0b67e..f839e76bbf 100644 --- a/client/src/components/Chat/Messages/Content/Parts/BashCall.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/BashCall.tsx @@ -10,6 +10,7 @@ import LangIcon from '~/components/Messages/Content/LangIcon'; import { sandboxStartingByToolCallId } from '~/store'; import useToolCallState from './useToolCallState'; import useLazyHighlight from './useLazyHighlight'; +import useFollowScroll from './useFollowScroll'; import { ERROR_PATTERNS } from './ExecuteCode'; import { AttachmentGroup } from './Attachment'; import { useToolCallIntent } from './intent'; @@ -52,6 +53,11 @@ export default function BashCall({ useToolCallState(initialProgress, isSubmitting, output, !!command, onExpand, runStepStatus); const highlighted = useLazyHighlight(command || undefined, 'bash'); + const { ref: commandPaneRef, onScroll: onCommandPaneScroll } = useFollowScroll( + highlighted ?? command, + progress < 1 && !cancelled, + showCode, + ); const outputHasError = useMemo(() => ERROR_PATTERNS.test(output), [output]); /** A backgrounded call's persisted output stays the dispatch handle until * the detached run settles and patches it; render a background state @@ -144,7 +150,11 @@ export default function BashCall({
{command && ( -
+
( + highlighted ?? code ?? '', + progress < 1 && !cancelled, + showCode, + ); const outputHasError = useMemo(() => ERROR_PATTERNS.test(output), [output]); /** A backgrounded call's persisted output stays the dispatch handle until * the detached run settles and patches it; render a background state @@ -157,7 +163,11 @@ export default function ExecuteCode({
{code && } {code && ( -
+              
                 {highlighted}
               
)} diff --git a/client/src/components/Chat/Messages/Content/Parts/FileAuthoringCall.tsx b/client/src/components/Chat/Messages/Content/Parts/FileAuthoringCall.tsx index bb531054e7..6b788e9c60 100644 --- a/client/src/components/Chat/Messages/Content/Parts/FileAuthoringCall.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/FileAuthoringCall.tsx @@ -6,6 +6,7 @@ import ProgressText from '~/components/Chat/Messages/Content/ProgressText'; import useToolCallState from './useToolCallState'; import useLazyHighlight from './useLazyHighlight'; import CodeWindowHeader from './CodeWindowHeader'; +import useFollowScroll from './useFollowScroll'; import { AttachmentGroup } from './Attachment'; import { langFromPath } from './ReadFileCall'; import { useToolCallIntent } from './intent'; @@ -162,6 +163,11 @@ export default function FileAuthoringCall({ ); const highlighted = useLazyHighlight(preview || undefined, previewLang); + const { ref: previewPaneRef, onScroll: onPreviewPaneScroll } = useFollowScroll( + highlighted ?? preview, + progress < 1 && !cancelled, + showCode, + ); const Icon = isCreate && !overwrote ? FilePlus2 : FilePenLine; let finishedKey: 'com_ui_created_file' | 'com_ui_updated_file' | 'com_ui_edited_file' = 'com_ui_edited_file'; @@ -207,7 +213,11 @@ export default function FileAuthoringCall({ {!!preview && (
-
+              
                 
                   {highlighted ?? preview}
                 
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 e4014e3303..701159aa84 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
@@ -1,7 +1,8 @@
 import React from 'react';
 import { RecoilRoot } from 'recoil';
-import { render, screen } from '@testing-library/react';
+import { render, screen, fireEvent } from '@testing-library/react';
 import BashCall from '../BashCall';
+import store from '~/store';
 
 jest.mock('~/hooks', () => ({
   useLocalize:
@@ -64,10 +65,20 @@ jest.mock('../Attachment', () => ({
   AttachmentGroup: () => 
, })); -jest.mock('../useLazyHighlight', () => ({ - __esModule: true, - default: () => null, -})); +jest.mock('../useLazyHighlight', () => { + const { useState, useEffect } = jest.requireActual('react'); + /** Mirrors the real hook's two-phase contract: the render that changed + * `code` still shows the previous highlight (or null), and the new + * nodes commit in a later passive effect. */ + const useMockLazyHighlight = (code?: string) => { + const [highlighted, setHighlighted] = useState(null); + useEffect(() => { + setHighlighted(code == null ? null : [code]); + }, [code]); + return highlighted; + }; + return { __esModule: true, default: useMockLazyHighlight }; +}); jest.mock('copy-to-clipboard', () => jest.fn()); @@ -265,3 +276,111 @@ describe('BashCall backgrounded calls', () => { expect(screen.getByText('hi')).toBeInTheDocument(); }); }); + +/** + * jsdom has no layout, so the capped pane's scroll geometry is stubbed: + * `clientHeight` is fixed, `scrollHeight` either reads from mutable state + * or derives from the pane's rendered text (so the async highlight commit + * measurably changes it), and every `scrollTop` write the component makes + * is recorded. Direct mutations of the returned state bypass the element + * setter, so `writes` only ever contains scrolls the component performed. + */ +const mockScrollMetrics = ( + el: HTMLElement, + clientHeight: number, + opts: { deriveHeightFromText?: boolean } = {}, +) => { + const state = { scrollHeight: 0, scrollTop: 0, writes: [] as number[] }; + Object.defineProperty(el, 'scrollHeight', { + configurable: true, + get: () => + opts.deriveHeightFromText === true ? (el.textContent?.length ?? 0) * 10 : state.scrollHeight, + }); + Object.defineProperty(el, 'clientHeight', { configurable: true, get: () => clientHeight }); + Object.defineProperty(el, 'scrollTop', { + configurable: true, + get: () => state.scrollTop, + set: (value: number) => { + state.scrollTop = value; + state.writes.push(value); + }, + }); + return state; +}; + +describe('BashCall streaming follow-scroll', () => { + const streamingArgs = (command: string) => `{"command":"${command}`; + + /** `autoExpandTools` opens the pane at mount, mirroring the user who + * watches args stream into an expanded card. */ + const streamingCall = (command: string) => ( + set(store.autoExpandTools, true)}> + + + ); + + it('pins the expanded box to the args as rendered, including the async highlight commit', () => { + const { container, rerender } = render(streamingCall('echo start')); + const box = container.querySelector('.overflow-auto') as HTMLElement; + const state = mockScrollMetrics(box, 300, { deriveHeightFromText: true }); + + rerender(streamingCall('echo start && echo a second streamed line')); + + expect(box.textContent).toContain('echo a second streamed line'); + expect(state.scrollTop).toBe((box.textContent?.length ?? 0) * 10); + }); + + it('stops following when the user scrolls up to read, and resumes once they return to the bottom', () => { + const { container, rerender } = render(streamingCall('echo start')); + const box = container.querySelector('.overflow-auto') as HTMLElement; + const state = mockScrollMetrics(box, 300); + state.scrollHeight = 900; + + state.scrollTop = 100; + fireEvent.scroll(box); + rerender(streamingCall('echo start && echo second')); + expect(state.writes).toHaveLength(0); + + state.scrollTop = 580; + fireEvent.scroll(box); + rerender(streamingCall('echo start && echo second && echo third')); + expect(state.writes).toEqual([900]); + }); + + it('leaves a collapsed pane alone while args stream (default autoExpandTools)', () => { + const collapsedCall = (command: string) => ( + + + + ); + const { container, rerender } = render(collapsedCall('echo start')); + const box = container.querySelector('.overflow-auto') as HTMLElement; + const state = mockScrollMetrics(box, 300); + state.scrollHeight = 900; + + rerender(collapsedCall('echo start && echo a second streamed line')); + + expect(state.writes).toHaveLength(0); + }); + + it('never scrolls a finished call', () => { + const finishedCall = (command: string) => ( + set(store.autoExpandTools, true)}> + + + ); + const { container, rerender } = render(finishedCall('echo done')); + const box = container.querySelector('.overflow-auto') as HTMLElement; + const state = mockScrollMetrics(box, 300); + state.scrollHeight = 900; + + rerender(finishedCall('echo done && echo a longer settled command')); + + expect(state.writes).toHaveLength(0); + }); +}); diff --git a/client/src/components/Chat/Messages/Content/Parts/__tests__/ExecuteCode.test.tsx b/client/src/components/Chat/Messages/Content/Parts/__tests__/ExecuteCode.test.tsx new file mode 100644 index 0000000000..6e37ed1bab --- /dev/null +++ b/client/src/components/Chat/Messages/Content/Parts/__tests__/ExecuteCode.test.tsx @@ -0,0 +1,136 @@ +import React from 'react'; +import { RecoilRoot } from 'recoil'; +import { render } from '@testing-library/react'; +import ExecuteCode from '../ExecuteCode'; +import store from '~/store'; + +jest.mock('~/hooks', () => ({ + useLocalize: + () => + (key: string): string => + key, + useProgress: (initialProgress: number) => initialProgress, + useExpandCollapse: (isExpanded: boolean) => ({ + style: { + display: 'grid', + gridTemplateRows: isExpanded ? '1fr' : '0fr', + opacity: isExpanded ? 1 : 0, + }, + ref: { current: null }, + }), +})); + +jest.mock('~/components/Chat/Messages/Content/ProgressText', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../CodeWindowHeader', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../Attachment', () => ({ + AttachmentGroup: () =>
, +})); + +jest.mock('../Stdout', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../useLazyHighlight', () => { + const { useState, useEffect } = jest.requireActual('react'); + /** Mirrors the real hook's two-phase contract: the render that changed + * `code` still shows the previous highlight (or null), and the new + * nodes commit in a later passive effect. */ + const useMockLazyHighlight = (code?: string) => { + const [highlighted, setHighlighted] = useState(null); + useEffect(() => { + setHighlighted(code == null ? null : [code]); + }, [code]); + return highlighted; + }; + return { __esModule: true, default: useMockLazyHighlight }; +}); + +jest.mock('~/utils', () => ({ + cn: (...classes: Array) => classes.filter(Boolean).join(' '), +})); + +/** + * jsdom has no layout, so the capped pane's scroll geometry is stubbed: + * `clientHeight` is fixed, `scrollHeight` either reads from mutable state + * or derives from the pane's rendered text (so the async highlight commit + * measurably changes it), and every `scrollTop` write the component makes + * is recorded. + */ +const mockScrollMetrics = ( + el: HTMLElement, + clientHeight: number, + opts: { deriveHeightFromText?: boolean } = {}, +) => { + const state = { scrollHeight: 0, scrollTop: 0, writes: [] as number[] }; + Object.defineProperty(el, 'scrollHeight', { + configurable: true, + get: () => + opts.deriveHeightFromText === true ? (el.textContent?.length ?? 0) * 10 : state.scrollHeight, + }); + Object.defineProperty(el, 'clientHeight', { configurable: true, get: () => clientHeight }); + Object.defineProperty(el, 'scrollTop', { + configurable: true, + get: () => state.scrollTop, + set: (value: number) => { + state.scrollTop = value; + state.writes.push(value); + }, + }); + return state; +}; + +describe('ExecuteCode streaming follow-scroll', () => { + /** `autoExpandTools` opens the pane at mount, mirroring the user who + * watches the code pane while the call runs. */ + const runningCall = (code: string) => ( + set(store.autoExpandTools, true)}> + + + ); + + it('pins the code pane to the code as rendered, including the async highlight commit', () => { + const { container, rerender } = render(runningCall('print(1)')); + const pane = container.querySelector('.overflow-auto') as HTMLElement; + const state = mockScrollMetrics(pane, 300, { deriveHeightFromText: true }); + + rerender(runningCall('print(1)\nprint(2)\nprint(3) # a much longer block')); + + expect(pane.textContent).toContain('a much longer block'); + expect(state.scrollTop).toBe((pane.textContent?.length ?? 0) * 10); + }); + + it('never scrolls a finished call', () => { + const finishedCall = (code: string) => ( + set(store.autoExpandTools, true)}> + + + ); + const { container, rerender } = render(finishedCall('print(1)')); + const pane = container.querySelector('.overflow-auto') as HTMLElement; + const state = mockScrollMetrics(pane, 300); + state.scrollHeight = 900; + + rerender(finishedCall('print(1)\nprint(2)')); + + expect(state.writes).toHaveLength(0); + }); +}); diff --git a/client/src/components/Chat/Messages/Content/Parts/__tests__/FileAuthoringCall.test.tsx b/client/src/components/Chat/Messages/Content/Parts/__tests__/FileAuthoringCall.test.tsx index a57a00c954..ddfed3b06e 100644 --- a/client/src/components/Chat/Messages/Content/Parts/__tests__/FileAuthoringCall.test.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/__tests__/FileAuthoringCall.test.tsx @@ -47,10 +47,20 @@ jest.mock('../Attachment', () => ({ AttachmentGroup: () =>
, })); -jest.mock('../useLazyHighlight', () => ({ - __esModule: true, - default: () => null, -})); +jest.mock('../useLazyHighlight', () => { + const { useState, useEffect } = jest.requireActual('react'); + /** Mirrors the real hook's two-phase contract: the render that changed + * `code` still shows the previous highlight (or null), and the new + * nodes commit in a later passive effect. */ + const useMockLazyHighlight = (code?: string) => { + const [highlighted, setHighlighted] = useState(null); + useEffect(() => { + setHighlighted(code == null ? null : [code]); + }, [code]); + return highlighted; + }; + return { __esModule: true, default: useMockLazyHighlight }; +}); jest.mock('../useToolCallState', () => ({ __esModule: true, @@ -279,3 +289,76 @@ describe('FileAuthoringCall', () => { expect(preview).toHaveTextContent('+second new'); }); }); + +/** + * jsdom has no layout, so the capped pane's scroll geometry is stubbed: + * `clientHeight` is fixed, `scrollHeight` either reads from mutable state + * or derives from the pane's rendered text (so the async highlight commit + * measurably changes it), and every `scrollTop` write the component makes + * is recorded. + */ +const mockScrollMetrics = ( + el: HTMLElement, + clientHeight: number, + opts: { deriveHeightFromText?: boolean } = {}, +) => { + const state = { scrollHeight: 0, scrollTop: 0, writes: [] as number[] }; + Object.defineProperty(el, 'scrollHeight', { + configurable: true, + get: () => + opts.deriveHeightFromText === true ? (el.textContent?.length ?? 0) * 10 : state.scrollHeight, + }); + Object.defineProperty(el, 'clientHeight', { configurable: true, get: () => clientHeight }); + Object.defineProperty(el, 'scrollTop', { + configurable: true, + get: () => state.scrollTop, + set: (value: number) => { + state.scrollTop = value; + state.writes.push(value); + }, + }); + return state; +}; + +describe('FileAuthoringCall streaming follow-scroll', () => { + const streamingCreate = (content: string) => ( + + ); + + it('pins the preview to the authored content as rendered, including the highlight commit', () => { + const { container, rerender } = render(streamingCreate('# Demo\\nline one')); + const pane = container.querySelector('.overflow-auto') as HTMLElement; + const state = mockScrollMetrics(pane, 300, { deriveHeightFromText: true }); + + rerender(streamingCreate('# Demo\\nline one\\nline two of the streamed body')); + + expect(pane.textContent).toContain('line two of the streamed body'); + expect(state.scrollTop).toBe((pane.textContent?.length ?? 0) * 10); + }); + + it('leaves a finished create_file preview alone', () => { + const finishedCreate = (content: string) => ( + + ); + const { container, rerender } = render(finishedCreate('# Demo\nline one')); + const pane = container.querySelector('.overflow-auto') as HTMLElement; + const state = mockScrollMetrics(pane, 300); + state.scrollHeight = 1200; + + rerender(finishedCreate('# Demo\nline one\nline two')); + + expect(state.writes).toHaveLength(0); + }); +}); diff --git a/client/src/components/Chat/Messages/Content/Parts/__tests__/useFollowScroll.spec.tsx b/client/src/components/Chat/Messages/Content/Parts/__tests__/useFollowScroll.spec.tsx new file mode 100644 index 0000000000..8573d53b64 --- /dev/null +++ b/client/src/components/Chat/Messages/Content/Parts/__tests__/useFollowScroll.spec.tsx @@ -0,0 +1,142 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react'; +import useFollowScroll from '../useFollowScroll'; + +function Probe({ + content, + active, + expanded, +}: { + content: string | readonly React.ReactNode[]; + active: boolean; + expanded: boolean; +}) { + const { ref, onScroll } = useFollowScroll(content, active, expanded); + return ( +
+ {content} +
+ ); +} + +/** + * jsdom has no layout, so scroll geometry is stubbed: `scrollHeight` reads + * from mutable state (grown by the test as content streams) and every + * `scrollTop` write the hook makes is recorded. Direct mutations of the + * returned state bypass the element setter, so `writes` only ever contains + * scrolls the hook itself performed. + */ +const mockScrollMetrics = (el: HTMLElement, clientHeight: number) => { + const state = { scrollHeight: 0, scrollTop: 0, writes: [] as number[] }; + Object.defineProperty(el, 'scrollHeight', { configurable: true, get: () => state.scrollHeight }); + Object.defineProperty(el, 'clientHeight', { configurable: true, get: () => clientHeight }); + Object.defineProperty(el, 'scrollTop', { + configurable: true, + get: () => state.scrollTop, + set: (value: number) => { + state.scrollTop = value; + state.writes.push(value); + }, + }); + return state; +}; + +describe('useFollowScroll', () => { + const setup = ({ + active = true, + expanded = true, + }: { active?: boolean; expanded?: boolean } = {}) => { + const utils = render(); + const pane = utils.getByTestId('pane'); + const state = mockScrollMetrics(pane, 300); + state.scrollHeight = 900; + return { ...utils, pane, state }; + }; + + it('pins to the bottom when content changes while active and expanded', () => { + const { rerender, state } = setup(); + rerender(); + expect(state.scrollTop).toBe(900); + }); + + it('re-pins on every delta as the content keeps growing', () => { + const { rerender, state } = setup(); + rerender(); + state.scrollHeight = 1200; + rerender(); + expect(state.scrollTop).toBe(1200); + }); + + it('re-pins when the rendered node identity changes for the same source text', () => { + const utils = render(); + const pane = utils.getByTestId('pane'); + const state = mockScrollMetrics(pane, 300); + state.scrollHeight = 900; + utils.rerender(); + expect(state.writes).toEqual([900]); + }); + + it('never scrolls while inactive', () => { + const { rerender, state } = setup({ active: false }); + rerender(); + expect(state.writes).toHaveLength(0); + }); + + it('never scrolls a collapsed pane, even while content streams', () => { + const { rerender, state } = setup({ expanded: false }); + rerender(); + expect(state.writes).toHaveLength(0); + }); + + it('pins immediately when the pane is expanded mid-stream', () => { + const { rerender, state } = setup({ expanded: false }); + rerender(); + expect(state.writes).toHaveLength(0); + rerender(); + expect(state.writes).toEqual([900]); + }); + + it('leaves a finished pane at the top when it is expanded for the first time', () => { + const { rerender, state } = setup({ expanded: false }); + rerender(); + rerender(); + rerender(); + expect(state.writes).toHaveLength(0); + }); + + it('stops pinning once the stream ends', () => { + const { rerender, state } = setup(); + rerender(); + expect(state.writes).toEqual([900]); + rerender(); + expect(state.writes).toEqual([900]); + }); + + it('detaches when the user scrolls up beyond the follow threshold', () => { + const { rerender, pane, state } = setup(); + state.scrollTop = 100; + fireEvent.scroll(pane); + rerender(); + expect(state.writes).toHaveLength(0); + }); + + it('stays attached at exactly the threshold distance', () => { + const { rerender, pane, state } = setup(); + state.scrollTop = 560; + fireEvent.scroll(pane); + rerender(); + expect(state.scrollTop).toBe(900); + }); + + it('re-attaches when the user returns to the bottom', () => { + const { rerender, pane, state } = setup(); + state.scrollTop = 100; + fireEvent.scroll(pane); + rerender(); + expect(state.writes).toHaveLength(0); + state.scrollTop = 590; + fireEvent.scroll(pane); + rerender(); + expect(state.writes).toEqual([900]); + }); +}); diff --git a/client/src/components/Chat/Messages/Content/Parts/useFollowScroll.ts b/client/src/components/Chat/Messages/Content/Parts/useFollowScroll.ts new file mode 100644 index 0000000000..2cf0d6ca4b --- /dev/null +++ b/client/src/components/Chat/Messages/Content/Parts/useFollowScroll.ts @@ -0,0 +1,62 @@ +import { useRef, useCallback, useLayoutEffect } from 'react'; +import type { ReactNode, RefObject, UIEventHandler } from 'react'; + +/** Bottom proximity (px) still treated as "following". Within it, new + * streamed content re-pins the pane; beyond it, the user has scrolled + * up to read and the stream must not snatch the viewport back. */ +const FOLLOW_THRESHOLD_PX = 40; + +/** + * Keeps a capped tool-detail pane (`max-h-* overflow-auto`) pinned to the + * tail of its content while that content is still streaming — the same + * follow contract as the message tree, scoped to the pane's own scroller. + * + * Under the cap the pane grows in place and the outer message auto-scroll + * already keeps the tail visible; this hook takes over at the moment the + * cap is hit and the pane becomes an internal scroller, which would + * otherwise freeze at the top while every subsequent delta lands below + * the fold. + * + * `content` is the node actually rendered inside the scroller — callers + * pass the highlighted output falling back to the raw string, so the pin + * re-fires on the async highlight commit, which is the render where the + * DOM really changes. Keying on the source string alone would measure the + * previous frame's DOM and leave the final chunk below the fold (or, for + * a block that appears in one update, pin an empty pane and never + * recover). Each such commit runs this layout effect before paint, so no + * observers are needed beyond React's own `onScroll`. + * + * `expanded` gates the pin to visible panes: a collapsed pane is still + * mounted (the disclosure is a CSS collapse) and would otherwise be + * scrolled invisibly, so a call that finishes before its first expansion + * would open at the tail. Gated, it opens reading from the top, while an + * expand mid-stream pins immediately. The pin's own scroll event measures + * distance zero and keeps the attached state, so programmatic scrolls + * need no special-casing. Finished panes (`active === false`) are never + * scrolled. + */ +export default function useFollowScroll( + content: string | readonly ReactNode[], + active: boolean, + expanded: boolean, +): { ref: RefObject; onScroll: UIEventHandler } { + const ref = useRef(null); + const followRef = useRef(true); + + const onScroll = useCallback>((event) => { + const el = event.currentTarget; + followRef.current = el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD_PX; + }, []); + + useLayoutEffect(() => { + if (!active || !expanded || !followRef.current) { + return; + } + const el = ref.current; + if (el) { + el.scrollTop = el.scrollHeight; + } + }, [content, active, expanded]); + + return { ref, onScroll }; +}