mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-06 06:41:56 +00:00
feat(web): session list status indicators (attention + scheduled) (#699)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) ? (
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '发送消息',
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user