From 0db7d68b374062f3a9ab7959c6c411f85532ff83 Mon Sep 17 00:00:00 2001 From: Ananovo Date: Fri, 31 Jul 2026 16:08:27 +0800 Subject: [PATCH] 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 --- .../SessionList.directory-action.test.tsx | 102 ++++++++++++++- web/src/components/SessionList.test.ts | 17 +++ web/src/components/SessionList.tsx | 117 ++++++++++++++---- web/src/lib/locales/en.ts | 4 +- web/src/lib/locales/zh-CN.ts | 4 +- 5 files changed, 212 insertions(+), 32 deletions(-) diff --git a/web/src/components/SessionList.directory-action.test.tsx b/web/src/components/SessionList.directory-action.test.tsx index fdb23635..9d186572 100644 --- a/web/src/components/SessionList.directory-action.test.tsx +++ b/web/src/components/SessionList.directory-action.test.tsx @@ -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() + }) }) diff --git a/web/src/components/SessionList.test.ts b/web/src/components/SessionList.test.ts index 28da88ee..b39b6b54 100644 --- a/web/src/components/SessionList.test.ts +++ b/web/src/components/SessionList.test.ts @@ -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([ diff --git a/web/src/components/SessionList.tsx b/web/src/components/SessionList.tsx index 28c29702..42ce5e96 100644 --- a/web/src/components/SessionList.tsx +++ b/web/src/components/SessionList.tsx @@ -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() @@ -467,6 +471,28 @@ function ChevronIcon(props: { className?: string; collapsed?: boolean }) { ) } +function SessionPreviewArrowIcon(props: { direction: 'up' | 'down'; className?: string }) { + return ( + + ) +} + 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>( () => 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) ? ( - + {group.sessions.length > sessionPreviewLimit && (hiddenSessionCount > 0 || canShowFewerSessions) ? ( +
+ {canShowFewerSessions ? ( + + ) : null} + {hiddenSessionCount > 0 ? ( + + ) : null} +
) : null} diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index b72beff8..9825a69c 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -71,8 +71,8 @@ export default { 'sessions.timeFilter.previousMonth': 'Previous month', 'sessions.timeFilter.nextMonth': 'Next month', 'sessions.timeFilter.dayWithActivity': '{date}, has session activity', - 'sessions.group.showMore': 'Show {n} more', - 'sessions.group.showLess': 'Show less', + 'sessions.group.expand': 'Expand {n}', + 'sessions.group.collapse': 'Collapse {n}', 'sessions.group.new': 'New session in this directory', 'sessions.machineFilter.label': 'Filter sessions by machine', 'sessions.machineFilter.all': 'All', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index e21bf6d1..dce2d218 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -71,8 +71,8 @@ export default { 'sessions.timeFilter.previousMonth': '上个月', 'sessions.timeFilter.nextMonth': '下个月', 'sessions.timeFilter.dayWithActivity': '{date},有会话活动', - 'sessions.group.showMore': '再显示 {n} 个', - 'sessions.group.showLess': '收起', + 'sessions.group.expand': '展开 {n} 个', + 'sessions.group.collapse': '收起 {n} 个', 'sessions.group.new': '在此目录新建会话', 'sessions.machineFilter.label': '按机器筛选会话', 'sessions.machineFilter.all': '全部',