feat(web): add stepwise session preview controls (#1266)

* feat(web): add stepwise session preview controls

* fix(web): avoid no-op session preview collapse

* fix(web): expand previews from rendered count
This commit is contained in:
Ananovo
2026-07-31 16:08:27 +08:00
committed by GitHub
parent b3a1f5fafd
commit 0db7d68b37
5 changed files with 212 additions and 32 deletions
@@ -409,9 +409,109 @@ describe('SessionList collapse behavior', () => {
expect(screen.queryByRole('button', { name: /Matching task 3/ })).toBeNull()
expect(screen.queryByRole('button', { name: /Matching task 4/ })).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Show 2 more' }))
fireEvent.click(screen.getByRole('button', { name: 'Expand 2' }))
expect(screen.getByRole('button', { name: /Matching task 3/ })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Matching task 4/ })).toBeInTheDocument()
})
it('expands and collapses the session preview one batch at a time', () => {
localStorage.setItem('hapi-session-preview-limit', '2')
const sessions = Array.from({ length: 6 }, (_, index) => makeSession({
id: `session-${index + 1}`,
updatedAt: 100 - index,
metadata: {
path: '/work/hapi',
name: `Task ${index + 1}`,
flavor: 'codex',
},
}))
render(renderSessionList(sessions, null))
expect(screen.getByRole('button', { name: 'Expand 2' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Collapse 2' })).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Expand 2' }))
expect(screen.getByRole('button', { name: /Task 4/ })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Task 5/ })).toBeNull()
expect(screen.getByRole('button', { name: 'Collapse 2' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Expand 2' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Expand 2' }))
expect(screen.getByRole('button', { name: /Task 6/ })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Collapse 2' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Expand 2' })).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Collapse 2' }))
expect(screen.queryByRole('button', { name: /Task 5/ })).toBeNull()
expect(screen.getByRole('button', { name: 'Collapse 2' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Expand 2' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Collapse 2' }))
expect(screen.queryByRole('button', { name: /Task 3/ })).toBeNull()
expect(screen.queryByRole('button', { name: 'Collapse 2' })).toBeNull()
expect(screen.getByRole('button', { name: 'Expand 2' })).toBeInTheDocument()
})
it('does not offer a no-op collapse when required sessions exceed the preview limit', () => {
localStorage.setItem('hapi-session-preview-limit', '2')
const sessions = Array.from({ length: 4 }, (_, index) => makeSession({
id: `session-${index + 1}`,
updatedAt: 100 - index,
pendingRequestsCount: index > 0 ? 1 : 0,
metadata: {
path: '/work/hapi',
name: `Task ${index + 1}`,
flavor: 'codex',
},
}))
render(renderSessionList(sessions, null))
expect(screen.queryByRole('button', { name: /Collapse/ })).toBeNull()
expect(screen.queryByRole('button', { name: /Task 1/ })).toBeNull()
expect(screen.getByRole('button', { name: 'Expand 1' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Expand 1' }))
expect(screen.getByRole('button', { name: /Task 1/ })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Collapse 1' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Collapse 1' }))
expect(screen.queryByRole('button', { name: /Task 1/ })).toBeNull()
expect(screen.queryByRole('button', { name: /Collapse/ })).toBeNull()
})
it('expands from the rendered count when required sessions exceed the preview limit', () => {
localStorage.setItem('hapi-session-preview-limit', '2')
const sessions = Array.from({ length: 8 }, (_, index) => makeSession({
id: `session-${index + 1}`,
updatedAt: 100 - index,
pendingRequestsCount: index < 5 ? 1 : 0,
metadata: {
path: '/work/hapi',
name: `Task ${index + 1}`,
flavor: 'codex',
},
}))
render(renderSessionList(sessions, null))
expect(screen.getByRole('button', { name: /Task 5/ })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Task 6/ })).toBeNull()
expect(screen.getByRole('button', { name: 'Expand 2' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Expand 2' }))
expect(screen.getByRole('button', { name: /Task 6/ })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Task 7/ })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Task 8/ })).toBeNull()
expect(screen.getByRole('button', { name: 'Expand 1' })).toBeInTheDocument()
})
})
+17
View File
@@ -6,6 +6,7 @@ import {
filterActiveSessionsOnly,
getSessionTimeRange,
getNextSessionVisibleCount,
getPreviousSessionVisibleCount,
getSessionDedupKey,
getWorktreeSessionLabel,
getVisibleSessionPreview,
@@ -428,6 +429,22 @@ describe('getNextSessionVisibleCount', () => {
})
})
describe('getPreviousSessionVisibleCount', () => {
it('collapses one batch of step size per call', () => {
expect(getPreviousSessionVisibleCount(20, 8)).toBe(12)
expect(getPreviousSessionVisibleCount(12, 8)).toBe(8)
})
it('never goes below the preview limit', () => {
expect(getPreviousSessionVisibleCount(10, 8)).toBe(8)
expect(getPreviousSessionVisibleCount(8, 8)).toBe(8)
})
it('uses a minimum batch size of one', () => {
expect(getPreviousSessionVisibleCount(5, 0)).toBe(4)
})
})
describe('expandSelectedSessionCollapseOverrides', () => {
it('expands the collapsed project group, but preserves session preview folding', () => {
const overrides = new Map<string, boolean>([
+90 -27
View File
@@ -219,13 +219,17 @@ export function filterActiveSessionsOnly(sessions: SessionSummary[], selectedSes
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.
// Paginated session previews move one batch at a time in either direction.
// Counts always stay within the configured preview floor and the group total.
export function getNextSessionVisibleCount(current: number, step: number, total: number): number {
return Math.min(current + Math.max(1, step), total)
}
export function getPreviousSessionVisibleCount(current: number, step: number): number {
const normalizedStep = Math.max(1, step)
return Math.max(normalizedStep, current - normalizedStep)
}
function groupSessionsByDirectory(sessions: SessionSummary[]): SessionGroup[] {
const groups = new Map<string, { directory: string; machineId: string | null; sessions: SessionSummary[] }>()
@@ -467,6 +471,28 @@ function ChevronIcon(props: { className?: string; collapsed?: boolean }) {
)
}
function SessionPreviewArrowIcon(props: { direction: 'up' | 'down'; className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={props.className}
aria-hidden="true"
>
{props.direction === 'up' ? (
<path d="M12 19V5m-6 6 6-6 6 6" />
) : (
<path d="M12 5v14m6-6-6 6-6-6" />
)}
</svg>
)
}
export { getSessionTitle } from '@/lib/sessionTitle'
export function getWorktreeSessionLabel(session: SessionSummary): string | null {
@@ -1124,8 +1150,8 @@ export function SessionList(props: {
})
}
// 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).
// 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>>(
() => new Map()
)
@@ -1137,17 +1163,36 @@ export function SessionList(props: {
const showMoreSessions = (group: SessionGroup) => {
setSessionVisibleCounts(prev => {
const next = new Map(prev)
const current = prev.get(group.key) ?? sessionPreviewLimit
next.set(group.key, getNextSessionVisibleCount(current, sessionPreviewLimit, group.sessions.length))
const currentLimit = Math.min(
prev.get(group.key) ?? sessionPreviewLimit,
group.sessions.length
)
const currentVisibleCount = getVisibleSessionPreview(group.sessions, {
selectedSessionId,
limit: currentLimit
}).length
next.set(group.key, getNextSessionVisibleCount(
Math.max(currentLimit, currentVisibleCount),
sessionPreviewLimit,
group.sessions.length
))
return next
})
}
const collapseSessionGroup = (group: SessionGroup) => {
const showFewerSessions = (group: SessionGroup) => {
setSessionVisibleCounts(prev => {
if (!prev.has(group.key)) return prev
const next = new Map(prev)
next.delete(group.key)
const current = Math.min(
prev.get(group.key) ?? sessionPreviewLimit,
group.sessions.length
)
const previous = getPreviousSessionVisibleCount(current, sessionPreviewLimit)
if (previous <= sessionPreviewLimit) {
next.delete(group.key)
} else {
next.set(group.key, previous)
}
return next
})
}
@@ -1292,8 +1337,18 @@ export function SessionList(props: {
const isCollapsed = isGroupCollapsed(group)
const visibleGroupSessions = getVisibleGroupSessions(group)
const hiddenSessionCount = group.sessions.length - visibleGroupSessions.length
const canCollapseSessions = getGroupVisibleCount(group) > sessionPreviewLimit
const showMoreCount = Math.min(sessionPreviewLimit, hiddenSessionCount)
const currentLimit = Math.min(
getGroupVisibleCount(group),
group.sessions.length
)
const previousLimit = getPreviousSessionVisibleCount(currentLimit, sessionPreviewLimit)
const previousGroupSessions = getVisibleSessionPreview(group.sessions, {
selectedSessionId,
limit: previousLimit
})
const collapseCount = visibleGroupSessions.length - previousGroupSessions.length
const canShowFewerSessions = previousLimit < currentLimit && collapseCount > 0
const expandCount = Math.min(sessionPreviewLimit, hiddenSessionCount)
const canStartInGroupDirectory = group.directory !== 'Other'
// With multiple machines in the unfiltered view, disambiguate
// same-named directories by suffixing the machine label.
@@ -1349,21 +1404,29 @@ export function SessionList(props: {
showDetailedStatus={showDetailedStatus}
/>
))}
{group.sessions.length > sessionPreviewLimit && (hiddenSessionCount > 0 || canCollapseSessions) ? (
<button
type="button"
onClick={() => hiddenSessionCount > 0
? showMoreSessions(group)
: collapseSessionGroup(group)}
className={cn(
'ml-2.5 mr-2 my-1 rounded-md px-2 py-1 text-center 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)]'
)}
>
{hiddenSessionCount > 0
? t('sessions.group.showMore', { n: showMoreCount })
: t('sessions.group.showLess')}
</button>
{group.sessions.length > sessionPreviewLimit && (hiddenSessionCount > 0 || canShowFewerSessions) ? (
<div className="ml-2.5 mr-2 my-1 flex gap-1.5">
{canShowFewerSessions ? (
<button
type="button"
onClick={() => showFewerSessions(group)}
className="flex min-w-0 flex-1 items-center justify-center gap-1 rounded-md border border-dashed border-[var(--app-border)] px-2 py-1 text-center text-xs text-[var(--app-hint)] transition-colors hover:bg-[var(--app-subtle-bg)] hover:text-[var(--app-fg)]"
>
<SessionPreviewArrowIcon direction="up" className="h-3 w-3 shrink-0" />
{t('sessions.group.collapse', { n: collapseCount })}
</button>
) : null}
{hiddenSessionCount > 0 ? (
<button
type="button"
onClick={() => showMoreSessions(group)}
className="flex min-w-0 flex-1 items-center justify-center gap-1 rounded-md border border-dashed border-[var(--app-border)] px-2 py-1 text-center text-xs text-[var(--app-hint)] transition-colors hover:bg-[var(--app-subtle-bg)] hover:text-[var(--app-fg)]"
>
<SessionPreviewArrowIcon direction="down" className="h-3 w-3 shrink-0" />
{t('sessions.group.expand', { n: expandCount })}
</button>
) : null}
</div>
) : null}
</div>
</div>