diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c8196c..0d590c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ All notable changes to DreamCoder will be documented in this file. ### Fixed +- The sidebar no longer re-renders on streamed text updates. Running indicators still follow session and tab status. - New sessions start on the active provider's main model. Before, activating a provider saved its main model in a hidden setting, and that saved value kept overriding the provider after its main model was edited. - The selected model card in the dark theme had light pink colors with text that was hard to read. diff --git a/desktop/src/components/layout/Sidebar.test.tsx b/desktop/src/components/layout/Sidebar.test.tsx index 97ee3eb..6220fef 100644 --- a/desktop/src/components/layout/Sidebar.test.tsx +++ b/desktop/src/components/layout/Sidebar.test.tsx @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { Profiler } from 'react' import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import '@testing-library/jest-dom' @@ -235,6 +236,7 @@ describe('Sidebar', () => { deleteSessions, }) useChatStore.setState({ + sessions: {}, connectToSession, disconnectSession, } as Partial>) @@ -879,6 +881,81 @@ describe('Sidebar', () => { expect(idleRow).toHaveAttribute('title', 'Idle Source') }) + it('does not re-render for streamed text or idle session changes', async () => { + vi.useFakeTimers() + useSessionStore.setState({ + sessions: [makeSession('running', 'Running session', '/workspace/repo', '2026-05-19T12:00:00.000Z')], + }) + useChatStore.setState({ + sessions: { running: { ...useChatStore.getState().getSession('running'), chatState: 'streaming' } }, + }) + const onRender = vi.fn() + await act(async () => { + render() + }) + onRender.mockClear() + + act(() => { + useChatStore.getState().handleServerMessage('running', { type: 'content_delta', text: 'Hello' }) + vi.advanceTimersByTime(50) + }) + + expect(useChatStore.getState().getSession('running').streamingText).toBe('Hello') + expect(onRender).not.toHaveBeenCalled() + expect(screen.getByLabelText('Session running')).toBeInTheDocument() + + act(() => { + useChatStore.setState((state) => ({ + sessions: { idle: state.getSession('idle'), ...state.sessions }, + })) + }) + + expect(onRender).not.toHaveBeenCalled() + }) + + it('updates running indicators only when running session membership changes', async () => { + useSessionStore.setState({ + sessions: [ + makeSession('first', 'First session', '/workspace/repo', '2026-05-19T12:00:00.000Z'), + makeSession('second', 'Second session', '/workspace/repo', '2026-05-19T11:00:00.000Z'), + ], + }) + const first = useChatStore.getState().getSession('first') + const second = useChatStore.getState().getSession('second') + const onRender = vi.fn() + await act(async () => { + render() + }) + onRender.mockClear() + expect(screen.queryByLabelText('Session running')).not.toBeInTheDocument() + + act(() => { + useChatStore.setState({ sessions: { first: { ...first, chatState: 'thinking' }, second } }) + }) + expect(onRender).toHaveBeenCalledOnce() + expect(within(screen.getByRole('button', { name: /^First session/ })).getByLabelText('Session running')).toBeInTheDocument() + onRender.mockClear() + + act(() => { + useChatStore.setState({ sessions: { first: { ...first, chatState: 'tool_executing' }, second } }) + }) + expect(onRender).not.toHaveBeenCalled() + + act(() => { + useChatStore.setState({ sessions: { first, second: { ...second, chatState: 'streaming' } } }) + }) + expect(onRender).toHaveBeenCalledOnce() + expect(within(screen.getByRole('button', { name: /^First session/ })).queryByLabelText('Session running')).not.toBeInTheDocument() + expect(within(screen.getByRole('button', { name: /^Second session/ })).getByLabelText('Session running')).toBeInTheDocument() + onRender.mockClear() + + act(() => { + useChatStore.setState({ sessions: {} }) + }) + expect(onRender).toHaveBeenCalledOnce() + expect(screen.queryByLabelText('Session running')).not.toBeInTheDocument() + }) + it('shows a toast when session creation fails', async () => { createSession.mockRejectedValue(new Error('boom')) diff --git a/desktop/src/components/layout/Sidebar.tsx b/desktop/src/components/layout/Sidebar.tsx index a0496d7..4a5a922 100644 --- a/desktop/src/components/layout/Sidebar.tsx +++ b/desktop/src/components/layout/Sidebar.tsx @@ -1,4 +1,5 @@ import { useEffect, useState, useCallback, useMemo, useRef } from 'react' +import { useShallow } from 'zustand/react/shallow' import { ChevronDown, ChevronRight, FolderOpen, GitBranch, ListChecks, LoaderCircle, MoreHorizontal, Pencil, Pin, PinOff, Plus, RotateCcw, SquarePen, Trash2, X } from 'lucide-react' import { DreamCoderIcon } from '../shared/DreamCoderIcon' import { useSessionStore } from '../../stores/sessionStore' @@ -66,7 +67,13 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) { const toggleSidebar = useUIStore((s) => s.toggleSidebar) const activeTabId = useTabStore((s) => s.activeTabId) const tabs = useTabStore((s) => s.tabs) - const chatSessions = useChatStore((s) => s.sessions) + const runningChatSessionIds = useChatStore(useShallow((s) => { + const ids = new Set() + for (const [sessionId, session] of Object.entries(s.sessions)) { + if (session.chatState !== 'idle') ids.add(sessionId) + } + return ids + })) const closeTab = useTabStore((s) => s.closeTab) const disconnectSession = useChatStore((s) => s.disconnectSession) const [searchQuery, setSearchQuery] = useState('') @@ -186,15 +193,12 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) { [sessions], ) const runningSessionIds = useMemo(() => { - const ids = new Set() + const ids = new Set(runningChatSessionIds) 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') ids.add(sessionId) - } return ids - }, [chatSessions, tabs]) + }, [runningChatSessionIds, tabs]) const pendingBatchDeleteSessions = useMemo( () => (pendingBatchDeleteSessionIds ?? []) .map((sessionId) => sessionsById.get(sessionId))