mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user