fix(sidebar): differentiate 'active' vs 'done' status colors (#1277)

Co-authored-by: Orca <help@stably.ai>
Co-authored-by: brennanb2025 <brennankbenson@gmail.com>
This commit is contained in:
Trevin Chow 2026-04-30 16:09:10 -07:00 committed by GitHub
parent 35c4b63ac3
commit caee6f91f8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 113 additions and 32 deletions

View File

@ -0,0 +1,22 @@
import React from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it } from 'vitest'
import { AgentStateDot, type AgentDotState } from './AgentStateDot'
function renderMarkup(state: AgentDotState): string {
return renderToStaticMarkup(React.createElement(AgentStateDot, { state }))
}
describe('AgentStateDot', () => {
it('renders done as an emerald check icon', () => {
const markup = renderMarkup('done')
// Why: 'done' renders a CircleCheck icon rather than a dot so it is
// visually distinct from other emerald-adjacent states across surfaces
// (mirrors StatusIndicator). Assertion targets the lucide 'circle-check'
// class hook + emerald text color, identifying the check icon without
// coupling to the exact SVG path markup lucide emits.
expect(markup).toContain('lucide-circle-check')
expect(markup).toContain('text-emerald-500')
})
})

View File

@ -1,11 +1,13 @@
import React from 'react'
import { CircleCheck } from 'lucide-react'
import { cn } from '@/lib/utils'
// Why: shared state-dot primitive so the dashboard and the sidebar's agent
// hover render the same state vocabulary identically. The dot sits next to the
// agent icon (Claude/Codex/etc.) — they are two distinct glyphs: one for *who*
// (icon) and one for *what state* (dot). Keeping them separate keeps each
// glyph scannable at a glance instead of fused into a single decorated icon.
// Why: shared state-indicator primitive so the dashboard and the sidebar's
// agent hover render the same state vocabulary identically. Most states render
// as a dot; 'working' renders a spinner and 'done' renders a check icon. It
// sits next to the agent icon (Claude/Codex/etc.) — two distinct glyphs: one
// for *who* (agent icon) and one for *what state* (this indicator). Keeping
// them separate keeps each scannable instead of fused into one decorated icon.
export type AgentDotState =
| 'working'
@ -49,6 +51,7 @@ export const AgentStateDot = React.memo(function AgentStateDot({
}: Props): React.JSX.Element {
const box = size === 'md' ? 'h-3 w-3' : 'h-2.5 w-2.5'
const inner = size === 'md' ? 'size-2' : 'size-1.5'
const icon = size === 'md' ? 'size-3' : 'size-2.5'
if (state === 'working') {
return (
@ -66,6 +69,22 @@ export const AgentStateDot = React.memo(function AgentStateDot({
)
}
if (state === 'done') {
// Why: match StatusIndicator — agent-reported completion renders as an
// emerald check icon instead of an emerald dot so 'done' is visually
// distinct from other emerald states (e.g., sidebar 'active'). Keeping
// the dashboard and sidebar on the same glyph for 'done' is the whole
// point of this shared primitive (see file header).
return (
<span
className={cn('inline-flex shrink-0 items-center justify-center', box, className)}
aria-label={agentStateLabel(state)}
>
<CircleCheck className={cn('text-emerald-500', icon)} aria-hidden="true" />
</span>
)
}
return (
<span
className={cn('inline-flex shrink-0 items-center justify-center', box, className)}
@ -77,11 +96,7 @@ export const AgentStateDot = React.memo(function AgentStateDot({
inner,
state === 'blocked' || state === 'waiting' || state === 'permission'
? 'bg-red-500'
: state === 'done'
? // Why: emerald-500 matches StatusIndicator's done dot so the
// dashboard and sidebar read as the same state.
'bg-emerald-500'
: 'bg-neutral-500/40'
: 'bg-neutral-500/40'
)}
/>
</span>

View File

@ -0,0 +1,38 @@
import React from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it } from 'vitest'
import StatusIndicator, { type Status } from './StatusIndicator'
function renderMarkup(status: Status): string {
return renderToStaticMarkup(React.createElement(StatusIndicator, { status }))
}
function renderDotClassNames(status: Status): string[] {
const markup = renderMarkup(status)
const dotClassName = markup.match(/<span class="([^"]*rounded-full[^"]*)"/)?.[1]
expect(dotClassName).toBeDefined()
return dotClassName!.split(/\s+/)
}
describe('StatusIndicator', () => {
it('renders active as full emerald dot', () => {
const classNames = renderDotClassNames('active')
expect(classNames).toContain('bg-emerald-500')
})
it('renders done as an emerald check icon, not a dot', () => {
const markup = renderMarkup('done')
// Why: 'done' uses a CircleCheck icon rather than a rounded-full dot
// so it is visually distinct from 'active' (also emerald). The assertion
// targets the lucide 'circle-check' class hook + emerald text color,
// which together identify the check icon without coupling to the exact
// SVG path markup lucide emits.
expect(markup).toContain('lucide-circle-check')
expect(markup).toContain('text-emerald-500')
expect(markup).not.toMatch(/<span class="[^"]*rounded-full[^"]*bg-emerald-500/)
})
})

View File

@ -1,4 +1,5 @@
import React from 'react'
import { CircleCheck } from 'lucide-react'
import { cn } from '@/lib/utils'
import { getWorktreeStatusLabel, type WorktreeStatus } from '@/lib/worktree-status'
@ -18,13 +19,12 @@ const StatusIndicator = React.memo(function StatusIndicator({
title,
...rest
}: StatusIndicatorProps) {
// Why: surface the status label as a native tooltip so hovering the dot
// reveals the state — matters especially for 'active' vs 'done', which
// share the same emerald dot until TODO(#1265) lands (see color-branch
// comment below). Callers pass aria-hidden="true" alongside an sr-only
// label, so the `title` attribute is ignored by AT and only serves
// sighted users on hover. Callers can override by passing their own
// `title`.
// Why: surface the status label as a native tooltip so hovering the
// indicator reveals the state — matters especially for 'active' vs
// 'done' (dot vs check both in emerald). Callers pass aria-hidden="true"
// alongside an sr-only label, so the `title` attribute is ignored by AT
// and only serves sighted users on hover. Callers can override by
// passing their own `title`.
const resolvedTitle = title ?? getWorktreeStatusLabel(status)
if (status === 'working') {
@ -39,6 +39,23 @@ const StatusIndicator = React.memo(function StatusIndicator({
)
}
if (status === 'done') {
// Why: agent-reported completion gets a check icon instead of a dot so
// it is visually distinct from 'active' (terminal open, quiet), which
// also renders emerald. Before this, users with the experimental
// agent-tracking toggle couldn't tell a newly-opened quiet terminal
// apart from a completed agent — both were emerald dots.
return (
<span
className={cn('inline-flex h-3 w-3 shrink-0 items-center justify-center', className)}
title={resolvedTitle}
{...rest}
>
<CircleCheck className="size-3 text-emerald-500" aria-hidden="true" />
</span>
)
}
return (
<span
className={cn('inline-flex h-3 w-3 shrink-0 items-center justify-center', className)}
@ -50,19 +67,8 @@ const StatusIndicator = React.memo(function StatusIndicator({
'block size-2 rounded-full',
status === 'permission'
? 'bg-red-500'
: status === 'done' || status === 'active'
? // Green dot for both hook-reported 'done' and the heuristic
// 'active' (terminal open, quiet). Without the 'active'
// branch users without the experimental agent-tracking
// setting never reach 'done' and lose the green dot
// entirely. Working uses a yellow spinner so working vs
// done differ by both hue and motion; 'inactive' stays grey.
// TODO(#1265): differentiate 'active' (quiet terminal)
// from 'done' (agent-reported completion) visually — today
// both share emerald, so with the experimental tracking
// toggle on a newly-opened quiet terminal reads the same
// as a completed agent.
'bg-emerald-500'
: status === 'active'
? 'bg-emerald-500'
: 'bg-neutral-500/40'
)}
/>

View File

@ -157,7 +157,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
// that the spinner flickered; the blocked/waiting/done states don't have
// that problem — they're terminal (done) or attention-needed (blocked/
// waiting) and persist until the user acts. Retained "done" snapshots are
// consulted too so the done dot keeps glowing after the agent process exits,
// consulted too so the done indicator keeps showing after the agent process exits,
// matching the dashboard's retention behavior.
//
// Priority (highest first): permission (blocked/waiting) > heuristic
@ -167,7 +167,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
// completion.
// heuristic 'working' wins over done because a spinner means the user has
// already re-prompted the agent after it reported done — the newer "work
// in progress" signal is more informative than a retained completion dot.
// in progress" signal is more informative than a retained completion indicator.
// Only the 'working' heuristic earns this precedence; 'active'/'inactive'
// mean "quiet terminal", which shouldn't drown out a recent done.
// Why: collapse live hook entries to booleans inside the selector so the