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 }) {...@@ -285,6 +285,9 @@ function WorkingDuration(props: { startedAt: string | null }) {
285}285}
286286
287const EMPTY_PROVIDER_ENTRIES: ReadonlyMap<string, ProviderInstanceEntry> = new Map();287const 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
289function terminalProcessLabel(count: number): string {292function terminalProcessLabel(count: number): string {
290 return `${count} terminal ${count === 1 ? "process" : "processes"} running`;293 return `${count} terminal ${count === 1 ? "process" : "processes"} running`;
...@@ -1535,7 +1538,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1535,7 +1538,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1535 data-thread-item1538 data-thread-item
1536 {...sortableRootProps}1539 {...sortableRootProps}
1537 className={cn(1540 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]",
1539 sortable?.isDragging && "relative z-20",1543 sortable?.isDragging && "relative z-20",
1540 )}1544 )}
1541 >1545 >
...@@ -1693,7 +1697,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1693,7 +1697,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1693 data-thread-item1697 data-thread-item
1694 {...sortableRootProps}1698 {...sortableRootProps}
1695 className={cn(1699 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]",
1697 sortable?.isDragging && "relative z-20",1702 sortable?.isDragging && "relative z-20",
1698 )}1703 )}
1699 >1704 >
...@@ -2664,12 +2669,12 @@ export default function Sidebar() {...@@ -2664,12 +2669,12 @@ export default function Sidebar() {
2664 );2669 );
2665 const renderedSettledThreads = useMemo(() => {2670 const renderedSettledThreads = useMemo(() => {
2666 if (settledShelfExpanded) return visibleSettledThreads;2671 if (settledShelfExpanded) return visibleSettledThreads;
2667 if (routeThreadKey === null) return [];2672 if (routeThreadKey === null) return EMPTY_THREADS;
2668 const routeThread = visibleSettledThreads.find(2673 const routeThread = visibleSettledThreads.find(
2669 (thread) =>2674 (thread) =>
2670 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeThreadKey,2675 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeThreadKey,
2671 );2676 );
2672 return routeThread === undefined ? [] : [routeThread];2677 return routeThread === undefined ? EMPTY_THREADS : [routeThread];
2673 }, [routeThreadKey, settledShelfExpanded, visibleSettledThreads]);2678 }, [routeThreadKey, settledShelfExpanded, visibleSettledThreads]);
26742679
2675 // The snoozed shelf is collapsed by default: out of the way, never gone.2680 // The snoozed shelf is collapsed by default: out of the way, never gone.
...@@ -2690,12 +2695,12 @@ export default function Sidebar() {...@@ -2690,12 +2695,12 @@ export default function Sidebar() {
2690 // snoozed thread reached by route (deep link, open before snoozing2695 // snoozed thread reached by route (deep link, open before snoozing
2691 // elsewhere) keeps its row — with highlight and wake affordance — same2696 // elsewhere) keeps its row — with highlight and wake affordance — same
2692 // exception the settled tail's "Show more" makes.2697 // exception the settled tail's "Show more" makes.
2693 if (routeThreadKey === null) return [];2698 if (routeThreadKey === null) return EMPTY_THREADS;
2694 const routeThread = snoozedThreads.find(2699 const routeThread = snoozedThreads.find(
2695 (thread) =>2700 (thread) =>
2696 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeThreadKey,2701 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeThreadKey,
2697 );2702 );
2698 return routeThread === undefined ? [] : [routeThread];2703 return routeThread === undefined ? EMPTY_THREADS : [routeThread];
2699 }, [routeThreadKey, snoozedShelfExpanded, snoozedThreads]);2704 }, [routeThreadKey, snoozedShelfExpanded, snoozedThreads]);
27002705
2701 const orderedThreads = useMemo(2706 const orderedThreads = useMemo(
...@@ -3282,15 +3287,32 @@ export default function Sidebar() {...@@ -3282,15 +3287,32 @@ export default function Sidebar() {
3282 }3287 }
3283 }, [cancelThreadDrag, dragState, sidebarListItems]);3288 }, [cancelThreadDrag, dragState, sidebarListItems]);
3284 const listMotionPaused = dragState !== null;3289 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;
3285 useLayoutEffect(() => {3302 useLayoutEffect(() => {
3286 // Drag release clears the baseline, so its commit cannot replay the3303 // Drag release clears the baseline, so its commit cannot replay the
3287 // sortable preview; rows glide from their released positions instead.3304 // sortable preview; rows glide from their released positions instead.
3288 // Later thread actions can animate while writes settle.3305 // Later thread actions can animate while writes settle.
3289 // Draft navigation can reveal a frozen row without changing the draft count.3306 // Draft navigation can reveal a frozen row without changing the draft count.
3290 listMotionRef.current?.update(3307 void sidebarListOrderKey;
3291 !listMotionPaused && sidebarListItems.length + visibleDraftSessionCount > 0,3308 listMotionRef.current?.update(!listMotionPaused && sidebarListHasRows);
3292 );3309 }, [
3293 }, [listMotionPaused, routeDraftIdForRows, sidebarListItems, visibleDraftSessionCount]);3310 listMotionPaused,
3311 routeDraftIdForRows,
3312 sidebarListHasRows,
3313 sidebarListOrderKey,
3314 visibleDraftSessionCount,
3315 ]);
3294 const handleThreadDragOver = useCallback(3316 const handleThreadDragOver = useCallback(
3295 (event: DragOverEvent) => {3317 (event: DragOverEvent) => {
3296 const target = event.over3318 const target = event.over
apps/web/src/components/ui/scroll-area.tsx+6-1
...@@ -25,12 +25,16 @@ function ScrollArea({...@@ -25,12 +25,16 @@ function ScrollArea({
25 className,25 className,
26 children,26 children,
27 scrollFade = false,27 scrollFade = false,
28 scrollFadePadding = true,
28 scrollbarGutter = false,29 scrollbarGutter = false,
29 hideScrollbars = false,30 hideScrollbars = false,
30 chainVerticalScroll = false,31 chainVerticalScroll = false,
31 ...props32 ...props
32}: ScrollAreaPrimitive.Root.Props & {33}: ScrollAreaPrimitive.Root.Props & {
33 scrollFade?: boolean;34 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;
34 scrollbarGutter?: boolean;38 scrollbarGutter?: boolean;
35 hideScrollbars?: boolean;39 hideScrollbars?: boolean;
36 chainVerticalScroll?: boolean;40 chainVerticalScroll?: boolean;
...@@ -45,7 +49,8 @@ function ScrollArea({...@@ -45,7 +49,8 @@ function ScrollArea({
45 "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",49 "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",
46 chainVerticalScroll && "overscroll-y-auto",50 chainVerticalScroll && "overscroll-y-auto",
47 scrollFade &&51 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)]",
49 scrollbarGutter && "scrollbar-gutter-stable",54 scrollbarGutter && "scrollbar-gutter-stable",
50 hideScrollbars &&55 hideScrollbars &&
51 "[-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",56 "[-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({...@@ -699,7 +699,14 @@ function SidebarContent({
699 return (699 return (
700 <>700 <>
701 {fixedHeader ? <div className="w-full shrink-0">{fixedHeader}</div> : null}701 {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 >
703 <div710 <div
704 className={cn(711 className={cn(
705 "flex w-full min-w-0 flex-col gap-2 group-data-[collapsible=icon]:overflow-hidden",712 "flex w-full min-w-0 flex-col gap-2 group-data-[collapsible=icon]:overflow-hidden",