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