diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css
index 09f930c216c..d9da4b9f78c 100644
--- a/src/renderer/src/assets/main.css
+++ b/src/renderer/src/assets/main.css
@@ -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;
}
diff --git a/src/renderer/src/components/sidebar/SidebarHeader.test.tsx b/src/renderer/src/components/sidebar/SidebarHeader.test.tsx
index d68d35e36fb..f7bbb4647dd 100644
--- a/src/renderer/src/components/sidebar/SidebarHeader.test.tsx
+++ b/src/renderer/src/components/sidebar/SidebarHeader.test.tsx
@@ -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())
+ 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())
+ 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()
diff --git a/src/renderer/src/components/sidebar/SidebarHeader.tsx b/src/renderer/src/components/sidebar/SidebarHeader.tsx
index b9642b94a04..bba2b317189 100644
--- a/src/renderer/src/components/sidebar/SidebarHeader.tsx
+++ b/src/renderer/src/components/sidebar/SidebarHeader.tsx
@@ -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')}
>
-
+
+
+
+
+ {counts.attention > 0 ? (
+
+ ) : (
+ 0 ? 'bg-(--status-warning)' : 'bg-muted-foreground'
+ )}
+ />
+ )}
+
+
+ 99 && 'origin-center scale-x-65'
+ )}
+ data-multiple-digits={activityCount > 9}
+ >
+ {activityCount > 99 ? '99+' : activityCount}
+
+
+
+
diff --git a/src/renderer/src/components/sidebar/WorktreeReferenceStack.tsx b/src/renderer/src/components/sidebar/WorktreeReferenceStack.tsx
index 559697ac634..4dcd6d98ef9 100644
--- a/src/renderer/src/components/sidebar/WorktreeReferenceStack.tsx
+++ b/src/renderer/src/components/sidebar/WorktreeReferenceStack.tsx
@@ -148,7 +148,7 @@ export function WorktreeReferenceStack({
{items.length > 1 ? (
9}
aria-hidden
diff --git a/tests/e2e/sidebar-activity-status-layout.spec.ts b/tests/e2e/sidebar-activity-status-layout.spec.ts
new file mode 100644
index 00000000000..9b4b3029fe7
--- /dev/null
+++ b/tests/e2e/sidebar-activity-status-layout.spec.ts
@@ -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 {
+ 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
+ )
+ })
+ }
+ }
+ })
+}