feat(web): make pinned In progress section optional (default off) (#1350)

Restores directory glanceability by default after #1315. Settings → Display
adds a toggle next to Active sessions only that re-enables the pinned section.

Fixes #1347

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
HeavyGee
2026-08-04 08:05:12 +08:00
committed by GitHub
co-authored by Cursor
parent d0ae6c1f8d
commit 99f4ca471d
7 changed files with 165 additions and 3 deletions
@@ -10,6 +10,7 @@ import { SessionList } from './SessionList'
afterEach(() => {
cleanup()
localStorage.removeItem('hapi-session-preview-limit')
localStorage.removeItem('hapi-pin-in-progress-sessions')
})
function makeSession(overrides: Partial<SessionSummary> & { id: string }): SessionSummary {
@@ -371,6 +372,7 @@ describe('SessionList collapse behavior', () => {
}
it('keeps a selected running path collapsed across live session-list refreshes', async () => {
localStorage.setItem('hapi-pin-in-progress-sessions', 'true')
const baseSessions = [
makeSession({
id: 'session-running',
@@ -407,6 +409,54 @@ describe('SessionList collapse behavior', () => {
})
})
it('leaves active sessions in directory groups when pin-in-progress is off', () => {
const sessions = [
makeSession({
id: 'session-running',
active: true,
thinking: true,
updatedAt: 100,
metadata: { path: '/work/hapi', name: 'Running task', flavor: 'codex' },
}),
makeSession({
id: 'session-idle',
updatedAt: 50,
metadata: { path: '/work/other', name: 'Other task', flavor: 'codex' },
}),
]
render(renderSessionList(sessions, null))
expect(screen.queryByTitle('In progress')).toBeNull()
expect(screen.getByTitle('/work/hapi')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Running task/ })).toBeInTheDocument()
// Active group stays expanded by default so the project glance is immediate.
expect(screen.getByTitle('/work/hapi').nextElementSibling?.getAttribute('data-open')).toBe('true')
})
it('pins active sessions into In progress when the preference is on', () => {
localStorage.setItem('hapi-pin-in-progress-sessions', 'true')
const sessions = [
makeSession({
id: 'session-running',
active: true,
thinking: true,
updatedAt: 100,
metadata: { path: '/work/hapi', name: 'Running task', flavor: 'codex' },
}),
makeSession({
id: 'session-idle',
updatedAt: 50,
metadata: { path: '/work/hapi', name: 'Idle task', flavor: 'codex' },
}),
]
render(renderSessionList(sessions, null))
expect(screen.getByTitle('In progress')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Running task/ })).toBeInTheDocument()
// Directory group retains only the inactive session.
expect(getProjectPanel().getAttribute('data-open')).toBeNull()
})
it('auto-expands the path again when the selected session changes', async () => {
const sessions = [
makeSession({
@@ -443,6 +493,7 @@ describe('SessionList collapse behavior', () => {
})
it('keeps the running section open while searching even when collapsed', () => {
localStorage.setItem('hapi-pin-in-progress-sessions', 'true')
const sessions = [
makeSession({
id: 'session-running',
@@ -480,6 +531,7 @@ describe('SessionList collapse behavior', () => {
})
it('toggles the running section with the keyboard', () => {
localStorage.setItem('hapi-pin-in-progress-sessions', 'true')
const sessions = [
makeSession({
id: 'session-running',
+12 -3
View File
@@ -14,6 +14,7 @@ import { useTranslation } from '@/lib/use-translation'
import { DEFAULT_SESSION_PREVIEW_LIMIT, useSessionPreviewLimit } from '@/hooks/useSessionPreviewLimit'
import { useSessionListStatusMode } from '@/hooks/useSessionListStatusMode'
import { useShowActiveSessionsOnly } from '@/hooks/useShowActiveSessionsOnly'
import { usePinInProgressSessions } from '@/hooks/usePinInProgressSessions'
import { classifySessionAttention } from '@/lib/sessionAttention'
import { getSessionLastSeenAt } from '@/lib/sessionLastSeen'
import { useSessionRowTooltipIds } from '@/components/HoverTooltip'
@@ -1008,6 +1009,7 @@ export function SessionList(props: {
const { sessionPreviewLimit } = useSessionPreviewLimit()
const { sessionListStatusMode } = useSessionListStatusMode()
const { showActiveSessionsOnly } = useShowActiveSessionsOnly()
const { pinInProgressSessions } = usePinInProgressSessions()
const { machineFilter, setMachineFilter } = useSessionListMachineFilter()
const showDetailedStatus = sessionListStatusMode === 'detailed'
const [searchQuery, setSearchQuery] = useState('')
@@ -1102,6 +1104,9 @@ export function SessionList(props: {
pending: [],
idle: []
}
if (!pinInProgressSessions) {
return buckets
}
for (const session of machineFilteredSessions) {
if (!session.active) {
continue
@@ -1119,13 +1124,17 @@ export function SessionList(props: {
buckets[key].sort(byRecent)
}
return buckets
}, [machineFilteredSessions])
}, [machineFilteredSessions, pinInProgressSessions])
const runningSessionTotal = runningSessions.working.length
+ runningSessions.pending.length
+ runningSessions.idle.length
const groups = useMemo(
() => groupSessionsByDirectory(machineFilteredSessions.filter((session) => !session.active)),
[machineFilteredSessions]
() => groupSessionsByDirectory(
pinInProgressSessions
? machineFilteredSessions.filter((session) => !session.active)
: machineFilteredSessions
),
[machineFilteredSessions, pinInProgressSessions]
)
const [collapseOverrides, setCollapseOverrides] = useState<Map<string, boolean>>(
() => new Map()
+90
View File
@@ -0,0 +1,90 @@
import { useCallback, useEffect, useState } from 'react'
export const DEFAULT_PIN_IN_PROGRESS_SESSIONS = false
function getPinInProgressSessionsStorageKey(): string {
return 'hapi-pin-in-progress-sessions'
}
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 parsePinInProgressSessions(raw: string | null): boolean {
if (raw === 'true') {
return true
}
return DEFAULT_PIN_IN_PROGRESS_SESSIONS
}
export function getInitialPinInProgressSessions(): boolean {
return parsePinInProgressSessions(safeGetItem(getPinInProgressSessionsStorageKey()))
}
export function usePinInProgressSessions(): {
pinInProgressSessions: boolean
setPinInProgressSessions: (value: boolean) => void
} {
const [pinInProgressSessions, setPinInProgressSessionsState] = useState<boolean>(getInitialPinInProgressSessions)
useEffect(() => {
if (!isBrowser()) {
return
}
const onStorage = (event: StorageEvent) => {
if (event.key !== getPinInProgressSessionsStorageKey()) {
return
}
setPinInProgressSessionsState(parsePinInProgressSessions(event.newValue))
}
window.addEventListener('storage', onStorage)
return () => window.removeEventListener('storage', onStorage)
}, [])
const setPinInProgressSessions = useCallback((value: boolean) => {
setPinInProgressSessionsState(value)
if (value === DEFAULT_PIN_IN_PROGRESS_SESSIONS) {
safeRemoveItem(getPinInProgressSessionsStorageKey())
} else {
safeSetItem(getPinInProgressSessionsStorageKey(), String(value))
}
}, [])
return { pinInProgressSessions, setPinInProgressSessions }
}
+2
View File
@@ -723,6 +723,8 @@ export default {
'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.pinInProgressSessions': 'Pin in-progress sessions',
'settings.display.pinInProgressSessions.desc': 'Move active sessions into a pinned In progress section at the top of the sidebar. Off keeps them inside their project directories.',
'settings.display.sessionListStatus': 'Session list status',
'settings.display.sessionListStatus.standard': 'Standard',
'settings.display.sessionListStatus.detailed': 'Detailed',
+2
View File
@@ -727,6 +727,8 @@ export default {
'settings.display.sessionPreviewLimit.increase': '增加折叠前显示的会话数',
'settings.display.activeSessionsOnly': '仅显示活跃会话',
'settings.display.activeSessionsOnly.desc': '在侧边栏隐藏非活跃会话;当前打开的会话仍会保留显示。',
'settings.display.pinInProgressSessions': '置顶进行中会话',
'settings.display.pinInProgressSessions.desc': '将活跃会话移到侧边栏顶部的「进行中」分区。关闭后仍保留在各自项目目录分组中。',
'settings.display.sessionListStatus': '会话列表状态',
'settings.display.sessionListStatus.standard': '标准',
'settings.display.sessionListStatus.detailed': '详细',
+3
View File
@@ -6,6 +6,7 @@ import { getFontScaleOptions, useFontScale } from '@/hooks/useFontScale'
import { getTerminalFontSizeOptions, useTerminalFontSize } from '@/hooks/useTerminalFontSize'
import { getSessionListStatusModeOptions, useSessionListStatusMode } from '@/hooks/useSessionListStatusMode'
import { useShowActiveSessionsOnly } from '@/hooks/useShowActiveSessionsOnly'
import { usePinInProgressSessions } from '@/hooks/usePinInProgressSessions'
import { MAX_SESSION_PREVIEW_LIMIT, MIN_SESSION_PREVIEW_LIMIT, normalizeSessionPreviewLimit, useSessionPreviewLimit } from '@/hooks/useSessionPreviewLimit'
import { useThemeColors, type ThemeColorKeyId } from '@/hooks/useThemeColors'
import { useSessionHeaderMetadata, type SessionHeaderMetadataKey } from '@/hooks/useSessionHeaderMetadata'
@@ -136,6 +137,7 @@ export default function SettingsDisplayPage() {
const { terminalFontSize, setTerminalFontSize } = useTerminalFontSize()
const { sessionListStatusMode, setSessionListStatusMode } = useSessionListStatusMode()
const { showActiveSessionsOnly, setShowActiveSessionsOnly } = useShowActiveSessionsOnly()
const { pinInProgressSessions, setPinInProgressSessions } = usePinInProgressSessions()
const { preferences: sessionHeaderMetadata, setPreference: setSessionHeaderMetadata } = useSessionHeaderMetadata()
const sessionHeaderOptions: ReadonlyArray<{ key: SessionHeaderMetadataKey; labelKey: string }> = [
{ key: 'showLabels', labelKey: 'settings.display.sessionHeader.showLabels' },
@@ -172,6 +174,7 @@ export default function SettingsDisplayPage() {
<SettingsSection title={t('settings.display.sessions')}>
<SessionPreviewLimitControl />
<SettingsSwitch label={t('settings.display.activeSessionsOnly')} description={t('settings.display.activeSessionsOnly.desc')} checked={showActiveSessionsOnly} onChange={setShowActiveSessionsOnly} />
<SettingsSwitch label={t('settings.display.pinInProgressSessions')} description={t('settings.display.pinInProgressSessions.desc')} checked={pinInProgressSessions} onChange={setPinInProgressSessions} />
<SettingsChoiceGroup
label={t('settings.display.sessionListStatus')}
description={t('settings.display.sessionListStatus.detailedDescription')}
+4
View File
@@ -77,6 +77,10 @@ vi.mock('@/hooks/useShowActiveSessionsOnly', () => ({
useShowActiveSessionsOnly: () => ({ showActiveSessionsOnly: false, setShowActiveSessionsOnly: vi.fn() }),
}))
vi.mock('@/hooks/usePinInProgressSessions', () => ({
usePinInProgressSessions: () => ({ pinInProgressSessions: false, setPinInProgressSessions: vi.fn() }),
}))
vi.mock('@/hooks/useSessionHeaderMetadata', () => ({
useSessionHeaderMetadata: () => ({
preferences: {