diff --git a/apps/desktop/src/app/chat/sidebar/session-row.tsx b/apps/desktop/src/app/chat/sidebar/session-row.tsx
index 99bd7c509b73..c6f8ebc7a332 100644
--- a/apps/desktop/src/app/chat/sidebar/session-row.tsx
+++ b/apps/desktop/src/app/chat/sidebar/session-row.tsx
@@ -13,6 +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 { $backgroundRunningSessionIds } from '@/store/composer-status'
import { $attentionSessionIds, $unreadFinishedSessionIds } from '@/store/session'
import { openSessionTile } from '@/store/session-states'
import { canOpenSessionWindow, openSessionInNewWindow } from '@/store/windows'
@@ -80,6 +81,21 @@ export function SidebarSessionRow({
// 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)
+ // True when a terminal(background=true) process is alive in this session.
+ const hasBackground = useStore($backgroundRunningSessionIds).includes(session.id)
+
+ // Resolve the dot's display state once — the four signals are mutually
+ // exclusive by priority, so threading them as booleans through wrappers just
+ // to collapse them at the leaf is backwards.
+ const dotState: SessionDotState = needsInput
+ ? 'needs-input'
+ : isWorking
+ ? 'working'
+ : hasBackground
+ ? 'background'
+ : isUnread
+ ? 'unread'
+ : 'idle'
return (
) : (
-
+
)}
{handoffSource && handoffLabel ? (
@@ -238,17 +252,18 @@ export function SidebarSessionRow({
)
}
+/** The session's display state for the sidebar lead dot. The call site
+ * resolves this from the four underlying signals (needs-input, working,
+ * background, unread) so the dot component itself is a pure lookup. */
+type SessionDotState = 'background' | 'idle' | 'needs-input' | 'unread' | 'working'
+
function SessionRowLeadDot({
branchStem,
- isWorking,
- needsInput = false,
- isUnread = false,
+ dotState = 'idle',
className
}: {
branchStem?: string
- isWorking: boolean
- needsInput?: boolean
- isUnread?: boolean
+ dotState?: SessionDotState
className?: string
}) {
return (
@@ -258,66 +273,77 @@ function SessionRowLeadDot({
{branchStem}
) : null}
-
+
)
}
-function SidebarRowDot({
- isWorking,
- needsInput = false,
- isUnread = false,
- className
-}: {
- isWorking: boolean
- needsInput?: boolean
- isUnread?: boolean
- className?: string
-}) {
+// A pure lookup table: each state maps to its className, aria-label, and
+// title. No priority resolution here — the call site already picked one.
+// Label/title are resolved from sidebar.row translations, keyed by name.
+type DotVariant = {
+ ariaLabel?: (r: Translations['sidebar']['row']) => string
+ className: string
+ role?: 'status'
+ title?: (r: Translations['sidebar']['row']) => string
+}
+
+// Shared base for every active dot; idle is smaller and uses its own class.
+const DOT_BASE = 'relative size-1.5 rounded-full'
+
+// Pseudo-element ping ring that scales outward and fades — shared scaffold for
+// the two pulsing dots. The `before:bg-*` color is written inline per variant
+// (NOT interpolated here): Tailwind only generates utilities it can see as
+// complete static strings, so a `before:bg-${color}` template never emits.
+const PING = "before:absolute before:inset-0 before:animate-ping before:rounded-full before:content-['']"
+
+const DOT_VARIANTS: Record = {
+ // Amber steady — a clarify/approval is blocking the turn. Steady (not
+ // pulsing) reads as "your turn", distinct from the accent pulse of a turn.
+ 'needs-input': {
+ ariaLabel: r => r.needsInput,
+ className: `${DOT_BASE} quest-glow bg-amber-500`,
+ role: 'status',
+ title: r => r.waitingForAnswer
+ },
+ // Accent pulse — the LLM turn is actively running.
+ working: {
+ ariaLabel: r => r.sessionRunning,
+ className: `${DOT_BASE} bg-(--ui-accent) shadow-[0_0_0.625rem_color-mix(in_srgb,var(--ui-accent)_55%,transparent)] ${PING} before:bg-(--ui-accent) before:opacity-70`,
+ role: 'status'
+ },
+ // Pulsing gray — a terminal(background=true) process is alive while the LLM
+ // is idle. Gray (not accent) reads as "something chugging along".
+ background: {
+ ariaLabel: r => r.backgroundRunning,
+ className: `${DOT_BASE} bg-muted-foreground/50 ${PING} before:bg-muted-foreground/50 before:opacity-50`,
+ role: 'status',
+ title: r => r.backgroundRunning
+ },
+ // Steady green — a background session's turn completed and the user hasn't
+ // opened it since. "Something new here, go look."
+ unread: {
+ ariaLabel: r => r.finishedUnread,
+ className: `${DOT_BASE} bg-emerald-500`,
+ role: 'status',
+ title: r => r.finishedUnread
+ },
+ idle: {
+ className: 'size-1 rounded-full bg-(--ui-text-quaternary) opacity-80'
+ }
+}
+
+function SidebarRowDot({ dotState, className }: { dotState: SessionDotState; className?: string }) {
const { t } = useI18n()
const r = t.sidebar.row
-
- // "Needs input" wins over "working": a clarify-blocked session is technically
- // still running, but the actionable state is that it's waiting on the user.
- // Amber + steady (no ping) reads as "your turn", distinct from the accent
- // pulse of an active turn.
- if (needsInput) {
- return (
-
- )
- }
-
- // "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 (
-
- )
- }
+ const variant = DOT_VARIANTS[dotState]
return (
)
}
diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts
index 00a799e31023..31c0a734f302 100644
--- a/apps/desktop/src/i18n/en.ts
+++ b/apps/desktop/src/i18n/en.ts
@@ -1656,6 +1656,7 @@ export const en: Translations = {
needsInput: 'Needs your input',
waitingForAnswer: 'Waiting for your answer',
finishedUnread: 'Finished — unread',
+ backgroundRunning: 'Background task running',
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 1cb2f86e1655..f6bb2c0d7b36 100644
--- a/apps/desktop/src/i18n/ja.ts
+++ b/apps/desktop/src/i18n/ja.ts
@@ -1574,6 +1574,7 @@ export const ja = defineLocale({
needsInput: '入力が必要です',
waitingForAnswer: '回答を待っています',
finishedUnread: '完了 — 未読',
+ backgroundRunning: 'バックグラウンドタスク実行中',
handoffOrigin: platform => `${platform} から引き継ぎ`,
renamed: '名前を変更しました',
renameFailed: '名前の変更に失敗しました',
diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts
index 35a30da7579e..5962d317221b 100644
--- a/apps/desktop/src/i18n/types.ts
+++ b/apps/desktop/src/i18n/types.ts
@@ -1379,6 +1379,7 @@ export interface Translations {
needsInput: string
waitingForAnswer: string
finishedUnread: string
+ backgroundRunning: 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 31a72122e84e..a02abba62e0a 100644
--- a/apps/desktop/src/i18n/zh-hant.ts
+++ b/apps/desktop/src/i18n/zh-hant.ts
@@ -1524,6 +1524,7 @@ export const zhHant = defineLocale({
needsInput: '需要您的輸入',
waitingForAnswer: '等待您的回答',
finishedUnread: '已完成 — 未讀',
+ backgroundRunning: '背景任務執行中',
handoffOrigin: platform => `從 ${platform} 轉接`,
renamed: '已重新命名',
renameFailed: '重新命名失敗',
diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts
index f97cd27a5036..1422fb886d7b 100644
--- a/apps/desktop/src/i18n/zh.ts
+++ b/apps/desktop/src/i18n/zh.ts
@@ -1832,6 +1832,7 @@ export const zh: Translations = {
needsInput: '需要你输入',
waitingForAnswer: '正在等待你的回答',
finishedUnread: '已完成 — 未读',
+ backgroundRunning: '后台任务运行中',
handoffOrigin: platform => `从 ${platform} 转接`,
renamed: '已重命名',
renameFailed: '重命名失败',
diff --git a/apps/desktop/src/store/composer-status.ts b/apps/desktop/src/store/composer-status.ts
index 4d20b476b747..5d5df96cb39f 100644
--- a/apps/desktop/src/store/composer-status.ts
+++ b/apps/desktop/src/store/composer-status.ts
@@ -6,6 +6,7 @@ import type { TodoItem, TodoStatus } from '@/lib/todos'
import { $gateway } from './gateway'
import { dispatchNativeNotification } from './native-notifications'
import { notifyError } from './notifications'
+import { $sessionStates } from './session-states'
import { $subagentsBySession, type SubagentProgress } from './subagents'
import { $todosBySession } from './todos'
@@ -35,6 +36,35 @@ export interface ComposerStatusItem {
// registry (`terminal(background=true)` spawns) via `process.list`.
export const $backgroundStatusBySession = atom>({})
+// Stored session ids that have at least one RUNNING background process. The
+// sidebar row reads this for a pulsing gray dot — distinct from the accent
+// pulse of an active LLM turn — so the user can tell at a glance "this session
+// has something chugging along in the background" even when the turn is idle.
+//
+// $backgroundStatusBySession is keyed by RUNTIME session id (gateway events
+// and process.list both speak that); the sidebar row knows only the STORED id.
+// $sessionStates bridges the two: runtime id → state.storedSessionId.
+export const $backgroundRunningSessionIds = computed(
+ [$backgroundStatusBySession, $sessionStates],
+ (bg, states) => {
+ const ids = new Set()
+
+ for (const [runtimeId, items] of Object.entries(bg)) {
+ if (!items.some(i => i.state === 'running')) {
+ continue
+ }
+
+ const storedId = states[runtimeId]?.storedSessionId
+
+ if (storedId) {
+ ids.add(storedId)
+ }
+ }
+
+ return [...ids]
+ }
+)
+
// Rows the user X-ed away. The registry keeps finished processes around for a
// while, so without this every refresh would resurrect a dismissed row.
const dismissedBySession = new Map>()