mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
* 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:
@@ -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>([
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
Reference in New Issue
Block a user