diff --git a/apps/desktop/src/app/chat/sidebar/session-row.tsx b/apps/desktop/src/app/chat/sidebar/session-row.tsx
index ff4cafa361dc..99bd7c509b73 100644
--- a/apps/desktop/src/app/chat/sidebar/session-row.tsx
+++ b/apps/desktop/src/app/chat/sidebar/session-row.tsx
@@ -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 (
) : (
-
+
)}
{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}
) : null}
-
+
)
}
@@ -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 (
+
+ )
+ }
+
return (
(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}
>
-
+
{sessionTitle(session)}
{i < 9 && (
)
diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts
index 078364e7ec01..00a799e31023 100644
--- a/apps/desktop/src/i18n/en.ts
+++ b/apps/desktop/src/i18n/en.ts
@@ -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',
diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts
index 24b09aa1a2ef..1cb2f86e1655 100644
--- a/apps/desktop/src/i18n/ja.ts
+++ b/apps/desktop/src/i18n/ja.ts
@@ -1573,6 +1573,7 @@ export const ja = defineLocale({
sessionRunning: 'セッション実行中',
needsInput: '入力が必要です',
waitingForAnswer: '回答を待っています',
+ finishedUnread: '完了 — 未読',
handoffOrigin: platform => `${platform} から引き継ぎ`,
renamed: '名前を変更しました',
renameFailed: '名前の変更に失敗しました',
diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts
index 8c1ac74b5cb6..35a30da7579e 100644
--- a/apps/desktop/src/i18n/types.ts
+++ b/apps/desktop/src/i18n/types.ts
@@ -1378,6 +1378,7 @@ export interface Translations {
sessionRunning: string
needsInput: string
waitingForAnswer: string
+ finishedUnread: string
handoffOrigin: (platform: string) => string
renamed: string
renameFailed: string
diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts
index 1897d225bc38..31a72122e84e 100644
--- a/apps/desktop/src/i18n/zh-hant.ts
+++ b/apps/desktop/src/i18n/zh-hant.ts
@@ -1523,6 +1523,7 @@ export const zhHant = defineLocale({
sessionRunning: '工作階段執行中',
needsInput: '需要您的輸入',
waitingForAnswer: '等待您的回答',
+ finishedUnread: '已完成 — 未讀',
handoffOrigin: platform => `從 ${platform} 轉接`,
renamed: '已重新命名',
renameFailed: '重新命名失敗',
diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts
index aefcaf47ef77..f97cd27a5036 100644
--- a/apps/desktop/src/i18n/zh.ts
+++ b/apps/desktop/src/i18n/zh.ts
@@ -1831,6 +1831,7 @@ export const zh: Translations = {
sessionRunning: '会话运行中',
needsInput: '需要你输入',
waitingForAnswer: '正在等待你的回答',
+ finishedUnread: '已完成 — 未读',
handoffOrigin: platform => `从 ${platform} 转接`,
renamed: '已重命名',
renameFailed: '重命名失败',
diff --git a/apps/desktop/src/store/gateway-switch.ts b/apps/desktop/src/store/gateway-switch.ts
index 27f9f952f67c..e728d57d46f0 100644
--- a/apps/desktop/src/store/gateway-switch.ts
+++ b/apps/desktop/src/store/gateway-switch.ts
@@ -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()
diff --git a/apps/desktop/src/store/session.test.ts b/apps/desktop/src/store/session.test.ts
index 013ad0efd480..7ff12111e70d 100644
--- a/apps/desktop/src/store/session.test.ts
+++ b/apps/desktop/src/store/session.test.ts
@@ -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([])
+ })
+})
diff --git a/apps/desktop/src/store/session.ts b/apps/desktop/src/store/session.ts
index 2ac8085838ac..47e10adde8cf 100644
--- a/apps/desktop/src/store/session.ts
+++ b/apps/desktop/src/store/session.ts
@@ -319,7 +319,14 @@ export const setSessionProfileTotals = (next: Updater>) =
export const setSessionsLoading = (next: Updater) => updateAtom($sessionsLoading, next)
export const setWorkingSessionIds = (next: Updater) => updateAtom($workingSessionIds, next)
export const setActiveSessionId = (next: Updater) => updateAtom($activeSessionId, next)
-export const setSelectedStoredSessionId = (next: Updater) => updateAtom($selectedStoredSessionId, next)
+export const setSelectedStoredSessionId = (next: Updater) => {
+ 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) => updateAtom($messages, next)
export const setFreshDraftReady = (next: Updater) => updateAtom($freshDraftReady, next)
export const setResumeFailedSessionId = (next: Updater) => updateAtom($resumeFailedSessionId, next)
@@ -512,6 +519,14 @@ const toggleMembership = (set: (next: Updater) => 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([])
+export const setUnreadFinishedSessionIds = (next: Updater) =>
+ 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)
+ }
}
}
}