feat(web): session list status indicators (attention + scheduled) (#699)

This commit is contained in:
HeavyGee
2026-05-29 15:43:21 +08:00
committed by GitHub
parent 7457a8fc32
commit ec3722aba9
33 changed files with 910 additions and 37 deletions
@@ -1,29 +1,11 @@
import { ComposerPrimitive } from '@assistant-ui/react'
import type { ConversationStatus } from '@/realtime/types'
import { useTranslation } from '@/lib/use-translation'
import { ScheduleIcon } from '@/components/icons'
import { ScheduleTimePicker } from './ScheduleTimePicker'
import type { PendingSchedule } from './ScheduleTimePicker'
import { useRef, useState } from 'react'
function ScheduleIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="9" />
<polyline points="12 7 12 12 15.5 14" />
</svg>
)
}
function VoiceAssistantIcon() {
return (
<svg
@@ -460,7 +442,7 @@ export function ComposerButtons(props: {
: 'text-[var(--app-fg)]/60 hover:bg-[var(--app-bg)] hover:text-[var(--app-fg)]'
}`}
>
<ScheduleIcon />
<ScheduleIcon className="h-[18px] w-[18px]" />
</button>
{showSchedulePicker && (
<ScheduleTimePicker
@@ -0,0 +1,29 @@
import type { SessionAttention } from '@/lib/sessionAttention'
import { getSessionAttentionLabelKey } from '@/lib/sessionAttention'
const ATTENTION_DOT_CLASS: Record<SessionAttention['kind'], string> = {
permission: 'bg-amber-500 animate-pulse',
input: 'bg-blue-500',
background: 'bg-blue-400',
unread: 'bg-[var(--app-link)]'
}
export function SessionAttentionIndicator(props: {
attention: SessionAttention
label: string
}) {
return (
<span
className={`inline-flex h-2 w-2 shrink-0 rounded-full ${ATTENTION_DOT_CLASS[props.attention.kind]}`}
title={props.label}
aria-label={props.label}
/>
)
}
export function getAttentionLabel(
attention: SessionAttention,
t: (key: string) => string
): string {
return t(getSessionAttentionLabelKey(attention))
}
@@ -17,6 +17,9 @@ function makeSession(overrides: Partial<SessionSummary> & { id: string }): Sessi
metadata: null,
todoProgress: null,
pendingRequestsCount: 0,
pendingRequestKinds: [],
backgroundTaskCount: 0,
futureScheduledMessageCount: 0,
model: null,
effort: null,
...overrides
+3
View File
@@ -11,6 +11,9 @@ function makeSession(overrides: Partial<SessionSummary> & { id: string }): Sessi
metadata: null,
todoProgress: null,
pendingRequestsCount: 0,
pendingRequestKinds: [],
backgroundTaskCount: 0,
futureScheduledMessageCount: 0,
model: null,
effort: null,
...overrides
+34 -3
View File
@@ -7,11 +7,15 @@ import { useSessionActions } from '@/hooks/mutations/useSessionActions'
import { SessionActionMenu } from '@/components/SessionActionMenu'
import { RenameSessionDialog } from '@/components/RenameSessionDialog'
import { ConfirmDialog } from '@/components/ui/ConfirmDialog'
import { CopyIcon, CheckIcon } from '@/components/icons'
import { CopyIcon, CheckIcon, ScheduleIcon } from '@/components/icons'
import { cn } from '@/lib/utils'
import { useTranslation } from '@/lib/use-translation'
import { DEFAULT_SESSION_PREVIEW_LIMIT, useSessionPreviewLimit } from '@/hooks/useSessionPreviewLimit'
import { AgentFlavorIcon } from '@/components/AgentFlavorIcon'
import { useSessionListStatusMode } from '@/hooks/useSessionListStatusMode'
import { classifySessionAttention } from '@/lib/sessionAttention'
import { getSessionLastSeenAt } from '@/lib/sessionLastSeen'
import { getAttentionLabel, SessionAttentionIndicator } from '@/components/SessionAttentionIndicator'
type SessionGroup = {
key: string
@@ -523,9 +527,10 @@ function SessionItem(props: {
showPath?: boolean
api: ApiClient | null
selected?: boolean
showDetailedStatus?: boolean
}) {
const { t } = useTranslation()
const { session: s, onSelect, showPath = true, api, selected = false } = props
const { session: s, onSelect, showPath = true, api, selected = false, showDetailedStatus = false } = props
const { haptic } = usePlatform()
const [menuOpen, setMenuOpen] = useState(false)
const [menuAnchorPoint, setMenuAnchorPoint] = useState<{ x: number; y: number }>({ x: 0, y: 0 })
@@ -555,6 +560,19 @@ function SessionItem(props: {
const sessionName = getSessionTitle(s)
const todoProgress = getTodoProgress(s)
const attention = useMemo(
() => showDetailedStatus
? classifySessionAttention(s, {
selected,
lastSeenAt: getSessionLastSeenAt(s.id)
})
: null,
[s, selected, showDetailedStatus]
)
const attentionLabel = attention ? getAttentionLabel(attention, t) : null
const scheduledLabel = s.futureScheduledMessageCount > 1
? t('session.item.scheduledMessages', { count: s.futureScheduledMessageCount })
: t('session.item.scheduledMessage')
return (
<>
<button
@@ -572,6 +590,16 @@ function SessionItem(props: {
</div>
{s.active && s.thinking ? (
<LoaderIcon className="h-3.5 w-3.5 shrink-0 text-[var(--app-hint)] animate-spin-slow" />
) : attention ? (
<SessionAttentionIndicator
attention={attention}
label={attentionLabel ?? ''}
/>
) : null}
{showDetailedStatus && s.futureScheduledMessageCount > 0 ? (
<span title={scheduledLabel} aria-label={scheduledLabel} className="inline-flex shrink-0">
<ScheduleIcon className="h-3.5 w-3.5 text-[var(--app-hint)]" />
</span>
) : null}
</div>
<div className="flex items-center gap-2 shrink-0 text-xs">
@@ -581,7 +609,7 @@ function SessionItem(props: {
{todoProgress.completed}/{todoProgress.total}
</span>
) : null}
{s.pendingRequestsCount > 0 ? (
{!attention && s.pendingRequestsCount > 0 ? (
<span className="text-[var(--app-badge-warning-text)]">
{t('session.item.pending')} {s.pendingRequestsCount}
</span>
@@ -659,6 +687,8 @@ export function SessionList(props: {
const { t } = useTranslation()
const { renderHeader = true, api, selectedSessionId, machineLabelsById = {}, onNewSessionInDirectory } = props
const { sessionPreviewLimit } = useSessionPreviewLimit()
const { sessionListStatusMode } = useSessionListStatusMode()
const showDetailedStatus = sessionListStatusMode === 'detailed'
const [searchQuery, setSearchQuery] = useState('')
const normalizedQuery = normalizeSearch(searchQuery)
const isSearching = normalizedQuery.length > 0
@@ -926,6 +956,7 @@ export function SessionList(props: {
showPath={false}
api={api}
selected={s.id === selectedSessionId}
showDetailedStatus={showDetailedStatus}
/>
))}
{!isSearching && group.sessions.length > sessionPreviewLimit && (sessionGroupExpanded || hiddenSessionCount > 0) ? (
+12
View File
@@ -60,3 +60,15 @@ export function CheckIcon(props: IconProps) {
2
)
}
/** Composer schedule-send clock — circle + hands (matches ComposerButtons). */
export function ScheduleIcon(props: IconProps) {
return createIcon(
<>
<circle cx="12" cy="12" r="9" />
<polyline points="12 7 12 12 15.5 14" />
</>,
props,
2
)
}
+1
View File
@@ -6,6 +6,7 @@ describe('useSSE scope handling', () => {
expect(isGlobalScopedMessageStreamEvent('global', 'message-received')).toBe(true)
expect(isGlobalScopedMessageStreamEvent('global', 'messages-consumed')).toBe(true)
expect(isGlobalScopedMessageStreamEvent('global', 'message-cancelled')).toBe(true)
expect(isGlobalScopedMessageStreamEvent('global', 'scheduled-matured')).toBe(true)
})
it('does not skip session lifecycle events on the global connection', () => {
+19 -4
View File
@@ -26,7 +26,8 @@ export type SSEScope = 'global' | 'full'
const MESSAGE_STREAM_EVENT_TYPES = new Set<SyncEvent['type']>([
'message-received',
'messages-consumed',
'message-cancelled'
'message-cancelled',
'scheduled-matured'
])
export function isGlobalScopedMessageStreamEvent(scope: SSEScope, eventType: SyncEvent['type']): boolean {
@@ -299,9 +300,13 @@ export function useSSE(options: {
return previous
}
const summary = toSessionSummary(session)
const existingIndex = previous.sessions.findIndex((item) => item.id === session.id)
const existing = existingIndex >= 0 ? previous.sessions[existingIndex] : undefined
const summary = {
...toSessionSummary(session),
futureScheduledMessageCount: existing?.futureScheduledMessageCount ?? 0
}
const nextSessions = previous.sessions.slice()
const existingIndex = nextSessions.findIndex((item) => item.id === session.id)
if (existingIndex >= 0) {
nextSessions[existingIndex] = summary
} else {
@@ -336,6 +341,9 @@ export function useSSE(options: {
thinking: patch.thinking ?? current.thinking,
activeAt: patch.activeAt ?? current.activeAt,
updatedAt: patch.updatedAt ?? current.updatedAt,
backgroundTaskCount: Object.prototype.hasOwnProperty.call(patch, 'backgroundTaskCount')
? patch.backgroundTaskCount ?? 0
: current.backgroundTaskCount,
model: Object.prototype.hasOwnProperty.call(patch, 'model') ? patch.model ?? null : current.model,
effort: Object.prototype.hasOwnProperty.call(patch, 'effort') ? patch.effort ?? null : current.effort
}
@@ -440,7 +448,14 @@ export function useSSE(options: {
}
if (scope === 'global' && MESSAGE_STREAM_EVENT_TYPES.has(event.type)) {
if (event.type === 'message-received') {
if (event.type === 'message-received' && event.message.scheduledAt != null) {
queueSessionListInvalidation()
}
if (
event.type === 'message-cancelled'
|| event.type === 'messages-consumed'
|| event.type === 'scheduled-matured'
) {
queueSessionListInvalidation()
}
onEventRef.current(event)
@@ -0,0 +1,32 @@
import { beforeEach, describe, expect, it } from 'vitest'
import {
DEFAULT_SESSION_LIST_STATUS_MODE,
getInitialSessionListStatusMode,
getSessionListStatusModeOptions,
} from './useSessionListStatusMode'
describe('useSessionListStatusMode helpers', () => {
beforeEach(() => {
window.localStorage.clear()
})
it('returns the allowed session list status mode options', () => {
expect(getSessionListStatusModeOptions()).toEqual([
{ value: 'standard', labelKey: 'settings.display.sessionListStatus.standard' },
{ value: 'detailed', labelKey: 'settings.display.sessionListStatus.detailed' },
])
})
it('falls back to the default mode for missing or invalid storage values', () => {
expect(getInitialSessionListStatusMode()).toBe(DEFAULT_SESSION_LIST_STATUS_MODE)
window.localStorage.setItem('hapi-session-list-status-mode', 'invalid')
expect(getInitialSessionListStatusMode()).toBe(DEFAULT_SESSION_LIST_STATUS_MODE)
})
it('reads a valid stored session list status mode', () => {
window.localStorage.setItem('hapi-session-list-status-mode', 'detailed')
expect(getInitialSessionListStatusMode()).toBe('detailed')
})
})
+99
View File
@@ -0,0 +1,99 @@
import { useCallback, useEffect, useState } from 'react'
export type SessionListStatusMode = 'standard' | 'detailed'
export const DEFAULT_SESSION_LIST_STATUS_MODE: SessionListStatusMode = 'standard'
export function getSessionListStatusModeOptions(): ReadonlyArray<{ value: SessionListStatusMode; labelKey: string }> {
return [
{ value: 'standard', labelKey: 'settings.display.sessionListStatus.standard' },
{ value: 'detailed', labelKey: 'settings.display.sessionListStatus.detailed' },
]
}
function getSessionListStatusModeStorageKey(): string {
return 'hapi-session-list-status-mode'
}
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 parseSessionListStatusMode(raw: string | null): SessionListStatusMode {
if (raw === 'standard' || raw === 'detailed') {
return raw
}
return DEFAULT_SESSION_LIST_STATUS_MODE
}
export function getInitialSessionListStatusMode(): SessionListStatusMode {
return parseSessionListStatusMode(safeGetItem(getSessionListStatusModeStorageKey()))
}
export function useSessionListStatusMode(): {
sessionListStatusMode: SessionListStatusMode
setSessionListStatusMode: (mode: SessionListStatusMode) => void
} {
const [sessionListStatusMode, setSessionListStatusModeState] = useState<SessionListStatusMode>(getInitialSessionListStatusMode)
useEffect(() => {
if (!isBrowser()) {
return
}
const onStorage = (event: StorageEvent) => {
if (event.key !== getSessionListStatusModeStorageKey()) {
return
}
setSessionListStatusModeState(parseSessionListStatusMode(event.newValue))
}
window.addEventListener('storage', onStorage)
return () => window.removeEventListener('storage', onStorage)
}, [])
const setSessionListStatusMode = useCallback((mode: SessionListStatusMode) => {
setSessionListStatusModeState(mode)
if (mode === DEFAULT_SESSION_LIST_STATUS_MODE) {
safeRemoveItem(getSessionListStatusModeStorageKey())
} else {
safeSetItem(getSessionListStatusModeStorageKey(), mode)
}
}, [])
return { sessionListStatusMode, setSessionListStatusMode }
}
+10
View File
@@ -61,6 +61,12 @@ export default {
'session.item.worktree': 'worktree',
'session.item.pending': 'pending',
'session.item.thinking': 'thinking',
'session.item.permission': 'Permission required',
'session.item.needsInput': 'Needs input',
'session.item.background': 'Background tasks running',
'session.item.newActivity': 'New activity',
'session.item.scheduledMessage': 'Scheduled message pending',
'session.item.scheduledMessages': '{count} scheduled messages pending',
'session.time.justNow': 'just now',
'session.time.minutesAgo': '{n}m ago',
'session.time.hoursAgo': '{n}h ago',
@@ -386,6 +392,10 @@ export default {
'settings.display.sessionPreviewLimit': 'Sessions Before Folding',
'settings.display.sessionPreviewLimit.decrease': 'Show fewer sessions before folding',
'settings.display.sessionPreviewLimit.increase': 'Show more sessions before folding',
'settings.display.sessionListStatus': 'Session list status',
'settings.display.sessionListStatus.standard': 'Standard',
'settings.display.sessionListStatus.detailed': 'Detailed',
'settings.display.sessionListStatus.detailedDescription': 'Shows why a session stopped: permission, input, background work, new activity, or a scheduled message (clock icon).',
'settings.chat.title': 'Chat',
'settings.chat.enterBehavior': 'Enter Key',
'settings.chat.enterBehavior.send': 'Send message',
+10
View File
@@ -61,6 +61,12 @@ export default {
'session.item.worktree': '工作树',
'session.item.pending': '待处理',
'session.item.thinking': '思考中',
'session.item.permission': '需要权限',
'session.item.needsInput': '需要输入',
'session.item.background': '后台任务运行中',
'session.item.newActivity': '有新活动',
'session.item.scheduledMessage': '有待发送的定时消息',
'session.item.scheduledMessages': '{count} 条定时消息待发送',
'session.time.justNow': '刚刚',
'session.time.minutesAgo': '{n} 分钟前',
'session.time.hoursAgo': '{n} 小时前',
@@ -388,6 +394,10 @@ export default {
'settings.display.sessionPreviewLimit': '会话折叠阈值',
'settings.display.sessionPreviewLimit.decrease': '减少折叠前显示的会话数',
'settings.display.sessionPreviewLimit.increase': '增加折叠前显示的会话数',
'settings.display.sessionListStatus': '会话列表状态',
'settings.display.sessionListStatus.standard': '标准',
'settings.display.sessionListStatus.detailed': '详细',
'settings.display.sessionListStatus.detailedDescription': '显示会话停止的原因:权限、输入、后台任务、新活动或定时消息(时钟图标)。',
'settings.chat.title': '聊天',
'settings.chat.enterBehavior': '回车键行为',
'settings.chat.enterBehavior.send': '发送消息',
+81
View File
@@ -0,0 +1,81 @@
import { describe, expect, it } from 'vitest'
import type { SessionSummary } from '@/types/api'
import { classifySessionAttention } from './sessionAttention'
function makeSummary(overrides: Partial<SessionSummary> & { id: string }): SessionSummary {
return {
active: true,
thinking: false,
activeAt: 0,
updatedAt: 1000,
metadata: null,
todoProgress: null,
pendingRequestsCount: 0,
pendingRequestKinds: [],
backgroundTaskCount: 0,
futureScheduledMessageCount: 0,
model: null,
effort: null,
...overrides
}
}
describe('classifySessionAttention', () => {
it('returns null for the selected session', () => {
const attention = classifySessionAttention(
makeSummary({ id: 'a', pendingRequestKinds: ['permission'] }),
{ selected: true, lastSeenAt: 0 }
)
expect(attention).toBeNull()
})
it('prioritizes permission over unread activity', () => {
const attention = classifySessionAttention(
makeSummary({
id: 'a',
pendingRequestKinds: ['permission'],
pendingRequestsCount: 1,
updatedAt: 5000
}),
{ selected: false, lastSeenAt: 0 }
)
expect(attention).toEqual({ kind: 'permission' })
})
it('shows unread activity when the session has updated since last seen', () => {
const attention = classifySessionAttention(
makeSummary({ id: 'a', updatedAt: 5000 }),
{ selected: false, lastSeenAt: 1000 }
)
expect(attention).toEqual({ kind: 'unread' })
})
it('shows background work without treating it as unread', () => {
const attention = classifySessionAttention(
makeSummary({ id: 'a', backgroundTaskCount: 2, updatedAt: 5000 }),
{ selected: false, lastSeenAt: 0 }
)
expect(attention).toEqual({ kind: 'background' })
})
it('shows unread activity for inactive sessions updated since last seen', () => {
const attention = classifySessionAttention(
makeSummary({ id: 'a', active: false, updatedAt: 5000 }),
{ selected: false, lastSeenAt: 1000 }
)
expect(attention).toEqual({ kind: 'unread' })
})
it('prefers unread over background for inactive sessions', () => {
const attention = classifySessionAttention(
makeSummary({
id: 'a',
active: false,
backgroundTaskCount: 2,
updatedAt: 5000
}),
{ selected: false, lastSeenAt: 1000 }
)
expect(attention).toEqual({ kind: 'unread' })
})
})
+47
View File
@@ -0,0 +1,47 @@
import type { SessionSummary } from '@/types/api'
export type SessionAttention =
| { kind: 'permission' }
| { kind: 'input' }
| { kind: 'background' }
| { kind: 'unread' }
export function classifySessionAttention(
summary: SessionSummary,
options: { selected: boolean; lastSeenAt: number }
): SessionAttention | null {
if (options.selected || summary.thinking) {
return null
}
if (summary.pendingRequestKinds.includes('permission')) {
return { kind: 'permission' }
}
if (summary.pendingRequestKinds.includes('input')) {
return { kind: 'input' }
}
if (summary.active && summary.backgroundTaskCount > 0) {
return { kind: 'background' }
}
if (summary.updatedAt > options.lastSeenAt) {
return { kind: 'unread' }
}
return null
}
export function getSessionAttentionLabelKey(attention: SessionAttention): string {
switch (attention.kind) {
case 'permission':
return 'session.item.permission'
case 'input':
return 'session.item.needsInput'
case 'background':
return 'session.item.background'
case 'unread':
return 'session.item.newActivity'
}
}
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, it, beforeEach, vi } from 'vitest'
import { getSessionLastSeenAt, markSessionSeen } from './sessionLastSeen'
describe('sessionLastSeen', () => {
beforeEach(() => {
localStorage.clear()
})
it('stores the latest seen timestamp for a session', () => {
markSessionSeen('session-a', 1000)
markSessionSeen('session-a', 2500)
expect(getSessionLastSeenAt('session-a')).toBe(2500)
})
it('does not move the watermark backwards', () => {
markSessionSeen('session-a', 5000)
markSessionSeen('session-a', 2000)
expect(getSessionLastSeenAt('session-a')).toBe(5000)
})
it('ignores localStorage write failures', () => {
const setItem = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('quota exceeded')
})
expect(() => markSessionSeen('session-a', 1000)).not.toThrow()
setItem.mockRestore()
})
it('returns zero when localStorage getter throws', () => {
const localStorageDescriptor = Object.getOwnPropertyDescriptor(window, 'localStorage')
Object.defineProperty(window, 'localStorage', {
configurable: true,
get() {
throw new Error('storage denied')
},
})
expect(getSessionLastSeenAt('session-a')).toBe(0)
expect(() => markSessionSeen('session-a', 1000)).not.toThrow()
if (localStorageDescriptor) {
Object.defineProperty(window, 'localStorage', localStorageDescriptor)
}
})
})
+60
View File
@@ -0,0 +1,60 @@
const STORAGE_KEY = 'hapi.sessionLastSeen.v1'
type LastSeenStore = Record<string, number>
function getLocalStorage(): Storage | null {
if (typeof window === 'undefined') {
return null
}
try {
return window.localStorage
} catch {
return null
}
}
function readStore(): LastSeenStore {
const storage = getLocalStorage()
if (!storage) {
return {}
}
try {
const raw = storage.getItem(STORAGE_KEY)
if (!raw) {
return {}
}
const parsed: unknown = JSON.parse(raw)
if (!parsed || typeof parsed !== 'object') {
return {}
}
return parsed as LastSeenStore
} catch {
return {}
}
}
function writeStore(store: LastSeenStore): void {
const storage = getLocalStorage()
if (!storage) {
return
}
try {
storage.setItem(STORAGE_KEY, JSON.stringify(store))
} catch {
// Ignore storage errors
}
}
export function getSessionLastSeenAt(sessionId: string): number {
return readStore()[sessionId] ?? 0
}
export function markSessionSeen(sessionId: string, seenAt: number): void {
if (!sessionId) {
return
}
const store = readStore()
store[sessionId] = Math.max(store[sessionId] ?? 0, seenAt)
writeStore(store)
}
+11
View File
@@ -34,6 +34,7 @@ import { useToast } from '@/lib/toast-context'
import { useTranslation } from '@/lib/use-translation'
import { fetchLatestMessages, seedMessageWindowFromSession } from '@/lib/message-window-store'
import { clearDraftsAfterSend } from '@/lib/clearDraftsAfterSend'
import { markSessionSeen } from '@/lib/sessionLastSeen'
import type { Machine } from '@/types/api'
import FilesPage from '@/routes/sessions/files'
import FilePage from '@/routes/sessions/file'
@@ -149,6 +150,16 @@ function SessionsPage() {
}, [machines])
const sessionMatch = matchRoute({ to: '/sessions/$sessionId', fuzzy: true })
const selectedSessionId = sessionMatch && sessionMatch.sessionId !== 'new' ? sessionMatch.sessionId : null
const selectedSession = useMemo(
() => sessions.find((session) => session.id === selectedSessionId) ?? null,
[sessions, selectedSessionId]
)
useEffect(() => {
if (!selectedSessionId || !selectedSession) {
return
}
markSessionSeen(selectedSessionId, selectedSession.updatedAt)
}, [selectedSessionId, selectedSession?.updatedAt])
const isSessionsIndex = pathname === '/sessions' || pathname === '/sessions/'
const sidebar = useSidebarResize()
const handleNewSessionInDirectory = useCallback((args: { machineId: string | null; directory: string }) => {
+16
View File
@@ -51,6 +51,14 @@ vi.mock('@/hooks/useTerminalToolDisplayMode', () => ({
],
}))
vi.mock('@/hooks/useSessionListStatusMode', () => ({
useSessionListStatusMode: () => ({ sessionListStatusMode: 'standard', setSessionListStatusMode: vi.fn() }),
getSessionListStatusModeOptions: () => [
{ value: 'standard', labelKey: 'settings.display.sessionListStatus.standard' },
{ value: 'detailed', labelKey: 'settings.display.sessionListStatus.detailed' },
],
}))
vi.mock('@/hooks/useSessionPreviewLimit', () => ({
MIN_SESSION_PREVIEW_LIMIT: 1,
MAX_SESSION_PREVIEW_LIMIT: 99,
@@ -216,6 +224,8 @@ describe('SettingsPage', () => {
expect(calledKeys).toContain('settings.display.sessionPreviewLimit')
expect(calledKeys).toContain('settings.display.sessionPreviewLimit.decrease')
expect(calledKeys).toContain('settings.display.sessionPreviewLimit.increase')
expect(calledKeys).toContain('settings.display.sessionListStatus')
expect(calledKeys).toContain('settings.display.sessionListStatus.standard')
})
it('renders the Terminal Font Size setting', () => {
@@ -232,6 +242,12 @@ describe('SettingsPage', () => {
expect(screen.getAllByLabelText('Show more sessions before folding').length).toBeGreaterThanOrEqual(1)
})
it('renders the Session list status setting', () => {
renderWithProviders(<SettingsPage />)
expect(screen.getAllByText('Session list status').length).toBeGreaterThanOrEqual(1)
expect(screen.getAllByText('Standard').length).toBeGreaterThanOrEqual(1)
})
it('renders the Enter Key setting', () => {
renderWithProviders(<SettingsPage />)
expect(screen.getAllByText('Enter Key').length).toBeGreaterThanOrEqual(1)
+72 -4
View File
@@ -9,6 +9,7 @@ import { getFontScaleOptions, useFontScale, type FontScale } from '@/hooks/useFo
import { getTerminalFontSizeOptions, useTerminalFontSize, type TerminalFontSize } from '@/hooks/useTerminalFontSize'
import { getComposerEnterBehaviorOptions, useComposerEnterBehavior, type ComposerEnterBehavior } from '@/hooks/useComposerEnterBehavior'
import { getTerminalToolDisplayModeOptions, useTerminalToolDisplayMode, type TerminalToolDisplayMode } from '@/hooks/useTerminalToolDisplayMode'
import { getSessionListStatusModeOptions, useSessionListStatusMode, type SessionListStatusMode } from '@/hooks/useSessionListStatusMode'
import {
MAX_SESSION_PREVIEW_LIMIT,
MIN_SESSION_PREVIEW_LIMIT,
@@ -307,6 +308,7 @@ export default function SettingsPage() {
const [isTerminalFontOpen, setIsTerminalFontOpen] = useState(false)
const [isChatOpen, setIsChatOpen] = useState(false)
const [isTerminalToolDisplayOpen, setIsTerminalToolDisplayOpen] = useState(false)
const [isSessionListStatusOpen, setIsSessionListStatusOpen] = useState(false)
const [isVoiceOpen, setIsVoiceOpen] = useState(false)
const [isVoicePickerOpen, setIsVoicePickerOpen] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
@@ -315,6 +317,7 @@ export default function SettingsPage() {
const terminalFontContainerRef = useRef<HTMLDivElement>(null)
const chatContainerRef = useRef<HTMLDivElement>(null)
const terminalToolDisplayContainerRef = useRef<HTMLDivElement>(null)
const sessionListStatusContainerRef = useRef<HTMLDivElement>(null)
const voiceContainerRef = useRef<HTMLDivElement>(null)
const voicePickerContainerRef = useRef<HTMLDivElement>(null)
const { fontScale, setFontScale } = useFontScale()
@@ -322,6 +325,7 @@ export default function SettingsPage() {
const { sessionPreviewLimit, setSessionPreviewLimit } = useSessionPreviewLimit()
const { composerEnterBehavior, setComposerEnterBehavior } = useComposerEnterBehavior()
const { terminalToolDisplayMode, setTerminalToolDisplayMode } = useTerminalToolDisplayMode()
const { sessionListStatusMode, setSessionListStatusMode } = useSessionListStatusMode()
const {
toolGroupBackground,
userMessageBackground,
@@ -349,6 +353,7 @@ export default function SettingsPage() {
const terminalFontSizeOptions = getTerminalFontSizeOptions()
const composerEnterBehaviorOptions = getComposerEnterBehaviorOptions()
const terminalToolDisplayModeOptions = getTerminalToolDisplayModeOptions()
const sessionListStatusModeOptions = getSessionListStatusModeOptions()
const appearanceOptions = getAppearanceOptions()
const currentLocale = locales.find((loc) => loc.value === locale)
const currentAppearanceLabel = appearanceOptions.find((opt) => opt.value === appearance)?.labelKey ?? 'settings.display.appearance.system'
@@ -356,6 +361,7 @@ export default function SettingsPage() {
const currentTerminalFontSizeLabel = terminalFontSizeOptions.find((opt) => opt.value === terminalFontSize)?.label ?? '13px'
const currentComposerEnterBehaviorLabel = composerEnterBehaviorOptions.find((opt) => opt.value === composerEnterBehavior)?.labelKey ?? 'settings.chat.enterBehavior.send'
const currentTerminalToolDisplayModeLabel = terminalToolDisplayModeOptions.find((opt) => opt.value === terminalToolDisplayMode)?.labelKey ?? 'settings.chat.terminalToolDisplay.compact'
const currentSessionListStatusModeLabel = sessionListStatusModeOptions.find((opt) => opt.value === sessionListStatusMode)?.labelKey ?? 'settings.display.sessionListStatus.standard'
const currentVoiceLanguage = voiceLanguages.find((lang) => lang.code === voiceLanguage)
// Voice list: dynamic (from ElevenLabs API, includes clones) or static fallback
@@ -398,6 +404,11 @@ export default function SettingsPage() {
setIsTerminalToolDisplayOpen(false)
}
const handleSessionListStatusModeChange = (newMode: SessionListStatusMode) => {
setSessionListStatusMode(newMode)
setIsSessionListStatusOpen(false)
}
const handleVoiceLanguageChange = (language: Language) => {
setVoiceLanguage(language.code)
if (language.code === null) {
@@ -457,7 +468,7 @@ export default function SettingsPage() {
// Close dropdown when clicking outside
useEffect(() => {
if (!isOpen && !isAppearanceOpen && !isFontOpen && !isTerminalFontOpen && !isChatOpen && !isTerminalToolDisplayOpen && !isVoiceOpen && !isVoicePickerOpen) return
if (!isOpen && !isAppearanceOpen && !isFontOpen && !isTerminalFontOpen && !isChatOpen && !isTerminalToolDisplayOpen && !isSessionListStatusOpen && !isVoiceOpen && !isVoicePickerOpen) return
const handleClickOutside = (event: MouseEvent) => {
if (isOpen && containerRef.current && !containerRef.current.contains(event.target as Node)) {
@@ -478,6 +489,9 @@ export default function SettingsPage() {
if (isTerminalToolDisplayOpen && terminalToolDisplayContainerRef.current && !terminalToolDisplayContainerRef.current.contains(event.target as Node)) {
setIsTerminalToolDisplayOpen(false)
}
if (isSessionListStatusOpen && sessionListStatusContainerRef.current && !sessionListStatusContainerRef.current.contains(event.target as Node)) {
setIsSessionListStatusOpen(false)
}
if (isVoiceOpen && voiceContainerRef.current && !voiceContainerRef.current.contains(event.target as Node)) {
setIsVoiceOpen(false)
}
@@ -488,11 +502,11 @@ export default function SettingsPage() {
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [isOpen, isAppearanceOpen, isFontOpen, isTerminalFontOpen, isChatOpen, isTerminalToolDisplayOpen, isVoiceOpen, isVoicePickerOpen])
}, [isOpen, isAppearanceOpen, isFontOpen, isTerminalFontOpen, isChatOpen, isTerminalToolDisplayOpen, isSessionListStatusOpen, isVoiceOpen, isVoicePickerOpen])
// Close on escape key
useEffect(() => {
if (!isOpen && !isAppearanceOpen && !isFontOpen && !isTerminalFontOpen && !isChatOpen && !isTerminalToolDisplayOpen && !isVoiceOpen && !isVoicePickerOpen) return
if (!isOpen && !isAppearanceOpen && !isFontOpen && !isTerminalFontOpen && !isChatOpen && !isTerminalToolDisplayOpen && !isSessionListStatusOpen && !isVoiceOpen && !isVoicePickerOpen) return
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
@@ -502,6 +516,7 @@ export default function SettingsPage() {
setIsTerminalFontOpen(false)
setIsChatOpen(false)
setIsTerminalToolDisplayOpen(false)
setIsSessionListStatusOpen(false)
setIsVoiceOpen(false)
setIsVoicePickerOpen(false)
}
@@ -509,7 +524,7 @@ export default function SettingsPage() {
document.addEventListener('keydown', handleEscape)
return () => document.removeEventListener('keydown', handleEscape)
}, [isOpen, isAppearanceOpen, isFontOpen, isTerminalFontOpen, isChatOpen, isTerminalToolDisplayOpen, isVoiceOpen, isVoicePickerOpen])
}, [isOpen, isAppearanceOpen, isFontOpen, isTerminalFontOpen, isChatOpen, isTerminalToolDisplayOpen, isSessionListStatusOpen, isVoiceOpen, isVoicePickerOpen])
return (
<div className="flex h-full min-h-0 flex-col">
@@ -739,6 +754,59 @@ export default function SettingsPage() {
decreaseLabel={t('settings.display.sessionPreviewLimit.decrease')}
increaseLabel={t('settings.display.sessionPreviewLimit.increase')}
/>
<div ref={sessionListStatusContainerRef} className="relative">
<button
type="button"
onClick={() => setIsSessionListStatusOpen(!isSessionListStatusOpen)}
className="flex w-full items-center justify-between px-3 py-3 text-left transition-colors hover:bg-[var(--app-subtle-bg)]"
aria-expanded={isSessionListStatusOpen}
aria-haspopup="listbox"
>
<span className="text-[var(--app-fg)]">{t('settings.display.sessionListStatus')}</span>
<span className="flex items-center gap-1 text-[var(--app-hint)]">
<span>{t(currentSessionListStatusModeLabel)}</span>
<ChevronDownIcon className={`transition-transform ${isSessionListStatusOpen ? 'rotate-180' : ''}`} />
</span>
</button>
{isSessionListStatusOpen && (
<div
className="absolute right-3 top-full mt-1 min-w-[220px] rounded-lg border border-[var(--app-border)] bg-[var(--app-bg)] shadow-lg overflow-hidden z-50"
role="listbox"
aria-label={t('settings.display.sessionListStatus')}
>
{sessionListStatusModeOptions.map((opt) => {
const isSelected = sessionListStatusMode === opt.value
return (
<button
key={opt.value}
type="button"
role="option"
aria-selected={isSelected}
onClick={() => handleSessionListStatusModeChange(opt.value)}
className={`flex items-center justify-between w-full px-3 py-2 text-base text-left transition-colors ${
isSelected
? 'text-[var(--app-link)] bg-[var(--app-subtle-bg)]'
: 'text-[var(--app-fg)] hover:bg-[var(--app-subtle-bg)]'
}`}
>
<span>{t(opt.labelKey)}</span>
{isSelected && (
<span className="ml-2 text-[var(--app-link)]">
<CheckIcon />
</span>
)}
</button>
)
})}
</div>
)}
</div>
{sessionListStatusMode === 'detailed' ? (
<div className="px-3 pb-3 text-xs text-[var(--app-hint)]">
{t('settings.display.sessionListStatus.detailedDescription')}
</div>
) : null}
</div>
{/* Chat section */}