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