Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
77 changes: 77 additions & 0 deletions desktop/src/components/layout/Sidebar.test.tsx
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -235,6 +236,7 @@ describe('Sidebar', () => {
deleteSessions,
})
useChatStore.setState({
sessions: {},
connectToSession,
disconnectSession,
} as Partial<ReturnType<typeof useChatStore.getState>>)
Expand Down Expand Up @@ -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(<Profiler id="sidebar" onRender={onRender}><Sidebar /></Profiler>)
})
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(<Profiler id="sidebar" onRender={onRender}><Sidebar /></Profiler>)
})
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'))

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