mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
feat(web): session list status indicators (attention + scheduled) (#699)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user