fix(ui): smooth desktop sidebar sticky transitions (#2528)

* fix(ui): blend vibrant sidebar transitions

* fix(ui): fade sidebar content below header

* fix(ui): smooth vibrant sidebar sticky headers

* fix(ui): scope sticky fade to desktop

* fix(ui): respect sticky header preference
This commit is contained in:
ChangeHow
2026-07-30 17:51:23 +03:00
committed by GitHub
parent 83a66d0a6d
commit 8130381f6c
8 changed files with 221 additions and 162 deletions
@@ -13,12 +13,13 @@ import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { formatDirectoryName, formatPathForDisplay, cn } from '@/lib/utils';
import type { SessionGroup } from './types';
import type { SortableDragHandleProps } from './sortableItems';
import { SortableGroupItem, SortableProjectItem } from './sortableItems';
import { ProjectHeaderIdentity, SortableGroupItem, SortableProjectItem } from './sortableItems';
import { formatProjectLabel } from './utils';
import { useI18n } from '@/lib/i18n';
import type { MainTab } from '@/stores/useUIStore';
import type { ProjectSortOrder } from '@/stores/useSessionDisplayStore';
import { streamPerfCount } from '@/stores/utils/streamDebug';
import { Icon } from '@/components/icon/Icon';
type ProjectSection = {
project: {
@@ -33,6 +34,18 @@ type ProjectSection = {
groups: SessionGroup[];
};
const TOP_FADE_MAX_SIZE = 48;
const TOP_FADE_MIN_SIZE = 32;
const TOP_FADE_CLEAR_MAX_SIZE = 24;
const getProjectLabel = (project: ProjectSection['project'], homeDirectory: string | null): string => (
formatProjectLabel(
project.label?.trim()
|| formatDirectoryName(project.normalizedPath, homeDirectory)
|| project.normalizedPath,
)
);
type Props = {
topContent?: React.ReactNode;
sharedSessionsOnly?: boolean;
@@ -61,6 +74,7 @@ type Props = {
hideDirectoryControls: boolean;
projectRepoStatus: Map<string, boolean | null>;
isDesktopShellRuntime: boolean;
stickyZoneHeaders: boolean;
stuckProjectHeaders: Set<string>;
mobileVariant: boolean;
alwaysShowActions: boolean;
@@ -84,6 +98,8 @@ 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 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
@@ -120,6 +136,46 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
// can resolve the scrolling ancestor synchronously (no getComputedStyle
// walk) and skip the cost of a style recalc on every render.
const scrollContainerRef = React.useRef<HTMLElement | null>(null);
// 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
// render boundary only when the sticky identity overlay appears or hides.
const syncTopFade = React.useCallback((scroller: HTMLElement) => {
const hasTopScroll = scroller.scrollTop > 1;
const topFadeSize = hasTopScroll
? 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(
'--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>,
) => {
if ((event.target as Element).closest('[data-overlay-scrollbar-thumb], [data-sidebar-sticky-header]')) return;
const eventY = event.clientY - event.currentTarget.getBoundingClientRect().top;
if (eventY >= topFadeSizeRef.current) return;
event.preventDefault();
event.stopPropagation();
}, []);
const hasProjectScroller = props.projectSections.length > 0 && props.sectionsForRender.length > 0;
React.useLayoutEffect(() => {
if (enableStickyFade && hasProjectScroller && scrollContainerRef.current) {
syncTopFade(scrollContainerRef.current);
}
}, [enableStickyFade, hasProjectScroller, syncTopFade]);
let stuckProject: ProjectSection['project'] | null = null;
for (const section of props.projectSections) {
if (props.stuckProjectHeaders.has(section.project.id)) {
stuckProject = section.project;
}
}
const stickyProjectLabel = stuckProject ? getProjectLabel(stuckProject, props.homeDirectory) : null;
if (props.sharedSessionsOnly) {
return (
@@ -144,7 +200,22 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
// button) and holds it in place, which makes newly revealed sessions look
// like they insert upward. With anchoring off, scrollTop stays put and new
// rows appear below naturally.
<ScrollableOverlay ref={scrollContainerRef} useScrollShadow hideTopScrollShadow scrollShadowSize={96} outerClassName="flex-1 min-h-0" className={cn('oc-sidebar-scroller space-y-1.5 pb-1 pl-2.5 pr-2 [overflow-anchor:none]', props.mobileVariant ? '' : '')}>
<div
className="relative flex min-h-0 flex-1"
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 space-y-1.5 pb-1 pl-2.5 pr-2 [overflow-anchor:none]', props.mobileVariant ? '' : '')}
style={enableStickyFade ? { '--scroll-shadow-top-size': '0px' } as React.CSSProperties : undefined}
onScroll={enableStickyFade ? (event) => syncTopFade(event.currentTarget) : undefined}
>
{props.topContent}
{props.showOnlyMainWorkspace ? (
<div className="space-y-[0.6rem] py-1">
@@ -198,14 +269,9 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
{props.sectionsForRender.map((section) => {
const project = section.project;
const projectKey = project.id;
const projectLabel = formatProjectLabel(
project.label?.trim()
|| formatDirectoryName(project.normalizedPath, props.homeDirectory)
|| project.normalizedPath,
);
const projectLabel = getProjectLabel(project, props.homeDirectory);
const projectDescription = formatPathForDisplay(project.normalizedPath, props.homeDirectory);
const isCollapsed = props.collapsedProjects.has(projectKey);
const isActiveProject = projectKey === props.activeProjectId;
const isRepo = props.projectRepoStatus.get(projectKey);
return (
@@ -220,10 +286,8 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
projectIconImage={project.iconImage}
projectIconBackground={project.iconBackground}
isCollapsed={isCollapsed}
isActiveProject={isActiveProject}
isRepo={Boolean(isRepo)}
isDesktopShell={props.isDesktopShellRuntime}
isStuck={props.stuckProjectHeaders.has(projectKey)}
hideDirectoryControls={props.hideDirectoryControls}
mobileVariant={props.mobileVariant}
alwaysShowActions={props.alwaysShowActions}
@@ -307,6 +371,31 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
</DndContext>
)}
</ScrollableOverlay>
{enableStickyFade && hasTopScroll && (stuckProject || 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"
aria-hidden="true"
>
{stuckProject && stickyProjectLabel ? (
<ProjectHeaderIdentity
id={stuckProject.id}
projectLabel={stickyProjectLabel}
projectIcon={stuckProject.icon}
projectColor={stuckProject.color}
projectIconImage={stuckProject.iconImage}
projectIconBackground={stuckProject.iconBackground}
/>
) : (
<>
<Icon name="history" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground/80" />
<span className="truncate text-[14px] font-semibold lowercase text-foreground">
{t('sessions.sidebar.activity.recentTitle')}
</span>
</>
)}
</div>
) : null}
</div>
);
}