From 9c3bcc89cdc58628d4daa11059f1e1662d425aa3 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Thu, 30 Jul 2026 22:50:56 -0700 Subject: [PATCH] fix(web-ui): stop the session nav flashing on delete Deleting a session made the nav list flash before the next session moved up. Four things stacked up: - Every row swapped between a bare div and a Tooltip wrapper whenever a row menu opened or closed, changing each row's element type and remounting the whole list. Rows now stay wrapped and the tooltip is suppressed with Tooltip's own `disabled`. - The collapsed view loaded exactly the rows it renders, so the row that takes the freed slot only arrived after a metadata round trip. It now keeps a small off-screen buffer and promotes a loaded row in the same commit. - The post-delete reconcile ran as a foreground load, flipping the expand toggle to a spinner and bouncing its remaining count. Background loads no longer touch the loading/error state, and no longer claim the request id that a user-driven load depends on. - Rows jumped into their new slots in a single frame. They now slide up from where they were, the backfilled row fades in, and the list closes its own height in step so the sections below follow instead of leading. Honors prefers-reduced-motion, and measures with offsetTop so a second delete mid-animation still lands on the real layout. --- .../sections/sessions/SessionsSection.tsx | 154 +++++++++- .../sessions/sessionNavExpand.test.ts | 63 ++++ .../sections/sessions/sessionNavExpand.ts | 29 ++ .../sections/sessions/sessionRowShift.test.ts | 66 ++++ .../sections/sessions/sessionRowShift.ts | 283 ++++++++++++++++++ .../sessionRowShiftTransition.test.tsx | 192 ++++++++++++ 6 files changed, 774 insertions(+), 13 deletions(-) create mode 100644 src/web-ui/src/app/components/NavPanel/sections/sessions/sessionRowShift.test.ts create mode 100644 src/web-ui/src/app/components/NavPanel/sections/sessions/sessionRowShift.ts create mode 100644 src/web-ui/src/app/components/NavPanel/sections/sessions/sessionRowShiftTransition.test.tsx diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx index 77c663063c..55a40f58b5 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx @@ -71,10 +71,12 @@ import { } from './sessionMetadataStartup'; import { getEffectiveTopLevelSessionCount, + getSessionBufferPrefetchLimit, getSessionExpandToggleState, SESSIONS_LEVEL_0, SESSIONS_LEVEL_1, } from './sessionNavExpand'; +import { useSessionRowRemovalTransition } from './sessionRowShift'; import './SessionsSection.scss'; const log = createLogger('SessionsSection'); @@ -86,6 +88,12 @@ type HistoryOpenIntentDispatchResult = 'none' | 'dispatched' | 'already-pending' /** Page size for the fully-expanded (level 2) session list. */ const SESSIONS_LEVEL_2_PAGE = 200; +/** + * Delay before topping the off-screen row buffer back up. Keeps the refill out + * of the startup burst and coalesces the repeated deletes of a cleanup pass. + */ +const SESSIONS_BUFFER_PREFETCH_DELAY_MS = 800; + const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); @@ -237,7 +245,14 @@ const SessionsSection: React.FC = ({ const sessionMenuPopoverRef = useRef(null); const sessionMenuAnchorRef = useRef(null); const metadataLoadRequestIdRef = useRef(0); + /** User-driven metadata loads still running; background loads yield to them. */ + const foregroundLoadCountRef = useRef(0); const initialMetadataLoadKeyRef = useRef(null); + const sessionListRef = useRef(null); + /** Last (scope, live, synced) triple a background reconcile ran for. */ + const liveReconcileSignatureRef = useRef(null); + /** Last (scope, cursor, size) triple a buffer prefetch ran for. */ + const bufferPrefetchSignatureRef = useRef(null); // Subscribe to state machine changes for running status useEffect(() => { @@ -326,6 +341,8 @@ const SessionsSection: React.FC = ({ useEffect(() => { metadataLoadRequestIdRef.current += 1; initialMetadataLoadKeyRef.current = null; + liveReconcileSignatureRef.current = null; + bufferPrefetchSignatureRef.current = null; setExpandLevel(0); setMetadataPageState({ totalTopLevelCount: null, @@ -338,18 +355,39 @@ const SessionsSection: React.FC = ({ }, [workspaceId, workspacePath, remoteConnectionId, remoteSshHost]); const loadMetadataPage = useCallback( - async (limit: number, cursor: string | undefined, source: string) => { + async ( + limit: number, + cursor: string | undefined, + source: string, + options?: { background?: boolean }, + ) => { if (!workspacePath || limit <= 0) { return null; } - const requestId = metadataLoadRequestIdRef.current + 1; - metadataLoadRequestIdRef.current = requestId; - setMetadataPageState(prev => ({ - ...prev, - isLoading: true, - loadError: false, - })); + // A background refresh only re-syncs counts for a list that is already on + // screen. Surfacing its spinner (or its retry state) would make routine + // upkeep — such as replacing the row a delete consumed — flash the list. + // It also leaves the request id alone so it cannot strand a user-driven + // load (which would otherwise never clear its spinner); instead it drops + // its own result if anything else claimed the list meanwhile. + const isBackgroundLoad = options?.background === true; + if (isBackgroundLoad && foregroundLoadCountRef.current > 0) { + return null; + } + + const requestId = isBackgroundLoad + ? metadataLoadRequestIdRef.current + : metadataLoadRequestIdRef.current + 1; + if (!isBackgroundLoad) { + metadataLoadRequestIdRef.current = requestId; + foregroundLoadCountRef.current += 1; + setMetadataPageState(prev => ({ + ...prev, + isLoading: true, + loadError: false, + })); + } try { const page = await flowChatStore.loadSessionMetadataPage( @@ -378,7 +416,7 @@ const SessionsSection: React.FC = ({ } return page; } catch (error) { - if (metadataLoadRequestIdRef.current === requestId) { + if (metadataLoadRequestIdRef.current === requestId && !isBackgroundLoad) { setMetadataPageState(prev => ({ ...prev, isLoading: false, @@ -387,6 +425,10 @@ const SessionsSection: React.FC = ({ } log.warn('Failed to load visible session metadata page', { error, workspacePath, cursor, limit }); return null; + } finally { + if (!isBackgroundLoad) { + foregroundLoadCountRef.current = Math.max(foregroundLoadCountRef.current - 1, 0); + } } }, [workspacePath, remoteConnectionId, remoteSshHost] @@ -490,6 +532,8 @@ const SessionsSection: React.FC = ({ useEffect(() => { const handler = () => { metadataLoadRequestIdRef.current += 1; + liveReconcileSignatureRef.current = null; + bufferPrefetchSignatureRef.current = null; setExpandLevel(0); setMetadataPageState({ totalTopLevelCount: null, @@ -690,8 +734,23 @@ const SessionsSection: React.FC = ({ return; } - void loadMetadataPage(SESSIONS_LEVEL_0, undefined, 'sessions_nav_live_reconcile'); + // Re-running for a scope whose counts have not moved would spin on a + // failing backend, since a background load leaves the state untouched. + const signature = [ + initialMetadataKey, + allTopLevelSessions.length, + metadataPageState.syncedTopLevelCount, + ].join('\n'); + if (liveReconcileSignatureRef.current === signature) { + return; + } + liveReconcileSignatureRef.current = signature; + + void loadMetadataPage(SESSIONS_LEVEL_0, undefined, 'sessions_nav_live_reconcile', { + background: true, + }); }, [ + initialMetadataKey, isVisible, loadMetadataPage, metadataPageState.isLoading, @@ -701,6 +760,60 @@ const SessionsSection: React.FC = ({ workspacePath, ]); + // Keep a few rows loaded past the visible slice. Deleting a session then + // promotes an already-loaded row in the same commit instead of leaving a gap + // until a metadata round trip lands. + useEffect(() => { + if ( + !isVisible || + !workspacePath || + metadataPageState.isLoading || + metadataPageState.loadError || + metadataPageState.totalTopLevelCount === null || + !metadataPageState.nextCursor + ) { + return; + } + + const prefetchLimit = getSessionBufferPrefetchLimit({ + expandLevel, + loadedTopLevelCount: allTopLevelSessions.length, + totalTopLevelCount: totalTopLevelSessionCount, + hasMore: metadataPageState.hasMore, + }); + if (prefetchLimit <= 0) { + return; + } + + const signature = [initialMetadataKey, metadataPageState.nextCursor, prefetchLimit].join('\n'); + if (bufferPrefetchSignatureRef.current === signature) { + return; + } + + const cursor = metadataPageState.nextCursor; + const timer = window.setTimeout(() => { + bufferPrefetchSignatureRef.current = signature; + void loadMetadataPage(prefetchLimit, cursor, 'sessions_nav_buffer_prefetch', { + background: true, + }); + }, SESSIONS_BUFFER_PREFETCH_DELAY_MS); + + return () => window.clearTimeout(timer); + }, [ + allTopLevelSessions.length, + expandLevel, + initialMetadataKey, + isVisible, + loadMetadataPage, + metadataPageState.hasMore, + metadataPageState.isLoading, + metadataPageState.loadError, + metadataPageState.nextCursor, + metadataPageState.totalTopLevelCount, + totalTopLevelSessionCount, + workspacePath, + ]); + const visibleItems = useMemo(() => { const visibleParents = topLevelSessions.slice(0, sessionDisplayLimit); const out: Array<{ session: Session; level: 0 | 1 }> = []; @@ -712,6 +825,12 @@ const SessionsSection: React.FC = ({ return out; }, [childrenByParent, sessionDisplayLimit, topLevelSessions]); + const visibleRowSignature = useMemo( + () => visibleItems.map(item => item.session.sessionId).join('|'), + [visibleItems], + ); + useSessionRowRemovalTransition(sessionListRef, visibleRowSignature); + const activeSessionId = flowChatState.activeSessionId; const scheduledJobsSession = scheduledJobsSessionId ? flowChatState.sessions.get(scheduledJobsSessionId) ?? null @@ -1102,7 +1221,7 @@ const SessionsSection: React.FC = ({ } return ( -
+
{dispatchTargetFilterOptions.length > 1 ? (
); - return isEditing || openMenuSessionId !== null ? row : ( - + // Always wrapped, even while editing or with a row menu open: swapping + // the wrapper out would change every row's element type, remounting + // the whole list (and flashing it) on each menu open/close. + return ( + {row} ); diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionNavExpand.test.ts b/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionNavExpand.test.ts index 8f9b3f5eb4..42d1c846f6 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionNavExpand.test.ts +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionNavExpand.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { getEffectiveTopLevelSessionCount, + getSessionBufferPrefetchLimit, getSessionExpandToggleState, } from './sessionNavExpand'; @@ -61,3 +62,65 @@ describe('getEffectiveTopLevelSessionCount', () => { expect(getEffectiveTopLevelSessionCount(12, 5, 10, true)).toBe(12); }); }); + +describe('getSessionBufferPrefetchLimit', () => { + it('tops the collapsed view up to a few rows past what it renders', () => { + expect(getSessionBufferPrefetchLimit({ + expandLevel: 0, + loadedTopLevelCount: 5, + totalTopLevelCount: 40, + hasMore: true, + })).toBe(3); + }); + + it('refills only the rows a delete consumed', () => { + expect(getSessionBufferPrefetchLimit({ + expandLevel: 0, + loadedTopLevelCount: 7, + totalTopLevelCount: 40, + hasMore: true, + })).toBe(1); + }); + + it('stops once the buffer is full', () => { + expect(getSessionBufferPrefetchLimit({ + expandLevel: 0, + loadedTopLevelCount: 8, + totalTopLevelCount: 40, + hasMore: true, + })).toBe(0); + }); + + it('never asks for more sessions than the workspace has', () => { + expect(getSessionBufferPrefetchLimit({ + expandLevel: 0, + loadedTopLevelCount: 5, + totalTopLevelCount: 6, + hasMore: true, + })).toBe(1); + }); + + it('buffers past the first expand step as well', () => { + expect(getSessionBufferPrefetchLimit({ + expandLevel: 1, + loadedTopLevelCount: 10, + totalTopLevelCount: 40, + hasMore: true, + })).toBe(3); + }); + + it('skips prefetching when everything is loaded or fully expanded', () => { + expect(getSessionBufferPrefetchLimit({ + expandLevel: 0, + loadedTopLevelCount: 5, + totalTopLevelCount: 5, + hasMore: false, + })).toBe(0); + expect(getSessionBufferPrefetchLimit({ + expandLevel: 2, + loadedTopLevelCount: 40, + totalTopLevelCount: 400, + hasMore: true, + })).toBe(0); + }); +}); diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionNavExpand.ts b/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionNavExpand.ts index a9722a4394..8759f07f6d 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionNavExpand.ts +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionNavExpand.ts @@ -1,6 +1,14 @@ export const SESSIONS_LEVEL_0 = 5; export const SESSIONS_LEVEL_1 = 10; +/** + * Rows kept loaded past the visible slice. Deleting a session drops one row out + * of the list, and without a buffer the row that takes its place only arrives + * after a metadata round trip — the list would visibly close up and then push a + * row back in. The buffer lets the replacement render in the same commit. + */ +export const SESSIONS_BUFFER_PREFETCH = 3; + export type SessionExpandLevel = 0 | 1 | 2; export type SessionExpandToggleAction = 'show-more' | 'show-all' | 'show-less'; @@ -31,6 +39,27 @@ export function getEffectiveTopLevelSessionCount( ); } +/** + * How many extra top-level rows the collapsed/expanded view should keep loaded + * beyond what it renders. Returns 0 when the buffer is already full, when every + * session is loaded, or when the list is fully expanded (level 2 pages itself). + */ +export function getSessionBufferPrefetchLimit(params: { + expandLevel: SessionExpandLevel; + loadedTopLevelCount: number; + totalTopLevelCount: number; + hasMore: boolean; +}): number { + const { expandLevel, loadedTopLevelCount, totalTopLevelCount, hasMore } = params; + if (!hasMore || expandLevel === 2) { + return 0; + } + + const visibleCount = expandLevel === 0 ? SESSIONS_LEVEL_0 : SESSIONS_LEVEL_1; + const targetLoadedCount = Math.min(visibleCount + SESSIONS_BUFFER_PREFETCH, totalTopLevelCount); + return Math.max(targetLoadedCount - loadedTopLevelCount, 0); +} + export function getSessionExpandToggleState( totalTopLevelSessionCount: number, expandLevel: SessionExpandLevel diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionRowShift.test.ts b/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionRowShift.test.ts new file mode 100644 index 0000000000..ee841ba3e7 --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionRowShift.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; + +import { computeSessionRowRemovalTransition } from './sessionRowShift'; + +const offsets = (entries: Array<[string, number]>): Map => new Map(entries); + +describe('computeSessionRowRemovalTransition', () => { + it('slides the rows below a deleted session up from their old slot', () => { + const transition = computeSessionRowRemovalTransition( + offsets([['a', 0], ['b', 26], ['c', 52], ['d', 78]]), + offsets([['a', 0], ['c', 26], ['d', 52]]), + ); + + expect(transition).toEqual({ + hasRemovedRow: true, + shifts: [ + { sessionId: 'c', fromOffsetY: 26 }, + { sessionId: 'd', fromOffsetY: 26 }, + ], + enteringSessionIds: [], + }); + }); + + it('leaves rows above the deleted session alone', () => { + const transition = computeSessionRowRemovalTransition( + offsets([['a', 0], ['b', 26], ['c', 52]]), + offsets([['a', 0], ['b', 26]]), + ); + + expect(transition.shifts).toEqual([]); + }); + + it('fades in the buffered row that backfills the freed slot', () => { + // `e` renders in the same commit the delete lands in. + const transition = computeSessionRowRemovalTransition( + offsets([['a', 0], ['b', 26], ['c', 52]]), + offsets([['a', 0], ['c', 26], ['e', 52]]), + ); + + expect(transition).toEqual({ + hasRemovedRow: true, + shifts: [{ sessionId: 'c', fromOffsetY: 26 }], + enteringSessionIds: ['e'], + }); + }); + + it('ignores renders that only add rows', () => { + expect(computeSessionRowRemovalTransition( + offsets([['a', 0]]), + offsets([['a', 26], ['b', 0]]), + )).toEqual({ hasRemovedRow: false, shifts: [], enteringSessionIds: [] }); + expect(computeSessionRowRemovalTransition( + offsets([]), + offsets([['a', 0]]), + )).toEqual({ hasRemovedRow: false, shifts: [], enteringSessionIds: [] }); + }); + + it('ignores sub-pixel movement', () => { + const transition = computeSessionRowRemovalTransition( + offsets([['a', 0], ['b', 26], ['c', 52.2]]), + offsets([['a', 0], ['c', 52]]), + ); + + expect(transition.shifts).toEqual([]); + }); +}); diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionRowShift.ts b/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionRowShift.ts new file mode 100644 index 0000000000..2bc1012d0e --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionRowShift.ts @@ -0,0 +1,283 @@ +/** + * Session nav rows are plain flex children, so removing one (delete / archive) + * snaps every row below it into its new slot within a single commit — and the + * buffered row that takes the freed slot appears out of nowhere at the same + * time. Measuring the rows before and after that commit lets the section replay + * the jump as a short slide, which reads as the list closing up instead of + * flashing. + */ + +import { useEffect, useLayoutEffect, useRef, type RefObject } from 'react'; + +export const SESSION_ROW_SHIFT_DURATION_MS = 180; +export const SESSION_ROW_SHIFT_EASING = 'cubic-bezier(0.22, 1, 0.36, 1)'; + +/** Sub-pixel jumps are invisible; animating them only costs a paint. */ +const MIN_SHIFT_PX = 0.5; +/** Selector for a top-level or child session row inside the inline list. */ +const SESSION_ROW_SELECTOR = '.bitfun-nav-panel__inline-item[data-session-id]'; + +export interface SessionRowShift { + sessionId: string; + /** Offset (px) the row starts from so it appears not to have moved yet. */ + fromOffsetY: number; +} + +export interface SessionRowRemovalTransition { + /** Whether a row left the list, which is what puts the list in motion. */ + hasRemovedRow: boolean; + /** Rows that must slide from their old slot into their new one. */ + shifts: SessionRowShift[]; + /** Rows pulled in to backfill the list, which fade in instead of popping. */ + enteringSessionIds: string[]; +} + +const NO_TRANSITION: SessionRowRemovalTransition = { + hasRemovedRow: false, + shifts: [], + enteringSessionIds: [], +}; + +/** + * What moved because a row left the list. Returns nothing when no row was + * removed, so plain inserts (metadata streaming in during startup, expanding + * the list) keep landing without motion. + */ +export function computeSessionRowRemovalTransition( + previousOffsets: ReadonlyMap, + nextOffsets: ReadonlyMap, +): SessionRowRemovalTransition { + let hasRemovedRow = false; + for (const sessionId of previousOffsets.keys()) { + if (!nextOffsets.has(sessionId)) { + hasRemovedRow = true; + break; + } + } + if (!hasRemovedRow) { + return NO_TRANSITION; + } + + const shifts: SessionRowShift[] = []; + const enteringSessionIds: string[] = []; + for (const [sessionId, nextOffset] of nextOffsets) { + const previousOffset = previousOffsets.get(sessionId); + if (previousOffset === undefined) { + enteringSessionIds.push(sessionId); + continue; + } + const fromOffsetY = previousOffset - nextOffset; + if (Math.abs(fromOffsetY) < MIN_SHIFT_PX) { + continue; + } + shifts.push({ sessionId, fromOffsetY }); + } + return { hasRemovedRow, shifts, enteringSessionIds }; +} + +const prefersReducedMotion = (): boolean => + typeof window !== 'undefined' + && typeof window.matchMedia === 'function' + && window.matchMedia('(prefers-reduced-motion: reduce)').matches; + +const clearRowTransition = (row: HTMLElement): void => { + row.style.transition = ''; + row.style.transform = ''; + row.style.opacity = ''; +}; + +const clearListCollapse = (list: HTMLElement): void => { + list.style.transition = ''; + list.style.height = ''; + list.style.overflow = ''; +}; + +/** + * Height the list would have without an in-flight collapse, so a second delete + * measures the layout rather than the frame the animation happens to be on. + */ +const measureNaturalHeight = (list: HTMLElement): number => { + const inlineHeight = list.style.height; + if (!inlineHeight) { + return list.offsetHeight; + } + list.style.height = ''; + const naturalHeight = list.offsetHeight; + list.style.height = inlineHeight; + return naturalHeight; +}; + +/** + * `offsetTop` ignores transforms, so a row measured mid-slide still reports the + * slot it settles into — a second delete during the animation stays correct. + */ +const readSessionRows = (container: HTMLElement): Map => { + const rows = new Map(); + for (const element of container.querySelectorAll(SESSION_ROW_SELECTOR)) { + const sessionId = element.dataset.sessionId; + if (sessionId) { + rows.set(sessionId, element); + } + } + return rows; +}; + +/** + * Slides the surviving rows up from where they used to be — and fades in the + * row that backfills the list — whenever a session row leaves `listRef`. + * `rowSignature` must change whenever the rendered row set changes, so the hook + * re-measures on the commit that removed the row. + */ +export function useSessionRowRemovalTransition( + listRef: RefObject, + rowSignature: string, +): void { + const previousOffsetsRef = useRef>(new Map()); + const previousHeightRef = useRef(null); + /** Row → timer that strips its inline styles once the animation settles. */ + const settleTimersRef = useRef>(new Map()); + const listSettleTimerRef = useRef(null); + + useEffect(() => { + const settleTimers = settleTimersRef.current; + const list = listRef.current; + return () => { + for (const [row, timer] of settleTimers) { + window.clearTimeout(timer); + clearRowTransition(row); + } + settleTimers.clear(); + if (listSettleTimerRef.current !== null) { + window.clearTimeout(listSettleTimerRef.current); + listSettleTimerRef.current = null; + } + if (list) { + clearListCollapse(list); + } + }; + }, [listRef]); + + useLayoutEffect(() => { + const container = listRef.current; + if (!container) { + previousOffsetsRef.current = new Map(); + previousHeightRef.current = null; + return; + } + + const rows = readSessionRows(container); + const nextOffsets = new Map(); + for (const [sessionId, row] of rows) { + nextOffsets.set(sessionId, row.offsetTop); + } + + const previousOffsets = previousOffsetsRef.current; + previousOffsetsRef.current = nextOffsets; + const previousHeight = previousHeightRef.current; + const nextHeight = measureNaturalHeight(container); + previousHeightRef.current = nextHeight; + + if (prefersReducedMotion()) { + return; + } + + const { hasRemovedRow, shifts, enteringSessionIds } = computeSessionRowRemovalTransition( + previousOffsets, + nextOffsets, + ); + + const scheduleListSettle = (): void => { + if (listSettleTimerRef.current !== null) { + window.clearTimeout(listSettleTimerRef.current); + } + listSettleTimerRef.current = window.setTimeout(() => { + listSettleTimerRef.current = null; + clearListCollapse(container); + }, SESSION_ROW_SHIFT_DURATION_MS + 60); + }; + + if (hasRemovedRow && previousHeight !== null && previousHeight > nextHeight + MIN_SHIFT_PX) { + // Shrink the list in step with the rows. Without this the sections below + // it would close the gap a frame after the delete, while the rows above + // are still sliding into it. + container.style.transition = 'none'; + container.style.overflow = 'hidden'; + container.style.height = `${previousHeight}px`; + requestAnimationFrame(() => { + container.style.transition = `height ${SESSION_ROW_SHIFT_DURATION_MS}ms ${SESSION_ROW_SHIFT_EASING}`; + container.style.height = `${nextHeight}px`; + }); + scheduleListSettle(); + } else if (listSettleTimerRef.current !== null && container.style.height) { + // A row landing mid-collapse (a background refill) would sit clipped + // under the pinned height; retarget the running animation instead. + container.style.height = `${nextHeight}px`; + scheduleListSettle(); + } + + if (!hasRemovedRow) { + return; + } + + const settleTimers = settleTimersRef.current; + const takeRow = (sessionId: string): HTMLElement | null => { + const row = rows.get(sessionId); + if (!row) { + return null; + } + const pendingTimer = settleTimers.get(row); + if (pendingTimer !== undefined) { + window.clearTimeout(pendingTimer); + settleTimers.delete(row); + } + return row; + }; + + const animatedRows: Array<{ row: HTMLElement; property: 'transform' | 'opacity' }> = []; + for (const shift of shifts) { + const row = takeRow(shift.sessionId); + if (!row) { + continue; + } + row.style.transition = 'none'; + row.style.transform = `translateY(${shift.fromOffsetY}px)`; + animatedRows.push({ row, property: 'transform' }); + } + for (const sessionId of enteringSessionIds) { + const row = takeRow(sessionId); + if (!row) { + continue; + } + row.style.transition = 'none'; + row.style.opacity = '0'; + animatedRows.push({ row, property: 'opacity' }); + } + if (animatedRows.length === 0) { + return; + } + + // Paint the starting values first, then release them so the browser has a + // start and an end to interpolate between. Rows keep animating across later + // renders (a background metadata refresh commits while the slide runs), so + // the styles are cleaned up per row on a timer, not on effect teardown. + requestAnimationFrame(() => { + for (const { row, property } of animatedRows) { + row.style.transition = `${property} ${SESSION_ROW_SHIFT_DURATION_MS}ms ${SESSION_ROW_SHIFT_EASING}`; + if (property === 'transform') { + row.style.transform = ''; + } else { + row.style.opacity = ''; + } + } + }); + for (const { row } of animatedRows) { + settleTimers.set( + row, + window.setTimeout(() => { + settleTimers.delete(row); + clearRowTransition(row); + }, SESSION_ROW_SHIFT_DURATION_MS + 60), + ); + } + }, [listRef, rowSignature]); +} diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionRowShiftTransition.test.tsx b/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionRowShiftTransition.test.tsx new file mode 100644 index 0000000000..2ea3cc51d2 --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionRowShiftTransition.test.tsx @@ -0,0 +1,192 @@ +// @vitest-environment jsdom + +import React, { useRef } from 'react'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + SESSION_ROW_SHIFT_DURATION_MS, + useSessionRowRemovalTransition, +} from './sessionRowShift'; + +const ROW_HEIGHT = 26; + +/** jsdom has no layout; stack rows by their index and size the list by count. */ +function stubRowLayout(): () => void { + const originalTop = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetTop'); + const originalHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetHeight'); + Object.defineProperty(HTMLElement.prototype, 'offsetTop', { + configurable: true, + get(this: HTMLElement) { + const parent = this.parentElement; + if (!parent) return 0; + return Array.prototype.indexOf.call(parent.children, this) * ROW_HEIGHT; + }, + }); + Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { + configurable: true, + get(this: HTMLElement) { + return this.classList.contains('bitfun-nav-panel__inline-list') + ? this.children.length * ROW_HEIGHT + : ROW_HEIGHT; + }, + }); + const restore = ( + property: 'offsetTop' | 'offsetHeight', + descriptor: PropertyDescriptor | undefined, + ) => { + if (descriptor) { + Object.defineProperty(HTMLElement.prototype, property, descriptor); + } else { + delete (HTMLElement.prototype as unknown as Record)[property]; + } + }; + return () => { + restore('offsetTop', originalTop); + restore('offsetHeight', originalHeight); + }; +} + +const SessionList: React.FC<{ sessionIds: string[] }> = ({ sessionIds }) => { + const listRef = useRef(null); + useSessionRowRemovalTransition(listRef, sessionIds.join('|')); + return ( +
+ {sessionIds.map(sessionId => ( +
+ ))} +
+ ); +}; + +const nextFrame = (): Promise => + new Promise(resolve => requestAnimationFrame(() => resolve())); + +const rowOf = (container: HTMLElement, sessionId: string): HTMLElement => + container.querySelector(`[data-session-id="${sessionId}"]`)!; + +describe('useSessionRowRemovalTransition', () => { + let container: HTMLDivElement; + let root: Root; + let restoreRowLayout: () => void; + + beforeEach(() => { + restoreRowLayout = stubRowLayout(); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + restoreRowLayout(); + }); + + it('starts the rows below a deleted session from their previous slot', async () => { + await act(async () => { + root.render(); + }); + + await act(async () => { + root.render(); + }); + + expect(rowOf(container, 'a').style.transform).toBe(''); + expect(rowOf(container, 'c').style.transform).toBe(`translateY(${ROW_HEIGHT}px)`); + expect(rowOf(container, 'c').style.transition).toBe('none'); + expect(rowOf(container, 'd').style.transform).toBe(`translateY(${ROW_HEIGHT}px)`); + + await act(async () => { await nextFrame(); }); + + expect(rowOf(container, 'c').style.transform).toBe(''); + expect(rowOf(container, 'c').style.transition).toContain(`${SESSION_ROW_SHIFT_DURATION_MS}ms`); + }); + + it('fades the backfilled row in instead of popping it into the freed slot', async () => { + await act(async () => { + root.render(); + }); + + await act(async () => { + root.render(); + }); + + expect(rowOf(container, 'd').style.opacity).toBe('0'); + + await act(async () => { await nextFrame(); }); + + expect(rowOf(container, 'd').style.opacity).toBe(''); + expect(rowOf(container, 'd').style.transition).toContain('opacity'); + }); + + it('keeps the slide running when a backfilled row lands mid-animation', async () => { + await act(async () => { + root.render(); + }); + await act(async () => { + root.render(); + }); + await act(async () => { await nextFrame(); }); + + // A background metadata refresh appends the replacement row. + await act(async () => { + root.render(); + }); + + expect(rowOf(container, 'c').style.transition).toContain('transform'); + }); + + it('closes the list height up with the rows so content below follows', async () => { + await act(async () => { + root.render(); + }); + + await act(async () => { + root.render(); + }); + + const list = container.querySelector('.bitfun-nav-panel__inline-list')!; + expect(list.style.height).toBe(`${3 * ROW_HEIGHT}px`); + expect(list.style.overflow).toBe('hidden'); + + await act(async () => { await nextFrame(); }); + + expect(list.style.height).toBe(`${2 * ROW_HEIGHT}px`); + expect(list.style.transition).toContain('height'); + }); + + it('retargets the collapse when a row lands before it settles', async () => { + await act(async () => { + root.render(); + }); + await act(async () => { + root.render(); + }); + await act(async () => { await nextFrame(); }); + + await act(async () => { + root.render(); + }); + + const list = container.querySelector('.bitfun-nav-panel__inline-list')!; + expect(list.style.height).toBe(`${3 * ROW_HEIGHT}px`); + }); + + it('does not animate when rows are only added', async () => { + await act(async () => { + root.render(); + }); + await act(async () => { + root.render(); + }); + + expect(rowOf(container, 'b').style.transform).toBe(''); + expect(rowOf(container, 'c').style.transform).toBe(''); + }); +});