Skip to content
Open
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
79 changes: 79 additions & 0 deletions web/src/components/SessionList.directory-action.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -779,6 +779,85 @@ describe('SessionList collapse behavior', () => {
expect(screen.getByTitle('In progress').getAttribute('aria-expanded')).toBe('true')
})

it('allows a directory group to collapse while searching', () => {
const sessions = [
makeSession({
id: 'session-match',
updatedAt: 100,
metadata: { path: '/work/hapi', name: 'Matching task', flavor: 'codex' },
}),
makeSession({
id: 'session-other',
updatedAt: 90,
metadata: { path: '/work/hapi', name: 'Other task', flavor: 'codex' },
}),
]
render(renderSessionList(sessions, null))

const projectHeader = screen.getByTitle('/work/hapi')
const projectPanel = projectHeader.nextElementSibling
expect(projectPanel?.getAttribute('data-open')).toBeNull()

// Establish a manual collapsed state before filtering. The filter
// still opens the group by default so matching sessions remain visible.
fireEvent.click(projectHeader)
fireEvent.click(projectHeader)
expect(projectPanel?.getAttribute('data-open')).toBeNull()

fireEvent.click(screen.getByRole('button', { name: SEARCH_LABEL }))
const searchInput = screen.getByPlaceholderText(SEARCH_PLACEHOLDER)
fireEvent.change(searchInput, {
target: { value: 'Matching' },
})

expect(projectPanel?.getAttribute('data-open')).toBe('true')

fireEvent.click(projectHeader)
expect(projectPanel?.getAttribute('data-open')).toBeNull()

fireEvent.click(projectHeader)
expect(projectPanel?.getAttribute('data-open')).toBe('true')

fireEvent.change(searchInput, { target: { value: '' } })
expect(projectPanel?.getAttribute('data-open')).toBeNull()
})

it('restores a selected group collapse after filtering excludes the selected session', async () => {
const sessions = [
makeSession({
id: 'session-selected',
updatedAt: 100,
metadata: { path: '/work/hapi', name: 'Selected task', flavor: 'codex' },
}),
makeSession({
id: 'session-match',
updatedAt: 90,
metadata: { path: '/work/hapi', name: 'Matching task', flavor: 'codex' },
}),
]
render(renderSessionList(sessions, 'session-selected'))

const projectHeader = screen.getByTitle('/work/hapi')
const projectPanel = projectHeader.nextElementSibling
await waitFor(() => {
expect(projectPanel?.getAttribute('data-open')).toBe('true')
})

fireEvent.click(projectHeader)
expect(projectPanel?.getAttribute('data-open')).toBeNull()

fireEvent.click(screen.getByRole('button', { name: SEARCH_LABEL }))
const searchInput = screen.getByPlaceholderText(SEARCH_PLACEHOLDER)
fireEvent.change(searchInput, { target: { value: 'Matching' } })
expect(screen.queryByRole('button', { name: /Selected task/ })).toBeNull()
expect(screen.getByRole('button', { name: /Matching task/ })).toBeInTheDocument()

fireEvent.change(searchInput, { target: { value: '' } })
await waitFor(() => {
expect(projectPanel?.getAttribute('data-open')).toBeNull()
})
})

it('toggles the running section with the keyboard', () => {
localStorage.setItem('hapi-pin-in-progress-sessions', 'true')
const sessions = [
Expand Down
28 changes: 22 additions & 6 deletions web/src/components/SessionList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1389,28 +1389,41 @@ export function SessionList(props: {
const [collapseOverrides, setCollapseOverrides] = useState<Map<string, boolean>>(
() => new Map()
)
const [filterCollapseOverrides, setFilterCollapseOverrides] = useState<Map<string, boolean>>(
() => new Map()
)
const [runningSectionCollapsed, setRunningSectionCollapsed] = useState(false)
const [activeSectionCollapsed, setActiveSectionCollapsed] = useState(false)
const [pinnedSectionCollapsed, setPinnedSectionCollapsed] = useState(false)
const autoExpandedSelectedSessionKeyRef = useRef<string | null>(null)
const isGroupCollapsed = (group: SessionGroup): boolean => {
if (isFiltering) return false
const override = collapseOverrides.get(group.key)
const override = isFiltering
? filterCollapseOverrides.get(group.key)
: collapseOverrides.get(group.key)
if (override !== undefined) return override
// Keep matching groups open by default while filtering. Explicit
// header clicks use a temporary override for the active filter.
if (isFiltering) return false
const hasSelectedSession = selectedSessionId
? group.sessions.some(session => session.id === selectedSessionId)
: false
return !group.hasActiveSession && !group.hasPinnedSession && !hasSelectedSession
}

const toggleGroup = (groupKey: string, isCollapsed: boolean) => {
setCollapseOverrides(prev => {
const setOverrides = isFiltering ? setFilterCollapseOverrides : setCollapseOverrides
setOverrides(prev => {
const next = new Map(prev)
next.set(groupKey, !isCollapsed)
return next
})
}

useEffect(() => {
if (isFiltering) return
setFilterCollapseOverrides(prev => prev.size === 0 ? prev : new Map())
}, [isFiltering])

// Per-group reveal cap for paginated session previews. Absent = the configured
// preview limit; expand/collapse controls move the cap by one preview-sized batch.
const [sessionVisibleCounts, setSessionVisibleCounts] = useState<Map<string, number>>(
Expand Down Expand Up @@ -1727,16 +1740,19 @@ export function SessionList(props: {
// (e.g. it moved to the pinned "in progress" section). Drop the
// guard so it auto-expands again when it transitions back into a
// group later.
autoExpandedSelectedSessionKeyRef.current = null
if (!isFiltering) {
autoExpandedSelectedSessionKeyRef.current = null
}
return
}

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

setCollapseOverrides(prev => expandSelectedSessionCollapseOverrides(prev, group))
}, [selectedSessionId, groups])
const setOverrides = isFiltering ? setFilterCollapseOverrides : setCollapseOverrides
setOverrides(prev => expandSelectedSessionCollapseOverrides(prev, group))
}, [selectedSessionId, groups, isFiltering])

// Clean up stale collapse overrides
useEffect(() => {
Expand Down
Loading