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:
@@ -13,6 +13,7 @@ import { triggerHaptic } from '@/lib/haptics'
|
|||||||
import { handoffOriginSource, sessionSourceLabel } from '@/lib/session-source'
|
import { handoffOriginSource, sessionSourceLabel } from '@/lib/session-source'
|
||||||
import { coarseElapsed } from '@/lib/time'
|
import { coarseElapsed } from '@/lib/time'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import { $backgroundRunningSessionIds } from '@/store/composer-status'
|
||||||
import { $attentionSessionIds, $unreadFinishedSessionIds } from '@/store/session'
|
import { $attentionSessionIds, $unreadFinishedSessionIds } from '@/store/session'
|
||||||
import { openSessionTile } from '@/store/session-states'
|
import { openSessionTile } from '@/store/session-states'
|
||||||
import { canOpenSessionWindow, openSessionInNewWindow } from '@/store/windows'
|
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
|
// 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.
|
// the user was viewing a different session) and hasn't been opened since.
|
||||||
const isUnread = useStore($unreadFinishedSessionIds).includes(session.id)
|
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 (
|
return (
|
||||||
<SessionContextMenu
|
<SessionContextMenu
|
||||||
@@ -210,14 +226,12 @@ export function SidebarSessionRow({
|
|||||||
<SessionRowLeadDot
|
<SessionRowLeadDot
|
||||||
branchStem={branchStem}
|
branchStem={branchStem}
|
||||||
className="transition-opacity group-hover/handle:opacity-0 group-focus-within/handle:opacity-0"
|
className="transition-opacity group-hover/handle:opacity-0 group-focus-within/handle:opacity-0"
|
||||||
isWorking={isWorking}
|
dotState={dotState}
|
||||||
needsInput={needsInput}
|
|
||||||
isUnread={isUnread}
|
|
||||||
/>
|
/>
|
||||||
</SidebarRowGrab>
|
</SidebarRowGrab>
|
||||||
) : (
|
) : (
|
||||||
<SidebarRowLead className={needsInput ? 'overflow-visible' : 'overflow-hidden'}>
|
<SidebarRowLead className={needsInput ? 'overflow-visible' : 'overflow-hidden'}>
|
||||||
<SessionRowLeadDot branchStem={branchStem} isWorking={isWorking} needsInput={needsInput} isUnread={isUnread} />
|
<SessionRowLeadDot branchStem={branchStem} dotState={dotState} />
|
||||||
</SidebarRowLead>
|
</SidebarRowLead>
|
||||||
)}
|
)}
|
||||||
{handoffSource && handoffLabel ? (
|
{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({
|
function SessionRowLeadDot({
|
||||||
branchStem,
|
branchStem,
|
||||||
isWorking,
|
dotState = 'idle',
|
||||||
needsInput = false,
|
|
||||||
isUnread = false,
|
|
||||||
className
|
className
|
||||||
}: {
|
}: {
|
||||||
branchStem?: string
|
branchStem?: string
|
||||||
isWorking: boolean
|
dotState?: SessionDotState
|
||||||
needsInput?: boolean
|
|
||||||
isUnread?: boolean
|
|
||||||
className?: string
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@@ -258,66 +273,77 @@ function SessionRowLeadDot({
|
|||||||
{branchStem}
|
{branchStem}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
<SidebarRowDot isWorking={isWorking} needsInput={needsInput} isUnread={isUnread} />
|
<SidebarRowDot dotState={dotState} />
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarRowDot({
|
// A pure lookup table: each state maps to its className, aria-label, and
|
||||||
isWorking,
|
// title. No priority resolution here — the call site already picked one.
|
||||||
needsInput = false,
|
// Label/title are resolved from sidebar.row translations, keyed by name.
|
||||||
isUnread = false,
|
type DotVariant = {
|
||||||
className
|
ariaLabel?: (r: Translations['sidebar']['row']) => string
|
||||||
}: {
|
className: string
|
||||||
isWorking: boolean
|
role?: 'status'
|
||||||
needsInput?: boolean
|
title?: (r: Translations['sidebar']['row']) => string
|
||||||
isUnread?: boolean
|
}
|
||||||
className?: 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 { t } = useI18n()
|
||||||
const r = t.sidebar.row
|
const r = t.sidebar.row
|
||||||
|
const variant = DOT_VARIANTS[dotState]
|
||||||
// "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}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
aria-label={isWorking ? r.sessionRunning : undefined}
|
aria-label={variant.ariaLabel?.(r)}
|
||||||
className={cn(
|
className={cn(variant.className, className)}
|
||||||
'rounded-full',
|
role={variant.role}
|
||||||
isWorking
|
title={variant.title?.(r)}
|
||||||
? "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}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1656,6 +1656,7 @@ export const en: Translations = {
|
|||||||
needsInput: 'Needs your input',
|
needsInput: 'Needs your input',
|
||||||
waitingForAnswer: 'Waiting for your answer',
|
waitingForAnswer: 'Waiting for your answer',
|
||||||
finishedUnread: 'Finished — unread',
|
finishedUnread: 'Finished — unread',
|
||||||
|
backgroundRunning: 'Background task running',
|
||||||
handoffOrigin: platform => `Handed off from ${platform}`,
|
handoffOrigin: platform => `Handed off from ${platform}`,
|
||||||
renamed: 'Renamed',
|
renamed: 'Renamed',
|
||||||
renameFailed: 'Rename failed',
|
renameFailed: 'Rename failed',
|
||||||
|
|||||||
@@ -1574,6 +1574,7 @@ export const ja = defineLocale({
|
|||||||
needsInput: '入力が必要です',
|
needsInput: '入力が必要です',
|
||||||
waitingForAnswer: '回答を待っています',
|
waitingForAnswer: '回答を待っています',
|
||||||
finishedUnread: '完了 — 未読',
|
finishedUnread: '完了 — 未読',
|
||||||
|
backgroundRunning: 'バックグラウンドタスク実行中',
|
||||||
handoffOrigin: platform => `${platform} から引き継ぎ`,
|
handoffOrigin: platform => `${platform} から引き継ぎ`,
|
||||||
renamed: '名前を変更しました',
|
renamed: '名前を変更しました',
|
||||||
renameFailed: '名前の変更に失敗しました',
|
renameFailed: '名前の変更に失敗しました',
|
||||||
|
|||||||
@@ -1379,6 +1379,7 @@ export interface Translations {
|
|||||||
needsInput: string
|
needsInput: string
|
||||||
waitingForAnswer: string
|
waitingForAnswer: string
|
||||||
finishedUnread: string
|
finishedUnread: string
|
||||||
|
backgroundRunning: string
|
||||||
handoffOrigin: (platform: string) => string
|
handoffOrigin: (platform: string) => string
|
||||||
renamed: string
|
renamed: string
|
||||||
renameFailed: string
|
renameFailed: string
|
||||||
|
|||||||
@@ -1524,6 +1524,7 @@ export const zhHant = defineLocale({
|
|||||||
needsInput: '需要您的輸入',
|
needsInput: '需要您的輸入',
|
||||||
waitingForAnswer: '等待您的回答',
|
waitingForAnswer: '等待您的回答',
|
||||||
finishedUnread: '已完成 — 未讀',
|
finishedUnread: '已完成 — 未讀',
|
||||||
|
backgroundRunning: '背景任務執行中',
|
||||||
handoffOrigin: platform => `從 ${platform} 轉接`,
|
handoffOrigin: platform => `從 ${platform} 轉接`,
|
||||||
renamed: '已重新命名',
|
renamed: '已重新命名',
|
||||||
renameFailed: '重新命名失敗',
|
renameFailed: '重新命名失敗',
|
||||||
|
|||||||
@@ -1832,6 +1832,7 @@ export const zh: Translations = {
|
|||||||
needsInput: '需要你输入',
|
needsInput: '需要你输入',
|
||||||
waitingForAnswer: '正在等待你的回答',
|
waitingForAnswer: '正在等待你的回答',
|
||||||
finishedUnread: '已完成 — 未读',
|
finishedUnread: '已完成 — 未读',
|
||||||
|
backgroundRunning: '后台任务运行中',
|
||||||
handoffOrigin: platform => `从 ${platform} 转接`,
|
handoffOrigin: platform => `从 ${platform} 转接`,
|
||||||
renamed: '已重命名',
|
renamed: '已重命名',
|
||||||
renameFailed: '重命名失败',
|
renameFailed: '重命名失败',
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { TodoItem, TodoStatus } from '@/lib/todos'
|
|||||||
import { $gateway } from './gateway'
|
import { $gateway } from './gateway'
|
||||||
import { dispatchNativeNotification } from './native-notifications'
|
import { dispatchNativeNotification } from './native-notifications'
|
||||||
import { notifyError } from './notifications'
|
import { notifyError } from './notifications'
|
||||||
|
import { $sessionStates } from './session-states'
|
||||||
import { $subagentsBySession, type SubagentProgress } from './subagents'
|
import { $subagentsBySession, type SubagentProgress } from './subagents'
|
||||||
import { $todosBySession } from './todos'
|
import { $todosBySession } from './todos'
|
||||||
|
|
||||||
@@ -35,6 +36,35 @@ export interface ComposerStatusItem {
|
|||||||
// registry (`terminal(background=true)` spawns) via `process.list`.
|
// registry (`terminal(background=true)` spawns) via `process.list`.
|
||||||
export const $backgroundStatusBySession = atom<Record<string, ComposerStatusItem[]>>({})
|
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
|
// 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.
|
// while, so without this every refresh would resurrect a dismissed row.
|
||||||
const dismissedBySession = new Map<string, Set<string>>()
|
const dismissedBySession = new Map<string, Set<string>>()
|
||||||
|
|||||||
Reference in New Issue
Block a user