Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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, '\\$&');

Expand Down Expand Up @@ -237,7 +245,14 @@ const SessionsSection: React.FC<SessionsSectionProps> = ({
const sessionMenuPopoverRef = useRef<HTMLDivElement>(null);
const sessionMenuAnchorRef = useRef<HTMLButtonElement>(null);
const metadataLoadRequestIdRef = useRef(0);
/** User-driven metadata loads still running; background loads yield to them. */
const foregroundLoadCountRef = useRef(0);
const initialMetadataLoadKeyRef = useRef<string | null>(null);
const sessionListRef = useRef<HTMLDivElement>(null);
/** Last (scope, live, synced) triple a background reconcile ran for. */
const liveReconcileSignatureRef = useRef<string | null>(null);
/** Last (scope, cursor, size) triple a buffer prefetch ran for. */
const bufferPrefetchSignatureRef = useRef<string | null>(null);

// Subscribe to state machine changes for running status
useEffect(() => {
Expand Down Expand Up @@ -326,6 +341,8 @@ const SessionsSection: React.FC<SessionsSectionProps> = ({
useEffect(() => {
metadataLoadRequestIdRef.current += 1;
initialMetadataLoadKeyRef.current = null;
liveReconcileSignatureRef.current = null;
bufferPrefetchSignatureRef.current = null;
setExpandLevel(0);
setMetadataPageState({
totalTopLevelCount: null,
Expand All @@ -338,18 +355,39 @@ const SessionsSection: React.FC<SessionsSectionProps> = ({
}, [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(
Expand Down Expand Up @@ -378,7 +416,7 @@ const SessionsSection: React.FC<SessionsSectionProps> = ({
}
return page;
} catch (error) {
if (metadataLoadRequestIdRef.current === requestId) {
if (metadataLoadRequestIdRef.current === requestId && !isBackgroundLoad) {
setMetadataPageState(prev => ({
...prev,
isLoading: false,
Expand All @@ -387,6 +425,10 @@ const SessionsSection: React.FC<SessionsSectionProps> = ({
}
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]
Expand Down Expand Up @@ -490,6 +532,8 @@ const SessionsSection: React.FC<SessionsSectionProps> = ({
useEffect(() => {
const handler = () => {
metadataLoadRequestIdRef.current += 1;
liveReconcileSignatureRef.current = null;
bufferPrefetchSignatureRef.current = null;
setExpandLevel(0);
setMetadataPageState({
totalTopLevelCount: null,
Expand Down Expand Up @@ -690,8 +734,23 @@ const SessionsSection: React.FC<SessionsSectionProps> = ({
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,
Expand All @@ -701,6 +760,60 @@ const SessionsSection: React.FC<SessionsSectionProps> = ({
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 }> = [];
Expand All @@ -712,6 +825,12 @@ const SessionsSection: React.FC<SessionsSectionProps> = ({
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
Expand Down Expand Up @@ -1102,7 +1221,7 @@ const SessionsSection: React.FC<SessionsSectionProps> = ({
}

return (
<div className="bitfun-nav-panel__inline-list">
<div className="bitfun-nav-panel__inline-list" ref={sessionListRef}>
{dispatchTargetFilterOptions.length > 1 ? (
<label className="bitfun-nav-panel__session-target-filter">
<span>{t('nav.sessions.filterLabel')}</span>
Expand Down Expand Up @@ -1578,8 +1697,17 @@ const SessionsSection: React.FC<SessionsSectionProps> = ({
)}
</div>
);
return isEditing || openMenuSessionId !== null ? row : (
<Tooltip key={session.sessionId} content={tooltipContent} placement="right" followCursor>
// 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 (
<Tooltip
key={session.sessionId}
content={tooltipContent}
placement="right"
followCursor
disabled={isEditing || openMenuSessionId !== null}
>
{row}
</Tooltip>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';

import {
getEffectiveTopLevelSessionCount,
getSessionBufferPrefetchLimit,
getSessionExpandToggleState,
} from './sessionNavExpand';

Expand Down Expand Up @@ -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);
});
});
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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
Expand Down
Loading