| ... | ... | @@ -285,6 +285,9 @@ function WorkingDuration(props: { startedAt: string | null }) { |
| 285 | 285 | } |
| 286 | 286 | |
| 287 | 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 | 292 | function terminalProcessLabel(count: number): string { |
| 290 | 293 | return `${count} terminal ${count === 1 ? "process" : "processes"} running`; |
| ... | ... | @@ -1535,7 +1538,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1535 | 1538 | data-thread-item |
| 1536 | 1539 | {...sortableRootProps} |
| 1537 | 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 | 1543 | sortable?.isDragging && "relative z-20", |
| 1540 | 1544 | )} |
| 1541 | 1545 | > |
| ... | ... | @@ -1693,7 +1697,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1693 | 1697 | data-thread-item |
| 1694 | 1698 | {...sortableRootProps} |
| 1695 | 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 | 1702 | sortable?.isDragging && "relative z-20", |
| 1698 | 1703 | )} |
| 1699 | 1704 | > |
| ... | ... | @@ -2664,12 +2669,12 @@ export default function Sidebar() { |
| 2664 | 2669 | ); |
| 2665 | 2670 | const renderedSettledThreads = useMemo(() => { |
| 2666 | 2671 | if (settledShelfExpanded) return visibleSettledThreads; |
| 2667 | | if (routeThreadKey === null) return []; |
| 2672 | if (routeThreadKey === null) return EMPTY_THREADS; |
| 2668 | 2673 | const routeThread = visibleSettledThreads.find( |
| 2669 | 2674 | (thread) => |
| 2670 | 2675 | scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeThreadKey, |
| 2671 | 2676 | ); |
| 2672 | | return routeThread === undefined ? [] : [routeThread]; |
| 2677 | return routeThread === undefined ? EMPTY_THREADS : [routeThread]; |
| 2673 | 2678 | }, [routeThreadKey, settledShelfExpanded, visibleSettledThreads]); |
| 2674 | 2679 | |
| 2675 | 2680 | // The snoozed shelf is collapsed by default: out of the way, never gone. |
| ... | ... | @@ -2690,12 +2695,12 @@ export default function Sidebar() { |
| 2690 | 2695 | // snoozed thread reached by route (deep link, open before snoozing |
| 2691 | 2696 | // elsewhere) keeps its row — with highlight and wake affordance — same |
| 2692 | 2697 | // exception the settled tail's "Show more" makes. |
| 2693 | | if (routeThreadKey === null) return []; |
| 2698 | if (routeThreadKey === null) return EMPTY_THREADS; |
| 2694 | 2699 | const routeThread = snoozedThreads.find( |
| 2695 | 2700 | (thread) => |
| 2696 | 2701 | scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeThreadKey, |
| 2697 | 2702 | ); |
| 2698 | | return routeThread === undefined ? [] : [routeThread]; |
| 2703 | return routeThread === undefined ? EMPTY_THREADS : [routeThread]; |
| 2699 | 2704 | }, [routeThreadKey, snoozedShelfExpanded, snoozedThreads]); |
| 2700 | 2705 | |
| 2701 | 2706 | const orderedThreads = useMemo( |
| ... | ... | @@ -3282,15 +3287,32 @@ export default function Sidebar() { |
| 3282 | 3287 | } |
| 3283 | 3288 | }, [cancelThreadDrag, dragState, sidebarListItems]); |
| 3284 | 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 | 3302 | useLayoutEffect(() => { |
| 3286 | 3303 | // Drag release clears the baseline, so its commit cannot replay the |
| 3287 | 3304 | // sortable preview; rows glide from their released positions instead. |
| 3288 | 3305 | // Later thread actions can animate while writes settle. |
| 3289 | 3306 | // 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 | ]); |
| 3294 | 3316 | const handleThreadDragOver = useCallback( |
| 3295 | 3317 | (event: DragOverEvent) => { |
| 3296 | 3318 | const target = event.over |