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