Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 47 additions & 3 deletions web/src/components/SessionList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@ import { SessionActionMenu } from '@/components/SessionActionMenu'
import { RenameSessionDialog } from '@/components/RenameSessionDialog'
import { ConfirmDialog } from '@/components/ui/ConfirmDialog'
import { useTranslation } from '@/lib/use-translation'
import {
getSessionReadState,
isSessionUnread,
markSessionReadInState,
persistSessionReadState,
type SessionReadState,
} from '@/lib/sessionReadState'

type SessionGroup = {
directory: string
Expand Down Expand Up @@ -163,13 +170,14 @@ function formatRelativeTime(value: number, t: (key: string, params?: Record<stri

function SessionItem(props: {
session: SessionSummary
unread: boolean
onSelect: (sessionId: string) => void
showPath?: boolean
api: ApiClient | null
selected?: boolean
}) {
const { t } = useTranslation()
const { session: s, onSelect, showPath = true, api, selected = false } = props
const { session: s, unread, onSelect, showPath = true, api, selected = false } = props
const { haptic } = usePlatform()
const [menuOpen, setMenuOpen] = useState(false)
const [menuAnchorPoint, setMenuAnchorPoint] = useState<{ x: number; y: number }>({ x: 0, y: 0 })
Expand Down Expand Up @@ -242,6 +250,11 @@ function SessionItem(props: {
{t('session.item.pending')} {s.pendingRequestsCount}
</span>
) : null}
{unread ? (
<span className="rounded-full bg-[#007AFF]/12 px-2 py-0.5 font-medium text-[#007AFF]">
{t('session.item.unread')}
</span>
) : null}
<span className="text-[var(--app-hint)]">
{formatRelativeTime(s.updatedAt, t)}
</span>
Expand Down Expand Up @@ -323,6 +336,7 @@ export function SessionList(props: {
}) {
const { t } = useTranslation()
const { renderHeader = true, api, selectedSessionId } = props
const [readState, setReadState] = useState<SessionReadState>(() => getSessionReadState())
const groups = useMemo(
() => groupSessionsByDirectory(props.sessions),
[props.sessions]
Expand All @@ -333,7 +347,10 @@ export function SessionList(props: {
const isGroupCollapsed = (group: SessionGroup): boolean => {
const override = collapseOverrides.get(group.directory)
if (override !== undefined) return override
return !group.hasActiveSession
const hasUnread = group.sessions.some(
session => session.id !== selectedSessionId && isSessionUnread(session, readState)
)
return !group.hasActiveSession && !hasUnread
}

const toggleGroup = (directory: string, isCollapsed: boolean) => {
Expand All @@ -360,6 +377,32 @@ export function SessionList(props: {
})
}, [groups])

useEffect(() => {
if (!selectedSessionId) return
const selectedSession = props.sessions.find(session => session.id === selectedSessionId)
if (!selectedSession) return

setReadState(prev => {
const next = markSessionReadInState(prev, selectedSession.id, selectedSession.updatedAt)
if (next === prev) return prev
persistSessionReadState(next)
return next
})
}, [props.sessions, selectedSessionId])

const handleSelect = (sessionId: string) => {
const target = props.sessions.find(session => session.id === sessionId)
if (target) {
setReadState(prev => {
const next = markSessionReadInState(prev, target.id, target.updatedAt)
if (next === prev) return prev
persistSessionReadState(next)
return next
})
}
props.onSelect(sessionId)
}

return (
<div className="mx-auto w-full max-w-content flex flex-col">
{renderHeader ? (
Expand Down Expand Up @@ -407,7 +450,8 @@ export function SessionList(props: {
<SessionItem
key={s.id}
session={s}
onSelect={props.onSelect}
unread={s.id !== selectedSessionId && isSessionUnread(s, readState)}
onSelect={handleSelect}
showPath={false}
api={api}
selected={s.id === selectedSessionId}
Expand Down
1 change: 1 addition & 0 deletions web/src/lib/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export default {
'session.item.modelMode': 'mode',
'session.item.worktree': 'worktree',
'session.item.pending': 'pending',
'session.item.unread': 'unread',
'session.item.thinking': 'thinking',
'session.time.justNow': 'just now',
'session.time.minutesAgo': '{n}m ago',
Expand Down
1 change: 1 addition & 0 deletions web/src/lib/locales/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export default {
'session.item.modelMode': '模型',
'session.item.worktree': '工作树',
'session.item.pending': '待处理',
'session.item.unread': '未读',
'session.item.thinking': '思考中',
'session.time.justNow': '刚刚',
'session.time.minutesAgo': '{n} 分钟前',
Expand Down
41 changes: 41 additions & 0 deletions web/src/lib/sessionReadState.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest'
import type { SessionSummary } from '@/types/api'
import { isSessionUnread, markSessionReadInState } from './sessionReadState'

function createSession(overrides: Partial<SessionSummary> = {}): SessionSummary {
return {
id: overrides.id ?? 'session-1',
updatedAt: overrides.updatedAt ?? 100,
activeAt: overrides.activeAt ?? 100,
active: overrides.active ?? false,
pendingRequestsCount: overrides.pendingRequestsCount ?? 0,
thinking: overrides.thinking ?? false,
modelMode: overrides.modelMode ?? undefined,
todoProgress: overrides.todoProgress ?? null,
metadata: {
path: '/root/project-a',
...overrides.metadata,
},
}
}

describe('sessionReadState', () => {
it('marks a session as unread when it has newer updates than the stored read timestamp', () => {
const session = createSession({ updatedAt: 200 })

expect(isSessionUnread(session, { [session.id]: 150 })).toBe(true)
})

it('clears unread once the stored read timestamp catches up', () => {
const session = createSession({ updatedAt: 200 })
const next = markSessionReadInState({}, session.id, session.updatedAt)

expect(isSessionUnread(session, next)).toBe(false)
})

it('does not move the read timestamp backwards', () => {
const next = markSessionReadInState({ 'session-1': 300 }, 'session-1', 200)

expect(next['session-1']).toBe(300)
})
})
69 changes: 69 additions & 0 deletions web/src/lib/sessionReadState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import type { SessionSummary } from '@/types/api'

const SESSION_READ_STATE_KEY = 'hapi-session-read-state'
const MAX_STORED_SESSIONS = 500

export type SessionReadState = Record<string, number>

function safeParseJson(value: string): unknown {
try {
return JSON.parse(value) as unknown
} catch {
return null
}
}

export function getSessionReadState(): SessionReadState {
if (typeof window === 'undefined') return {}
try {
const raw = localStorage.getItem(SESSION_READ_STATE_KEY)
if (!raw) return {}
const parsed = safeParseJson(raw)
if (!parsed || typeof parsed !== 'object') return {}

const result: SessionReadState = {}
for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
if (typeof key !== 'string' || key.trim().length === 0) continue
if (typeof value !== 'number' || !Number.isFinite(value)) continue
result[key] = value
}
return result
} catch {
return {}
}
}

export function markSessionReadInState(
state: SessionReadState,
sessionId: string,
updatedAt: number
): SessionReadState {
if (!sessionId || !Number.isFinite(updatedAt)) return state
const current = state[sessionId] ?? 0
if (current >= updatedAt) return state

const next = {
...state,
[sessionId]: updatedAt
}

return Object.fromEntries(
Object.entries(next)
.sort((left, right) => right[1] - left[1])
.slice(0, MAX_STORED_SESSIONS)
)
}

export function persistSessionReadState(state: SessionReadState): void {
if (typeof window === 'undefined') return
try {
localStorage.setItem(SESSION_READ_STATE_KEY, JSON.stringify(state))
} catch {
// Ignore storage errors
}
}

export function isSessionUnread(session: SessionSummary, readState: SessionReadState): boolean {
const readAt = readState[session.id] ?? 0
return session.updatedAt > readAt
}
Loading