mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 12:13:30 +00:00
📜 feat: Follow Streaming Args in Tool Detail Panes (#14906)
* 📜 feat: Follow Streaming Args in Tool Detail Panes * 📜 fix: Gate Follow-Scroll to Expanded Panes, Re-Pin on Highlight Commit
This commit is contained in:
parent
c519f26904
commit
107050396e
8 changed files with 584 additions and 12 deletions
|
|
@ -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<HTMLDivElement>(
|
||||
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({
|
|||
<div className="overflow-hidden" ref={expandRef}>
|
||||
<div className="my-2 overflow-hidden rounded-lg border border-border-light">
|
||||
{command && (
|
||||
<div className="relative max-h-[300px] overflow-auto bg-surface-tertiary dark:bg-gray-950">
|
||||
<div
|
||||
ref={commandPaneRef}
|
||||
onScroll={onCommandPaneScroll}
|
||||
className="relative max-h-[300px] overflow-auto bg-surface-tertiary dark:bg-gray-950"
|
||||
>
|
||||
<CopyButton
|
||||
iconOnly
|
||||
isCopied={isCopied}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { sandboxStartingByToolCallId } from '~/store';
|
|||
import useLazyHighlight from './useLazyHighlight';
|
||||
import useToolCallState from './useToolCallState';
|
||||
import CodeWindowHeader from './CodeWindowHeader';
|
||||
import useFollowScroll from './useFollowScroll';
|
||||
import { AttachmentGroup } from './Attachment';
|
||||
import { useToolCallIntent } from './intent';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
|
@ -90,6 +91,11 @@ export default function ExecuteCode({
|
|||
useToolCallState(initialProgress, isSubmitting, output, !!code, onExpand, runStepStatus);
|
||||
|
||||
const highlighted = useLazyHighlight(code, lang);
|
||||
const { ref: codePaneRef, onScroll: onCodePaneScroll } = useFollowScroll<HTMLPreElement>(
|
||||
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({
|
|||
<div className="my-2 overflow-hidden rounded-lg border border-border-light bg-surface-secondary">
|
||||
{code && <CodeWindowHeader language={lang} code={code} />}
|
||||
{code && (
|
||||
<pre className="max-h-[300px] overflow-auto bg-surface-chat p-4 font-mono text-xs dark:bg-surface-primary-alt">
|
||||
<pre
|
||||
ref={codePaneRef}
|
||||
onScroll={onCodePaneScroll}
|
||||
className="max-h-[300px] overflow-auto bg-surface-chat p-4 font-mono text-xs dark:bg-surface-primary-alt"
|
||||
>
|
||||
<code className={`hljs language-${lang} !whitespace-pre`}>{highlighted}</code>
|
||||
</pre>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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<HTMLPreElement>(
|
||||
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 && (
|
||||
<div className="my-2 overflow-hidden rounded-lg border border-border-light bg-surface-secondary">
|
||||
<CodeWindowHeader language={previewIsDiff ? 'diff' : fileName} code={preview} />
|
||||
<pre className="max-h-[300px] overflow-auto bg-surface-chat p-4 font-mono text-xs dark:bg-surface-primary-alt">
|
||||
<pre
|
||||
ref={previewPaneRef}
|
||||
onScroll={onPreviewPaneScroll}
|
||||
className="max-h-[300px] overflow-auto bg-surface-chat p-4 font-mono text-xs dark:bg-surface-primary-alt"
|
||||
>
|
||||
<code className={`hljs language-${previewLang} !whitespace-pre`}>
|
||||
{highlighted ?? preview}
|
||||
</code>
|
||||
|
|
|
|||
|
|
@ -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: () => <div data-testid="attachment-group" />,
|
||||
}));
|
||||
|
||||
jest.mock('../useLazyHighlight', () => ({
|
||||
__esModule: true,
|
||||
default: () => null,
|
||||
}));
|
||||
jest.mock('../useLazyHighlight', () => {
|
||||
const { useState, useEffect } = jest.requireActual<typeof import('react')>('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<string[] | null>(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) => (
|
||||
<RecoilRoot initializeState={({ set }) => set(store.autoExpandTools, true)}>
|
||||
<BashCall initialProgress={0.1} isSubmitting={true} args={streamingArgs(command)} output="" />
|
||||
</RecoilRoot>
|
||||
);
|
||||
|
||||
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) => (
|
||||
<RecoilRoot>
|
||||
<BashCall
|
||||
initialProgress={0.1}
|
||||
isSubmitting={true}
|
||||
args={streamingArgs(command)}
|
||||
output=""
|
||||
/>
|
||||
</RecoilRoot>
|
||||
);
|
||||
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) => (
|
||||
<RecoilRoot initializeState={({ set }) => set(store.autoExpandTools, true)}>
|
||||
<BashCall initialProgress={1} isSubmitting={false} args={{ command }} output="done" />
|
||||
</RecoilRoot>
|
||||
);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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: () => <div data-testid="progress-text" />,
|
||||
}));
|
||||
|
||||
jest.mock('../CodeWindowHeader', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div data-testid="code-window-header" />,
|
||||
}));
|
||||
|
||||
jest.mock('../Attachment', () => ({
|
||||
AttachmentGroup: () => <div data-testid="attachment-group" />,
|
||||
}));
|
||||
|
||||
jest.mock('../Stdout', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div data-testid="stdout" />,
|
||||
}));
|
||||
|
||||
jest.mock('../useLazyHighlight', () => {
|
||||
const { useState, useEffect } = jest.requireActual<typeof import('react')>('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<string[] | null>(null);
|
||||
useEffect(() => {
|
||||
setHighlighted(code == null ? null : [code]);
|
||||
}, [code]);
|
||||
return highlighted;
|
||||
};
|
||||
return { __esModule: true, default: useMockLazyHighlight };
|
||||
});
|
||||
|
||||
jest.mock('~/utils', () => ({
|
||||
cn: (...classes: Array<string | false | null | undefined>) => 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) => (
|
||||
<RecoilRoot initializeState={({ set }) => set(store.autoExpandTools, true)}>
|
||||
<ExecuteCode
|
||||
initialProgress={0.5}
|
||||
isSubmitting={true}
|
||||
args={{ lang: 'py', code }}
|
||||
output=""
|
||||
/>
|
||||
</RecoilRoot>
|
||||
);
|
||||
|
||||
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) => (
|
||||
<RecoilRoot initializeState={({ set }) => set(store.autoExpandTools, true)}>
|
||||
<ExecuteCode
|
||||
initialProgress={1}
|
||||
isSubmitting={false}
|
||||
args={{ lang: 'py', code }}
|
||||
output="done"
|
||||
/>
|
||||
</RecoilRoot>
|
||||
);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
@ -47,10 +47,20 @@ jest.mock('../Attachment', () => ({
|
|||
AttachmentGroup: () => <div data-testid="attachment-group" />,
|
||||
}));
|
||||
|
||||
jest.mock('../useLazyHighlight', () => ({
|
||||
__esModule: true,
|
||||
default: () => null,
|
||||
}));
|
||||
jest.mock('../useLazyHighlight', () => {
|
||||
const { useState, useEffect } = jest.requireActual<typeof import('react')>('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<string[] | null>(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) => (
|
||||
<FileAuthoringCall
|
||||
toolName="create_file"
|
||||
initialProgress={0.5}
|
||||
isSubmitting={true}
|
||||
args={`{"file_path":"skills/demo/SKILL.md","content":"${content}`}
|
||||
output=""
|
||||
/>
|
||||
);
|
||||
|
||||
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) => (
|
||||
<FileAuthoringCall
|
||||
toolName="create_file"
|
||||
initialProgress={1}
|
||||
isSubmitting={false}
|
||||
args={{ file_path: 'skills/demo/SKILL.md', content }}
|
||||
output="Created skills/demo/SKILL.md (4096 chars)."
|
||||
/>
|
||||
);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<HTMLDivElement>(content, active, expanded);
|
||||
return (
|
||||
<div data-testid="pane" ref={ref} onScroll={onScroll}>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(<Probe content="a" active={active} expanded={expanded} />);
|
||||
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(<Probe content="ab" active expanded />);
|
||||
expect(state.scrollTop).toBe(900);
|
||||
});
|
||||
|
||||
it('re-pins on every delta as the content keeps growing', () => {
|
||||
const { rerender, state } = setup();
|
||||
rerender(<Probe content="ab" active expanded />);
|
||||
state.scrollHeight = 1200;
|
||||
rerender(<Probe content="abc" active expanded />);
|
||||
expect(state.scrollTop).toBe(1200);
|
||||
});
|
||||
|
||||
it('re-pins when the rendered node identity changes for the same source text', () => {
|
||||
const utils = render(<Probe content={['abc']} active expanded />);
|
||||
const pane = utils.getByTestId('pane');
|
||||
const state = mockScrollMetrics(pane, 300);
|
||||
state.scrollHeight = 900;
|
||||
utils.rerender(<Probe content={['abc']} active expanded />);
|
||||
expect(state.writes).toEqual([900]);
|
||||
});
|
||||
|
||||
it('never scrolls while inactive', () => {
|
||||
const { rerender, state } = setup({ active: false });
|
||||
rerender(<Probe content="ab" active={false} expanded />);
|
||||
expect(state.writes).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('never scrolls a collapsed pane, even while content streams', () => {
|
||||
const { rerender, state } = setup({ expanded: false });
|
||||
rerender(<Probe content="ab" active expanded={false} />);
|
||||
expect(state.writes).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('pins immediately when the pane is expanded mid-stream', () => {
|
||||
const { rerender, state } = setup({ expanded: false });
|
||||
rerender(<Probe content="ab" active expanded={false} />);
|
||||
expect(state.writes).toHaveLength(0);
|
||||
rerender(<Probe content="ab" active expanded />);
|
||||
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(<Probe content="ab" active expanded={false} />);
|
||||
rerender(<Probe content="ab" active={false} expanded={false} />);
|
||||
rerender(<Probe content="ab" active={false} expanded />);
|
||||
expect(state.writes).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('stops pinning once the stream ends', () => {
|
||||
const { rerender, state } = setup();
|
||||
rerender(<Probe content="ab" active expanded />);
|
||||
expect(state.writes).toEqual([900]);
|
||||
rerender(<Probe content="ab" active={false} expanded />);
|
||||
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(<Probe content="ab" active expanded />);
|
||||
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(<Probe content="ab" active expanded />);
|
||||
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(<Probe content="ab" active expanded />);
|
||||
expect(state.writes).toHaveLength(0);
|
||||
state.scrollTop = 590;
|
||||
fireEvent.scroll(pane);
|
||||
rerender(<Probe content="abc" active expanded />);
|
||||
expect(state.writes).toEqual([900]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<T extends HTMLElement>(
|
||||
content: string | readonly ReactNode[],
|
||||
active: boolean,
|
||||
expanded: boolean,
|
||||
): { ref: RefObject<T>; onScroll: UIEventHandler<T> } {
|
||||
const ref = useRef<T>(null);
|
||||
const followRef = useRef(true);
|
||||
|
||||
const onScroll = useCallback<UIEventHandler<T>>((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 };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue