authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-08 03:10:54-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-08 03:10:54-07:00
logd7a59c63c6a227ac295041660228a4aaa427ead0
tree41c9b066a91f0f7b148209782cc76310d942703e
parent82451eeb7277b0b7fb5a7cb53156c83f948f32c1
signature Signed by PGP key B5690EEEBB952194

fix(web): stop sidebar rows flashing and shifting on click (#10713)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

3 files changed, 46 insertions(+), 12 deletions(-)

apps/web/src/components/Sidebar.tsx+32-10
......@@ -285,6 +285,9 @@ function WorkingDuration(props: { startedAt: string | null }) {
285285}
286286
287287const EMPTY_PROVIDER_ENTRIES: ReadonlyMap<string, ProviderInstanceEntry> = new Map();
288// Collapsed shelves share one empty list so a route change alone does not
289// give the sidebar list a new identity.
290const EMPTY_THREADS: readonly EnvironmentThreadShell[] = [];
288291
289292function terminalProcessLabel(count: number): string {
290293 return `${count} terminal ${count === 1 ? "process" : "processes"} running`;
......@@ -1535,7 +1538,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
15351538 data-thread-item
15361539 {...sortableRootProps}
15371540 className={cn(
1538 "list-none [content-visibility:auto] [contain-intrinsic-size:auto_34px]",
1541 // Matches the h-9 row so unrendered rows never shift the list when they paint.
1542 "list-none [content-visibility:auto] [contain-intrinsic-size:auto_36px]",
15391543 sortable?.isDragging && "relative z-20",
15401544 )}
15411545 >
......@@ -1693,7 +1697,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
16931697 data-thread-item
16941698 {...sortableRootProps}
16951699 className={cn(
1696 "list-none py-0.5 [content-visibility:auto] [contain-intrinsic-size:auto_96px]",
1700 // Matches the h-[4.875rem] content box; the py-0.5 padding is added on top.
1701 "list-none py-0.5 [content-visibility:auto] [contain-intrinsic-size:auto_78px]",
16971702 sortable?.isDragging && "relative z-20",
16981703 )}
16991704 >
......@@ -2664,12 +2669,12 @@ export default function Sidebar() {
26642669 );
26652670 const renderedSettledThreads = useMemo(() => {
26662671 if (settledShelfExpanded) return visibleSettledThreads;
2667 if (routeThreadKey === null) return [];
2672 if (routeThreadKey === null) return EMPTY_THREADS;
26682673 const routeThread = visibleSettledThreads.find(
26692674 (thread) =>
26702675 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeThreadKey,
26712676 );
2672 return routeThread === undefined ? [] : [routeThread];
2677 return routeThread === undefined ? EMPTY_THREADS : [routeThread];
26732678 }, [routeThreadKey, settledShelfExpanded, visibleSettledThreads]);
26742679
26752680 // The snoozed shelf is collapsed by default: out of the way, never gone.
......@@ -2690,12 +2695,12 @@ export default function Sidebar() {
26902695 // snoozed thread reached by route (deep link, open before snoozing
26912696 // elsewhere) keeps its row — with highlight and wake affordance — same
26922697 // exception the settled tail's "Show more" makes.
2693 if (routeThreadKey === null) return [];
2698 if (routeThreadKey === null) return EMPTY_THREADS;
26942699 const routeThread = snoozedThreads.find(
26952700 (thread) =>
26962701 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeThreadKey,
26972702 );
2698 return routeThread === undefined ? [] : [routeThread];
2703 return routeThread === undefined ? EMPTY_THREADS : [routeThread];
26992704 }, [routeThreadKey, snoozedShelfExpanded, snoozedThreads]);
27002705
27012706 const orderedThreads = useMemo(
......@@ -3282,15 +3287,32 @@ export default function Sidebar() {
32823287 }
32833288 }, [cancelThreadDrag, dragState, sidebarListItems]);
32843289 const listMotionPaused = dragState !== null;
3290 // Every shell event rebuilds sidebarListItems, but rows only move when the
3291 // rendered order or a row's section changes. Keying the motion pass on that
3292 // keeps ordinary updates from forcing a layout read and animating rows
3293 // whose position drifted for other reasons.
3294 const sidebarListOrderKey = useMemo(
3295 () =>
3296 sidebarListItems
3297 .map((item) => (item.kind === "thread" ? `${item.key}:${item.section}` : item.marker))
3298 .join("\0"),
3299 [sidebarListItems],
3300 );
3301 const sidebarListHasRows = sidebarListItems.length + visibleDraftSessionCount > 0;
32853302 useLayoutEffect(() => {
32863303 // Drag release clears the baseline, so its commit cannot replay the
32873304 // sortable preview; rows glide from their released positions instead.
32883305 // Later thread actions can animate while writes settle.
32893306 // Draft navigation can reveal a frozen row without changing the draft count.
3290 listMotionRef.current?.update(
3291 !listMotionPaused && sidebarListItems.length + visibleDraftSessionCount > 0,
3292 );
3293 }, [listMotionPaused, routeDraftIdForRows, sidebarListItems, visibleDraftSessionCount]);
3307 void sidebarListOrderKey;
3308 listMotionRef.current?.update(!listMotionPaused && sidebarListHasRows);
3309 }, [
3310 listMotionPaused,
3311 routeDraftIdForRows,
3312 sidebarListHasRows,
3313 sidebarListOrderKey,
3314 visibleDraftSessionCount,
3315 ]);
32943316 const handleThreadDragOver = useCallback(
32953317 (event: DragOverEvent) => {
32963318 const target = event.over
apps/web/src/components/ui/scroll-area.tsx+6-1
......@@ -25,12 +25,16 @@ function ScrollArea({
2525 className,
2626 children,
2727 scrollFade = false,
28 scrollFadePadding = true,
2829 scrollbarGutter = false,
2930 hideScrollbars = false,
3031 chainVerticalScroll = false,
3132 ...props
3233}: ScrollAreaPrimitive.Root.Props & {
3334 scrollFade?: boolean;
35 /** Keep focused and highlighted items clear of the fade. Off for lists
36 * whose rows take focus on click, where the scroll would nudge the list. */
37 scrollFadePadding?: boolean;
3438 scrollbarGutter?: boolean;
3539 hideScrollbars?: boolean;
3640 chainVerticalScroll?: boolean;
......@@ -45,7 +49,8 @@ function ScrollArea({
4549 "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",
4650 chainVerticalScroll && "overscroll-y-auto",
4751 scrollFade &&
48 "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]",
52 "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]",
53 scrollFade && scrollFadePadding && "scroll-p-[var(--fade-size)]",
4954 scrollbarGutter && "scrollbar-gutter-stable",
5055 hideScrollbars &&
5156 "[-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
apps/web/src/components/ui/sidebar.tsx+8-1
......@@ -699,7 +699,14 @@ function SidebarContent({
699699 return (
700700 <>
701701 {fixedHeader ? <div className="w-full shrink-0">{fixedHeader}</div> : null}
702 <ScrollArea hideScrollbars scrollFade className="h-auto min-h-0 flex-1">
702 {/* Rows take focus on click. Scroll padding would make the browser nudge
703 the list whenever a focused row sits under the fade. */}
704 <ScrollArea
705 hideScrollbars
706 scrollFade
707 scrollFadePadding={false}
708 className="h-auto min-h-0 flex-1"
709 >
703710 <div
704711 className={cn(
705712 "flex w-full min-w-0 flex-col gap-2 group-data-[collapsible=icon]:overflow-hidden",