Merge pull request #65109 from NousResearch/ethie/finished-indicator

feat(desktop): green unread dot for background-finished sessions
This commit is contained in:
brooklyn! 2026-07-15 13:55:41 -04:00 committed by GitHub
commit da4a28ec6d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 116 additions and 12 deletions

View file

@ -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}

View file

@ -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'
)}
/>
)

View file

@ -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',

View file

@ -1573,6 +1573,7 @@ export const ja = defineLocale({
sessionRunning: 'セッション実行中',
needsInput: '入力が必要です',
waitingForAnswer: '回答を待っています',
finishedUnread: '完了 — 未読',
handoffOrigin: platform => `${platform} から引き継ぎ`,
renamed: '名前を変更しました',
renameFailed: '名前の変更に失敗しました',

View file

@ -1378,6 +1378,7 @@ export interface Translations {
sessionRunning: string
needsInput: string
waitingForAnswer: string
finishedUnread: string
handoffOrigin: (platform: string) => string
renamed: string
renameFailed: string

View file

@ -1523,6 +1523,7 @@ export const zhHant = defineLocale({
sessionRunning: '工作階段執行中',
needsInput: '需要您的輸入',
waitingForAnswer: '等待您的回答',
finishedUnread: '已完成 — 未讀',
handoffOrigin: platform => `${platform} 轉接`,
renamed: '已重新命名',
renameFailed: '重新命名失敗',

View file

@ -1831,6 +1831,7 @@ export const zh: Translations = {
sessionRunning: '会话运行中',
needsInput: '需要你输入',
waitingForAnswer: '正在等待你的回答',
finishedUnread: '已完成 — 未读',
handoffOrigin: platform => `${platform} 转接`,
renamed: '已重命名',
renameFailed: '重命名失败',

View file

@ -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()

View file

@ -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([])
})
})

View file

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