diff --git a/src/web-ui/src/flow_chat/components/CodePreview.test.tsx b/src/web-ui/src/flow_chat/components/CodePreview.test.tsx index dd50f0c31..c9bdf73ec 100644 --- a/src/web-ui/src/flow_chat/components/CodePreview.test.tsx +++ b/src/web-ui/src/flow_chat/components/CodePreview.test.tsx @@ -107,4 +107,25 @@ describe('CodePreview', () => { expect(highlighter.textContent).not.toContain('line 090'); expect(Number(highlighter.dataset.startingLineNumber)).toBeLessThanOrEqual(104); }); + + it('fits the streaming tail in the viewport when nested autoscroll is disabled', async () => { + await act(async () => { + root.render( + + ); + }); + + const highlighter = container.querySelector('[data-testid="syntax-highlighter"]') as HTMLElement; + expect(highlighter).not.toBeNull(); + expect(highlighter.textContent).toContain('line 120'); + expect(highlighter.textContent).toContain('line 117'); + expect(highlighter.textContent).not.toContain('line 116'); + expect(Number(highlighter.dataset.startingLineNumber)).toBe(117); + }); }); diff --git a/src/web-ui/src/flow_chat/components/CodePreview.tsx b/src/web-ui/src/flow_chat/components/CodePreview.tsx index 4ff2a3561..a9d137956 100644 --- a/src/web-ui/src/flow_chat/components/CodePreview.tsx +++ b/src/web-ui/src/flow_chat/components/CodePreview.tsx @@ -64,19 +64,24 @@ function countNewlines(value: string, endExclusive = value.length): number { return count; } -function getStreamingTailLineLimit(maxHeight: number): number { - const visibleLines = Math.ceil(maxHeight / CODE_PREVIEW_STREAMING_LINE_HEIGHT_PX); +function getStreamingTailLineLimit(maxHeight: number, includeOverscan: boolean): number { + const visibleLines = Math.max(1, Math.ceil(maxHeight / CODE_PREVIEW_STREAMING_LINE_HEIGHT_PX)); + const desiredLines = includeOverscan + ? visibleLines + STREAMING_TAIL_OVERSCAN_LINES + : visibleLines; + const minimumLines = includeOverscan ? STREAMING_TAIL_MIN_LINES : 1; + return Math.min( STREAMING_TAIL_MAX_LINES, - Math.max(STREAMING_TAIL_MIN_LINES, visibleLines + STREAMING_TAIL_OVERSCAN_LINES), + Math.max(minimumLines, desiredLines), ); } -function getStreamingTailDisplayContent(content: string, maxHeight: number): { +function getStreamingTailDisplayContent(content: string, maxHeight: number, includeOverscan: boolean): { content: string; startingLineNumber: number; } { - const tailLineLimit = getStreamingTailLineLimit(maxHeight); + const tailLineLimit = getStreamingTailLineLimit(maxHeight, includeOverscan); const totalLineCount = countNewlines(content) + 1; if (totalLineCount <= tailLineLimit && content.length <= STREAMING_TAIL_MAX_CHARS) { @@ -142,8 +147,8 @@ export const CodePreview: React.FC = memo(({ return { content: deferredContent, startingLineNumber: 1 }; } - return getStreamingTailDisplayContent(deferredContent, maxHeight); - }, [isStreaming, deferredContent, maxHeight]); + return getStreamingTailDisplayContent(deferredContent, maxHeight, autoScrollToBottom); + }, [isStreaming, deferredContent, maxHeight, autoScrollToBottom]); const displayContent = displayContentInfo.content; diff --git a/src/web-ui/src/flow_chat/tool-cards/README.md b/src/web-ui/src/flow_chat/tool-cards/README.md index f9791c088..15342ff65 100644 --- a/src/web-ui/src/flow_chat/tool-cards/README.md +++ b/src/web-ui/src/flow_chat/tool-cards/README.md @@ -39,9 +39,10 @@ Current examples: When the preview uses a nested scrolling code viewer, avoid forcing that nested viewer to auto-scroll while params are streaming. Streaming code previews already -render the latest viewport-sized tail, and the outer conversation list owns the -high-level follow behavior. Writing `scrollTop` on every preview batch can force -layout work inside the WebView and make long code output less responsive. +render the latest viewport-sized tail without overscan when nested auto-scroll is +disabled, and the outer conversation list owns the high-level follow behavior. +Writing `scrollTop` on every preview batch can force layout work inside the +WebView and make long code output less responsive. Preferred pattern: