mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-21 15:41:15 +00:00
Merge pull request #65109 from NousResearch/ethie/finished-indicator
feat(desktop): green unread dot for background-finished sessions
This commit is contained in:
commit
da4a28ec6d
10 changed files with 116 additions and 12 deletions
|
|
@ -13,7 +13,7 @@ import { triggerHaptic } from '@/lib/haptics'
|
|||
import { handoffOriginSource, sessionSourceLabel } from '@/lib/session-source'
|
||||
import { coarseElapsed } from '@/lib/time'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $attentionSessionIds } from '@/store/session'
|
||||
import { $attentionSessionIds, $unreadFinishedSessionIds } from '@/store/session'
|
||||
import { openSessionTile } from '@/store/session-states'
|
||||
import { canOpenSessionWindow, openSessionInNewWindow } from '@/store/windows'
|
||||
|
||||
|
|
@ -75,10 +75,11 @@ export function SidebarSessionRow({
|
|||
// Telegram thread continued here still reads as Telegram.
|
||||
const handoffSource = handoffOriginSource(session.handoff_state, session.handoff_platform)
|
||||
const handoffLabel = handoffSource ? (sessionSourceLabel(handoffSource) ?? handoffSource) : null
|
||||
// Subscribe per-row (the leaf) instead of drilling a set through the list —
|
||||
// the atom is tiny and rarely non-empty. True when a clarify prompt in this
|
||||
// session is waiting on the user.
|
||||
// True when a clarify prompt in this session is waiting on the user.
|
||||
const needsInput = useStore($attentionSessionIds).includes(session.id)
|
||||
// True when the session's most recent turn finished in the background (while
|
||||
// the user was viewing a different session) and hasn't been opened since.
|
||||
const isUnread = useStore($unreadFinishedSessionIds).includes(session.id)
|
||||
|
||||
return (
|
||||
<SessionContextMenu
|
||||
|
|
@ -211,11 +212,12 @@ export function SidebarSessionRow({
|
|||
className="transition-opacity group-hover/handle:opacity-0 group-focus-within/handle:opacity-0"
|
||||
isWorking={isWorking}
|
||||
needsInput={needsInput}
|
||||
isUnread={isUnread}
|
||||
/>
|
||||
</SidebarRowGrab>
|
||||
) : (
|
||||
<SidebarRowLead className={needsInput ? 'overflow-visible' : 'overflow-hidden'}>
|
||||
<SessionRowLeadDot branchStem={branchStem} isWorking={isWorking} needsInput={needsInput} />
|
||||
<SessionRowLeadDot branchStem={branchStem} isWorking={isWorking} needsInput={needsInput} isUnread={isUnread} />
|
||||
</SidebarRowLead>
|
||||
)}
|
||||
{handoffSource && handoffLabel ? (
|
||||
|
|
@ -240,11 +242,13 @@ function SessionRowLeadDot({
|
|||
branchStem,
|
||||
isWorking,
|
||||
needsInput = false,
|
||||
isUnread = false,
|
||||
className
|
||||
}: {
|
||||
branchStem?: string
|
||||
isWorking: boolean
|
||||
needsInput?: boolean
|
||||
isUnread?: boolean
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
|
|
@ -254,7 +258,7 @@ function SessionRowLeadDot({
|
|||
{branchStem}
|
||||
</span>
|
||||
) : null}
|
||||
<SidebarRowDot isWorking={isWorking} needsInput={needsInput} />
|
||||
<SidebarRowDot isWorking={isWorking} needsInput={needsInput} isUnread={isUnread} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
|
@ -262,10 +266,12 @@ function SessionRowLeadDot({
|
|||
function SidebarRowDot({
|
||||
isWorking,
|
||||
needsInput = false,
|
||||
isUnread = false,
|
||||
className
|
||||
}: {
|
||||
isWorking: boolean
|
||||
needsInput?: boolean
|
||||
isUnread?: boolean
|
||||
className?: string
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
|
|
@ -286,6 +292,21 @@ function SidebarRowDot({
|
|||
)
|
||||
}
|
||||
|
||||
// "Unread finished" wins over the default gray dot: a background session's
|
||||
// turn completed and the user hasn't opened it since. Steady green (no pulse)
|
||||
// reads as "something new here, go look" — distinct from the live accent
|
||||
// pulse of a running turn.
|
||||
if (isUnread) {
|
||||
return (
|
||||
<span
|
||||
aria-label={r.finishedUnread}
|
||||
className={cn('size-1.5 rounded-full bg-emerald-500', className)}
|
||||
role="status"
|
||||
title={r.finishedUnread}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
aria-label={isWorking ? r.sessionRunning : undefined}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { useNavigate } from 'react-router-dom'
|
|||
|
||||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $attentionSessionIds, $workingSessionIds } from '@/store/session'
|
||||
import { $attentionSessionIds, $unreadFinishedSessionIds, $workingSessionIds } from '@/store/session'
|
||||
import { $switcherIndex, $switcherOpen, $switcherSessions, closeSwitcher } from '@/store/session-switcher'
|
||||
|
||||
import { HUD_ITEM, HUD_POSITION, HUD_SURFACE, HUD_TEXT } from './floating-hud'
|
||||
|
|
@ -19,6 +19,7 @@ export function SessionSwitcher() {
|
|||
const index = useStore($switcherIndex)
|
||||
const working = useStore($workingSessionIds)
|
||||
const attention = useStore($attentionSessionIds)
|
||||
const unread = useStore($unreadFinishedSessionIds)
|
||||
const navigate = useNavigate()
|
||||
|
||||
const activeRef = useRef<HTMLDivElement>(null)
|
||||
|
|
@ -33,6 +34,7 @@ export function SessionSwitcher() {
|
|||
|
||||
const workingIds = new Set(working)
|
||||
const attentionIds = new Set(attention)
|
||||
const unreadIds = new Set(unread)
|
||||
|
||||
const pick = (sessionId: string) => {
|
||||
closeSwitcher()
|
||||
|
|
@ -74,7 +76,7 @@ export function SessionSwitcher() {
|
|||
}}
|
||||
ref={selected ? activeRef : undefined}
|
||||
>
|
||||
<SwitcherDot attention={attentionIds.has(session.id)} working={workingIds.has(session.id)} />
|
||||
<SwitcherDot attention={attentionIds.has(session.id)} working={workingIds.has(session.id)} unread={unreadIds.has(session.id)} />
|
||||
<span className="min-w-0 flex-1 truncate">{sessionTitle(session)}</span>
|
||||
{i < 9 && (
|
||||
<span
|
||||
|
|
@ -95,12 +97,18 @@ export function SessionSwitcher() {
|
|||
)
|
||||
}
|
||||
|
||||
function SwitcherDot({ attention, working }: { attention: boolean; working: boolean }) {
|
||||
function SwitcherDot({ attention, working, unread }: { attention: boolean; working: boolean; unread: boolean }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'size-1 shrink-0 rounded-full',
|
||||
attention ? 'bg-amber-400' : working ? 'animate-pulse bg-(--ui-accent)' : 'bg-(--ui-text-quaternary)/50'
|
||||
attention
|
||||
? 'bg-amber-400'
|
||||
: working
|
||||
? 'animate-pulse bg-(--ui-accent)'
|
||||
: unread
|
||||
? 'bg-emerald-500'
|
||||
: 'bg-(--ui-text-quaternary)/50'
|
||||
)}
|
||||
/>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1655,6 +1655,7 @@ export const en: Translations = {
|
|||
sessionRunning: 'Session running',
|
||||
needsInput: 'Needs your input',
|
||||
waitingForAnswer: 'Waiting for your answer',
|
||||
finishedUnread: 'Finished — unread',
|
||||
handoffOrigin: platform => `Handed off from ${platform}`,
|
||||
renamed: 'Renamed',
|
||||
renameFailed: 'Rename failed',
|
||||
|
|
|
|||
|
|
@ -1573,6 +1573,7 @@ export const ja = defineLocale({
|
|||
sessionRunning: 'セッション実行中',
|
||||
needsInput: '入力が必要です',
|
||||
waitingForAnswer: '回答を待っています',
|
||||
finishedUnread: '完了 — 未読',
|
||||
handoffOrigin: platform => `${platform} から引き継ぎ`,
|
||||
renamed: '名前を変更しました',
|
||||
renameFailed: '名前の変更に失敗しました',
|
||||
|
|
|
|||
|
|
@ -1378,6 +1378,7 @@ export interface Translations {
|
|||
sessionRunning: string
|
||||
needsInput: string
|
||||
waitingForAnswer: string
|
||||
finishedUnread: string
|
||||
handoffOrigin: (platform: string) => string
|
||||
renamed: string
|
||||
renameFailed: string
|
||||
|
|
|
|||
|
|
@ -1523,6 +1523,7 @@ export const zhHant = defineLocale({
|
|||
sessionRunning: '工作階段執行中',
|
||||
needsInput: '需要您的輸入',
|
||||
waitingForAnswer: '等待您的回答',
|
||||
finishedUnread: '已完成 — 未讀',
|
||||
handoffOrigin: platform => `從 ${platform} 轉接`,
|
||||
renamed: '已重新命名',
|
||||
renameFailed: '重新命名失敗',
|
||||
|
|
|
|||
|
|
@ -1831,6 +1831,7 @@ export const zh: Translations = {
|
|||
sessionRunning: '会话运行中',
|
||||
needsInput: '需要你输入',
|
||||
waitingForAnswer: '正在等待你的回答',
|
||||
finishedUnread: '已完成 — 未读',
|
||||
handoffOrigin: platform => `从 ${platform} 转接`,
|
||||
renamed: '已重命名',
|
||||
renameFailed: '重命名失败',
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
setSessions,
|
||||
setSessionsLoading,
|
||||
setSessionsTotal,
|
||||
setUnreadFinishedSessionIds,
|
||||
setWorkingSessionIds
|
||||
} from '@/store/session'
|
||||
|
||||
|
|
@ -46,6 +47,7 @@ export function wipeSessionListsForGatewaySwitch(): void {
|
|||
setMessagingTruncated(false)
|
||||
setWorkingSessionIds([])
|
||||
setAttentionSessionIds([])
|
||||
setUnreadFinishedSessionIds([])
|
||||
setSessionsLoading(true)
|
||||
resetSessionsLimit()
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { SessionInfo } from '@/types/hermes'
|
||||
|
||||
|
|
@ -7,12 +7,14 @@ import {
|
|||
$attentionSessionIds,
|
||||
$connection,
|
||||
$currentCwd,
|
||||
$unreadFinishedSessionIds,
|
||||
$workingSessionIds,
|
||||
applyConfiguredDefaultProjectDir,
|
||||
getRecentlySettledSessionIds,
|
||||
mergeSessionPage,
|
||||
sessionPinId,
|
||||
setCurrentCwd,
|
||||
setSelectedStoredSessionId,
|
||||
setSessionAttention,
|
||||
setSessionWorking,
|
||||
workspaceCwdForNewSession
|
||||
|
|
@ -297,3 +299,48 @@ describe('getRecentlySettledSessionIds', () => {
|
|||
expect(getRecentlySettledSessionIds()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('unread finished sessions', () => {
|
||||
beforeEach(() => {
|
||||
$unreadFinishedSessionIds.set([])
|
||||
$workingSessionIds.set([])
|
||||
setSelectedStoredSessionId(() => null)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
$workingSessionIds.set([])
|
||||
$unreadFinishedSessionIds.set([])
|
||||
setSelectedStoredSessionId(() => null)
|
||||
})
|
||||
|
||||
it('marks a session unread when its turn finishes in the background', () => {
|
||||
setSelectedStoredSessionId(() => 'other-session')
|
||||
setSessionWorking('s1', true)
|
||||
setSessionWorking('s1', false)
|
||||
expect($unreadFinishedSessionIds.get()).toEqual(['s1'])
|
||||
})
|
||||
|
||||
it('does NOT mark unread when the finishing session is the active one', () => {
|
||||
setSelectedStoredSessionId(() => 's1')
|
||||
setSessionWorking('s1', true)
|
||||
setSessionWorking('s1', false)
|
||||
expect($unreadFinishedSessionIds.get()).toEqual([])
|
||||
})
|
||||
|
||||
it('does NOT mark unread on idle→idle re-asserts (no prior working state)', () => {
|
||||
setSelectedStoredSessionId(() => 'other-session')
|
||||
setSessionWorking('s1', false)
|
||||
setSessionWorking('s1', false)
|
||||
expect($unreadFinishedSessionIds.get()).toEqual([])
|
||||
})
|
||||
|
||||
it('clears unread when the user opens the session', () => {
|
||||
setSelectedStoredSessionId(() => 'other')
|
||||
setSessionWorking('s1', true)
|
||||
setSessionWorking('s1', false)
|
||||
expect($unreadFinishedSessionIds.get()).toEqual(['s1'])
|
||||
|
||||
setSelectedStoredSessionId(() => 's1')
|
||||
expect($unreadFinishedSessionIds.get()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -319,7 +319,14 @@ export const setSessionProfileTotals = (next: Updater<Record<string, number>>) =
|
|||
export const setSessionsLoading = (next: Updater<boolean>) => updateAtom($sessionsLoading, next)
|
||||
export const setWorkingSessionIds = (next: Updater<string[]>) => updateAtom($workingSessionIds, next)
|
||||
export const setActiveSessionId = (next: Updater<string | null>) => updateAtom($activeSessionId, next)
|
||||
export const setSelectedStoredSessionId = (next: Updater<string | null>) => updateAtom($selectedStoredSessionId, next)
|
||||
export const setSelectedStoredSessionId = (next: Updater<string | null>) => {
|
||||
updateAtom($selectedStoredSessionId, next)
|
||||
// Opening a session clears its unread state — the user is now looking at it.
|
||||
const id = $selectedStoredSessionId.get()
|
||||
if (id && $unreadFinishedSessionIds.get().includes(id)) {
|
||||
toggleMembership(setUnreadFinishedSessionIds, id, false)
|
||||
}
|
||||
}
|
||||
export const setMessages = (next: Updater<ChatMessage[]>) => updateAtom($messages, next)
|
||||
export const setFreshDraftReady = (next: Updater<boolean>) => updateAtom($freshDraftReady, next)
|
||||
export const setResumeFailedSessionId = (next: Updater<string | null>) => updateAtom($resumeFailedSessionId, next)
|
||||
|
|
@ -512,6 +519,14 @@ const toggleMembership = (set: (next: Updater<string[]>) => void, id: string, on
|
|||
return present ? current.filter(x => x !== id) : current
|
||||
})
|
||||
|
||||
// Stored session ids whose most recent turn finished while the user was
|
||||
// looking at a different session. The sidebar renders a steady green dot for
|
||||
// these so the user can tab back and find newly-completed work. Cleared on
|
||||
// session open (setSelectedStoredSessionId) and on gateway-mode wipe.
|
||||
export const $unreadFinishedSessionIds = atom<string[]>([])
|
||||
export const setUnreadFinishedSessionIds = (next: Updater<string[]>) =>
|
||||
updateAtom($unreadFinishedSessionIds, next)
|
||||
|
||||
// Stored session ids with a blocking prompt (clarify) waiting on the user.
|
||||
// Separate from $workingSessionIds: a session can be "working" (turn running)
|
||||
// AND need input. The sidebar row reads this for a persistent indicator that,
|
||||
|
|
@ -548,6 +563,12 @@ export function setSessionWorking(sessionId: string | null | undefined, working:
|
|||
// aggregator to return its now-persisted row.
|
||||
if (wasWorking) {
|
||||
markSessionSettled(sessionId)
|
||||
|
||||
// Mark unread when a background session finishes — only if the user
|
||||
// isn't currently viewing it. The active session's finish is seen live.
|
||||
if (sessionId !== $selectedStoredSessionId.get()) {
|
||||
toggleMembership(setUnreadFinishedSessionIds, sessionId, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue