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