📜 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:
Danny Avila 2026-08-16 21:27:03 -04:00 committed by GitHub
parent c519f26904
commit 107050396e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 584 additions and 12 deletions

View file

@ -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}

View file

@ -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>
)}

View file

@ -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>

View file

@ -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);
});
});

View file

@ -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);
});
});

View file

@ -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);
});
});

View file

@ -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]);
});
});

View file

@ -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 };
}