diff --git a/web/src/components/SessionList.directory-action.test.tsx b/web/src/components/SessionList.directory-action.test.tsx index bcedec27..09bdb36f 100644 --- a/web/src/components/SessionList.directory-action.test.tsx +++ b/web/src/components/SessionList.directory-action.test.tsx @@ -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 & { 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', diff --git a/web/src/components/SessionList.tsx b/web/src/components/SessionList.tsx index d91eea28..5ddc1bd9 100644 --- a/web/src/components/SessionList.tsx +++ b/web/src/components/SessionList.tsx @@ -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>( () => new Map() diff --git a/web/src/hooks/usePinInProgressSessions.ts b/web/src/hooks/usePinInProgressSessions.ts new file mode 100644 index 00000000..8f3bfc22 --- /dev/null +++ b/web/src/hooks/usePinInProgressSessions.ts @@ -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(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 } +} diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 94a62d7a..ea9e633b 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -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', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 19552127..01c49c15 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -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': '详细', diff --git a/web/src/routes/settings/display.tsx b/web/src/routes/settings/display.tsx index c56d76e1..11682b9b 100644 --- a/web/src/routes/settings/display.tsx +++ b/web/src/routes/settings/display.tsx @@ -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() { + ({ useShowActiveSessionsOnly: () => ({ showActiveSessionsOnly: false, setShowActiveSessionsOnly: vi.fn() }), })) +vi.mock('@/hooks/usePinInProgressSessions', () => ({ + usePinInProgressSessions: () => ({ pinInProgressSessions: false, setPinInProgressSessions: vi.fn() }), +})) + vi.mock('@/hooks/useSessionHeaderMetadata', () => ({ useSessionHeaderMetadata: () => ({ preferences: {