diff --git a/src/renderer/components/team/messages/MessagesPanel.tsx b/src/renderer/components/team/messages/MessagesPanel.tsx index d8407004..ce58a93d 100644 --- a/src/renderer/components/team/messages/MessagesPanel.tsx +++ b/src/renderer/components/team/messages/MessagesPanel.tsx @@ -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 | 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): void => { + persistMessagesScrollTop(event.currentTarget.scrollTop); + }, + [persistMessagesScrollTop] + ); + useEffect(() => { setTeamMessagesSidebarUiState(teamName, { messagesSearchQuery, @@ -1355,7 +1391,7 @@ export const MessagesPanel = memo(function MessagesPanel({
setMessagesScrollTop(e.currentTarget.scrollTop)} + onScroll={handleSidebarScroll} >
{defaultComposerSection} diff --git a/test/renderer/components/team/messages/MessagesPanel.test.ts b/test/renderer/components/team/messages/MessagesPanel.test.ts index c9af6a97..66fd1697 100644 --- a/test/renderer/components/team/messages/MessagesPanel.test.ts +++ b/test/renderer/components/team/messages/MessagesPanel.test.ts @@ -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');