Skip to content
Open
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
192 changes: 192 additions & 0 deletions desktop/src/components/layout/Sidebar.test.tsx
Original file line number Diff line number Diff line change
@@ -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(),
Expand Down Expand Up @@ -1295,4 +1296,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(
<Profiler id="stream-text" onRender={onRender}>
<Sidebar />
</Profiler>,
)

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(
<Profiler id="stream-multi" onRender={onRender}>
<Sidebar />
</Profiler>,
)

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(
<Profiler id="elapsed-seconds" onRender={onRender}>
<Sidebar />
</Profiler>,
)

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(
<Profiler id="chatstate-on" onRender={onRender}>
<Sidebar />
</Profiler>,
)

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(
<Profiler id="chatstate-off" onRender={onRender}>
<Sidebar />
</Profiler>,
)

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()
})
})
})
21 changes: 11 additions & 10 deletions desktop/src/components/layout/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -137,17 +144,12 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
[sessions],
)
const runningSessionIds = useMemo(() => {
const ids = new Set<string>()
const ids = new Set<string>(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))
Expand Down Expand Up @@ -1222,7 +1224,6 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
/>

<GlobalSearchModal open={activeModal === 'globalSearch'} onClose={closeModal} />
<FindInPageModal open={activeModal === 'findInPage'} onClose={closeModal} />
</aside>
)
}
Expand Down
Loading