perf: debounce messages scroll persistence

This commit is contained in:
777genius 2026-05-29 12:04:33 +03:00
parent fa3f8ce85c
commit 3c37b22379
2 changed files with 100 additions and 1 deletions

View file

@ -87,6 +87,7 @@ const BOTTOM_SHEET_COLLAPSED_SNAP_INDEX = 1;
const BOTTOM_SHEET_COMPOSER_SNAP_INDEX = 2;
const BOTTOM_SHEET_FULL_SNAP_INDEX = 4;
const OPENCODE_RUNTIME_DELIVERY_STATUS_REFRESH_DELAYS_MS = [15_000, 45_000, 90_000] as const;
const MESSAGES_SCROLL_TOP_PERSIST_DELAY_MS = 100;
interface MessagesPanelProps {
teamName: string;
@ -551,6 +552,8 @@ export const MessagesPanel = memo(function MessagesPanel({
const [messagesScrollTop, setMessagesScrollTop] = useState(
initialSidebarStateRef.current.messagesScrollTop
);
const messagesScrollTopRef = useRef(initialSidebarStateRef.current.messagesScrollTop);
const messagesScrollPersistTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [bottomSheetSnapIndex, setBottomSheetSnapIndex] = useState(
initialSidebarStateRef.current.bottomSheetSnapIndex
);
@ -565,10 +568,43 @@ export const MessagesPanel = memo(function MessagesPanel({
setMessagesCollapsed(initialSidebarStateRef.current.messagesCollapsed);
setMessagesSearchBarVisible(initialSidebarStateRef.current.messagesSearchBarVisible);
setExpandedItemKey(initialSidebarStateRef.current.expandedItemKey);
messagesScrollTopRef.current = initialSidebarStateRef.current.messagesScrollTop;
setMessagesScrollTop(initialSidebarStateRef.current.messagesScrollTop);
setBottomSheetSnapIndex(initialSidebarStateRef.current.bottomSheetSnapIndex);
}, [teamName]);
useEffect(
() => () => {
if (messagesScrollPersistTimerRef.current) {
clearTimeout(messagesScrollPersistTimerRef.current);
messagesScrollPersistTimerRef.current = null;
}
},
[]
);
const persistMessagesScrollTop = useCallback((nextScrollTop: number): void => {
messagesScrollTopRef.current = nextScrollTop;
if (messagesScrollPersistTimerRef.current) {
clearTimeout(messagesScrollPersistTimerRef.current);
}
messagesScrollPersistTimerRef.current = setTimeout(() => {
messagesScrollPersistTimerRef.current = null;
setMessagesScrollTop((current) =>
Math.abs(current - messagesScrollTopRef.current) < 1
? current
: messagesScrollTopRef.current
);
}, MESSAGES_SCROLL_TOP_PERSIST_DELAY_MS);
}, []);
const handleSidebarScroll = useCallback(
(event: React.UIEvent<HTMLDivElement>): void => {
persistMessagesScrollTop(event.currentTarget.scrollTop);
},
[persistMessagesScrollTop]
);
useEffect(() => {
setTeamMessagesSidebarUiState(teamName, {
messagesSearchQuery,
@ -1355,7 +1391,7 @@ export const MessagesPanel = memo(function MessagesPanel({
<div
ref={sidebarScrollRef}
className="min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-hidden pb-14 pr-3 pt-2"
onScroll={(e) => setMessagesScrollTop(e.currentTarget.scrollTop)}
onScroll={handleSidebarScroll}
>
<div className="pl-3">
{defaultComposerSection}

View file

@ -8,6 +8,7 @@ import {
MessagesPanel,
reconcilePendingRepliesByMember,
} from '@renderer/components/team/messages/MessagesPanel';
import { setTeamMessagesSidebarUiState } from '@renderer/components/team/sidebar/teamSidebarUiState';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { OpenCodeRuntimeDeliveryDebugDetails } from '@renderer/utils/openCodeRuntimeDeliveryDiagnostics';
@ -333,6 +334,68 @@ describe('MessagesPanel idle summary invariants', () => {
});
});
it('persists sidebar scroll position after scroll settles', async () => {
vi.useFakeTimers();
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
storeState.teamMessagesByName['atlas-hq'] = {
canonicalMessages: [makeMessage({ messageId: 'm-1', text: 'hello' })],
optimisticMessages: [],
feedRevision: 'rev-1',
nextCursor: null,
hasMore: false,
lastFetchedAt: Date.now(),
loadingHead: false,
loadingOlder: false,
headHydrated: true,
};
root.render(
React.createElement(MessagesPanel, {
teamName: 'atlas-hq',
position: 'sidebar',
onPositionChange: vi.fn(),
members: [],
tasks: [],
timeWindow: null,
pendingRepliesByMember: {},
onPendingReplyChange: vi.fn(),
})
);
await Promise.resolve();
});
vi.mocked(setTeamMessagesSidebarUiState).mockClear();
const scrollContainer = host.querySelector('.overflow-y-auto') as HTMLDivElement | null;
expect(scrollContainer).not.toBeNull();
await act(async () => {
scrollContainer!.scrollTop = 320;
scrollContainer!.dispatchEvent(new Event('scroll', { bubbles: true }));
await Promise.resolve();
});
expect(setTeamMessagesSidebarUiState).not.toHaveBeenCalled();
await act(async () => {
vi.advanceTimersByTime(100);
await Promise.resolve();
});
expect(setTeamMessagesSidebarUiState).toHaveBeenCalledWith(
'atlas-hq',
expect.objectContaining({ messagesScrollTop: 320 })
);
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('hides passive peer summaries by default while unread badge only counts filtered unread messages', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');