mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-30 16:23:35 +08:00
Merge e9b328d65b721e4d087648f4c6e9b5fc29d15ab4 into d587ddbb196a61c49f7edc941479b0a9ee416ebe
This commit is contained in:
commit
519afb1e4d
@ -1,6 +1,7 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||||
import '@testing-library/jest-dom'
|
import '@testing-library/jest-dom'
|
||||||
|
import { Profiler, type ProfilerOnRenderCallback } from 'react'
|
||||||
|
|
||||||
const desktopUiPreferencesApiMock = vi.hoisted(() => ({
|
const desktopUiPreferencesApiMock = vi.hoisted(() => ({
|
||||||
getPreferences: vi.fn(),
|
getPreferences: vi.fn(),
|
||||||
@ -1313,4 +1314,195 @@ describe('Sidebar', () => {
|
|||||||
value: originalVisibility,
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -5,10 +5,10 @@ import { useUIStore } from '../../stores/uiStore'
|
|||||||
import { useTranslation, type TranslationKey } from '../../i18n'
|
import { useTranslation, type TranslationKey } from '../../i18n'
|
||||||
import { ConfirmDialog } from '../shared/ConfirmDialog'
|
import { ConfirmDialog } from '../shared/ConfirmDialog'
|
||||||
import { GlobalSearchModal } from '../search/GlobalSearchModal'
|
import { GlobalSearchModal } from '../search/GlobalSearchModal'
|
||||||
import { FindInPageModal } from '../search/FindInPageModal'
|
|
||||||
import type { SessionListItem } from '../../types/session'
|
import type { SessionListItem } from '../../types/session'
|
||||||
import { useTabStore, SETTINGS_TAB_ID, SCHEDULED_TAB_ID, MARKET_TAB_ID } from '../../stores/tabStore'
|
import { useTabStore, SETTINGS_TAB_ID, SCHEDULED_TAB_ID, MARKET_TAB_ID } from '../../stores/tabStore'
|
||||||
import { useChatStore } from '../../stores/chatStore'
|
import { useChatStore } from '../../stores/chatStore'
|
||||||
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
import { useOpenTargetStore } from '../../stores/openTargetStore'
|
import { useOpenTargetStore } from '../../stores/openTargetStore'
|
||||||
import { desktopUiPreferencesApi, type SidebarProjectPreferences } from '../../api/desktopUiPreferences'
|
import { desktopUiPreferencesApi, type SidebarProjectPreferences } from '../../api/desktopUiPreferences'
|
||||||
import { getDesktopHost } from '../../lib/desktopHost'
|
import { getDesktopHost } from '../../lib/desktopHost'
|
||||||
@ -70,7 +70,14 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
|
|||||||
const closeModal = useUIStore((s) => s.closeModal)
|
const closeModal = useUIStore((s) => s.closeModal)
|
||||||
const activeTabId = useTabStore((s) => s.activeTabId)
|
const activeTabId = useTabStore((s) => s.activeTabId)
|
||||||
const tabs = useTabStore((s) => s.tabs)
|
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 closeTab = useTabStore((s) => s.closeTab)
|
||||||
const disconnectSession = useChatStore((s) => s.disconnectSession)
|
const disconnectSession = useChatStore((s) => s.disconnectSession)
|
||||||
const [contextMenu, setContextMenu] = useState<{ id: string; x: number; y: number } | null>(null)
|
const [contextMenu, setContextMenu] = useState<{ id: string; x: number; y: number } | null>(null)
|
||||||
@ -137,17 +144,12 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
|
|||||||
[sessions],
|
[sessions],
|
||||||
)
|
)
|
||||||
const runningSessionIds = useMemo(() => {
|
const runningSessionIds = useMemo(() => {
|
||||||
const ids = new Set<string>()
|
const ids = new Set<string>(chatRunningSessionIds)
|
||||||
for (const tab of tabs) {
|
for (const tab of tabs) {
|
||||||
if (tab.type === 'session' && tab.status === 'running') ids.add(tab.sessionId)
|
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
|
return ids
|
||||||
}, [chatSessions, tabs])
|
}, [chatRunningSessionIds, tabs])
|
||||||
const pendingBatchDeleteSessions = useMemo(
|
const pendingBatchDeleteSessions = useMemo(
|
||||||
() => (pendingBatchDeleteSessionIds ?? [])
|
() => (pendingBatchDeleteSessionIds ?? [])
|
||||||
.map((sessionId) => sessionsById.get(sessionId))
|
.map((sessionId) => sessionsById.get(sessionId))
|
||||||
@ -1222,7 +1224,6 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<GlobalSearchModal open={activeModal === 'globalSearch'} onClose={closeModal} />
|
<GlobalSearchModal open={activeModal === 'globalSearch'} onClose={closeModal} />
|
||||||
<FindInPageModal open={activeModal === 'findInPage'} onClose={closeModal} />
|
|
||||||
</aside>
|
</aside>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user