From 7b206b1014b4172b4a0c77a13a4a200421564591 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 7 Sep 2026 20:38:00 +0300 Subject: [PATCH] fix(chat): release thinking follow when dragging the scrollbar Retain the capped reasoning implementation and adopt the scroll-position release behavior from #3394. Cover pause, resume, and observer cleanup on the mounted component. Co-authored-by: alvins82 --- .../chat/message/parts/ReasoningPart.test.tsx | 169 ++++++++++++++++-- .../chat/message/parts/ReasoningPart.tsx | 2 +- packages/ui/src/types/bun-test.d.ts | 13 ++ 3 files changed, 169 insertions(+), 15 deletions(-) diff --git a/packages/ui/src/components/chat/message/parts/ReasoningPart.test.tsx b/packages/ui/src/components/chat/message/parts/ReasoningPart.test.tsx index 590a879d..7f0ea5b5 100644 --- a/packages/ui/src/components/chat/message/parts/ReasoningPart.test.tsx +++ b/packages/ui/src/components/chat/message/parts/ReasoningPart.test.tsx @@ -1,14 +1,55 @@ import React, { act } from 'react'; import { describe, expect, test } from 'bun:test'; +import { plugin } from 'bun'; +import { pathToFileURL } from 'node:url'; import { renderToStaticMarkup } from 'react-dom/server'; import { createRoot } from 'react-dom/client'; import { Window } from 'happy-dom'; -import type { Part } from '@opencode-ai/sdk/v2'; +import { createOpencodeClient, type Part } from '@opencode-ai/sdk/v2'; +import { SyncProvider } from '@/sync/sync-context'; + +import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext'; +import type { RuntimeAPIs } from '@/lib/api/types'; import { I18nProvider } from '@/lib/i18n'; import ReasoningPart, { ReasoningTimelineBlock } from './ReasoningPart'; import type { StreamPhase } from '../types'; +// Bun does not implement Vite's asset-query imports. Preserve the real asset +// URL while keeping the renderer and worker client modules unchanged. +plugin({ + name: 'reasoning-worker-url', + setup(build) { + build.onLoad({ filter: /markdown-shiki\.worker\.ts\?worker&url$/ }, ({ path }) => ({ + contents: `export default ${JSON.stringify(pathToFileURL(path.split('?')[0]).href)};`, + loader: 'js', + })); + }, +}); + +const unavailable = (): never => { throw new Error('Reasoning scrolling must not call runtime APIs'); }; +const runtimeApis: RuntimeAPIs = { + runtime: { platform: 'web', isDesktop: false, isVSCode: false }, + get terminal() { return unavailable(); }, + get git() { return unavailable(); }, + get files() { return unavailable(); }, + get settings() { return unavailable(); }, + get permissions() { return unavailable(); }, + get notifications() { return unavailable(); }, + get tools() { return unavailable(); }, +}; +const sdk = createOpencodeClient({ + baseUrl: 'http://localhost', + fetch: async () => new Response('[]', { headers: { 'Content-Type': 'application/json' } }), +}); +const TestProviders = ({ children }: { children: React.ReactNode }) => ( + + + {children} + + +); + type ReasoningPartFixture = Extract; /** @@ -22,9 +63,18 @@ const DOM_GLOBAL_NAMES = [ 'window', 'document', 'navigator', + 'localStorage', + 'customElements', 'Node', + 'NodeList', 'Element', 'HTMLElement', + 'SVGElement', + 'requestAnimationFrame', + 'cancelAnimationFrame', + 'getComputedStyle', + 'ResizeObserver', + 'MutationObserver', 'IS_REACT_ACT_ENVIRONMENT', ] as const; @@ -33,13 +83,40 @@ const installDomStub = () => { const previous = DOM_GLOBAL_NAMES.map( (name) => [name, Object.getOwnPropertyDescriptor(globalThis, name)] as const, ); + const observers: ResizeObserverStub[] = []; + class ResizeObserverStub implements ResizeObserver { + readonly targets = new Set(); + disconnectCount = 0; + + constructor(private readonly callback: ResizeObserverCallback) { + observers.push(this); + } + observe(target: Element) { this.targets.add(target); } + unobserve(target: Element) { this.targets.delete(target); } + disconnect() { + this.disconnectCount += 1; + this.targets.clear(); + } + notify() { + if (this.targets.size > 0) this.callback([], this); + } + } const values = { window: happyWindow, document: happyWindow.document, navigator: happyWindow.navigator, + localStorage: happyWindow.localStorage, + customElements: happyWindow.customElements, Node: happyWindow.Node, + NodeList: happyWindow.NodeList, Element: happyWindow.Element, HTMLElement: happyWindow.HTMLElement, + SVGElement: happyWindow.SVGElement, + requestAnimationFrame: happyWindow.requestAnimationFrame.bind(happyWindow), + cancelAnimationFrame: happyWindow.cancelAnimationFrame.bind(happyWindow), + getComputedStyle: happyWindow.getComputedStyle.bind(happyWindow), + ResizeObserver: ResizeObserverStub, + MutationObserver: happyWindow.MutationObserver, IS_REACT_ACT_ENVIRONMENT: true, }; for (const name of DOM_GLOBAL_NAMES) { @@ -53,6 +130,7 @@ const installDomStub = () => { return { container, + observers, restore: () => { for (const [name, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, name, descriptor); @@ -78,14 +156,14 @@ const LONG_JUSTIFICATION = describe('ReasoningTimelineBlock', () => { test('renders reasoning traces behind an accessible collapsed disclosure by default', () => { const markup = renderToStaticMarkup( - + - , + , ); // Accessible toggle row is rendered @@ -103,7 +181,7 @@ describe('ReasoningTimelineBlock', () => { test('renders "Justification" label for justification variant when pre-expanded and not streaming', () => { const markup = renderToStaticMarkup( - + { showDuration={false} defaultExpanded={true} /> - , + , ); // Label shown in expanded header should be "Justification" not "Thinking" @@ -121,7 +199,7 @@ describe('ReasoningTimelineBlock', () => { test('renders "Thinking" label for thinking variant when pre-expanded and not streaming', () => { const markup = renderToStaticMarkup( - + { showDuration={false} defaultExpanded={true} /> - , + , ); // Label shown in expanded header should be "Thinking" @@ -138,14 +216,14 @@ describe('ReasoningTimelineBlock', () => { test('header summary is a truncated excerpt from the beginning', () => { const markup = renderToStaticMarkup( - + - , + , ); // Deep body content beyond 120 chars should be cut from the summary span @@ -156,14 +234,14 @@ describe('ReasoningTimelineBlock', () => { test('omits trailing empty HTML comments from the header summary', () => { const markup = renderToStaticMarkup( - + '} variant="thinking" blockId="reasoning-comment-test" showDuration={false} /> - , + , ); expect(markup).toContain('Planning accessible icon labels with translations'); @@ -198,9 +276,9 @@ describe('ReasoningPart streaming gating (issue #2020)', () => { // reachable and the issue reproduces. const renderPart = (part: ReasoningPartFixture, streamPhase?: StreamPhase): string => renderToStaticMarkup( - + - , + , ); test('reasoning without time.end and without a live stream phase renders complete, not streaming', () => { @@ -276,7 +354,7 @@ describe('ReasoningPart streaming gating (issue #2020)', () => { const renderTree = () => React.createElement( - I18nProvider, + TestProviders, null, React.createElement(ReasoningPart, { part, messageId: 'msg_2020', streamPhase: undefined }), ); @@ -306,3 +384,66 @@ describe('ReasoningPart streaming gating (issue #2020)', () => { } }); }); + +describe('ReasoningTimelineBlock live follow', () => { + test('scrollbar scrolling releases live follow and returning to the bottom resumes it', async () => { + const dom = installDomStub(); + const root = createRoot(dom.container); + const renderBlock = (isStreaming: boolean) => ( + + + + ); + + try { + await act(async () => { root.render(renderBlock(true)); }); + const scroller = dom.container.querySelector('[data-scrollable="true"]'); + if (!scroller) throw new Error('Expected the mounted reasoning scroll box'); + const body = scroller.firstElementChild; + const followObserver = dom.observers.find((observer) => observer.targets.size === 1 && body && observer.targets.has(body)); + if (!followObserver) throw new Error('Expected an observer of the reasoning body'); + + let contentHeight = 800; + Object.defineProperties(scroller, { + clientHeight: { configurable: true, value: 320 }, + scrollHeight: { configurable: true, get: () => contentHeight }, + }); + await act(async () => { followObserver.notify(); }); + expect(scroller.scrollTop).toBe(480); + + // A scrollbar drag emits scroll, without a wheel or touch event. + await act(async () => { + scroller.scrollTop = 120; + scroller.dispatchEvent(new window.Event('scroll')); + }); + contentHeight = 1000; + await act(async () => { followObserver.notify(); }); + expect(scroller.scrollTop).toBe(120); + + await act(async () => { + scroller.scrollTop = 680; + scroller.dispatchEvent(new window.Event('scroll')); + }); + contentHeight = 1200; + await act(async () => { followObserver.notify(); }); + expect(scroller.scrollTop).toBe(880); + + await act(async () => { root.render(renderBlock(false)); }); + expect(followObserver.disconnectCount).toBe(1); + expect(followObserver.targets.size).toBe(0); + contentHeight = 1400; + await act(async () => { followObserver.notify(); }); + expect(scroller.scrollTop).toBe(880); + } finally { + await act(async () => { root.unmount(); }); + dom.restore(); + } + }); +}); diff --git a/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx b/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx index d080be23..dddeae6f 100644 --- a/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx @@ -152,7 +152,7 @@ export const ReasoningTimelineBlock: React.FC = ({ const handleBoxScroll = React.useCallback((event: React.UIEvent) => { const node = event.currentTarget; const distanceToEnd = node.scrollHeight - node.clientHeight - node.scrollTop; - if (distanceToEnd <= 2) followBoxEndRef.current = true; + followBoxEndRef.current = distanceToEnd <= 2; }, []); React.useEffect(() => { diff --git a/packages/ui/src/types/bun-test.d.ts b/packages/ui/src/types/bun-test.d.ts index 931daf52..dd3e502e 100644 --- a/packages/ui/src/types/bun-test.d.ts +++ b/packages/ui/src/types/bun-test.d.ts @@ -57,3 +57,16 @@ declare module "bun:test" { function restore(): void; } } + +// Vite asset-query imports need a URL loader when real UI modules run in Bun. +declare module "bun" { + export function plugin(options: { + name: string; + setup(build: { + onLoad(options: { filter: RegExp }, callback: (args: { path: string }) => { + contents: string; + loader: "js"; + }): void; + }): void; + }): void; +}