diff --git a/desktop/src/components/layout/Sidebar.test.tsx b/desktop/src/components/layout/Sidebar.test.tsx index b058f593..0a2453f2 100644 --- a/desktop/src/components/layout/Sidebar.test.tsx +++ b/desktop/src/components/layout/Sidebar.test.tsx @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import '@testing-library/jest-dom' +import { Profiler, type ProfilerOnRenderCallback } from 'react' const desktopUiPreferencesApiMock = vi.hoisted(() => ({ getPreferences: vi.fn(), @@ -1313,4 +1314,195 @@ describe('Sidebar', () => { value: originalVisibility, }) }) + + describe('streaming optimization', () => { + it('does not re-render when only streamingText changes during token streaming', async () => { + const now = new Date().toISOString() + useSessionStore.setState({ + sessions: [makeSession('session-1', 'Test Session', '/workspace/alpha', now)], + }) + useChatStore.setState({ + sessions: { + 'session-1': makeChatSessionState({ chatState: 'idle', streamingText: '' }), + }, + }) + + const renders: string[] = [] + const onRender: ProfilerOnRenderCallback = (_, phase) => { renders.push(phase) } + + render( + + + , + ) + + await act(async () => { await Promise.resolve() }) + await act(async () => { await Promise.resolve() }) + const initialCount = renders.length + + // Simulate streaming token flush: only streamingText changes, + // creating a new top-level sessions reference (as updateSessionIn does) + act(() => { + const prev = useChatStore.getState().sessions + const session = prev['session-1'] + useChatStore.setState({ + sessions: { ...prev, 'session-1': { ...session, streamingText: 'Hello world' } }, + }) + }) + + expect(renders.length).toBe(initialCount) + }) + + it('does not re-render across multiple rapid streamingText updates', async () => { + const now = new Date().toISOString() + useSessionStore.setState({ + sessions: [makeSession('session-1', 'Test Session', '/workspace/alpha', now)], + }) + useChatStore.setState({ + sessions: { + 'session-1': makeChatSessionState({ chatState: 'idle', streamingText: '' }), + }, + }) + + const renders: string[] = [] + const onRender: ProfilerOnRenderCallback = (_, phase) => { renders.push(phase) } + + render( + + + , + ) + + await act(async () => { await Promise.resolve() }) + await act(async () => { await Promise.resolve() }) + const initialCount = renders.length + + // Simulate 5 rapid token flushes (as the 50ms interval would produce) + for (let i = 0; i < 5; i++) { + act(() => { + const prev = useChatStore.getState().sessions + const session = prev['session-1'] + useChatStore.setState({ + sessions: { + ...prev, + 'session-1': { ...session, streamingText: session.streamingText + ' token' + i }, + }, + }) + }) + } + + expect(renders.length).toBe(initialCount) + }) + + it('does not re-render when only elapsedSeconds changes', async () => { + const now = new Date().toISOString() + useSessionStore.setState({ + sessions: [makeSession('session-1', 'Test Session', '/workspace/alpha', now)], + }) + useChatStore.setState({ + sessions: { + 'session-1': makeChatSessionState({ chatState: 'idle', elapsedSeconds: 0 }), + }, + }) + + const renders: string[] = [] + const onRender: ProfilerOnRenderCallback = (_, phase) => { renders.push(phase) } + + render( + + + , + ) + + await act(async () => { await Promise.resolve() }) + await act(async () => { await Promise.resolve() }) + const initialCount = renders.length + + act(() => { + const prev = useChatStore.getState().sessions + const session = prev['session-1'] + useChatStore.setState({ + sessions: { ...prev, 'session-1': { ...session, elapsedSeconds: 42 } }, + }) + }) + + expect(renders.length).toBe(initialCount) + }) + + it('re-renders and shows running indicator when chatState transitions to streaming', async () => { + const now = new Date().toISOString() + useSessionStore.setState({ + sessions: [makeSession('session-1', 'Test Session', '/workspace/alpha', now)], + }) + useChatStore.setState({ + sessions: { + 'session-1': makeChatSessionState({ chatState: 'idle' }), + }, + }) + + const renders: string[] = [] + const onRender: ProfilerOnRenderCallback = (_, phase) => { renders.push(phase) } + + render( + + + , + ) + + await act(async () => { await Promise.resolve() }) + await act(async () => { await Promise.resolve() }) + + expect(screen.queryByLabelText('Session running')).not.toBeInTheDocument() + const countBefore = renders.length + + act(() => { + const prev = useChatStore.getState().sessions + const session = prev['session-1'] + useChatStore.setState({ + sessions: { ...prev, 'session-1': { ...session, chatState: 'streaming' } }, + }) + }) + + expect(renders.length).toBeGreaterThan(countBefore) + expect(screen.getByLabelText('Session running')).toBeInTheDocument() + }) + + it('re-renders and hides running indicator when chatState transitions back to idle', async () => { + const now = new Date().toISOString() + useSessionStore.setState({ + sessions: [makeSession('session-1', 'Test Session', '/workspace/alpha', now)], + }) + useChatStore.setState({ + sessions: { + 'session-1': makeChatSessionState({ chatState: 'streaming' }), + }, + }) + + const renders: string[] = [] + const onRender: ProfilerOnRenderCallback = (_, phase) => { renders.push(phase) } + + render( + + + , + ) + + await act(async () => { await Promise.resolve() }) + await act(async () => { await Promise.resolve() }) + + expect(screen.getByLabelText('Session running')).toBeInTheDocument() + const countBefore = renders.length + + act(() => { + const prev = useChatStore.getState().sessions + const session = prev['session-1'] + useChatStore.setState({ + sessions: { ...prev, 'session-1': { ...session, chatState: 'idle' } }, + }) + }) + + expect(renders.length).toBeGreaterThan(countBefore) + expect(screen.queryByLabelText('Session running')).not.toBeInTheDocument() + }) + }) }) diff --git a/desktop/src/components/layout/Sidebar.tsx b/desktop/src/components/layout/Sidebar.tsx index c6080879..f6389f2f 100644 --- a/desktop/src/components/layout/Sidebar.tsx +++ b/desktop/src/components/layout/Sidebar.tsx @@ -5,10 +5,10 @@ import { useUIStore } from '../../stores/uiStore' import { useTranslation, type TranslationKey } from '../../i18n' import { ConfirmDialog } from '../shared/ConfirmDialog' import { GlobalSearchModal } from '../search/GlobalSearchModal' -import { FindInPageModal } from '../search/FindInPageModal' import type { SessionListItem } from '../../types/session' import { useTabStore, SETTINGS_TAB_ID, SCHEDULED_TAB_ID, MARKET_TAB_ID } from '../../stores/tabStore' import { useChatStore } from '../../stores/chatStore' +import { useShallow } from 'zustand/react/shallow' import { useOpenTargetStore } from '../../stores/openTargetStore' import { desktopUiPreferencesApi, type SidebarProjectPreferences } from '../../api/desktopUiPreferences' import { getDesktopHost } from '../../lib/desktopHost' @@ -70,7 +70,14 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) { const closeModal = useUIStore((s) => s.closeModal) const activeTabId = useTabStore((s) => s.activeTabId) const tabs = useTabStore((s) => s.tabs) - const chatSessions = useChatStore((s) => s.sessions) + const chatRunningSessionIds = useChatStore(useShallow((s) => + Object.entries(s.sessions) + .filter(([, st]) => + st.chatState !== 'idle' || hasRunningBackgroundTasks(st.backgroundAgentTasks), + ) + .map(([id]) => id) + .sort(), + )) const closeTab = useTabStore((s) => s.closeTab) const disconnectSession = useChatStore((s) => s.disconnectSession) const [contextMenu, setContextMenu] = useState<{ id: string; x: number; y: number } | null>(null) @@ -137,17 +144,12 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) { [sessions], ) const runningSessionIds = useMemo(() => { - const ids = new Set() + const ids = new Set(chatRunningSessionIds) for (const tab of tabs) { if (tab.type === 'session' && tab.status === 'running') ids.add(tab.sessionId) } - for (const [sessionId, sessionState] of Object.entries(chatSessions)) { - if (sessionState.chatState !== 'idle' || hasRunningBackgroundTasks(sessionState.backgroundAgentTasks)) { - ids.add(sessionId) - } - } return ids - }, [chatSessions, tabs]) + }, [chatRunningSessionIds, tabs]) const pendingBatchDeleteSessions = useMemo( () => (pendingBatchDeleteSessionIds ?? []) .map((sessionId) => sessionsById.get(sessionId)) @@ -1222,7 +1224,6 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) { /> - ) }