From d1a6955273f612bea3066e2858d847343eb978fb Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 25 Aug 2026 11:02:01 +0300 Subject: [PATCH] fix(chat): status overlay is opaque and occludes the scroll math MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The floating status/working row let streaming text show through it and, because the scroll math did not know the overlay existed, the reveal correction targeted the very bottom edge — the live line ended up hidden beneath the status row ('catches up to almost-the-end'). The overlay now has a solid background, its height is measured with a ResizeObserver and fed to the timeline as the occluding overlay height, so the live streaming line glides to rest just above the status row and the list reserves matching end inset at rest. --- .../ui/src/components/chat/ChatContainer.tsx | 48 +++++++++++++++---- 1 file changed, 40 insertions(+), 8 deletions(-) diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index 260328c9..bcb57d2d 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -147,6 +147,7 @@ type HydratingToolSkeletonRow = { }; type ChatViewportProps = { + onStatusOverlayNode: (node: HTMLDivElement | null) => void; currentSessionId: string; currentSessionKey: string; isDesktopExpandedInput: boolean; @@ -191,6 +192,7 @@ type ChatViewportProps = { }; const ChatViewport = React.memo(({ + onStatusOverlayNode, currentSessionId, currentSessionKey, isDesktopExpandedInput, @@ -414,9 +416,15 @@ const ChatViewport = React.memo(({ /> {/* Static above the composer: inside the list it walked down - with every streamed line while a turn was anchored. */} -
-
+ with every streamed line while a turn was anchored. Solid + background — text streams beneath it; the measured height + feeds composerOverlayHeight so the live line never hides + under it. */} +
+
@@ -435,7 +443,8 @@ const ChatViewport = React.memo(({
); }, (prev, next) => { - return prev.currentSessionId === next.currentSessionId + return prev.onStatusOverlayNode === next.onStatusOverlayNode + && prev.currentSessionId === next.currentSessionId && prev.currentSessionKey === next.currentSessionKey && prev.isDesktopExpandedInput === next.isDesktopExpandedInput && prev.isMobile === next.isMobile @@ -917,10 +926,32 @@ export const ChatContainer: React.FC = ({ activeTurnChangeRef.current(turnId); }, []); - // The composer sits below the timeline rather than over it, so no part of - // the scroll container is occluded. Mobile surfaces that float the composer - // pass their measured height here instead. - const composerOverlayHeight = 0; + // The composer sits below the timeline, but the status/working row floats + // OVER the timeline's bottom edge; its measured height keeps the live + // streaming line above it and reserves matching end inset in the list. + const [statusOverlayHeight, setStatusOverlayHeight] = React.useState(0); + const composerOverlayHeight = statusOverlayHeight; + const statusOverlayObserverRef = React.useRef(null); + const onStatusOverlayNode = React.useCallback((node: HTMLDivElement | null) => { + statusOverlayObserverRef.current?.disconnect(); + statusOverlayObserverRef.current = null; + if (!node || !globalThis.ResizeObserver) { + setStatusOverlayHeight(0); + return; + } + const update = () => { + const height = node.getBoundingClientRect().height; + setStatusOverlayHeight((prev) => (Math.abs(prev - height) < 1 ? prev : height)); + }; + const observer = new ResizeObserver(update); + observer.observe(node); + statusOverlayObserverRef.current = observer; + update(); + }, []); + React.useEffect(() => () => { + statusOverlayObserverRef.current?.disconnect(); + statusOverlayObserverRef.current = null; + }, []); const lastUserMessageId = React.useMemo(() => { for (let index = sessionMessages.length - 1; index >= 0; index -= 1) { const message = sessionMessages[index]; @@ -1326,6 +1357,7 @@ export const ChatContainer: React.FC = ({ return (