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
+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)
}