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 + ) + }) + } + } + }) +}