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(''); + }); +});