feat(desktop): add background-task indicator to sidebar session rows (#65174)

* feat(desktop): add background-task indicator to sidebar session rows

A session with a live terminal(background=true) process but no active LLM
turn now shows a pulsing gray dot in the sidebar — distinct from the accent
pulse of an active turn and the steady amber/green of needs-input/unread.

New $backgroundRunningSessionIds computed atom joins
$backgroundStatusBySession (runtime-keyed) with $sessionStates
(runtime→stored) to produce stored session ids the sidebar row can match
against — same pattern as $attentionSessionIds and $unreadFinishedSessionIds.

Also refactors SidebarRowDot from a 3-branch if-else chain into a
table-driven priority array of DotState entries. Each state declares its
active flag, className, ariaLabel, and title in one place — adding a new
indicator state is now one array entry instead of another conditional.

* fix(desktop): keep pulse-dot before:bg-* static so Tailwind emits it

The PING() helper interpolated the color into `before:bg-${color}`, but
Tailwind v4 only generates utilities it finds as complete static strings — a
template-composed `before:bg-(--ui-accent)` / `before:bg-muted-foreground/50`
is never emitted, so both pulse rings (working + new background) lost their
halo color. Make PING a static scaffold and write the before:bg color inline
per variant.

---------

Co-authored-by: Brooklyn Nicholson <brooklyn.bb.nicholson@gmail.com>
This commit is contained in:
ethernet 2026-07-15 16:02:23 -04:00 committed by GitHub
parent 8b209e0dd7
commit b80b52aa46
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 122 additions and 61 deletions

View file

@ -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 (
<SessionContextMenu
@ -210,14 +226,12 @@ export function SidebarSessionRow({
<SessionRowLeadDot
branchStem={branchStem}
className="transition-opacity group-hover/handle:opacity-0 group-focus-within/handle:opacity-0"
isWorking={isWorking}
needsInput={needsInput}
isUnread={isUnread}
dotState={dotState}
/>
</SidebarRowGrab>
) : (
<SidebarRowLead className={needsInput ? 'overflow-visible' : 'overflow-hidden'}>
<SessionRowLeadDot branchStem={branchStem} isWorking={isWorking} needsInput={needsInput} isUnread={isUnread} />
<SessionRowLeadDot branchStem={branchStem} dotState={dotState} />
</SidebarRowLead>
)}
{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}
</span>
) : null}
<SidebarRowDot isWorking={isWorking} needsInput={needsInput} isUnread={isUnread} />
<SidebarRowDot dotState={dotState} />
</span>
)
}
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<SessionDotState, DotVariant> = {
// 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 (
<span
aria-label={r.needsInput}
className={cn('quest-glow relative size-1.5 rounded-full bg-amber-500', className)}
role="status"
title={r.waitingForAnswer}
/>
)
}
// "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}
/>
)
}
const variant = DOT_VARIANTS[dotState]
return (
<span
aria-label={isWorking ? r.sessionRunning : undefined}
className={cn(
'rounded-full',
isWorking
? "relative size-1.5 bg-(--ui-accent) shadow-[0_0_0.625rem_color-mix(in_srgb,var(--ui-accent)_55%,transparent)] before:absolute before:inset-0 before:animate-ping before:rounded-full before:bg-(--ui-accent) before:opacity-70 before:content-['']"
: 'size-1 bg-(--ui-text-quaternary) opacity-80',
className
)}
role={isWorking ? 'status' : undefined}
aria-label={variant.ariaLabel?.(r)}
className={cn(variant.className, className)}
role={variant.role}
title={variant.title?.(r)}
/>
)
}

View file

@ -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',

View file

@ -1574,6 +1574,7 @@ export const ja = defineLocale({
needsInput: '入力が必要です',
waitingForAnswer: '回答を待っています',
finishedUnread: '完了 — 未読',
backgroundRunning: 'バックグラウンドタスク実行中',
handoffOrigin: platform => `${platform} から引き継ぎ`,
renamed: '名前を変更しました',
renameFailed: '名前の変更に失敗しました',

View file

@ -1379,6 +1379,7 @@ export interface Translations {
needsInput: string
waitingForAnswer: string
finishedUnread: string
backgroundRunning: string
handoffOrigin: (platform: string) => string
renamed: string
renameFailed: string

View file

@ -1524,6 +1524,7 @@ export const zhHant = defineLocale({
needsInput: '需要您的輸入',
waitingForAnswer: '等待您的回答',
finishedUnread: '已完成 — 未讀',
backgroundRunning: '背景任務執行中',
handoffOrigin: platform => `${platform} 轉接`,
renamed: '已重新命名',
renameFailed: '重新命名失敗',

View file

@ -1832,6 +1832,7 @@ export const zh: Translations = {
needsInput: '需要你输入',
waitingForAnswer: '正在等待你的回答',
finishedUnread: '已完成 — 未读',
backgroundRunning: '后台任务运行中',
handoffOrigin: platform => `${platform} 转接`,
renamed: '已重命名',
renameFailed: '重命名失败',

View file

@ -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<Record<string, ComposerStatusItem[]>>({})
// 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<string>()
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<string, Set<string>>()