Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 118 additions & 0 deletions src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -144,3 +144,121 @@ describe('ModelThinkingDisplay reasoning summary', () => {
?.getAttribute('data-expanded')).toBe('true');
});
});

describe('ModelThinkingDisplay scroll ownership', () => {
let container: HTMLDivElement;
let root: Root;
let frames: Map<number, FrameRequestCallback>;
let frameId: number;
let height: number;
let viewport: number;
let clockMs: number;

beforeEach(() => {
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean })
.IS_REACT_ACT_ENVIRONMENT = true;
frames = new Map();
frameId = 0;
height = 1000;
viewport = 300;
clockMs = 1000;
vi.spyOn(performance, 'now').mockImplementation(() => clockMs);
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
frames.set(++frameId, callback);
return frameId;
});
vi.stubGlobal('cancelAnimationFrame', (id: number) => frames.delete(id));
vi.stubGlobal('ResizeObserver', class {
observe() {}
disconnect() {}
});
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});

afterEach(() => {
act(() => root.unmount());
container.remove();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});

function render(content = 'Thinking') {
act(() => root.render(<ModelThinkingDisplay thinkingItem={{
...summaryItem(content), reasoningKind: 'reasoning',
}} />));
}

function nextFrame() {
const pending = [...frames.values()];
frames.clear();
act(() => pending.forEach((callback) => callback(clockMs)));
}

function startFollow(initialTop = 640) {
render();
const el = container.querySelector('[data-testid="chat-thinking-content"]') as HTMLDivElement;
Object.defineProperties(el, {
scrollHeight: { get: () => height },
clientHeight: { get: () => viewport },
});
el.scrollTop = initialTop;
nextFrame();
expect(el.scrollTop).toBeGreaterThan(initialTop);
expect(frames.size).toBe(1);
return el;
}

it.each([true, false])('pauses a scrollbar drag with scroll event delivered: %s', (deliverScroll) => {
const el = startFollow();
el.scrollTop = 400;
if (deliverScroll) act(() => el.dispatchEvent(new Event('scroll')));
nextFrame();
expect(el.scrollTop).toBe(400);
expect(frames.size).toBe(0);
clockMs += 1000;
height += 20;
render('More thinking');
nextFrame();
expect(el.scrollTop).toBe(400);
});

it('continues following after its own scroll events', () => {
const el = startFollow();
const before = el.scrollTop;
act(() => el.dispatchEvent(new Event('scroll')));
nextFrame();
expect(el.scrollTop).toBeGreaterThan(before);
});

it.each(['shrink', 'resize', 'rounding'])('does not pause for %s', (change) => {
const el = startFollow();
if (change === 'shrink') height -= 20;
if (change === 'resize') viewport += 20;
el.scrollTop -= change === 'rounding' ? 0.5 : 20;
const before = el.scrollTop;
act(() => el.dispatchEvent(new Event('scroll')));
nextFrame();
expect(el.scrollTop).toBeGreaterThan(before);
});

it.each([19, 20, 75])('only resumes within 20 px after the 700 ms pause (gap: %s)', (gap) => {
const el = startFollow(690);
const pausedTop = 700 - gap;
el.scrollTop = pausedTop;
act(() => el.dispatchEvent(new Event('scroll')));
clockMs += 600;
render('Still paused');
nextFrame();
expect(el.scrollTop).toBe(pausedTop);
clockMs += 101;
render('Resume near bottom');
nextFrame();
if (gap < 20) {
expect(el.scrollTop).toBeGreaterThan(pausedTop);
} else {
expect(el.scrollTop).toBe(pausedTop);
}
});
});
53 changes: 44 additions & 9 deletions src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ export const ModelThinkingDisplay: React.FC<ModelThinkingDisplayProps> = ({
/** Frame the follow has booked, and the sign that it is still travelling. */
const tailFollowFrameRef = useRef<number | null>(null);
const touchScrollStartYRef = useRef<number | null>(null);
const lastScrollPositionRef = useRef<{
top: number;
height: number;
viewport: number;
} | null>(null);

const isActive = isStreaming || status === 'streaming';
const { displayText: displayContent, isRevealing } = useTypewriter(
Expand Down Expand Up @@ -98,6 +103,38 @@ export const ModelThinkingDisplay: React.FC<ModelThinkingDisplayProps> = ({
tailFollowFrameRef.current = null;
}, []);

const pauseTailFollowForUserScroll = useCallback(() => {
shouldFollowTailRef.current = false;
tailFollowPauseVersionRef.current += 1;
tailFollowUserPauseUntilMsRef.current = performance.now() + 700;
stopTailFollow();
}, [stopTailFollow]);

const recordScrollPosition = useCallback((el: HTMLElement) => {
lastScrollPositionRef.current = {
top: el.scrollTop,
height: el.scrollHeight,
viewport: el.clientHeight,
};
}, []);

const detectUpwardScroll = useCallback((el: HTMLElement) => {
const previous = lastScrollPositionRef.current;
// Scrollbar drags have no wheel/key event. Compare with our last actual
// offset, allowing rounding noise and excluding layout-driven movement.
const movedUp = isExpanded && previous !== null &&
el.scrollHeight >= previous.height &&
el.clientHeight === previous.viewport &&
el.scrollTop < previous.top - 1;
recordScrollPosition(el);
if (movedUp) pauseTailFollowForUserScroll();
return movedUp;
}, [isExpanded, pauseTailFollowForUserScroll, recordScrollPosition]);

useLayoutEffect(() => {
lastScrollPositionRef.current = null;
}, [isExpanded]);

/**
* Follow the tail across the frames it is given, rather than in one write.
*
Expand All @@ -119,6 +156,8 @@ export const ModelThinkingDisplay: React.FC<ModelThinkingDisplayProps> = ({
tailFollowFrameRef.current = null;
const el = contentRef.current;
if (!el) return;
// The browser may update the offset before delivering its scroll event.
if (detectUpwardScroll(el)) return;
if (expectedPauseVersion !== tailFollowPauseVersionRef.current) return;
if (!shouldFollowTailRef.current) return;

Expand All @@ -132,6 +171,7 @@ export const ModelThinkingDisplay: React.FC<ModelThinkingDisplayProps> = ({
: { offsetPx: targetPx, outcome: 'snapped' as const };

el.scrollTop = step.offsetPx;
recordScrollPosition(el);
// Read back rather than taken from the step: the browser clamps to the
// scrollable range, and a platform without fractional scroll offsets
// rounds the last part of an ease away entirely. Believing the step there
Expand All @@ -155,23 +195,17 @@ export const ModelThinkingDisplay: React.FC<ModelThinkingDisplayProps> = ({
};

tailFollowFrameRef.current = requestAnimationFrame(runFrame);
}, []);
}, [detectUpwardScroll, recordScrollPosition]);

/** A follow in flight outlives neither the card nor its collapse. */
useEffect(() => stopTailFollow, [isExpanded, stopTailFollow]);

const pauseTailFollowForUserScroll = useCallback(() => {
shouldFollowTailRef.current = false;
tailFollowPauseVersionRef.current += 1;
tailFollowUserPauseUntilMsRef.current = performance.now() + 700;
}, []);

// Auto-scroll to bottom while content grows.
useEffect(() => {
if (isExpanded && contentRef.current) {
const el = contentRef.current;
const gap = getThinkingScrollGap(el);
const wasNearBottom = gap < 80;
const wasNearBottom = gap < 20;
const userPauseActive = performance.now() <= tailFollowUserPauseUntilMsRef.current;
if (wasNearBottom && !userPauseActive) {
shouldFollowTailRef.current = true;
Expand Down Expand Up @@ -215,6 +249,7 @@ export const ModelThinkingDisplay: React.FC<ModelThinkingDisplayProps> = ({
const checkScrollState = useCallback(() => {
const el = contentRef.current;
if (!el) return;
detectUpwardScroll(el);
const gap = getThinkingScrollGap(el);
const nextScrollState = {
hasScroll: el.scrollHeight > el.clientHeight,
Expand Down Expand Up @@ -249,7 +284,7 @@ export const ModelThinkingDisplay: React.FC<ModelThinkingDisplayProps> = ({
atBottom: nextScrollState.atBottom,
}
));
}, [getThinkingScrollGap]);
}, [detectUpwardScroll, getThinkingScrollGap]);

useEffect(() => {
if (isExpanded) {
Expand Down
Loading