fix: active-only session filter + paginated "Show N more" (closes #901) (#903)

* test: reproduce issue #901 (active-only filter + paginated show more)

* fix: active-only session filter + paginated 'Show N more' (closes #901)

Add a persisted 'Active sessions only' toggle in Settings -> Display that
hides inactive sessions in the sidebar while keeping the selected session
visible. Change 'Show N more' to reveal one batch (preview-limit size) per
click instead of expanding every hidden session at once, with 'Show less'
to collapse back to the initial preview.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

---------

Co-authored-by: HAPI <noreply@hapi.run>
This commit is contained in:
SSU-WEI HUANG
2026-06-18 10:16:42 +08:00
committed by GitHub
co-authored by HAPI
parent 22bf7e04d2
commit f5c0ef245b
6 changed files with 230 additions and 21 deletions
+47
View File
@@ -3,6 +3,8 @@ import type { SessionSummary } from '@/types/api'
import {
deduplicateSessionsByAgentId,
expandSelectedSessionCollapseOverrides,
filterActiveSessionsOnly,
getNextSessionVisibleCount,
getSessionDedupKey,
getVisibleSessionPreview,
isSidebarEmptySessionStub,
@@ -298,6 +300,51 @@ describe('getVisibleSessionPreview', () => {
})
describe('filterActiveSessionsOnly', () => {
it('keeps only active sessions when no selection', () => {
const sessions = [
makeSession({ id: 'live', active: true, metadata: { path: '/p' } }),
makeSession({ id: 'dead', metadata: { path: '/p' } })
]
expect(filterActiveSessionsOnly(sessions).map(s => s.id)).toEqual(['live'])
})
it('keeps the selected inactive session visible', () => {
const sessions = [
makeSession({ id: 'live', active: true, metadata: { path: '/p' } }),
makeSession({ id: 'dead', metadata: { path: '/p' } }),
makeSession({ id: 'selected-dead', metadata: { path: '/p' } })
]
expect(filterActiveSessionsOnly(sessions, 'selected-dead').map(s => s.id).sort())
.toEqual(['live', 'selected-dead'])
})
it('preserves input order', () => {
const sessions = [
makeSession({ id: 'a', active: true, metadata: { path: '/p' } }),
makeSession({ id: 'b', metadata: { path: '/p' } }),
makeSession({ id: 'c', active: true, metadata: { path: '/p' } })
]
expect(filterActiveSessionsOnly(sessions).map(s => s.id)).toEqual(['a', 'c'])
})
})
describe('getNextSessionVisibleCount', () => {
it('reveals one batch of step size per call', () => {
expect(getNextSessionVisibleCount(8, 8, 20)).toBe(16)
expect(getNextSessionVisibleCount(16, 8, 20)).toBe(20)
})
it('never exceeds the total session count', () => {
expect(getNextSessionVisibleCount(18, 8, 20)).toBe(20)
expect(getNextSessionVisibleCount(20, 8, 20)).toBe(20)
})
it('always advances by at least one even with a zero step', () => {
expect(getNextSessionVisibleCount(5, 0, 20)).toBe(6)
})
})
describe('expandSelectedSessionCollapseOverrides', () => {
it('expands collapsed project and machine, but preserves session preview folding', () => {
const overrides = new Map<string, boolean>([
+70 -21
View File
@@ -13,6 +13,7 @@ import { useTranslation } from '@/lib/use-translation'
import { DEFAULT_SESSION_PREVIEW_LIMIT, useSessionPreviewLimit } from '@/hooks/useSessionPreviewLimit'
import { AgentFlavorIcon } from '@/components/AgentFlavorIcon'
import { useSessionListStatusMode } from '@/hooks/useSessionListStatusMode'
import { useShowActiveSessionsOnly } from '@/hooks/useShowActiveSessionsOnly'
import { classifySessionAttention } from '@/lib/sessionAttention'
import { getSessionLastSeenAt } from '@/lib/sessionLastSeen'
import { getAttentionLabel, SessionAttentionIndicator } from '@/components/SessionAttentionIndicator'
@@ -173,6 +174,20 @@ export function prepareSidebarSessions(sessions: SessionSummary[], selectedSessi
.filter(session => shouldShowSessionInSidebar(session, selectedSessionId))
}
// "Active sessions only" view: hide inactive sessions, but never hide the one the
// operator currently has open — otherwise toggling the filter would yank the
// selected session out from under them.
export function filterActiveSessionsOnly(sessions: SessionSummary[], selectedSessionId?: string | null): SessionSummary[] {
return sessions.filter(session => session.active || session.id === selectedSessionId)
}
// Paginated "Show N more": reveal one batch (step) at a time instead of expanding
// every hidden session at once. Always advances by at least one and never exceeds
// the total so the button reliably reaches a fully-expanded state.
export function getNextSessionVisibleCount(current: number, step: number, total: number): number {
return Math.min(current + Math.max(1, step), total)
}
function groupSessionsByDirectory(sessions: SessionSummary[]): SessionGroup[] {
const groups = new Map<string, { directory: string; machineId: string | null; sessions: SessionSummary[] }>()
@@ -793,6 +808,7 @@ export function SessionList(props: {
const { renderHeader = true, api, selectedSessionId, machineLabelsById = {}, onNewSessionInDirectory } = props
const { sessionPreviewLimit } = useSessionPreviewLimit()
const { sessionListStatusMode } = useSessionListStatusMode()
const { showActiveSessionsOnly } = useShowActiveSessionsOnly()
const showDetailedStatus = sessionListStatusMode === 'detailed'
const [searchQuery, setSearchQuery] = useState('')
const [, setCodexImportedSessionsVersion] = useState(0)
@@ -817,8 +833,11 @@ export function SessionList(props: {
}
const allSessions = useMemo(
() => prepareSidebarSessions(props.sessions, selectedSessionId),
[props.sessions, selectedSessionId]
() => {
const prepared = prepareSidebarSessions(props.sessions, selectedSessionId)
return showActiveSessionsOnly ? filterActiveSessionsOnly(prepared, selectedSessionId) : prepared
},
[props.sessions, selectedSessionId, showActiveSessionsOnly]
)
const visibleSessions = useMemo(
() => isSearching
@@ -860,20 +879,30 @@ export function SessionList(props: {
})
}
const isSessionGroupExpanded = (group: SessionGroup): boolean => {
if (isSearching || group.sessions.length <= sessionPreviewLimit) return true
const key = `sessions::${group.key}`
const override = collapseOverrides.get(key)
if (override !== undefined) return !override
return false
// Per-group reveal cap for paginated "Show N more". Absent = collapsed to the
// preview limit; each "Show more" bumps it by one batch (step = preview limit).
const [sessionVisibleCounts, setSessionVisibleCounts] = useState<Map<string, number>>(
() => new Map()
)
const getGroupVisibleCount = (group: SessionGroup): number => {
return sessionVisibleCounts.get(group.key) ?? sessionPreviewLimit
}
const toggleSessionGroup = (group: SessionGroup) => {
const key = `sessions::${group.key}`
const expanded = isSessionGroupExpanded(group)
setCollapseOverrides(prev => {
const showMoreSessions = (group: SessionGroup) => {
setSessionVisibleCounts(prev => {
const next = new Map(prev)
next.set(key, expanded)
const current = prev.get(group.key) ?? sessionPreviewLimit
next.set(group.key, getNextSessionVisibleCount(current, sessionPreviewLimit, group.sessions.length))
return next
})
}
const collapseSessionGroup = (group: SessionGroup) => {
setSessionVisibleCounts(prev => {
if (!prev.has(group.key)) return prev
const next = new Map(prev)
next.delete(group.key)
return next
})
}
@@ -882,9 +911,9 @@ export function SessionList(props: {
return getVisibleSessionPreview(
group.sessions,
{
expanded: isSessionGroupExpanded(group),
expanded: isSearching,
selectedSessionId,
limit: sessionPreviewLimit
limit: getGroupVisibleCount(group)
}
)
}
@@ -959,6 +988,23 @@ export function SessionList(props: {
})
}, [allGroups])
// Clean up reveal caps for groups that no longer exist.
useEffect(() => {
setSessionVisibleCounts(prev => {
if (prev.size === 0) return prev
const knownKeys = new Set(allGroups.map(g => g.key))
const next = new Map(prev)
let changed = false
for (const key of next.keys()) {
if (!knownKeys.has(key)) {
next.delete(key)
changed = true
}
}
return changed ? next : prev
})
}, [allGroups])
return (
<div className="mx-auto w-full max-w-content flex flex-col">
{renderHeader ? (
@@ -1021,7 +1067,8 @@ export function SessionList(props: {
const isCollapsed = isGroupCollapsed(group)
const visibleGroupSessions = getVisibleGroupSessions(group)
const hiddenSessionCount = group.sessions.length - visibleGroupSessions.length
const sessionGroupExpanded = isSessionGroupExpanded(group)
const canCollapseSessions = getGroupVisibleCount(group) > sessionPreviewLimit
const showMoreCount = Math.min(sessionPreviewLimit, hiddenSessionCount)
const canStartInGroupDirectory = group.directory !== 'Other'
return (
<div key={group.key}>
@@ -1072,18 +1119,20 @@ export function SessionList(props: {
showDetailedStatus={showDetailedStatus}
/>
))}
{!isSearching && group.sessions.length > sessionPreviewLimit && (sessionGroupExpanded || hiddenSessionCount > 0) ? (
{!isSearching && group.sessions.length > sessionPreviewLimit && (hiddenSessionCount > 0 || canCollapseSessions) ? (
<button
type="button"
onClick={() => toggleSessionGroup(group)}
onClick={() => hiddenSessionCount > 0
? showMoreSessions(group)
: collapseSessionGroup(group)}
className={cn(
'mx-2 my-1 rounded-md px-2 py-1 text-left text-xs text-[var(--app-hint)] transition-colors hover:bg-[var(--app-subtle-bg)] hover:text-[var(--app-fg)]',
hiddenSessionCount > 0 && 'border border-dashed border-[var(--app-border)]'
)}
>
{sessionGroupExpanded
? t('sessions.group.showLess')
: t('sessions.group.showMore', { n: hiddenSessionCount })}
{hiddenSessionCount > 0
? t('sessions.group.showMore', { n: showMoreCount })
: t('sessions.group.showLess')}
</button>
) : null}
</div>
@@ -0,0 +1,90 @@
import { useCallback, useEffect, useState } from 'react'
export const DEFAULT_SHOW_ACTIVE_SESSIONS_ONLY = false
function getShowActiveSessionsOnlyStorageKey(): string {
return 'hapi-show-active-sessions-only'
}
function isBrowser(): boolean {
return typeof window !== 'undefined' && typeof document !== 'undefined'
}
function safeGetItem(key: string): string | null {
if (!isBrowser()) {
return null
}
try {
return localStorage.getItem(key)
} catch {
return null
}
}
function safeSetItem(key: string, value: string): void {
if (!isBrowser()) {
return
}
try {
localStorage.setItem(key, value)
} catch {
// Ignore storage errors
}
}
function safeRemoveItem(key: string): void {
if (!isBrowser()) {
return
}
try {
localStorage.removeItem(key)
} catch {
// Ignore storage errors
}
}
function parseShowActiveSessionsOnly(raw: string | null): boolean {
if (raw === 'true') {
return true
}
return DEFAULT_SHOW_ACTIVE_SESSIONS_ONLY
}
export function getInitialShowActiveSessionsOnly(): boolean {
return parseShowActiveSessionsOnly(safeGetItem(getShowActiveSessionsOnlyStorageKey()))
}
export function useShowActiveSessionsOnly(): {
showActiveSessionsOnly: boolean
setShowActiveSessionsOnly: (value: boolean) => void
} {
const [showActiveSessionsOnly, setShowActiveSessionsOnlyState] = useState<boolean>(getInitialShowActiveSessionsOnly)
useEffect(() => {
if (!isBrowser()) {
return
}
const onStorage = (event: StorageEvent) => {
if (event.key !== getShowActiveSessionsOnlyStorageKey()) {
return
}
setShowActiveSessionsOnlyState(parseShowActiveSessionsOnly(event.newValue))
}
window.addEventListener('storage', onStorage)
return () => window.removeEventListener('storage', onStorage)
}, [])
const setShowActiveSessionsOnly = useCallback((value: boolean) => {
setShowActiveSessionsOnlyState(value)
if (value === DEFAULT_SHOW_ACTIVE_SESSIONS_ONLY) {
safeRemoveItem(getShowActiveSessionsOnlyStorageKey())
} else {
safeSetItem(getShowActiveSessionsOnlyStorageKey(), String(value))
}
}, [])
return { showActiveSessionsOnly, setShowActiveSessionsOnly }
}
+2
View File
@@ -532,6 +532,8 @@ export default {
'settings.display.sessionPreviewLimit': 'Sessions Before Folding',
'settings.display.sessionPreviewLimit.decrease': 'Show fewer sessions before folding',
'settings.display.sessionPreviewLimit.increase': 'Show more sessions before folding',
'settings.display.activeSessionsOnly': 'Active sessions only',
'settings.display.activeSessionsOnly.desc': 'Hide inactive sessions in the sidebar. The session you have open stays visible.',
'settings.display.sessionListStatus': 'Session list status',
'settings.display.sessionListStatus.standard': 'Standard',
'settings.display.sessionListStatus.detailed': 'Detailed',
+2
View File
@@ -536,6 +536,8 @@ export default {
'settings.display.sessionPreviewLimit': '会话折叠阈值',
'settings.display.sessionPreviewLimit.decrease': '减少折叠前显示的会话数',
'settings.display.sessionPreviewLimit.increase': '增加折叠前显示的会话数',
'settings.display.activeSessionsOnly': '仅显示活跃会话',
'settings.display.activeSessionsOnly.desc': '在侧边栏隐藏非活跃会话;当前打开的会话仍会保留显示。',
'settings.display.sessionListStatus': '会话列表状态',
'settings.display.sessionListStatus.standard': '标准',
'settings.display.sessionListStatus.detailed': '详细',
+19
View File
@@ -19,6 +19,7 @@ import { getTerminalFontSizeOptions, useTerminalFontSize, type TerminalFontSize
import { getComposerEnterBehaviorOptions, useComposerEnterBehavior, type ComposerEnterBehavior } from '@/hooks/useComposerEnterBehavior'
import { getTerminalToolDisplayModeOptions, useTerminalToolDisplayMode, type TerminalToolDisplayMode } from '@/hooks/useTerminalToolDisplayMode'
import { getSessionListStatusModeOptions, useSessionListStatusMode, type SessionListStatusMode } from '@/hooks/useSessionListStatusMode'
import { useShowActiveSessionsOnly } from '@/hooks/useShowActiveSessionsOnly'
import {
MAX_SESSION_PREVIEW_LIMIT,
MIN_SESSION_PREVIEW_LIMIT,
@@ -398,6 +399,7 @@ export default function SettingsPage() {
const { composerEnterBehavior, setComposerEnterBehavior } = useComposerEnterBehavior()
const { terminalToolDisplayMode, setTerminalToolDisplayMode } = useTerminalToolDisplayMode()
const { sessionListStatusMode, setSessionListStatusMode } = useSessionListStatusMode()
const { showActiveSessionsOnly, setShowActiveSessionsOnly } = useShowActiveSessionsOnly()
const {
toolGroupBackground,
userMessageBackground,
@@ -897,6 +899,23 @@ export default function SettingsPage() {
decreaseLabel={t('settings.display.sessionPreviewLimit.decrease')}
increaseLabel={t('settings.display.sessionPreviewLimit.increase')}
/>
<div className="flex items-center justify-between gap-3 px-3 py-3">
<div className="flex flex-col">
<span className="text-[var(--app-fg)]">{t('settings.display.activeSessionsOnly')}</span>
<span className="text-xs text-[var(--app-hint)]">{t('settings.display.activeSessionsOnly.desc')}</span>
</div>
<label className="relative inline-flex h-5 w-9 shrink-0 items-center">
<input
type="checkbox"
checked={showActiveSessionsOnly}
onChange={(e) => setShowActiveSessionsOnly(e.target.checked)}
className="peer sr-only"
aria-label={t('settings.display.activeSessionsOnly')}
/>
<span className="absolute inset-0 rounded-full bg-[var(--app-border)] transition-colors peer-checked:bg-[var(--app-link)]" />
<span className="absolute left-0.5 h-4 w-4 rounded-full bg-[var(--app-bg)] transition-transform peer-checked:translate-x-4" />
</label>
</div>
<div ref={sessionListStatusContainerRef} className="relative">
<button
type="button"