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
101 changes: 100 additions & 1 deletion web/src/components/SessionList.directory-action.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ReactNode } from 'react'
Expand Down Expand Up @@ -95,3 +95,102 @@ describe('SessionList directory action', () => {
expect(screen.queryByRole('button', { name: 'New session in this directory' })).toBeNull()
})
})

describe('SessionList collapse behavior', () => {
function renderSessionList(sessions: SessionSummary[], selectedSessionId = 'session-running') {
return (
<QueryClientProvider client={new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
}
})}>
<I18nProvider>
<SessionList
sessions={sessions}
selectedSessionId={selectedSessionId}
onSelect={vi.fn()}
onNewSession={vi.fn()}
onRefresh={vi.fn()}
isLoading={false}
renderHeader={false}
api={null}
/>
</I18nProvider>
</QueryClientProvider>
)
}

function getProjectPanel(): Element {
const header = screen.getByTitle('/work/hapi')
const panel = header.nextElementSibling
if (!panel) {
throw new Error('Expected project collapse panel')
}
return panel
}

it('keeps a selected running path collapsed across live session-list refreshes', async () => {
const baseSessions = [
makeSession({
id: 'session-running',
active: true,
thinking: true,
pendingRequestsCount: 1,
updatedAt: 100,
metadata: { path: '/work/hapi', name: 'Running task', flavor: 'codex' },
}),
makeSession({
id: 'session-old',
updatedAt: 50,
metadata: { path: '/work/hapi', name: 'Older task', flavor: 'codex' },
})
]
const { rerender } = render(renderSessionList(baseSessions))

expect(getProjectPanel().getAttribute('data-open')).toBe('true')

fireEvent.click(screen.getByTitle('/work/hapi'))
expect(getProjectPanel().getAttribute('data-open')).toBeNull()

rerender(renderSessionList([
{
...baseSessions[0]!,
pendingRequestsCount: 2,
updatedAt: 200,
},
baseSessions[1]!
]))

await waitFor(() => {
expect(getProjectPanel().getAttribute('data-open')).toBeNull()
})
})

it('auto-expands the path again when the selected session changes', async () => {
const sessions = [
makeSession({
id: 'session-running',
active: true,
thinking: true,
updatedAt: 100,
metadata: { path: '/work/hapi', name: 'Running task', flavor: 'codex' },
}),
makeSession({
id: 'session-next',
updatedAt: 90,
metadata: { path: '/work/hapi', name: 'Next task', flavor: 'codex' },
})
]
const { rerender } = render(renderSessionList(sessions))

fireEvent.click(screen.getByTitle('/work/hapi'))
expect(getProjectPanel().getAttribute('data-open')).toBeNull()

rerender(renderSessionList(sessions, 'session-next'))

await waitFor(() => {
expect(getProjectPanel().getAttribute('data-open')).toBe('true')
})
})
})
29 changes: 20 additions & 9 deletions web/src/components/SessionList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -739,6 +739,7 @@ export function SessionList(props: {
const [collapseOverrides, setCollapseOverrides] = useState<Map<string, boolean>>(
() => new Map()
)
const autoExpandedSelectedSessionKeyRef = useRef<string | null>(null)
const isGroupCollapsed = (group: SessionGroup): boolean => {
if (isSearching) return false
const override = collapseOverrides.get(group.key)
Expand Down Expand Up @@ -812,16 +813,26 @@ export function SessionList(props: {
})
}

// Auto-expand group (and machine) containing selected session
// Auto-expand group (and machine) containing the selected session only when
// the selected-session/group pair changes. Without this guard, every live
// session-list refresh (for example tool-call updates from a running selected
// session) reopens a path the user just collapsed.
useEffect(() => {
if (!selectedSessionId) return
setCollapseOverrides(prev => {
const group = allGroups.find(g =>
g.sessions.some(s => s.id === selectedSessionId)
)
if (!group) return prev
return expandSelectedSessionCollapseOverrides(prev, group)
})
if (!selectedSessionId) {
autoExpandedSelectedSessionKeyRef.current = null
return
}

const group = allGroups.find(g =>
g.sessions.some(s => s.id === selectedSessionId)
)
if (!group) return

const autoExpandKey = `${selectedSessionId}::${group.key}`
if (autoExpandedSelectedSessionKeyRef.current === autoExpandKey) return
autoExpandedSelectedSessionKeyRef.current = autoExpandKey

setCollapseOverrides(prev => expandSelectedSessionCollapseOverrides(prev, group))
}, [selectedSessionId, allGroups])

// Clean up stale collapse overrides
Expand Down
Loading