From 0a1c376bc491d895f16d247f2e73b7d0ad7def02 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 8 Sep 2026 03:00:13 -0700 Subject: [PATCH] fix(web): stop sidebar rows flashing and shifting on click The sidebar list motion ran on every shell event and route change, and the content-visibility size hints did not match the real row heights. Rows that scrolled into view snapped to their true size, and the next update animated every displaced row, which read as a repaint flash. Match the size hints to the rendered rows, run the motion pass only when the rendered order changes, and keep the collapsed shelves on one shared empty list. Drop the scroll fade padding from the sidebar viewport so focusing a row under the fade no longer nudges the list. Co-Authored-By: Claude Fable 5.1 --- apps/web/src/components/Sidebar.tsx | 42 ++++++++++++++++------ apps/web/src/components/ui/scroll-area.tsx | 7 +++- apps/web/src/components/ui/sidebar.tsx | 9 ++++- 3 files changed, 46 insertions(+), 12 deletions(-) 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. */} +