| ... | @@ -285,6 +285,9 @@ function WorkingDuration(props: { startedAt: string | null }) { | ... | @@ -285,6 +285,9 @@ function WorkingDuration(props: { startedAt: string | null }) { |
| 285 | } | 285 | } |
| 286 | | 286 | |
| 287 | const EMPTY_PROVIDER_ENTRIES: ReadonlyMap<string, ProviderInstanceEntry> = new Map(); | 287 | const 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. |
| | 290 | const EMPTY_THREADS: readonly EnvironmentThreadShell[] = []; |
| 288 | | 291 | |
| 289 | function terminalProcessLabel(count: number): string { | 292 | function 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-item | 1538 | 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-item | 1697 | 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]); |
| 2674 | | 2679 | |
| 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 snoozing | 2695 | // snoozed thread reached by route (deep link, open before snoozing |
| 2691 | // elsewhere) keeps its row — with highlight and wake affordance — same | 2696 | // 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]); |
| 2700 | | 2705 | |
| 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 the | 3303 | // 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.over | 3318 | const target = event.over |