perf(ui): isolate scroll shadow effects

This commit is contained in:
c_w_xiaohei
2026-08-26 00:42:36 +08:00
parent dd6a632bc5
commit 9b9d7069c7
15 changed files with 283 additions and 189 deletions
@@ -628,6 +628,7 @@ const MobileDiffDetail: React.FC<{
<MobileChangesState icon message={t('mobile.changes.diffDetail.imageUnavailable')} />
) : (
<ScrollShadow
viewportClassName="h-full"
className="h-full overflow-y-auto overflow-x-hidden p-3"
data-diff-virtual-root
data-diff-virtual-content
+1 -1
View File
@@ -278,7 +278,7 @@ export const MobileFilesSurface: React.FC<MobileFilesSurfaceProps> = ({ onClose
</div>
</div>
<ScrollShadow className="min-h-0 flex-1 overflow-y-auto px-4 pb-3">
<ScrollShadow viewportClassName="min-h-0 flex-1" className="min-h-0 flex-1 overflow-y-auto px-4 pb-3">
{directoryError ? (
<MobileFilesState message={directoryError} />
) : query.trim() ? (
+1 -1
View File
@@ -1455,7 +1455,7 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
// clipped overflow swallowed the footer.
const surfaceContent = (
<div ref={contentRootRef} className="flex min-h-0 flex-1 flex-col">
<ScrollShadow className="min-h-0 flex-1 overflow-y-auto pb-4">
<ScrollShadow viewportClassName="min-h-0 flex-1" className="min-h-0 flex-1 overflow-y-auto pb-4">
{/* The search bar scrolls WITH the list (iOS-style): the open-time
auto-scroll to the current session naturally tucks it away, and
scrolling to the very top brings it back. */}
@@ -327,7 +327,8 @@ const ChatViewport = React.memo(({
>
<div className="absolute inset-0">
<ScrollShadow
className="absolute inset-0 overflow-y-auto overflow-x-hidden z-0 chat-scroll overlay-scrollbar-target"
viewportClassName="absolute inset-0"
className="min-h-0 w-full flex-1 overflow-y-auto overflow-x-hidden chat-scroll overlay-scrollbar-target"
ref={scrollRef}
style={CHAT_SCROLL_STYLE}
observeMutations={false}
@@ -335,7 +336,6 @@ const ChatViewport = React.memo(({
tabIndex={0}
onClick={focusScrollContainer}
onScroll={handleHistoryScroll}
data-scroll-shadow="true"
data-scrollbar="chat"
>
<div className="relative z-0 min-h-full">
@@ -242,6 +242,7 @@ export const WorkStatusPanel: React.FC<Props> = ({ sessionId, directory, visible
<WorkStatusPresenceProvider onChange={setRenderedSections}>
<ScrollShadow
ref={restore}
viewportClassName="min-h-0 flex-1 [--scroll-shadow-color:color-mix(in_srgb,var(--surface-muted)_var(--oc-glass-opacity),transparent)]"
onScroll={handleScroll}
size={24}
className="oc-hide-scrollbar min-h-0 flex-1 overflow-y-auto overflow-x-hidden p-2"
@@ -545,8 +545,9 @@ export const ModelPickerList: React.FC<ModelPickerListProps> = ({
? Math.min(STICKY_FADE_MIN_SIZE + scroller.scrollTop, STICKY_FADE_MAX_SIZE)
: 0;
stickyFadeSizeRef.current = fadeSize;
scroller.style.setProperty('--scroll-shadow-top-size', `${fadeSize}px`);
scroller.style.setProperty(
const fadeRoot = scroller.closest<HTMLElement>('.oc-sticky-fade-root');
fadeRoot?.style.setProperty('--scroll-shadow-top-size', `${fadeSize}px`);
fadeRoot?.style.setProperty(
'--scroll-shadow-top-clear-size',
`${Math.min(Math.max(fadeSize - 8, 0), STICKY_FADE_CLEAR_MAX_SIZE)}px`,
);
@@ -876,24 +877,23 @@ export const ModelPickerList: React.FC<ModelPickerListProps> = ({
<div
className="oc-sticky-fade-root relative flex min-h-0 flex-1"
// SAFETY: these custom properties configure the viewport-owned edge fade.
style={stickyHeaders ? { '--scroll-shadow-top-size': '0px' } as React.CSSProperties : undefined}
onPointerDownCapture={stickyHeaders ? blockStickyFadeInteraction : undefined}
onClickCapture={stickyHeaders ? blockStickyFadeInteraction : undefined}
onContextMenuCapture={stickyHeaders ? blockStickyFadeInteraction : undefined}
>
<ScrollableOverlay
ref={scrollRef}
useScrollShadow={stickyHeaders}
hideBottomScrollShadow
scrollShadowSize={12}
outerClassName={maxHeightClassName}
className="oc-sticky-fade-scroller overlay-scrollbar-target--no-gutter"
style={{
...(stickyHeaders ? { '--scroll-shadow-top-size': '0px' } as React.CSSProperties : {}),
...maxHeightStyle,
}}
onScroll={stickyHeaders ? (event) => syncStickyFade(event.currentTarget) : undefined}
>
<div className="px-1">
<ScrollableOverlay
ref={scrollRef}
useScrollShadow={stickyHeaders}
hideBottomScrollShadow
scrollShadowSize={12}
outerClassName={maxHeightClassName}
className="overlay-scrollbar-target--no-gutter"
style={maxHeightStyle}
onScroll={stickyHeaders ? (event) => syncStickyFade(event.currentTarget) : undefined}
>
<div className="px-1">
{includeNotSelected ? (
<>
<button
@@ -964,16 +964,16 @@ export const ModelPickerList: React.FC<ModelPickerListProps> = ({
</div>
))
)}
</div>
</ScrollableOverlay>
{stickyHeaders && leadingSectionKey ? (
<div
className="oc-sticky-fade-overlay pointer-events-none absolute inset-x-0 top-0 z-30 flex items-center gap-2 px-3 py-1.5 typography-micro font-semibold uppercase tracking-wider text-muted-foreground"
aria-hidden="true"
>
{renderSectionIdentity(leadingSectionKey)}
</div>
) : null}
</div>
</ScrollableOverlay>
{stickyHeaders && leadingSectionKey ? (
<div
className="oc-sticky-fade-overlay pointer-events-none absolute inset-x-0 top-0 z-30 flex items-center gap-2 px-3 py-1.5 typography-micro font-semibold uppercase tracking-wider text-muted-foreground"
aria-hidden="true"
>
{renderSectionIdentity(leadingSectionKey)}
</div>
) : null}
</div>
<div className="px-3 pt-1 pb-1.5 border-t border-border/40 typography-micro text-muted-foreground">
@@ -152,7 +152,7 @@ export const ModelMultiSelect: React.FC<ModelMultiSelectProps> = ({
// Find the nearest dialog or overflow ancestor to constrain within
let container: HTMLElement | null = triggerRef.current.parentElement;
while (container) {
if (container.getAttribute('role') === 'dialog' || container.hasAttribute('data-scroll-shadow')) {
if (container.matches('[role="dialog"], [data-scroll-shadow-scroller]')) {
break;
}
const style = getComputedStyle(container);
@@ -447,7 +447,7 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
</header>
) : null}
<ScrollShadow className="flex-1 min-h-0 overflow-auto" size={64} hideTopShadow>
<ScrollShadow viewportClassName="flex-1 min-h-0" className="flex-1 min-h-0 overflow-auto" size={64} hideTopShadow>
<div className="mx-auto w-full max-w-2xl px-4 sm:px-6 py-5">
<div className="flex flex-col gap-5">
@@ -1782,6 +1782,7 @@ export function ScheduledTaskEditorDialog(props: {
</header>
<ScrollShadow
viewportClassName="flex-1 min-h-0"
className="flex-1 min-h-0 overflow-auto [scrollbar-gutter:stable_both-edges]"
size={64}
hideTopShadow
@@ -10,7 +10,7 @@ import {
} from '@dnd-kit/core';
import { SortableContext, arrayMove, sortableKeyboardCoordinates, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { formatDirectoryName, formatPathForDisplay, cn } from '@/lib/utils';
import { formatDirectoryName, formatPathForDisplay } from '@/lib/utils';
import type { SessionGroup } from '../types';
import { ProjectHeaderIdentity, SortableGroupItem, SortableProjectItem } from './sortableItems';
import { SessionGroupSection, type SessionGroupSectionProps } from './SessionGroupSection';
@@ -155,7 +155,7 @@ function SessionProjectScrollerComponent(props: Props): React.ReactNode {
// Keep per-scroll measurements out of React state so the interaction guard
// can read the current fade boundary without rerendering the sidebar.
const topFadeSizeRef = React.useRef(0);
// Update the compositor-owned mask on every scroll, but cross the React
// Update the viewport-owned fade on every scroll, but cross the React
// render boundary only when the sticky identity overlay appears or hides.
const syncTopFade = React.useCallback((scroller: HTMLElement) => {
const hasTopScroll = scroller.scrollTop > 1;
@@ -163,8 +163,9 @@ function SessionProjectScrollerComponent(props: Props): React.ReactNode {
? Math.min(TOP_FADE_MIN_SIZE + scroller.scrollTop, TOP_FADE_MAX_SIZE)
: 0;
topFadeSizeRef.current = topFadeSize;
scroller.style.setProperty('--scroll-shadow-top-size', `${topFadeSize}px`);
scroller.style.setProperty(
const fadeRoot = scroller.closest<HTMLElement>('.oc-sticky-fade-root');
fadeRoot?.style.setProperty('--scroll-shadow-top-size', `${topFadeSize}px`);
fadeRoot?.style.setProperty(
'--scroll-shadow-top-clear-size',
`${Math.min(Math.max(topFadeSize - 8, 0), TOP_FADE_CLEAR_MAX_SIZE)}px`,
);
@@ -192,7 +193,7 @@ function SessionProjectScrollerComponent(props: Props): React.ReactNode {
}
}
// The IntersectionObserver reports the stuck header asynchronously, a frame or
// two after the (synchronous) mask has already hidden the real header — which
// two after the synchronous fade has already hidden the real header — which
// otherwise leaves a one-frame gap where the title blinks out with no crisp
// replacement. Seed the overlay with the topmost rendered project so it is
// ready in the same frame; the observer then corrects it. When shared sessions
@@ -202,11 +203,11 @@ function SessionProjectScrollerComponent(props: Props): React.ReactNode {
const leadingProjectLabel = leadingProject ? getProjectLabel(leadingProject, view.homeDirectory) : null;
if (model.projectSections.length === 0) {
return <ScrollableOverlay useScrollShadow scrollShadowSize={96} outerClassName="flex-1 min-h-0" className={cn('space-y-1 pb-1 pl-2.5 pr-2', view.mobileVariant ? '' : '')}>{model.topContent}{model.emptyState}</ScrollableOverlay>;
return <ScrollableOverlay useScrollShadow scrollShadowSize={96} outerClassName="flex-1 min-h-0" className="space-y-1 pb-1 pl-2.5 pr-2">{model.topContent}{model.emptyState}</ScrollableOverlay>;
}
if (model.sectionsForRender.length === 0) {
return <ScrollableOverlay useScrollShadow scrollShadowSize={96} outerClassName="flex-1 min-h-0" className={cn('space-y-1 pb-1 pl-2.5 pr-2', view.mobileVariant ? '' : '')}>{model.searchEmptyState}</ScrollableOverlay>;
return <ScrollableOverlay useScrollShadow scrollShadowSize={96} outerClassName="flex-1 min-h-0" className="space-y-1 pb-1 pl-2.5 pr-2">{model.searchEmptyState}</ScrollableOverlay>;
}
return (
@@ -217,21 +218,21 @@ function SessionProjectScrollerComponent(props: Props): React.ReactNode {
// rows appear below naturally.
<div
className="oc-sticky-fade-root relative flex min-h-0 flex-1"
// SAFETY: this custom property configures the viewport-owned edge fade.
style={enableStickyFade ? { '--scroll-shadow-top-size': '0px' } as React.CSSProperties : undefined}
onPointerDownCapture={enableStickyFade ? blockObscuredInteraction : undefined}
onClickCapture={enableStickyFade ? blockObscuredInteraction : undefined}
onContextMenuCapture={enableStickyFade ? blockObscuredInteraction : undefined}
>
<ScrollableOverlay
ref={scrollContainerRef}
useScrollShadow
hideTopScrollShadow={!enableStickyFade}
scrollShadowSize={96}
outerClassName="flex-1 min-h-0"
className={cn('oc-sidebar-scroller oc-sticky-fade-scroller space-y-1.5 pb-1 pl-2.5 pr-2 [overflow-anchor:none]', view.mobileVariant ? '' : '')}
// SAFETY: the custom property is the only dynamic CSS declaration here.
style={enableStickyFade ? { '--scroll-shadow-top-size': '0px' } as React.CSSProperties : undefined}
onScroll={enableStickyFade ? (event) => syncTopFade(event.currentTarget) : undefined}
>
<ScrollableOverlay
ref={scrollContainerRef}
useScrollShadow
hideTopScrollShadow={!enableStickyFade}
scrollShadowSize={96}
outerClassName="flex-1 min-h-0"
className="oc-sidebar-scroller space-y-1.5 pb-1 pl-2.5 pr-2 [overflow-anchor:none]"
onScroll={enableStickyFade ? (event) => syncTopFade(event.currentTarget) : undefined}
>
{model.topContent}
{view.showOnlyMainWorkspace ? (
<div className="space-y-[0.6rem] py-1">
@@ -372,7 +373,7 @@ function SessionProjectScrollerComponent(props: Props): React.ReactNode {
<DragOverlay dropAnimation={null} />
</DndContext>
)}
</ScrollableOverlay>
</ScrollableOverlay>
{enableStickyFade && (leadingProject || model.hasSharedSessions) ? (
<div
className="oc-sticky-fade-overlay pointer-events-none absolute inset-x-0 top-0 z-30 flex items-center gap-1.5 py-1 pl-4 pr-5"
@@ -0,0 +1,87 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { Window } from 'happy-dom';
import React, { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { ScrollShadow } from './ScrollShadow';
describe('ScrollShadow', () => {
let windowInstance: Window;
let host: HTMLDivElement;
let root: Root;
beforeEach(() => {
windowInstance = new Window();
Object.assign(globalThis, {
window: windowInstance,
document: windowInstance.document,
HTMLElement: windowInstance.HTMLElement,
Element: windowInstance.Element,
Node: windowInstance.Node,
IS_REACT_ACT_ENVIRONMENT: true,
});
host = document.createElement('div');
document.body.appendChild(host);
root = createRoot(host);
});
afterEach(async () => {
await act(async () => root.unmount());
windowInstance.close();
});
test('keeps edge effects outside the forwarded scroll element', async () => {
let scrollElement: HTMLElement | null = null;
let scrollTop = 0;
await act(async () => {
root.render(
<ScrollShadow
ref={(element) => { scrollElement = element; }}
viewportClassName="absolute inset-0"
observeMutations={false}
>
<div>content</div>
</ScrollShadow>,
);
});
const scroller = host.querySelector<HTMLElement>('[data-scroll-shadow-scroller]');
if (!scroller) throw new Error('ScrollShadow did not render its scroll element');
expect(scrollElement).toBe(scroller);
Object.defineProperties(scroller, {
clientHeight: { configurable: true, get: () => 100 },
scrollHeight: { configurable: true, get: () => 500 },
scrollTop: {
configurable: true,
get: () => scrollTop,
set: (value: number) => { scrollTop = value; },
},
});
const viewport = scroller.parentElement;
expect(viewport?.hasAttribute('data-scroll-shadow-viewport')).toBe(true);
expect(viewport?.classList.contains('absolute')).toBe(true);
expect(viewport?.classList.contains('relative')).toBe(false);
expect(scroller.hasAttribute('data-scroll-shadow-scroller')).toBe(true);
expect(viewport?.style.getPropertyValue('--scroll-shadow-size')).toBe('48px');
await act(async () => scroller.dispatchEvent(new window.Event('scroll')));
expect(viewport?.getAttribute('data-bottom-scroll')).toBe('true');
scrollTop = 200;
await act(async () => scroller.dispatchEvent(new window.Event('scroll')));
expect(viewport?.getAttribute('data-top-bottom-scroll')).toBe('true');
expect(viewport?.hasAttribute('data-bottom-scroll')).toBe(false);
let attributeWrites = 0;
if (!viewport) throw new Error('ScrollShadow did not render its viewport');
const setAttribute = viewport.setAttribute.bind(viewport);
viewport.setAttribute = (name, value) => {
attributeWrites += 1;
setAttribute(name, value);
};
await act(async () => scroller.dispatchEvent(new window.Event('scroll')));
expect(attributeWrites).toBe(0);
});
});
+73 -65
View File
@@ -1,7 +1,9 @@
import React from "react";
import { cn } from "@/lib/utils";
export type ScrollShadowProps = React.HTMLAttributes<HTMLElement> & {
as?: React.ElementType;
viewportClassName?: string;
orientation?: "vertical" | "horizontal";
offset?: number;
size?: number;
@@ -12,22 +14,23 @@ export type ScrollShadowProps = React.HTMLAttributes<HTMLElement> & {
onVisibilityChange?: (state: "both" | "none" | "top" | "bottom" | "left" | "right") => void;
};
function mergeRefs<T>(...refs: Array<React.Ref<T>>): React.RefCallback<T> {
return (value) => {
refs.forEach((ref) => {
if (typeof ref === "function") {
ref(value);
} else if (ref && typeof ref === "object") {
(ref as React.MutableRefObject<T | null>).current = value;
}
});
};
}
type EdgeState = "both" | "none" | "top" | "bottom" | "left" | "right";
type ScrollShadowViewportStyle = React.CSSProperties & { "--scroll-shadow-size": string };
const EDGE_ATTRIBUTES = [
"data-top-scroll",
"data-bottom-scroll",
"data-top-bottom-scroll",
"data-left-scroll",
"data-right-scroll",
"data-left-right-scroll",
] as const;
export const ScrollShadow = React.forwardRef<HTMLElement, ScrollShadowProps>(
(
(
{
as: Component = "div",
viewportClassName,
orientation = "vertical",
offset = 0,
size = 48,
@@ -40,52 +43,46 @@ export const ScrollShadow = React.forwardRef<HTMLElement, ScrollShadowProps>(
className,
children,
...rest
},
},
ref,
) => {
const internalRef = React.useRef<HTMLElement>(null);
const visibleRef = React.useRef<"both" | "none" | "top" | "bottom" | "left" | "right">("none");
const viewportRef = React.useRef<HTMLDivElement>(null);
const visibleRef = React.useRef<EdgeState>("none");
const edgeStateRef = React.useRef("");
React.useImperativeHandle(ref, () => {
const element = internalRef.current;
if (!element) throw new Error("ScrollShadow scroll element is unavailable");
return element;
}, []);
const dataScrollShadow = (rest as Record<string, unknown>)["data-scroll-shadow"];
delete (rest as Record<string, unknown>)["data-scroll-shadow"];
const mergedStyle = React.useMemo<React.CSSProperties>(() => {
const next: React.CSSProperties = {
...(style as React.CSSProperties),
};
(next as Record<string, string>)["--scroll-shadow-size"] = `${size}px`;
return next;
}, [size, style]);
const setAttributes = React.useCallback(
(el: HTMLElement, hasBefore: boolean, hasAfter: boolean, prefix: "top" | "left", suffix: "bottom" | "right") => {
const bothKey = `${prefix}${suffix.charAt(0).toUpperCase()}${suffix.slice(1)}Scroll` as const;
if (hasBefore && hasAfter) {
(el.dataset as Record<string, string>)[bothKey] = "true";
el.removeAttribute(`data-${prefix}-scroll`);
el.removeAttribute(`data-${suffix}-scroll`);
} else {
el.dataset[`${prefix}Scroll`] = String(hasBefore);
el.dataset[`${suffix}Scroll`] = String(hasAfter);
el.removeAttribute(`data-${prefix}-${suffix}-scroll`);
}
},
[],
const viewportStyle = React.useMemo<ScrollShadowViewportStyle>(
() => ({ "--scroll-shadow-size": `${size}px` }),
[size],
);
const clearAttributes = React.useCallback((el: HTMLElement) => {
["top", "bottom", "top-bottom", "left", "right", "left-right"].forEach((attr) => {
el.removeAttribute(`data-${attr}-scroll`);
});
EDGE_ATTRIBUTES.forEach((attribute) => el.removeAttribute(attribute));
}, []);
const setEdgeAttribute = React.useCallback((el: HTMLElement, state: EdgeState) => {
clearAttributes(el);
if (state === "none") return;
const attribute = state === "both"
? orientation === "vertical" ? "data-top-bottom-scroll" : "data-left-right-scroll"
: `data-${state}-scroll`;
el.setAttribute(attribute, "true");
}, [clearAttributes, orientation]);
const checkOverflow = React.useCallback(() => {
const el = internalRef.current;
if (!el) return;
const viewport = viewportRef.current;
if (!el || !viewport) return;
if (!isEnabled) {
clearAttributes(el);
clearAttributes(viewport);
edgeStateRef.current = "";
visibleRef.current = "none";
return;
}
@@ -97,25 +94,29 @@ export const ScrollShadow = React.forwardRef<HTMLElement, ScrollShadowProps>(
orientation === "vertical"
? el.scrollTop > offset + SUBPIXEL_TOLERANCE
: el.scrollLeft > offset + SUBPIXEL_TOLERANCE;
let hasAfter =
const hasAfter =
orientation === "vertical"
? el.scrollHeight - (el.scrollTop + el.clientHeight) > offset + SUBPIXEL_TOLERANCE
: el.scrollWidth - (el.scrollLeft + el.clientWidth) > offset + SUBPIXEL_TOLERANCE;
const effectiveHasBefore = hideTopShadow && orientation === "vertical" ? false : hasBefore;
if (hideBottomShadow && orientation === "vertical") {
hasAfter = false;
const effectiveHasBefore = hasBefore && !(orientation === "vertical" && hideTopShadow);
const effectiveHasAfter = hasAfter && !(orientation === "vertical" && hideBottomShadow);
const beforeEdge = orientation === "vertical" ? "top" : "left";
const afterEdge = orientation === "vertical" ? "bottom" : "right";
let next: EdgeState = "none";
if (effectiveHasBefore && effectiveHasAfter) next = "both";
else if (effectiveHasBefore) next = beforeEdge;
else if (effectiveHasAfter) next = afterEdge;
const edgeState = `${orientation}:${next}`;
if (edgeState !== edgeStateRef.current) {
edgeStateRef.current = edgeState;
setEdgeAttribute(viewport, next);
}
setAttributes(el, effectiveHasBefore, hasAfter, orientation === "vertical" ? "top" : "left", orientation === "vertical" ? "bottom" : "right");
const next = effectiveHasBefore && hasAfter ? "both" : effectiveHasBefore ? (orientation === "vertical" ? "top" : "left") : hasAfter ? (orientation === "vertical" ? "bottom" : "right") : "none";
if (next !== visibleRef.current) {
visibleRef.current = next;
onVisibilityChange?.(next);
}
}, [clearAttributes, hideTopShadow, hideBottomShadow, isEnabled, offset, onVisibilityChange, orientation, setAttributes]);
}, [clearAttributes, hideTopShadow, hideBottomShadow, isEnabled, offset, onVisibilityChange, orientation, setEdgeAttribute]);
React.useEffect(() => {
const el = internalRef.current;
@@ -132,9 +133,9 @@ export const ScrollShadow = React.forwardRef<HTMLElement, ScrollShadowProps>(
};
const handleScroll = () => checkOverflow(); // Scroll should be immediate
const resizeObserver = typeof ResizeObserver !== "undefined" ? new ResizeObserver(throttledCheck) : null;
const resizeObserver = "ResizeObserver" in globalThis ? new ResizeObserver(throttledCheck) : null;
const mutationObserver =
observeMutations && typeof MutationObserver !== "undefined" ? new MutationObserver(throttledCheck) : null;
observeMutations && "MutationObserver" in globalThis ? new MutationObserver(throttledCheck) : null;
checkOverflow();
@@ -157,16 +158,23 @@ export const ScrollShadow = React.forwardRef<HTMLElement, ScrollShadowProps>(
}, [checkOverflow, observeMutations]);
return (
<Component
{...rest}
ref={mergeRefs(internalRef, ref)}
className={className}
<div
ref={viewportRef}
className={cn("relative flex min-h-0 min-w-0 flex-col", viewportClassName)}
data-scroll-shadow-viewport
data-orientation={orientation}
data-scroll-shadow={dataScrollShadow ?? true}
style={mergedStyle}
style={viewportStyle}
>
{children}
</Component>
<Component
{...rest}
ref={internalRef}
className={className}
data-scroll-shadow-scroller
style={style}
>
{children}
</Component>
</div>
);
},
);
@@ -47,6 +47,16 @@ export const ScrollableOverlay = React.forwardRef<HTMLElement, ScrollableOverlay
...rest
}, ref) => {
const containerRef = React.useRef<HTMLElement | null>(null);
const containerSizeClassName = fillContainer
? "flex-1 min-h-0 w-full"
: "flex-none w-full h-auto";
const containerClassName = cn(
"overlay-scrollbar-target overlay-scrollbar-container",
preventOverscroll && "overscroll-none",
containerSizeClassName,
disableHorizontal ? "overflow-y-auto overflow-x-hidden" : "overflow-auto",
className,
);
React.useImperativeHandle(ref, () => containerRef.current as HTMLElement, []);
@@ -62,16 +72,11 @@ export const ScrollableOverlay = React.forwardRef<HTMLElement, ScrollableOverlay
<ScrollShadow
as={Component}
ref={containerRef as React.Ref<HTMLElement>}
viewportClassName={containerSizeClassName}
size={scrollShadowSize}
hideTopShadow={hideTopScrollShadow}
hideBottomShadow={hideBottomScrollShadow}
className={cn(
"overlay-scrollbar-target overlay-scrollbar-container",
preventOverscroll && "overscroll-none",
fillContainer ? "flex-1 min-h-0 w-full" : "flex-none w-full h-auto",
disableHorizontal ? "overflow-y-auto overflow-x-hidden" : "overflow-auto",
className
)}
className={containerClassName}
style={style as React.CSSProperties}
observeMutations={observeMutations}
{...rest}
@@ -81,13 +86,7 @@ export const ScrollableOverlay = React.forwardRef<HTMLElement, ScrollableOverlay
) : (
<Component
ref={containerRef as React.Ref<HTMLElement>}
className={cn(
"overlay-scrollbar-target overlay-scrollbar-container",
preventOverscroll && "overscroll-none",
fillContainer ? "flex-1 min-h-0 w-full" : "flex-none w-full h-auto",
disableHorizontal ? "overflow-y-auto overflow-x-hidden" : "overflow-auto",
className
)}
className={containerClassName}
style={style}
{...rest}
>
@@ -488,6 +488,7 @@ export const ChangesPanel: React.FC<ChangesPanelProps> = ({
<div className="relative flex h-full min-h-0 w-full flex-col overflow-hidden">
<ScrollShadow
ref={scrollRef}
viewportClassName="min-h-0 w-full flex-1"
className="overlay-scrollbar-target overlay-scrollbar-container min-h-0 w-full flex-1 overflow-x-hidden overflow-y-auto"
>
{shouldVirtualize ? (
+54 -59
View File
@@ -256,86 +256,81 @@ div[data-chat-input-footer="true"] {
-webkit-appearance: none;
}
/* Scroll shadow (HeroUI) fallback styling to ensure visible gradients without the Tailwind plugin */
[data-scroll-shadow="true"] {
--scroll-shadow-size: var(--scroll-shadow-size, 48px);
/* Scroll edge fades live on the viewport, never on the scrolling content. */
[data-scroll-shadow-viewport]::before,
[data-scroll-shadow-viewport]::after {
content: "";
pointer-events: none;
position: absolute;
z-index: 10;
opacity: 0;
}
[data-scroll-shadow="true"][data-orientation="vertical"] {
mask-mode: alpha;
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
-webkit-mask-size: 100% 100%;
mask-size: 100% 100%;
[data-scroll-shadow-viewport][data-orientation="vertical"]::before,
[data-scroll-shadow-viewport][data-orientation="vertical"]::after {
inset-inline: 0;
}
[data-scroll-shadow="true"][data-orientation="vertical"][data-top-bottom-scroll="true"] {
mask-image: linear-gradient(
[data-scroll-shadow-viewport][data-orientation="vertical"]::before {
top: 0;
height: var(--scroll-shadow-top-size, var(--scroll-shadow-size));
background: linear-gradient(
to bottom,
transparent 0%,
transparent var(--scroll-shadow-top-clear-size, 0px),
#000 var(--scroll-shadow-top-size, var(--scroll-shadow-size)),
#000 calc(100% - var(--scroll-shadow-size)),
transparent 100%
);
-webkit-mask-image: linear-gradient(
to bottom,
transparent 0%,
transparent var(--scroll-shadow-top-clear-size, 0px),
#000 var(--scroll-shadow-top-size, var(--scroll-shadow-size)),
#000 calc(100% - var(--scroll-shadow-size)),
var(--scroll-shadow-color, var(--surface-background)) 0,
var(--scroll-shadow-color, var(--surface-background)) var(--scroll-shadow-top-clear-size, 0px),
transparent 100%
);
}
[data-scroll-shadow="true"][data-orientation="vertical"][data-top-scroll="true"] {
mask-image: linear-gradient(
to bottom,
transparent 0%,
transparent var(--scroll-shadow-top-clear-size, 0px),
#000 var(--scroll-shadow-top-size, var(--scroll-shadow-size)),
#000 100%
);
-webkit-mask-image: linear-gradient(
to bottom,
transparent 0%,
transparent var(--scroll-shadow-top-clear-size, 0px),
#000 var(--scroll-shadow-top-size, var(--scroll-shadow-size)),
#000 100%
);
[data-scroll-shadow-viewport][data-orientation="vertical"]::after {
bottom: 0;
height: var(--scroll-shadow-size);
background: linear-gradient(to top, var(--scroll-shadow-color, var(--surface-background)), transparent);
}
[data-scroll-shadow-viewport][data-orientation="horizontal"]::before,
[data-scroll-shadow-viewport][data-orientation="horizontal"]::after {
inset-block: 0;
width: var(--scroll-shadow-size);
}
[data-scroll-shadow-viewport][data-orientation="horizontal"]::before {
left: 0;
background: linear-gradient(to right, var(--scroll-shadow-color, var(--surface-background)), transparent);
}
[data-scroll-shadow-viewport][data-orientation="horizontal"]::after {
right: 0;
background: linear-gradient(to left, var(--scroll-shadow-color, var(--surface-background)), transparent);
}
[data-scroll-shadow-viewport][data-top-scroll="true"]::before,
[data-scroll-shadow-viewport][data-top-bottom-scroll="true"]::before,
[data-scroll-shadow-viewport][data-bottom-scroll="true"]::after,
[data-scroll-shadow-viewport][data-top-bottom-scroll="true"]::after,
[data-scroll-shadow-viewport][data-left-scroll="true"]::before,
[data-scroll-shadow-viewport][data-left-right-scroll="true"]::before,
[data-scroll-shadow-viewport][data-right-scroll="true"]::after,
[data-scroll-shadow-viewport][data-left-right-scroll="true"]::after {
opacity: 1;
}
/* Sticky-fade overlay: the crisp duplicate identity stays
mounted and its visibility is driven synchronously by the scroller's own
top-scroll data attribute the same signal that engages the mask so the
overlay appears in the exact frame the mask hides the real header (no blink).
mounted and its visibility is driven synchronously by the viewport's
top-scroll data attribute, so the overlay appears in the exact frame the
edge fade covers the real header (no blink).
ScrollShadow sets data-top-scroll when nothing is below the viewport and
data-top-bottom-scroll when content is both above and below. */
.oc-sticky-fade-overlay {
opacity: 0;
}
.oc-sticky-fade-root:has(.oc-sticky-fade-scroller[data-top-scroll="true"]) .oc-sticky-fade-overlay,
.oc-sticky-fade-root:has(.oc-sticky-fade-scroller[data-top-bottom-scroll="true"]) .oc-sticky-fade-overlay {
.oc-sticky-fade-root:has(
[data-scroll-shadow-viewport]:is([data-top-scroll="true"], [data-top-bottom-scroll="true"])
) .oc-sticky-fade-overlay {
opacity: 1;
}
[data-scroll-shadow="true"][data-orientation="vertical"][data-bottom-scroll="true"] {
mask-image: linear-gradient(
to bottom,
#000 0%,
#000 calc(100% - var(--scroll-shadow-size)),
transparent 100%
);
-webkit-mask-image: linear-gradient(
to bottom,
#000 0%,
#000 calc(100% - var(--scroll-shadow-size)),
transparent 100%
);
}
@keyframes spin-once {
from {
transform: rotate(0deg);