Show activity status with worktree-style counts (#26443)

* Show prioritized agent counts on the activity bell

* Move activity badge into the corner and shrink its count

* Use a fixed side column for activity status and counts

* Fix activity count spacing and status alignment

* Use worktree-style superscript counts for activity

* Keep activity count positions and update only number styling

* Keep idle layout fixtures out of active agent status updates
This commit is contained in:
Neil
2026-10-23 01:30:46 -07:00
committed by GitHub
parent 6d473f4bea
commit f9e883a4bf
5 changed files with 351 additions and 4 deletions
+4 -1
View File
@@ -3395,6 +3395,9 @@ html.native-shell .app-layout {
position: absolute;
top: -8px;
right: -5px;
}
.icon-superscript-count {
z-index: 3;
font-size: 11px;
font-weight: 600;
@@ -3403,6 +3406,6 @@ html.native-shell .app-layout {
text-shadow: 0px 1px 1px var(--background);
}
.workspace-reference-stack-count[data-multiple-digits='true'] {
.icon-superscript-count[data-multiple-digits='true'] {
letter-spacing: -0.02em;
}
@@ -12,6 +12,7 @@ const mocks = vi.hoisted(() => {
const shortcutLabel: { current: string | null } = { current: '⌘N' }
return {
bucketCounts: { attention: 0, working: 0, done: 0, idle: 0 },
openWorkspaceCreationComposerWithTourHandoff: vi.fn(),
popoverContentProps,
shortcutLabel,
@@ -44,7 +45,7 @@ vi.mock('@/store', () => {
})
vi.mock('@/components/dashboard/useAgentBucketCounts', () => ({
useAgentBucketCounts: () => ({ attention: 0, working: 0, done: 0, idle: 0 })
useAgentBucketCounts: () => mocks.bucketCounts
}))
vi.mock('./SidebarWorkspaceOptionsMenu', () => ({
@@ -106,6 +107,7 @@ function createButton(): HTMLButtonElement {
}
beforeEach(() => {
mocks.bucketCounts = { attention: 0, working: 0, done: 0, idle: 0 }
mocks.openWorkspaceCreationComposerWithTourHandoff.mockClear()
mocks.toast.mockClear()
mocks.shortcutLabel.current = '⌘N'
@@ -131,6 +133,44 @@ afterEach(() => {
})
describe('SidebarHeader', () => {
it.each(['workspaces', 'agents'] as const)(
'prioritizes questions, then working, and shows idle zero in the %s view',
(sidebarBody) => {
mockState.sidebarBody = sidebarBody
const render = () => {
act(() => root.render(<SidebarHeader onWorkspaceBoardMenuOpenChange={vi.fn()} />))
return headerButton(sidebarBody === 'agents' ? 'Turn off activity view' : 'View activity')
}
mocks.bucketCounts = { attention: 2, working: 5, done: 3, idle: 4 }
let button = render()
expect(button.querySelector('[aria-label="Needs You: 2"]')?.textContent).toBe('2')
expect(button.querySelector('.lucide-message-circle-question-mark')).toBeTruthy()
expect(button.querySelector('[aria-label="Working: 5"]')).toBeNull()
mocks.bucketCounts.attention = 0
button = render()
expect(button.querySelector('[aria-label="Working: 5"]')?.textContent).toBe('5')
expect(button.querySelector('.lucide-message-circle-question-mark')).toBeNull()
mocks.bucketCounts.working = 0
button = render()
expect(button.querySelector('[aria-label="Idle: 0"]')?.textContent).toBe('0')
expect(button.querySelector('.lucide-message-circle-question-mark')).toBeNull()
expect(button.getAttribute('aria-describedby')).toBe(
button.querySelector('[aria-label="Idle: 0"]')?.id
)
}
)
it.each([9, 12, 99, 100])('keeps the working count accessible at %s agents', (working) => {
mocks.bucketCounts.working = working
act(() => root.render(<SidebarHeader onWorkspaceBoardMenuOpenChange={vi.fn()} />))
const button = headerButton('View activity')
const status = button.querySelector(`[aria-label="Working: ${working}"]`)
expect(status?.textContent).toBe(working > 99 ? '99+' : String(working))
expect(button.getAttribute('aria-describedby')).toBe(status?.id)
})
it('keeps New workspace clickable with zero projects, since the composer adds the first one', async () => {
act(() => {
root.render(<SidebarHeader onWorkspaceBoardMenuOpenChange={vi.fn()} />)
@@ -8,6 +8,8 @@ import { Popover, PopoverAnchor, PopoverArrow, PopoverContent } from '@/componen
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { Sparkles, Bell } from 'lucide-react'
import { cn } from '@/lib/utils'
import { AgentQuestionIcon } from '@/components/AgentQuestionIcon'
import { useAgentBucketCounts } from '@/components/dashboard/useAgentBucketCounts'
type SidebarHeaderProps = {
onWorkspaceBoardMenuOpenChange: (open: boolean) => void
@@ -25,6 +27,14 @@ const SidebarHeader = React.memo(function SidebarHeader({
const setSidebarBody = useAppStore((s) => s.setSidebarBody)
const updateSettings = useAppStore((s) => s.updateSettings)
const agentsViewActive = sidebarBody === 'agents'
const counts = useAgentBucketCounts()
const activityBadgeId = useId()
const activityCount = counts.attention || counts.working
const activityStatusLabel = counts.attention
? translate('dashboardPopout.bucket.attention', 'Needs You')
: counts.working
? translate('dashboardPopout.bucket.working', 'Working')
: translate('dashboardPopout.bucket.idle', 'Idle')
const agentsSidebarIntroShown = useAppStore((s) => s.settings?.agentsSidebarIntroShown === true)
const migratedFromExperimental = useAppStore(
(s) => s.settings?.agentsSidebarMigratedFromExperimental === true
@@ -80,9 +90,41 @@ const SidebarHeader = React.memo(function SidebarHeader({
)}
aria-label={activityLabel}
aria-pressed={agentsViewActive}
aria-describedby={activityBadgeId}
onClick={() => setSidebarBody?.(agentsViewActive ? 'workspaces' : 'agents')}
>
<Bell className="size-3.5" strokeWidth={2.25} />
<span className="relative size-6 shrink-0">
<Bell className="absolute left-0 top-1.5 size-3" />
<span
className="pointer-events-none absolute inset-0"
id={activityBadgeId}
aria-label={`${activityStatusLabel}: ${activityCount}`}
>
<span className="absolute right-0 top-px flex h-2 w-3 items-center justify-center">
{counts.attention > 0 ? (
<AgentQuestionIcon className="size-2" />
) : (
<span
className={cn(
'size-1 rounded-full',
counts.working > 0 ? 'bg-(--status-warning)' : 'bg-muted-foreground'
)}
/>
)}
</span>
<span className="absolute right-0 top-3 flex h-2.5 w-3 items-center justify-center">
<span
className={cn(
'icon-superscript-count',
activityCount > 99 && 'origin-center scale-x-65'
)}
data-multiple-digits={activityCount > 9}
>
{activityCount > 99 ? '99+' : activityCount}
</span>
</span>
</span>
</span>
</Button>
</PopoverAnchor>
</span>
@@ -148,7 +148,7 @@ export function WorktreeReferenceStack({
</span>
{items.length > 1 ? (
<span
className="workspace-reference-stack-count"
className="workspace-reference-stack-count icon-superscript-count"
data-reference-count=""
data-multiple-digits={items.length > 9}
aria-hidden
@@ -0,0 +1,262 @@
import type { Locator, Page } from '@stablyai/playwright-test'
import { expect, test } from './helpers/orca-app'
import { waitForActiveWorktree, waitForSessionReady } from './helpers/store'
type ActivityState = 'idle' | 'working' | 'blocked'
type CountTypography = {
fontFamily: string
fontSize: string
fontWeight: string
lineHeight: string
color: string
textShadow: string
fontVariantNumeric: string
}
const CASES = [
{ state: 'idle', count: 0 },
{ state: 'working', count: 1 },
{ state: 'working', count: 12 },
{ state: 'working', count: 88 },
{ state: 'working', count: 99 },
{ state: 'working', count: 100 },
{ state: 'blocked', count: 1 },
{ state: 'blocked', count: 12 },
{ state: 'blocked', count: 99 },
{ state: 'blocked', count: 100 }
] as const
async function seedActivity(
page: Page,
worktreeId: string,
state: ActivityState,
count: number
): Promise<void> {
await page.evaluate(
({ worktreeId, state, count }) => {
const store = window.__store
if (!store) {
throw new Error('window.__store is unavailable')
}
const tab = store.getState().tabsByWorktree[worktreeId]?.[0]
if (!tab) {
throw new Error('The seeded workspace needs a terminal tab')
}
store.setState({
agentStatusByPaneKey: {},
retainedAgentsByPaneKey: {},
acknowledgedAgentsByPaneKey: {}
})
if (state === 'idle') {
return
}
const now = Date.now()
for (let index = 0; index < count; index += 1) {
const leafId = `00000000-0000-4000-8000-${String(index + 1).padStart(12, '0')}`
store
.getState()
.setAgentStatus(
`${tab.id}:${leafId}`,
{ state, prompt: `Activity layout ${index}`, agentType: 'codex' },
'Codex',
{ updatedAt: now, stateStartedAt: now },
{ tabId: tab.id, worktreeId, terminalHandle: `activity-layout-${index}` }
)
}
},
{ worktreeId, state, count }
)
}
async function expectActivityGeometry(
button: Locator,
state: ActivityState,
count: number,
referenceTypography: CountTypography,
referenceLetterSpacing: string
) {
const label = state === 'blocked' ? 'Needs You' : state === 'working' ? 'Working' : 'Idle'
const description = button.locator(`[aria-label="${label}: ${count}"]`)
await expect(description).toHaveText(count > 99 ? '99+' : String(count))
const geometry = await button.evaluate((element) => {
const descriptionId = element.getAttribute('aria-describedby')
const description = descriptionId ? document.getElementById(descriptionId) : null
const bell = element.querySelector('svg')
const statusSlot = description?.firstElementChild
const status = statusSlot?.firstElementChild
const countSlot = description?.lastElementChild
const countText = countSlot?.firstElementChild ?? countSlot
if (!bell || !status || !countSlot || !countText) {
throw new Error('Activity glyphs are missing')
}
const textRange = document.createRange()
textRange.selectNodeContents(countText)
const style = getComputedStyle(countText)
const rect = (value: DOMRect) => ({
left: value.left,
right: value.right,
top: value.top,
bottom: value.bottom,
width: value.width,
height: value.height,
centerX: value.left + value.width / 2
})
return {
button: rect(element.getBoundingClientRect()),
bell: rect(bell.getBoundingClientRect()),
status: rect(status.getBoundingClientRect()),
count: rect(textRange.getBoundingClientRect()),
countSlot: rect(countSlot.getBoundingClientRect()),
countLineBox: rect(countText.getBoundingClientRect()),
countTypography: {
fontFamily: style.fontFamily,
fontSize: style.fontSize,
fontWeight: style.fontWeight,
lineHeight: style.lineHeight,
color: style.color,
textShadow: style.textShadow,
fontVariantNumeric: style.fontVariantNumeric
},
letterSpacing: style.letterSpacing
}
})
expect(geometry.button.width, 'The control must keep its existing width').toBe(24)
expect(geometry.button.height, 'The control must keep its existing height').toBe(24)
expect(
Math.abs(geometry.status.centerX - geometry.count.centerX),
'The status and actual count text must share a vertical axis'
).toBeLessThanOrEqual(0.25)
expect(geometry.status.bottom, 'The status must clear the count').toBeLessThanOrEqual(
geometry.countLineBox.top + 0.25
)
expect(geometry.bell.left, 'The bell must retain its left anchor').toBe(geometry.button.left)
expect(geometry.bell.top, 'The bell must retain its vertical position').toBe(
geometry.button.top + 6
)
expect(geometry.bell.width, 'The bell must retain its existing size').toBe(12)
expect(geometry.bell.height, 'The bell must retain its existing size').toBe(12)
expect(geometry.countSlot.centerX, 'The count slot must retain its side column').toBe(
geometry.button.right - 6
)
expect(geometry.count.width, 'The count text must be rendered').toBeGreaterThan(0)
expect(geometry.count.width, 'Keep wider digits within a slight overhang').toBeLessThanOrEqual(
geometry.countSlot.width + 2
)
expect(geometry.count.right, 'Keep text overhang within one pixel').toBeLessThanOrEqual(
geometry.button.right + 1
)
expect(geometry.countTypography, 'Use the same count styling as worktree references').toEqual(
referenceTypography
)
if (count > 9) {
expect(geometry.letterSpacing, 'Keep the established spacing for multiple digits').toBe(
referenceLetterSpacing
)
}
for (const [name, glyph] of Object.entries({
bell: geometry.bell,
status: geometry.status,
countSlot: geometry.countSlot
})) {
expect(glyph.width, `${name} must be rendered`).toBeGreaterThan(0)
expect(glyph.left, `${name} must fit the left edge`).toBeGreaterThanOrEqual(
geometry.button.left - 0.25
)
expect(glyph.right, `${name} must fit the right edge`).toBeLessThanOrEqual(
geometry.button.right + 0.25
)
expect(glyph.top, `${name} must fit the top edge`).toBeGreaterThanOrEqual(
geometry.button.top - 0.25
)
expect(glyph.bottom, `${name} must fit the bottom edge`).toBeLessThanOrEqual(
geometry.button.bottom + 0.25
)
}
// Range height includes unpainted font ascent; the line box owns vertical layout.
expect(geometry.countLineBox.top).toBeGreaterThanOrEqual(geometry.button.top - 0.25)
expect(geometry.countLineBox.bottom).toBeLessThanOrEqual(geometry.button.bottom + 0.25)
}
for (const theme of ['dark', 'light'] as const) {
test(`activity side-column counts align with their status and preserve the control size (${theme})`, async ({
orcaPage
}) => {
await waitForSessionReady(orcaPage)
const worktreeId = await waitForActiveWorktree(orcaPage)
await orcaPage.evaluate(
async ({ theme, worktreeId }) => {
const store = window.__store
if (!store) {
throw new Error('window.__store is unavailable')
}
await store.getState().updateSettings({ theme, agentsSidebarIntroShown: true })
store.getState().setSidebarBody('workspaces')
store.setState({
worktreesByRepo: Object.fromEntries(
Object.entries(store.getState().worktreesByRepo).map(([repoId, worktrees]) => [
repoId,
worktrees.map((worktree) =>
worktree.id === worktreeId
? {
...worktree,
linkedItems: Array.from({ length: 12 }, (_, index) => ({
provider: 'github' as const,
type: 'pr' as const,
number: index + 1
}))
}
: worktree
)
])
)
})
await document.fonts.ready
},
{ theme, worktreeId }
)
await expect(orcaPage.locator('html')).toHaveClass(theme === 'dark' ? /\bdark\b/ : /\blight\b/)
const referenceCount = orcaPage.locator('[data-reference-count]').first()
await expect(referenceCount).toHaveText('12')
const { referenceTypography, referenceLetterSpacing } = await referenceCount.evaluate(
(element) => {
const style = getComputedStyle(element)
return {
referenceTypography: {
fontFamily: style.fontFamily,
fontSize: style.fontSize,
fontWeight: style.fontWeight,
lineHeight: style.lineHeight,
color: style.color,
textShadow: style.textShadow,
fontVariantNumeric: style.fontVariantNumeric
},
referenceLetterSpacing: style.letterSpacing
}
}
)
for (const sidebarBody of ['workspaces', 'agents'] as const) {
if (sidebarBody === 'agents') {
await orcaPage.getByRole('button', { name: 'View activity', exact: true }).click()
}
const button = orcaPage.getByRole('button', {
name: sidebarBody === 'agents' ? 'Turn off activity view' : 'View activity',
exact: true
})
for (const { state, count } of CASES) {
await test.step(`${sidebarBody}: ${state} ${count}`, async () => {
await seedActivity(orcaPage, worktreeId, state, count)
await expectActivityGeometry(
button,
state,
count,
referenceTypography,
referenceLetterSpacing
)
})
}
}
})
}