From 587e76fbc2b98ddf1f0559a6545117599510fc11 Mon Sep 17 00:00:00 2001 From: ethernet Date: Wed, 15 Jul 2026 13:13:10 -0400 Subject: [PATCH] feat(desktop): green unread dot for background-finished sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an agent turn finishes while the user is viewing a different session, the sidebar now shows a steady green dot on that session — distinct from the blue pulsing dot of a running turn and the gray dot of an idle one. Opening the session clears the indicator. The unread state is ephemeral renderer-side state, matching the existing $workingSessionIds and $attentionSessionIds pattern: no persistence, no backend involvement, wiped on gateway-mode switch. Co-authored-by: liuhao1024 Co-authored-by: dschnurbusch Co-authored-by: Flow Digital Inc. --- .../src/app/chat/sidebar/session-row.tsx | 33 ++++++++++--- apps/desktop/src/app/session-switcher.tsx | 16 ++++-- apps/desktop/src/i18n/en.ts | 1 + apps/desktop/src/i18n/ja.ts | 1 + apps/desktop/src/i18n/types.ts | 1 + apps/desktop/src/i18n/zh-hant.ts | 1 + apps/desktop/src/i18n/zh.ts | 1 + apps/desktop/src/store/gateway-switch.ts | 2 + apps/desktop/src/store/session.test.ts | 49 ++++++++++++++++++- apps/desktop/src/store/session.ts | 23 ++++++++- 10 files changed, 116 insertions(+), 12 deletions(-) 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) + } } } }