Skip to content
Merged
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
33 changes: 27 additions & 6 deletions apps/desktop/src/app/chat/sidebar/session-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 ? (
Expand All @@ -240,11 +242,13 @@ function SessionRowLeadDot({
branchStem,
isWorking,
needsInput = false,
isUnread = false,
className
}: {
branchStem?: string
isWorking: boolean
needsInput?: boolean
isUnread?: boolean
className?: string
}) {
return (
Expand All @@ -254,18 +258,20 @@ function SessionRowLeadDot({
{branchStem}
</span>
) : null}
<SidebarRowDot isWorking={isWorking} needsInput={needsInput} />
<SidebarRowDot isWorking={isWorking} needsInput={needsInput} isUnread={isUnread} />
</span>
)
}

function SidebarRowDot({
isWorking,
needsInput = false,
isUnread = false,
className
}: {
isWorking: boolean
needsInput?: boolean
isUnread?: boolean
className?: string
}) {
const { t } = useI18n()
Expand All @@ -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}
Expand Down
16 changes: 12 additions & 4 deletions apps/desktop/src/app/session-switcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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)
Expand All @@ -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()
Expand Down Expand Up @@ -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
Expand All @@ -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'
)}
/>
)
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1573,6 +1573,7 @@ export const ja = defineLocale({
sessionRunning: 'セッション実行中',
needsInput: '入力が必要です',
waitingForAnswer: '回答を待っています',
finishedUnread: '完了 — 未読',
handoffOrigin: platform => `${platform} から引き継ぎ`,
renamed: '名前を変更しました',
renameFailed: '名前の変更に失敗しました',
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/i18n/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1378,6 +1378,7 @@ export interface Translations {
sessionRunning: string
needsInput: string
waitingForAnswer: string
finishedUnread: string
handoffOrigin: (platform: string) => string
renamed: string
renameFailed: string
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/i18n/zh-hant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1523,6 +1523,7 @@ export const zhHant = defineLocale({
sessionRunning: '工作階段執行中',
needsInput: '需要您的輸入',
waitingForAnswer: '等待您的回答',
finishedUnread: '已完成 — 未讀',
handoffOrigin: platform => `從 ${platform} 轉接`,
renamed: '已重新命名',
renameFailed: '重新命名失敗',
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1831,6 +1831,7 @@ export const zh: Translations = {
sessionRunning: '会话运行中',
needsInput: '需要你输入',
waitingForAnswer: '正在等待你的回答',
finishedUnread: '已完成 — 未读',
handoffOrigin: platform => `从 ${platform} 转接`,
renamed: '已重命名',
renameFailed: '重命名失败',
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/store/gateway-switch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
setSessions,
setSessionsLoading,
setSessionsTotal,
setUnreadFinishedSessionIds,
setWorkingSessionIds
} from '@/store/session'

Expand Down Expand Up @@ -46,6 +47,7 @@ export function wipeSessionListsForGatewaySwitch(): void {
setMessagingTruncated(false)
setWorkingSessionIds([])
setAttentionSessionIds([])
setUnreadFinishedSessionIds([])
setSessionsLoading(true)
resetSessionsLimit()

Expand Down
49 changes: 48 additions & 1 deletion apps/desktop/src/store/session.test.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -7,12 +7,14 @@ import {
$attentionSessionIds,
$connection,
$currentCwd,
$unreadFinishedSessionIds,
$workingSessionIds,
applyConfiguredDefaultProjectDir,
getRecentlySettledSessionIds,
mergeSessionPage,
sessionPinId,
setCurrentCwd,
setSelectedStoredSessionId,
setSessionAttention,
setSessionWorking,
workspaceCwdForNewSession
Expand Down Expand Up @@ -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([])
})
})
23 changes: 22 additions & 1 deletion apps/desktop/src/store/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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