From e9b328d65b721e4d087648f4c6e9b5fc29d15ab4 Mon Sep 17 00:00:00 2001 From: zhb <50901800+zhbdesign@users.noreply.github.com> Date: Fri, 10 Jul 2026 09:24:52 +0800 Subject: [PATCH] =?UTF-8?q?Sidebar=E4=BB=8E=E8=AE=A2=E9=98=85=20sessions?= =?UTF-8?q?=EF=BC=88=E6=AF=8F=E6=AC=A1=20token=20flush=20=E6=96=B0?= =?UTF-8?q?=E5=BC=95=E7=94=A8=EF=BC=89=E6=94=B9=E4=B8=BA=E8=AE=A2=E9=98=85?= =?UTF-8?q?=20runningSession?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sidebar从订阅 sessions(每次 token flush 新引用)改为订阅 runningSession --- .../src/components/layout/Sidebar.test.tsx | 192 ++++++++++++++++++ 1 file changed, 192 insertions(+) diff --git a/desktop/src/components/layout/Sidebar.test.tsx b/desktop/src/components/layout/Sidebar.test.tsx index 99465d3a..e1abd1e0 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(), @@ -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( + + + , + ) + + 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() + }) + }) })