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:
parent
35c4b63ac3
commit
caee6f91f8
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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/)
|
||||
})
|
||||
})
|
||||
|
|
@ -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'
|
||||
)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue