fix(ui): remove blink and shift in desktop sidebar sticky headers

- Drive the sticky-fade identity overlay's visibility synchronously from the scroller's top-scroll attribute (CSS :has), and seed its leading project synchronously, so it appears in the same frame the mask hides the real header instead of a React commit later (removes the one-frame blink)

- Match the overlay's vertical box to the real header rows (py-1 instead of a centered h-7) so the identity does not shift a few pixels on the sticky handoff
This commit is contained in:
Bohdan Triapitsyn
2026-07-30 18:46:53 +03:00
parent 8130381f6c
commit 5328245b63
2 changed files with 34 additions and 13 deletions
@@ -98,7 +98,6 @@ type Props = {
function SidebarProjectsListComponent(props: Props): React.ReactNode {
streamPerfCount('ui.sidebar_projects_list.render');
const { t } = useI18n();
const [hasTopScroll, setHasTopScroll] = React.useState(false);
const enableStickyFade = props.isDesktopShellRuntime && props.stickyZoneHeaders;
const projectSensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
@@ -152,7 +151,6 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
'--scroll-shadow-top-clear-size',
`${Math.min(Math.max(topFadeSize - 8, 0), TOP_FADE_CLEAR_MAX_SIZE)}px`,
);
setHasTopScroll((prev) => (prev === hasTopScroll ? prev : hasTopScroll));
}, []);
const blockObscuredInteraction = React.useCallback((
event: React.MouseEvent<HTMLDivElement> | React.PointerEvent<HTMLDivElement>,
@@ -175,7 +173,15 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
stuckProject = section.project;
}
}
const stickyProjectLabel = stuckProject ? getProjectLabel(stuckProject, props.homeDirectory) : null;
// The IntersectionObserver reports the stuck header asynchronously, a frame or
// two after the (synchronous) mask 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
// lead the list, the Recent fallback below owns the top instead of a project.
const leadingProject =
stuckProject ?? (props.hasSharedSessions ? null : props.sectionsForRender[0]?.project ?? null);
const leadingProjectLabel = leadingProject ? getProjectLabel(leadingProject, props.homeDirectory) : null;
if (props.sharedSessionsOnly) {
return (
@@ -201,7 +207,7 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
// like they insert upward. With anchoring off, scrollTop stays put and new
// rows appear below naturally.
<div
className="relative flex min-h-0 flex-1"
className="oc-sticky-fade-root relative flex min-h-0 flex-1"
onPointerDownCapture={enableStickyFade ? blockObscuredInteraction : undefined}
onClickCapture={enableStickyFade ? blockObscuredInteraction : undefined}
onContextMenuCapture={enableStickyFade ? blockObscuredInteraction : undefined}
@@ -371,19 +377,19 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
</DndContext>
)}
</ScrollableOverlay>
{enableStickyFade && hasTopScroll && (stuckProject || props.hasSharedSessions) ? (
{enableStickyFade && (leadingProject || props.hasSharedSessions) ? (
<div
className="pointer-events-none absolute inset-x-0 top-0 z-30 flex h-7 items-center gap-1.5 pl-4 pr-5"
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"
aria-hidden="true"
>
{stuckProject && stickyProjectLabel ? (
{leadingProject && leadingProjectLabel ? (
<ProjectHeaderIdentity
id={stuckProject.id}
projectLabel={stickyProjectLabel}
projectIcon={stuckProject.icon}
projectColor={stuckProject.color}
projectIconImage={stuckProject.iconImage}
projectIconBackground={stuckProject.iconBackground}
id={leadingProject.id}
projectLabel={leadingProjectLabel}
projectIcon={leadingProject.icon}
projectColor={leadingProject.color}
projectIconImage={leadingProject.iconImage}
projectIconBackground={leadingProject.iconBackground}
/>
) : (
<>
+15
View File
@@ -348,6 +348,21 @@ div[data-chat-input-footer="true"] {
);
}
/* Sticky-fade overlay (desktop sidebar): 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).
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-sidebar-scroller[data-top-scroll="true"]) .oc-sticky-fade-overlay,
.oc-sticky-fade-root:has(.oc-sidebar-scroller[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,