fix(app): keep web scrollbars from shifting content

Use an overlay scrollbar for desktop chat timelines so scrollbar gutters cannot move centered content. Share its behavior with existing workspace scrollbars and derive resting and hover colors from semantic foreground tokens.
This commit is contained in:
Mohamed Boudra
2026-08-16 12:45:06 +02:00
parent d4435bd016
commit d99258cdd0
9 changed files with 352 additions and 207 deletions
@@ -27,6 +27,7 @@ vi.hoisted(() => {
});
vi.mock("react-native-unistyles", () => ({
StyleSheet: { create: () => ({}) },
withUnistyles: (Component: React.ComponentType) => Component,
}));
@@ -152,6 +153,51 @@ describe("createWebStreamStrategy", () => {
expect(rowRenderCount.mock.calls.length).toBeLessThanOrEqual(historyVirtualized.length);
});
it("keeps the timeline width stable with an overlay scrollbar", () => {
const strategy = createWebStreamStrategy({ isMobileBreakpoint: false });
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
act(() => {
root?.render(
strategy.render({
agentId: "agent",
segments: {
historyVirtualized: [],
historyMounted: [userMessage(1)],
liveHead: [],
},
boundary: {
hasVirtualizedHistory: false,
hasMountedHistory: true,
hasLiveHead: false,
},
renderers: createRenderers(vi.fn()),
listEmptyComponent: null,
viewportRef: React.createRef<StreamViewportHandle>(),
routeBottomAnchorRequest: null,
isAuthoritativeHistoryReady: true,
onNearBottomChange: vi.fn(),
onNearHistoryStart: vi.fn().mockReturnValue(true),
isLoadingOlderHistory: false,
hasOlderHistory: false,
olderHistoryProgressKey: null,
scrollEnabled: true,
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
}),
);
});
const scrollContainer = container.querySelector<HTMLElement>(
'[data-testid="agent-chat-scroll"]',
);
expect(scrollContainer?.style.scrollbarWidth).toBe("none");
expect(scrollContainer?.dataset.overlayScrollbar).toBe("true");
});
it("rerenders a stable live-head row when its revision changes", () => {
const strategy = createWebStreamStrategy({ isMobileBreakpoint: false });
const viewportRef = React.createRef<StreamViewportHandle>();
+60 -35
View File
@@ -14,6 +14,7 @@ import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useStableEvent } from "@/hooks/use-stable-event";
import type { Theme } from "@/styles/theme";
import { WEB_SCROLLBAR_SIZE_PX } from "@/styles/web-scrollbar";
import { DomOverlayScrollbar } from "@/components/ui/overlay-scrollbar/dom-overlay-scrollbar";
import { estimateStreamItemHeight } from "./web-virtualization";
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy";
import { createStreamStrategy } from "./strategy";
@@ -1134,14 +1135,25 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
};
}, [isMobileBreakpoint]);
const scrollContainerStyle = useMemo((): CSSProperties => {
const overlayScrollbarEnabled = scrollEnabled && !isMobileBreakpoint;
return {
flex: 1,
minHeight: 0,
width: "100%",
height: "100%",
overflowX: "hidden",
overflowY: scrollEnabled ? "auto" : "hidden",
overscrollBehaviorY: "contain",
scrollbarWidth: overlayScrollbarEnabled ? "none" : undefined,
};
}, [scrollEnabled]);
}, [isMobileBreakpoint, scrollEnabled]);
const viewportStyle = useMemo(
(): CSSProperties => ({
position: "relative",
flex: 1,
minWidth: 0,
minHeight: 0,
}),
[],
);
const virtualRowsContainerStyle = useMemo((): CSSProperties => {
return {
position: "relative",
@@ -1202,39 +1214,52 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
!liveAuxiliary;
return (
<div
ref={handleScrollContainerRef}
data-testid="agent-chat-scroll"
id={`agent-chat-scroll-${shouldUseVirtualizer ? "web-dom-virtualized" : "web-dom-scroll"}`}
style={scrollContainerStyle}
>
<div ref={handleContentRef} style={contentContainerStyle}>
{historyStartSlot}
{shouldUseVirtualizer ? (
<div style={virtualRowsContainerStyle}>
{virtualRows.map((virtualRow) => {
const item = segments.historyVirtualized[virtualRow.index];
if (!item) {
return null;
}
return (
<div
key={virtualRow.key}
data-index={virtualRow.index}
data-history-row-id={item.id}
ref={measureVirtualizedRowElement}
style={renderVirtualRowStyle(virtualRow.start)}
>
{renderHistoryVirtualizedRow(item, virtualRow.index, segments.historyVirtualized)}
</div>
);
})}
</div>
) : null}
{mountedRows}
{liveAuxiliary}
{shouldRenderEmpty ? listEmptyComponent : null}
<div style={viewportStyle}>
<div
ref={handleScrollContainerRef}
data-testid="agent-chat-scroll"
data-overlay-scrollbar={scrollEnabled && !isMobileBreakpoint ? "true" : undefined}
id={`agent-chat-scroll-${shouldUseVirtualizer ? "web-dom-virtualized" : "web-dom-scroll"}`}
style={scrollContainerStyle}
>
<div ref={handleContentRef} style={contentContainerStyle}>
{historyStartSlot}
{shouldUseVirtualizer ? (
<div style={virtualRowsContainerStyle}>
{virtualRows.map((virtualRow) => {
const item = segments.historyVirtualized[virtualRow.index];
if (!item) {
return null;
}
return (
<div
key={virtualRow.key}
data-index={virtualRow.index}
data-history-row-id={item.id}
ref={measureVirtualizedRowElement}
style={renderVirtualRowStyle(virtualRow.start)}
>
{renderHistoryVirtualizedRow(
item,
virtualRow.index,
segments.historyVirtualized,
)}
</div>
);
})}
</div>
) : null}
{mountedRows}
{liveAuxiliary}
{shouldRenderEmpty ? listEmptyComponent : null}
</div>
</div>
{scrollEnabled && !isMobileBreakpoint ? (
<DomOverlayScrollbar
scrollContainerRef={scrollContainerRef}
onUserScrollUp={stopFollowingOutputFromUserIntent}
/>
) : null}
</div>
);
}