diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 674a8c3ef8f5..f790969baf7b 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -285,6 +285,9 @@ function WorkingDuration(props: { startedAt: string | null }) { } const EMPTY_PROVIDER_ENTRIES: ReadonlyMap = new Map(); +// Collapsed shelves share one empty list so a route change alone does not +// give the sidebar list a new identity. +const EMPTY_THREADS: readonly EnvironmentThreadShell[] = []; function terminalProcessLabel(count: number): string { return `${count} terminal ${count === 1 ? "process" : "processes"} running`; @@ -1535,7 +1538,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { data-thread-item {...sortableRootProps} className={cn( - "list-none [content-visibility:auto] [contain-intrinsic-size:auto_34px]", + // Matches the h-9 row so unrendered rows never shift the list when they paint. + "list-none [content-visibility:auto] [contain-intrinsic-size:auto_36px]", sortable?.isDragging && "relative z-20", )} > @@ -1693,7 +1697,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { data-thread-item {...sortableRootProps} className={cn( - "list-none py-0.5 [content-visibility:auto] [contain-intrinsic-size:auto_96px]", + // Matches the h-[4.875rem] content box; the py-0.5 padding is added on top. + "list-none py-0.5 [content-visibility:auto] [contain-intrinsic-size:auto_78px]", sortable?.isDragging && "relative z-20", )} > @@ -2664,12 +2669,12 @@ export default function Sidebar() { ); const renderedSettledThreads = useMemo(() => { if (settledShelfExpanded) return visibleSettledThreads; - if (routeThreadKey === null) return []; + if (routeThreadKey === null) return EMPTY_THREADS; const routeThread = visibleSettledThreads.find( (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeThreadKey, ); - return routeThread === undefined ? [] : [routeThread]; + return routeThread === undefined ? EMPTY_THREADS : [routeThread]; }, [routeThreadKey, settledShelfExpanded, visibleSettledThreads]); // The snoozed shelf is collapsed by default: out of the way, never gone. @@ -2690,12 +2695,12 @@ export default function Sidebar() { // snoozed thread reached by route (deep link, open before snoozing // elsewhere) keeps its row — with highlight and wake affordance — same // exception the settled tail's "Show more" makes. - if (routeThreadKey === null) return []; + if (routeThreadKey === null) return EMPTY_THREADS; const routeThread = snoozedThreads.find( (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeThreadKey, ); - return routeThread === undefined ? [] : [routeThread]; + return routeThread === undefined ? EMPTY_THREADS : [routeThread]; }, [routeThreadKey, snoozedShelfExpanded, snoozedThreads]); const orderedThreads = useMemo( @@ -3282,15 +3287,32 @@ export default function Sidebar() { } }, [cancelThreadDrag, dragState, sidebarListItems]); const listMotionPaused = dragState !== null; + // Every shell event rebuilds sidebarListItems, but rows only move when the + // rendered order or a row's section changes. Keying the motion pass on that + // keeps ordinary updates from forcing a layout read and animating rows + // whose position drifted for other reasons. + const sidebarListOrderKey = useMemo( + () => + sidebarListItems + .map((item) => (item.kind === "thread" ? `${item.key}:${item.section}` : item.marker)) + .join("\0"), + [sidebarListItems], + ); + const sidebarListHasRows = sidebarListItems.length + visibleDraftSessionCount > 0; useLayoutEffect(() => { // Drag release clears the baseline, so its commit cannot replay the // sortable preview; rows glide from their released positions instead. // Later thread actions can animate while writes settle. // Draft navigation can reveal a frozen row without changing the draft count. - listMotionRef.current?.update( - !listMotionPaused && sidebarListItems.length + visibleDraftSessionCount > 0, - ); - }, [listMotionPaused, routeDraftIdForRows, sidebarListItems, visibleDraftSessionCount]); + void sidebarListOrderKey; + listMotionRef.current?.update(!listMotionPaused && sidebarListHasRows); + }, [ + listMotionPaused, + routeDraftIdForRows, + sidebarListHasRows, + sidebarListOrderKey, + visibleDraftSessionCount, + ]); const handleThreadDragOver = useCallback( (event: DragOverEvent) => { const target = event.over diff --git a/apps/web/src/components/ui/scroll-area.tsx b/apps/web/src/components/ui/scroll-area.tsx index bfc10825b460..852a3ed10053 100644 --- a/apps/web/src/components/ui/scroll-area.tsx +++ b/apps/web/src/components/ui/scroll-area.tsx @@ -25,12 +25,16 @@ function ScrollArea({ className, children, scrollFade = false, + scrollFadePadding = true, scrollbarGutter = false, hideScrollbars = false, chainVerticalScroll = false, ...props }: ScrollAreaPrimitive.Root.Props & { scrollFade?: boolean; + /** Keep focused and highlighted items clear of the fade. Off for lists + * whose rows take focus on click, where the scroll would nudge the list. */ + scrollFadePadding?: boolean; scrollbarGutter?: boolean; hideScrollbars?: boolean; chainVerticalScroll?: boolean; @@ -45,7 +49,8 @@ function ScrollArea({ "h-full max-h-[inherit] overflow-auto overscroll-contain rounded-[inherit] outline-none transition-shadows focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background data-has-overflow-x:overscroll-x-contain", chainVerticalScroll && "overscroll-y-auto", scrollFade && - "scroll-p-[var(--fade-size)] mask-t-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-y-start)))] mask-b-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-y-end)))] mask-l-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-start)))] mask-r-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-end)))] [--fade-size:1.5rem]", + "mask-t-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-y-start)))] mask-b-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-y-end)))] mask-l-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-start)))] mask-r-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-end)))] [--fade-size:1.5rem]", + scrollFade && scrollFadePadding && "scroll-p-[var(--fade-size)]", scrollbarGutter && "scrollbar-gutter-stable", hideScrollbars && "[-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden", diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx index 22cb4808fadd..425db645736a 100644 --- a/apps/web/src/components/ui/sidebar.tsx +++ b/apps/web/src/components/ui/sidebar.tsx @@ -699,7 +699,14 @@ function SidebarContent({ return ( <> {fixedHeader ?
{fixedHeader}
: null} - + {/* Rows take focus on click. Scroll padding would make the browser nudge + the list whenever a focused row sits under the fade. */} +