fix(ui): label agent state glyphs and swap monitoring to a heartbeat (#16981)

* fix(ui): label agent state glyphs and swap monitoring to a heartbeat

The monitoring glyph read as unlabeled: AgentStateDot set only aria-label,
which renders no hover tooltip, so hovering it showed the row's own title —
the same truncated text already visible. Its row siblings (agent icon, model
chip) both had hover titles, leaving this glyph the odd one out.

Give every state a native title in the shared primitive, so done/working/
blocked/idle gain the same affordance across the sidebar, tab bar, dashboard,
kanban, cmd-J palette and AI Vault at once. Callers can override via a new
optional title prop; AiVaultSessionSubagents drops its now-redundant wrapper.

Native title rather than the Radix tooltip: AgentStateDot renders in two
surfaces with no TooltipProvider above it — the dashboard popout is its own
React root, and AgentMapScene — so Radix would throw there. StatusIndicator
already sets a native title for the same reason.

Also swap lucide Radio for Activity. Radio reads as "broadcasting"; the
heartbeat line reads as "still running", which is what the state means.
Mobile keeps its documented 1:1 parity with the desktop primitive.

Fixes STA-5794

* fix(ui): avoid duplicate agent state tooltips

* fix(ui): preserve disabled agent tooltip reason

* fix(ui): stop the state dot from shadowing a row's disabled reason

The shared AgentStateDot now emits a native title on every state, so at
any call site nested inside an element that already has a title, the
dot's generic state word wins on hover over the more useful ancestor
text. That regressed the sidebar agent row, which carries
`sendTargetDisabledReason ?? rowTitle`: hovering the dot showed
"Working" instead of the actionable send-target reason. Same guard the
review-notes send menu already uses.

Also covers three hunks that shipped untested: the Radix opt-outs in
ActivityPrototypePage and the AI Vault subagent line's dropped wrapper
title both stayed green when reverted, and the suppression test was a
`not.toContain` sweep that passed against the pre-fix tree.

* fix(ui): preserve heartbeat hover tooltips

* fix(ui): preserve lineage drop hit zones

* Use styled tooltips for state indicators

* Update jump palette tooltip assertions

* Limit status tooltips to agents

* Restore agent workspace status tooltips

* Keep status tooltips on agent indicators

* Clarify agent status tooltip ownership

* Restore agent-derived workspace status tooltips
This commit is contained in:
Brennan Benson
2026-08-28 15:34:47 -07:00
committed by GitHub
parent 5c10bf9001
commit 5dc09db2cd
34 changed files with 706 additions and 305 deletions
+2 -2
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef } from 'react'
import { Radio } from 'lucide-react-native'
import { Activity } from 'lucide-react-native'
import { Animated, Easing, StyleSheet, View } from 'react-native'
import type { AgentWorkingMode } from '../../../src/shared/agent-status-types'
@@ -50,7 +50,7 @@ export function AgentSpinner({
if (monitoring) {
return (
<View style={styles.wrapper} accessibilityLabel="Monitoring background tasks">
<Radio size={12} color={STATUS_COLORS.working} />
<Activity size={12} color={STATUS_COLORS.working} />
</View>
)
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef } from 'react'
import { Radio } from 'lucide-react-native'
import { Activity } from 'lucide-react-native'
import { Animated, Easing, StyleSheet, View } from 'react-native'
import type { AgentDotState } from '../worktree/agent-row-display'
@@ -49,7 +49,7 @@ export function AgentStateDot({ state }: { state: AgentDotState }) {
if (state === 'monitoring') {
return (
<View style={styles.wrapper} accessibilityLabel="Monitoring background tasks">
<Radio size={10} color={WORKING_COLOR} />
<Activity size={10} color={WORKING_COLOR} />
</View>
)
}
@@ -19,7 +19,7 @@ const { animationLoop, animationTiming, setValue } = vi.hoisted(() => ({
setValue: vi.fn()
}))
vi.mock('lucide-react-native', () => ({ Radio: 'Radio' }))
vi.mock('lucide-react-native', () => ({ Activity: 'Activity' }))
vi.mock('react-native', () => ({
Animated: {
Value: function Value() {
@@ -48,12 +48,12 @@ describe('mobile monitoring indicators', () => {
renderer = null
})
it('renders a static Radio for a monitoring agent', async () => {
it('renders a static Activity heartbeat for a monitoring agent', async () => {
await act(async () => {
renderer = create(createElement(AgentStateDot, { state: 'monitoring' }))
})
expect(renderer?.root.findByType('Radio').props).toMatchObject({
expect(renderer?.root.findByType('Activity').props).toMatchObject({
color: DESKTOP_WORKING_COLOR,
size: 10
})
@@ -61,14 +61,14 @@ describe('mobile monitoring indicators', () => {
expect(animationLoop).not.toHaveBeenCalled()
})
it('renders a static Radio for an all-monitoring workspace', async () => {
it('renders a static Activity heartbeat for an all-monitoring workspace', async () => {
await act(async () => {
renderer = create(
createElement(AgentSpinner, { status: 'working', workingMode: 'monitoring' })
)
})
expect(renderer?.root.findByType('Radio').props).toMatchObject({
expect(renderer?.root.findByType('Activity').props).toMatchObject({
color: DESKTOP_WORKING_COLOR,
size: 12
})
@@ -2,8 +2,24 @@ import React from 'react'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it } from 'vitest'
import { AgentStateDot, type AgentDotState } from './AgentStateDot'
import { describe, expect, it, vi } from 'vitest'
import { AgentStateDot, agentStateLabel, type AgentDotState } from './AgentStateDot'
vi.mock('@/components/StateIndicatorTooltip', async () => {
const { createElement } = await import('react')
return {
StateIndicatorTooltip: ({
label,
children
}: {
label: string | null
children: React.ReactElement
}) =>
label === null
? children
: createElement('span', { 'data-state-indicator-tooltip': label }, children)
}
})
function renderMarkup(state: AgentDotState): string {
return renderToStaticMarkup(React.createElement(AgentStateDot, { state }))
@@ -42,11 +58,11 @@ describe('AgentStateDot', () => {
expect(markup).toContain('motion-reduce:border-t-yellow-500')
})
it('renders monitoring as a static yellow radio glyph', () => {
it('renders monitoring as a static yellow heartbeat glyph', () => {
const markup = renderMarkup('monitoring')
expect(markup).toContain('aria-label="Monitoring background tasks"')
expect(markup).toContain('lucide-radio')
expect(markup).toContain('lucide-activity')
expect(markup).toContain('text-yellow-500')
expect(markup).not.toContain('data-agent-spinner')
})
@@ -86,4 +102,51 @@ describe('AgentStateDot', () => {
expect(classNames).not.toContain('bg-amber-500')
}
)
const ALL_STATES = [
'working',
'monitoring',
'blocked',
'waiting',
'interrupted',
'failed',
'done',
'idle',
'permission'
] satisfies AgentDotState[]
it.each(ALL_STATES)('labels %s with the shared hover tooltip', (state) => {
const markup = renderMarkup(state)
expect(markup).toContain(`data-state-indicator-tooltip="${agentStateLabel(state)}"`)
expect(markup).not.toContain(' title=')
})
// Typecheck-time guard: a new AgentDotState member that ALL_STATES omits
// fails `pnpm tc`, so the tooltip case above can never silently skip a state.
type UncoveredState = Exclude<AgentDotState, (typeof ALL_STATES)[number]>
const _allStatesAreCovered: UncoveredState extends never ? true : never = true
void _allStatesAreCovered
it('lets a caller override the tooltip', () => {
const markup = renderToStaticMarkup(
React.createElement(AgentStateDot, { state: 'done', title: 'Finished 2m ago' })
)
expect(markup).toContain('data-state-indicator-tooltip="Finished 2m ago"')
expect(markup).not.toContain(' title=')
expect(markup).toContain('aria-label="Done"')
})
it('lets a caller with an existing tooltip suppress the shared tooltip', () => {
const markup = renderToStaticMarkup(
React.createElement(AgentStateDot, { state: 'interrupted', title: null })
)
expect(markup).not.toContain('data-state-indicator-tooltip')
expect(markup).toContain('aria-label="Interrupted"')
expect(renderMarkup('interrupted')).toContain(
`data-state-indicator-tooltip="${agentStateLabel('interrupted')}"`
)
})
})
+42 -30
View File
@@ -1,8 +1,12 @@
import React from 'react'
import { CircleCheck, Radio } from 'lucide-react'
import { Activity, CircleCheck } from 'lucide-react'
import { cn } from '@/lib/utils'
import { AgentQuestionIcon } from '@/components/AgentQuestionIcon'
import { AgentWorkingSpinner } from '@/components/AgentWorkingSpinner'
import {
StateIndicatorTooltip,
type StateIndicatorTooltipSide
} from '@/components/StateIndicatorTooltip'
// Why: shared state-indicator primitive so the dashboard and the sidebar's
// agent hover share a single state vocabulary. Most states render as a dot;
@@ -61,20 +65,28 @@ type Props = {
state: AgentDotState
size?: 'sm' | 'md'
className?: string
/** Overrides the hover tooltip; null suppresses it for an existing tooltip. */
title?: string | null
tooltipSide?: StateIndicatorTooltipSide
}
/** Render the compact state glyph used by agent rows and terminal tabs. */
export const AgentStateDot = React.memo(function AgentStateDot({
state,
size = 'sm',
className
className,
title,
tooltipSide
}: 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'
const tooltipLabel = title === null ? null : (title ?? agentStateLabel(state))
let indicator: React.JSX.Element
if (state === 'working') {
return (
indicator = (
<span
className={cn('inline-flex shrink-0 items-center justify-center', box, className)}
aria-label={agentStateLabel(state)}
@@ -82,25 +94,21 @@ export const AgentStateDot = React.memo(function AgentStateDot({
<AgentWorkingSpinner className={inner} />
</span>
)
}
if (state === 'monitoring') {
return (
} else if (state === 'monitoring') {
indicator = (
<span
className={cn('inline-flex shrink-0 items-center justify-center', box, className)}
aria-label={agentStateLabel(state)}
>
<Radio className={cn('text-yellow-500', icon)} aria-hidden="true" />
<Activity className={cn('text-yellow-500', icon)} aria-hidden="true" />
</span>
)
}
if (state === 'done') {
} else if (state === 'done') {
// Why: the dashboard lists many agents, so a check glyph scans well for
// agent-reported completion and keeps 'done' visually distinct from
// 'idle' and other dot states at a glance. The sidebar's StatusIndicator
// intentionally diverges (emerald dot + tooltip) — see file header.
return (
indicator = (
<span
className={cn('inline-flex shrink-0 items-center justify-center', box, className)}
aria-label={agentStateLabel(state)}
@@ -108,10 +116,8 @@ export const AgentStateDot = React.memo(function AgentStateDot({
<CircleCheck className={cn('text-emerald-500', icon)} aria-hidden="true" />
</span>
)
}
if (state === 'permission' || state === 'waiting') {
return (
} else if (state === 'permission' || state === 'waiting') {
indicator = (
<span
className={cn('inline-flex shrink-0 items-center justify-center', box, className)}
aria-label={agentStateLabel(state)}
@@ -119,22 +125,28 @@ export const AgentStateDot = React.memo(function AgentStateDot({
<AgentQuestionIcon className={icon} />
</span>
)
} else {
indicator = (
<span
className={cn('inline-flex shrink-0 items-center justify-center', box, className)}
aria-label={agentStateLabel(state)}
>
<span
className={cn(
'block rounded-full',
inner,
state === 'blocked' || state === 'interrupted' || state === 'failed'
? 'bg-red-500'
: 'bg-neutral-500/40'
)}
/>
</span>
)
}
return (
<span
className={cn('inline-flex shrink-0 items-center justify-center', box, className)}
aria-label={agentStateLabel(state)}
>
<span
className={cn(
'block rounded-full',
inner,
state === 'blocked' || state === 'interrupted' || state === 'failed'
? 'bg-red-500'
: 'bg-neutral-500/40'
)}
/>
</span>
<StateIndicatorTooltip label={tooltipLabel} side={tooltipSide}>
{indicator}
</StateIndicatorTooltip>
)
})
@@ -0,0 +1,53 @@
import type { ReactNode } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import { STATE_INDICATOR_TOOLTIP_DELAY_MS, StateIndicatorTooltip } from './StateIndicatorTooltip'
vi.mock('@/components/ui/tooltip', () => ({
Tooltip: ({ delayDuration, children }: { delayDuration: number; children: ReactNode }) => (
<span data-delay-duration={delayDuration}>{children}</span>
),
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipContent: ({ children, side }: { children: ReactNode; side: string }) => (
<span data-tooltip-content="" data-side={side}>
{children}
</span>
)
}))
describe('StateIndicatorTooltip', () => {
it('uses Orca tooltip chrome with an explicit 200ms delay', () => {
const markup = renderToStaticMarkup(
<StateIndicatorTooltip label="Monitoring background tasks">
<span data-heartbeat="" />
</StateIndicatorTooltip>
)
expect(STATE_INDICATOR_TOOLTIP_DELAY_MS).toBe(200)
expect(markup).toContain('data-delay-duration="200"')
expect(markup).toContain('data-tooltip-content=""')
expect(markup).toContain('data-side="top"')
expect(markup).toContain('Monitoring background tasks')
})
it('supports surface-aware placement without changing the delay', () => {
const markup = renderToStaticMarkup(
<StateIndicatorTooltip label="Monitoring background tasks" side="right">
<span data-heartbeat="" />
</StateIndicatorTooltip>
)
expect(markup).toContain('data-delay-duration="200"')
expect(markup).toContain('data-side="right"')
})
it('renders only the indicator when a caller already owns the tooltip', () => {
const markup = renderToStaticMarkup(
<StateIndicatorTooltip label={null}>
<span data-heartbeat="" />
</StateIndicatorTooltip>
)
expect(markup).toBe('<span data-heartbeat=""></span>')
})
})
@@ -0,0 +1,28 @@
import type { ComponentProps, ReactElement } from 'react'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
export const STATE_INDICATOR_TOOLTIP_DELAY_MS = 200
export type StateIndicatorTooltipSide = ComponentProps<typeof TooltipContent>['side']
export function StateIndicatorTooltip({
label,
side = 'top',
children
}: {
label: string | null
side?: StateIndicatorTooltipSide
children: ReactElement
}): React.JSX.Element {
if (label === null) {
return children
}
return (
<Tooltip delayDuration={STATE_INDICATOR_TOOLTIP_DELAY_MS}>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent side={side} sideOffset={6}>
{label}
</TooltipContent>
</Tooltip>
)
}
@@ -827,11 +827,10 @@ describe('WorktreeJumpPalette recent chats & terminals', () => {
})
await flushEffects()
// Why: the row stays where the frozen order put it, and its badge must keep resolving — row
// data covers every open tab, so inclusion dropping it can't blank the pip mid-open.
// Why: a frozen row must retain its live badge while staying in its original slot.
expect(getTabRowIds()).toContain('tab-alpha')
expect(testContainer.textContent).toContain('Alpha chat')
expect(testContainer.querySelector('[title="Working"]')).not.toBeNull()
expect(document.querySelector('[data-slot=tooltip-trigger]')?.textContent).toContain('Working')
})
it('keeps a frozen current row listed when its agent finishes mid-open', async () => {
@@ -859,10 +858,9 @@ describe('WorktreeJumpPalette recent chats & terminals', () => {
})
await flushEffects()
// Why: `done` gates entry, not rendering — a row already in the frozen order keeps its slot and
// flips to the completed check rather than blanking under the cursor.
// Why: completion changes the frozen row's badge without removing its reserved slot.
expect(getTabRowIds()).toContain('tab-alpha')
expect(testContainer.querySelector('[title="Done"]')).not.toBeNull()
expect(document.querySelector('[data-slot=tooltip-trigger]')?.textContent).toContain('Done')
})
it('activates the row a digit chord addresses while open', async () => {
@@ -920,8 +918,7 @@ describe('WorktreeJumpPalette recent chats & terminals', () => {
})
)
// Why not optional-call: a skipped setter would leave the empty-query Recent section standing
// and the assertions below would pass without the query path ever running.
// Why: require the setter so this cannot silently exercise the empty-query section.
const applyQuery = setCommandQuery
if (!applyQuery) {
throw new Error('CommandInput never installed a query setter')
@@ -937,7 +934,7 @@ describe('WorktreeJumpPalette recent chats & terminals', () => {
const alphaRow = testContainer.querySelector<HTMLElement>(
'[data-command-item="workspace-tab:tab-alpha"]'
)
expect(alphaRow?.querySelector('[title="Working"]')).not.toBeNull()
expect(alphaRow?.querySelector('[data-slot=tooltip-trigger]')?.textContent).toContain('Working')
})
it('keeps create-worktree below the matches it would otherwise outrank', async () => {
@@ -1,11 +1,15 @@
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it } from 'vitest'
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
import { formatAgentTypeLabel } from '@/lib/agent-status'
import { TooltipProvider } from '@/components/ui/tooltip'
import {
buildActivityThreadGroups,
buildActivityEvents,
buildAgentPaneThreads,
getActivityThreadGroup
getActivityThreadGroup,
ThreadAgentStateIndicator
} from './ActivityPrototypePage'
import {
makeActivityResult,
@@ -23,6 +27,28 @@ import {
UNKNOWN_PANE_KEY
} from './ActivityPrototypePage-test-fixtures'
describe('ThreadAgentStateIndicator', () => {
it('labels the state through its Radix tooltip only', () => {
const threads = makeThreads(
makeActivityResult({ entries: { [PANE_KEY]: makeWorkingEntryWithoutHistory() } })
)
const markup = renderToStaticMarkup(
createElement(
TooltipProvider,
null,
createElement(ThreadAgentStateIndicator, { thread: threads[0]! })
)
)
const titles = [...markup.matchAll(/\stitle="([^"]*)"/g)].map((match) => match[1])
expect(markup).toContain('data-slot="tooltip-trigger"')
expect(markup).toContain('aria-label="Working"')
// A native title here would fire alongside the Radix tooltip as a double label.
expect(titles).toEqual([])
})
})
describe('activity thread grouping', () => {
it('status grouping separates interrupted done from normal done and keeps Interrupted label', () => {
const repo = makeRepo()
@@ -1180,14 +1180,18 @@ export function handleActivityFilterFocusShortcut({
return true
}
function ThreadAgentStateIndicator({ thread }: { thread: AgentPaneThread }): React.JSX.Element {
export function ThreadAgentStateIndicator({
thread
}: {
thread: AgentPaneThread
}): React.JSX.Element {
const state = threadAgentState(thread)
const label = threadAgentStateLabel(thread)
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex size-4 shrink-0 items-center justify-center">
<AgentStateDot state={state} size="md" />
<AgentStateDot state={state} size="md" title={null} />
</span>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
@@ -4,12 +4,13 @@ import React, { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAppStore } from '@/store'
import { TooltipProvider } from '@/components/ui/tooltip'
import type { AppState } from '@/store/types'
import type { AgentStatusEntry, AgentStatusState } from '../../../../shared/agent-status-types'
import { makePaneKey } from '../../../../shared/stable-pane-id'
import type { TerminalTab } from '../../../../shared/terminal-tab-types'
import {
PaletteLiveStatusProvider,
PaletteLiveStatusProvider as ProductionPaletteLiveStatusProvider,
PaletteRecentTabStatusDot,
PaletteWorktreeStatusDot
} from './palette-live-status'
@@ -21,6 +22,16 @@ vi.mock('@/components/AgentWorkingSpinner', () => ({
const initialAppState = useAppStore.getInitialState()
const LEAF = '11111111-2222-4333-8444-555555555555'
function PaletteLiveStatusProvider(
props: React.ComponentProps<typeof ProductionPaletteLiveStatusProvider>
): React.JSX.Element {
return (
<TooltipProvider>
<ProductionPaletteLiveStatusProvider {...props} />
</TooltipProvider>
)
}
let testRoot: Root
let testContainer: HTMLDivElement
@@ -79,6 +90,13 @@ function dotLabels(): string[] {
)
}
function expectStyledStatusTooltip(label: string): void {
const trigger = testContainer.querySelector<HTMLElement>('[data-slot="tooltip-trigger"]')
expect(trigger).not.toBeNull()
expect(trigger?.getAttribute('title')).toBeNull()
expect(trigger?.textContent).toContain(label)
}
describe('palette live status', () => {
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
@@ -118,6 +136,7 @@ describe('palette live status', () => {
setAgentState('working')
await render()
expect(dotLabels()).toEqual(['Working'])
expect(testContainer.querySelector('[data-slot="tooltip-trigger"]')).not.toBeNull()
await act(async () => {
setAgentState('blocked')
@@ -136,7 +155,7 @@ describe('palette live status', () => {
await render()
expect(testContainer.querySelector('[data-spinner]')).toBeNull()
expect(testContainer.querySelector('.lucide-radio')?.classList).toContain('text-yellow-500')
expect(testContainer.querySelector('.lucide-activity')?.classList).toContain('text-yellow-500')
expect(dotLabels()).toEqual(['Monitoring background tasks'])
})
@@ -244,8 +263,7 @@ describe('palette live status', () => {
expect(testContainer.querySelector('[data-fallback]')).not.toBeNull()
expect(testContainer.querySelector('[data-spinner]')).not.toBeNull()
expect(dotLabels()).toEqual(['Working'])
// Hover tooltip on the outer hit target (badge is pointer-events-none).
expect(testContainer.querySelector('[title="Working"]')).not.toBeNull()
expectStyledStatusTooltip('Working')
await act(async () => {
setAgentState('blocked')
@@ -253,7 +271,7 @@ describe('palette live status', () => {
expect(testContainer.querySelector('[data-fallback]')).not.toBeNull()
expect(testContainer.querySelector('[data-spinner]')).toBeNull()
expect(dotLabels()).toEqual(['Needs permission'])
expect(testContainer.querySelector('[title="Needs permission"]')).not.toBeNull()
expectStyledStatusTooltip('Needs permission')
})
it('shows monitoring with a static radio instead of the working spinner', async () => {
@@ -276,9 +294,9 @@ describe('palette live status', () => {
})
expect(testContainer.querySelector('[data-spinner]')).toBeNull()
expect(testContainer.querySelector('.lucide-radio')?.classList).toContain('text-yellow-500')
expect(testContainer.querySelector('.lucide-activity')?.classList).toContain('text-yellow-500')
expect(dotLabels()).toEqual(['Monitoring background tasks'])
expect(testContainer.querySelector('[title="Monitoring background tasks"]')).not.toBeNull()
expectStyledStatusTooltip('Monitoring background tasks')
})
it('shows only the content icon when a terminal-backed row is inactive', async () => {
@@ -331,7 +349,7 @@ describe('palette live status', () => {
expect(testContainer.querySelector('[data-fallback]')).not.toBeNull()
expect(testContainer.querySelector('[data-spinner]')).toBeNull()
expect(dotLabels()).toEqual(['Unread agent completion'])
expect(testContainer.querySelector('[title="Unread agent completion"]')).not.toBeNull()
expectStyledStatusTooltip('Unread agent completion')
})
it('prefers working over unread on the same row', async () => {
@@ -379,7 +397,7 @@ describe('palette live status', () => {
})
expect(testContainer.querySelector('[data-fallback]')).not.toBeNull()
expect(dotLabels()).toEqual(['Done'])
expect(testContainer.querySelector('[title="Done"]')).not.toBeNull()
expectStyledStatusTooltip('Done')
// lucide CircleCheck class marker
expect(testContainer.innerHTML).toContain('lucide-circle-check')
})
@@ -2,6 +2,7 @@ import React, { createContext, useContext, useMemo } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { useAppStore } from '@/store'
import { AgentStateDot } from '@/components/AgentStateDot'
import { StateIndicatorTooltip } from '@/components/StateIndicatorTooltip'
import StatusIndicator from '@/components/sidebar/StatusIndicator'
import { FilledBellIcon } from '@/components/sidebar/WorktreeCardHelpers'
import {
@@ -244,30 +245,28 @@ export function PaletteRecentTabStatusDot({
'Unread agent completion'
)
: getWorktreeStatusLabel(badge)
// Why: title on the outer hit target (not the pointer-events-none pip) so hover still reveals
// status — matches StatusIndicator's tooltip placement.
// Why: the outer hit target owns the tooltip because the overlaid pip ignores pointer events.
return (
<span
className="relative inline-flex size-3.5 shrink-0 items-center justify-center"
title={statusLabel}
>
{fallback}
<span
className={cn(
// Why popover, not background: the dialog surface is --popover (#171717 in dark), while
// --background is the app canvas (#0a0a0a) — using it punched a dark halo through every
// dark-mode row. Selected rows use --jump-palette-selection-surface so the cutout tracks
// the stronger keyboard highlight from main.css.
'pointer-events-none absolute -right-0.5 -bottom-0.5 flex items-center justify-center rounded-full',
'bg-popover ring-2 ring-popover',
'group-data-[selected=true]:bg-[var(--jump-palette-selection-surface)] group-data-[selected=true]:ring-[var(--jump-palette-selection-surface)]'
)}
aria-hidden="true"
>
<RecentTabAttentionBadgeGlyph badge={badge} />
<StateIndicatorTooltip label={statusLabel}>
<span className="relative inline-flex size-3.5 shrink-0 items-center justify-center">
{fallback}
<span
className={cn(
// Why popover, not background: the dialog surface is --popover (#171717 in dark), while
// --background is the app canvas (#0a0a0a) — using it punched a dark halo through every
// dark-mode row. Selected rows use --jump-palette-selection-surface so the cutout tracks
// the stronger keyboard highlight from main.css.
'pointer-events-none absolute -right-0.5 -bottom-0.5 flex items-center justify-center rounded-full',
'bg-popover ring-2 ring-popover',
'group-data-[selected=true]:bg-[var(--jump-palette-selection-surface)] group-data-[selected=true]:ring-[var(--jump-palette-selection-surface)]'
)}
aria-hidden="true"
>
<RecentTabAttentionBadgeGlyph badge={badge} />
</span>
<span className="sr-only">{statusLabel}</span>
</span>
<span className="sr-only">{statusLabel}</span>
</span>
</StateIndicatorTooltip>
)
}
@@ -281,5 +280,5 @@ function RecentTabAttentionBadgeGlyph({
return <FilledBellIcon className="size-2.5 text-amber-500 drop-shadow-sm" />
}
// Why: AgentStateDot owns working/permission/done glyphs app-wide (spinner / ? / check).
return <AgentStateDot state={badge} size="sm" />
return <AgentStateDot state={badge} size="sm" title={null} />
}
@@ -7,6 +7,7 @@ import type {
DashboardSpawnAgentArgs
} from '../../../../shared/dashboard-snapshot'
import type { TuiAgent } from '../../../../shared/tui-agent'
import { TooltipProvider } from '@/components/ui/tooltip'
import { AgentMap } from './AgentMap'
import type { AgentMapState } from './agent-map-filter'
@@ -76,7 +77,8 @@ export function renderMap(
launchableAgentsByWorktreeId={launchableAgentsByWorktreeId}
onSpawnAgent={onSpawnAgent}
onSleepWorkspace={onSleepWorkspace}
/>
/>,
{ wrapper: TooltipProvider }
)
}
@@ -320,6 +320,7 @@ describe('DashboardAgentRow', () => {
// on the response line so it does not compete with the user's prompt.
expect(markup).toContain('data-slot="tooltip-trigger"')
expect(markup).toContain('aria-label="Interrupted by user"')
expect(markup).not.toContain('title="Interrupted"')
expect(markup).toContain('bg-red-500')
expect(markup).not.toContain('data-slot="badge"')
expect(interruptedIndex).toBeGreaterThan(promptIndex)
@@ -362,7 +363,7 @@ describe('DashboardAgentRow', () => {
)
expect(markup).toContain('Monitoring background tasks')
expect(markup).toContain('lucide-radio')
expect(markup).toContain('lucide-activity')
expect(classTokens(markup)).toContain('text-yellow-500')
expect(markup).not.toContain('data-agent-spinner')
expect(markup).not.toContain('data-agent-row-tool-slot')
@@ -253,7 +253,7 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({
className="inline-flex shrink-0 items-center justify-center"
aria-label={dotTooltipLabel}
>
<AgentStateDot state={dotState} size={stateDotSize} />
<AgentStateDot state={dotState} size={stateDotSize} title={null} />
</span>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
@@ -506,9 +506,11 @@ describe('ReviewNotesSendMenuContent', () => {
const tree = render()
const item = findByType(tree, 'DropdownMenuItem')
const stateDot = findByType(item, 'AgentStateDot')
expect(item.props.disabled).toBe(true)
expect(item.props.title).toBe('Agent needs permission')
expect(stateDot.props.title).toBeNull()
;(item.props.onSelect as () => void)()
expect(harness.sendNotesToActiveAgentSession).not.toHaveBeenCalled()
})
@@ -247,6 +247,7 @@ function AgentTargetMenuItem({
const tabTitle = target.tabTitle.trim()
const state = asDotState(agent?.state ?? 'idle', agent?.entry.workingMode)
const timeAgo = agent ? formatAgentRelativeTime(agent, now) : null
const disabledReason = target.status === 'disabled' ? target.disabledReason : undefined
const secondaryParts = [
agentStateLabel(state),
...(timeAgo ? [timeAgo] : []),
@@ -259,10 +260,16 @@ function AgentTargetMenuItem({
// Why: surface the ineligibility reason (permission/stale/no-terminal) as a
// hover tooltip rather than inline text, matching DashboardAgentRow's
// title-attribute treatment of the same disabledReason.
title={target.status === 'disabled' ? target.disabledReason : undefined}
title={disabledReason}
className="min-w-[240px] gap-2 rounded-[7px] px-2 py-1.5 text-[12px] leading-5 font-medium"
>
<AgentStateDot state={state} size="sm" className="shrink-0" />
{/* Why: the ancestor's actionable disabled reason must win on every hit area. */}
<AgentStateDot
state={state}
size="sm"
className="shrink-0"
title={disabledReason ? null : undefined}
/>
<AgentIcon agent={agentTypeToIconAgent(target.agentType ?? agent?.agentType)} size={14} />
<span className="grid min-w-0 flex-1 text-left">
<span className="truncate">
@@ -1,12 +1,24 @@
// @vitest-environment happy-dom
import type { ComponentProps, JSX } from 'react'
import { act, render } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { TooltipProvider } from '@/components/ui/tooltip'
import type { AiVaultSession, AiVaultSubagentListResult } from '../../../../shared/ai-vault-types'
import { SessionSubagentsSection } from './AiVaultSessionSubagents'
import { SessionSubagentsSection as ProductionSessionSubagentsSection } from './AiVaultSessionSubagents'
const listSubagentSessions = vi.fn<(args: unknown) => Promise<AiVaultSubagentListResult>>()
function SessionSubagentsSection(
props: ComponentProps<typeof ProductionSessionSubagentsSection>
): JSX.Element {
return (
<TooltipProvider>
<ProductionSessionSubagentsSection {...props} />
</TooltipProvider>
)
}
beforeEach(() => {
listSubagentSessions.mockReset()
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- test-only window.api shim
@@ -86,6 +98,22 @@ describe('SessionSubagentsSection', () => {
expect(queryByText('First pass')).toBeNull()
})
it('labels the subagent run state exactly once, on the dot itself', async () => {
listSubagentSessions.mockResolvedValueOnce({
sessions: [makeSubagent('Running task')],
issues: []
})
const { container } = render(<SessionSubagentsSection session={makeSession()} />)
await act(async () => {})
const titles = [...container.querySelectorAll('[title]')].map((element) =>
element.getAttribute('title')
)
expect(titles).toEqual(['Running task', 'View Log'])
expect(container.querySelector('[data-slot="tooltip-trigger"]')).not.toBeNull()
})
it('does not fetch for remote sessions even when the scan counted transcripts', async () => {
const { container } = render(
<SessionSubagentsSection
@@ -3,7 +3,7 @@ import type React from 'react'
import { Bot, FileJson } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { AgentStateDot, agentStateLabel, type AgentDotState } from '@/components/AgentStateDot'
import { AgentStateDot, type AgentDotState } from '@/components/AgentStateDot'
import type { AiVaultSession, AiVaultSubagentRunStatus } from '../../../../shared/ai-vault-types'
import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host'
import { canOpenAiVaultSessionLogInOrca } from './ai-vault-session-path-actions'
@@ -120,7 +120,7 @@ function SubagentSessionLine({ session }: { session: AiVaultSession }): React.JS
{dotState ? (
// Why: a plain inline span would baseline-align the dot; flex keeps it
// vertically centered with the row text.
<span className="flex shrink-0 items-center" title={agentStateLabel(dotState)}>
<span className="flex shrink-0 items-center">
<AgentStateDot state={dotState} />
</span>
) : null}
@@ -1,8 +1,24 @@
import React from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import StatusIndicator, { type Status } from './StatusIndicator'
vi.mock('@/components/StateIndicatorTooltip', async () => {
const { createElement } = await import('react')
return {
StateIndicatorTooltip: ({
label,
children
}: {
label: string | null
children: React.ReactElement
}) =>
label === null
? children
: createElement('span', { 'data-state-indicator-tooltip': label }, children)
}
})
function renderMarkup(status: Status): string {
return renderToStaticMarkup(React.createElement(StatusIndicator, { status }))
}
@@ -31,11 +47,12 @@ describe('StatusIndicator', () => {
expect(markup).toContain('motion-reduce:border-t-yellow-500')
})
it('renders monitoring as a static radio glyph', () => {
it('renders monitoring as a static heartbeat glyph', () => {
const markup = renderMarkup('monitoring')
expect(markup).toContain('title="Monitoring background tasks"')
expect(markup).toContain('lucide-radio')
expect(markup).toContain('data-state-indicator-tooltip="Monitoring background tasks"')
expect(markup).not.toContain(' title=')
expect(markup).toContain('lucide-activity')
expect(markup).toContain('text-yellow-500')
expect(markup).not.toContain('data-agent-spinner')
})
@@ -67,4 +84,36 @@ describe('StatusIndicator', () => {
expect(classNames).toContain('bg-red-500')
expect(classNames).not.toContain('bg-emerald-500')
})
it.each([
['working', 'Working'],
['monitoring', 'Monitoring background tasks'],
['permission', 'Needs permission'],
['interrupted', 'Interrupted'],
['done', 'Done']
] as const)('labels the agent-derived %s workspace state', (status, label) => {
const markup = renderMarkup(status)
expect(markup).toContain(`data-state-indicator-tooltip="${label}"`)
expect(markup).not.toContain(' title=')
})
it.each(['active', 'inactive'] as const)(
'does not label the passive %s workspace state',
(status) => {
const markup = renderMarkup(status)
expect(markup).not.toContain('data-state-indicator-tooltip')
expect(markup).not.toContain(' title=')
}
)
it('lets an enclosing action own the tooltip', () => {
const markup = renderToStaticMarkup(
React.createElement(StatusIndicator, { status: 'working', showTooltip: false })
)
expect(markup).not.toContain('data-state-indicator-tooltip')
expect(markup).toContain('data-agent-spinner')
})
})
@@ -1,8 +1,12 @@
import React from 'react'
import { Radio } from 'lucide-react'
import { Activity } from 'lucide-react'
import { cn } from '@/lib/utils'
import { AgentQuestionIcon } from '@/components/AgentQuestionIcon'
import { AgentWorkingSpinner } from '@/components/AgentWorkingSpinner'
import {
StateIndicatorTooltip,
type StateIndicatorTooltipSide
} from '@/components/StateIndicatorTooltip'
import { getWorktreeStatusLabel, type WorktreeStatus } from '@/lib/worktree-status'
// Why: re-export WorktreeStatus under the existing `Status` alias so the
@@ -11,90 +15,92 @@ import { getWorktreeStatusLabel, type WorktreeStatus } from '@/lib/worktree-stat
// (e.g., 'error') and the other didn't.
export type Status = WorktreeStatus
type StatusIndicatorProps = React.ComponentProps<'span'> & {
type StatusIndicatorProps = Omit<React.ComponentProps<'span'>, 'title'> & {
status: Status
showTooltip?: boolean
tooltipSide?: StateIndicatorTooltipSide
}
const AGENT_STATUS_TOOLTIP_STATUSES = new Set<Status>([
'working',
'monitoring',
'permission',
'interrupted',
'done'
])
const StatusIndicator = React.memo(function StatusIndicator({
status,
className,
title,
showTooltip = true,
tooltipSide,
...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. 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)
const tooltipLabel =
showTooltip && AGENT_STATUS_TOOLTIP_STATUSES.has(status) ? getWorktreeStatusLabel(status) : null
let indicator: React.JSX.Element
if (status === 'working') {
return (
indicator = (
<span
className={cn('inline-flex h-3 w-3 shrink-0 items-center justify-center', className)}
title={resolvedTitle}
{...rest}
>
<AgentWorkingSpinner className="size-2" />
</span>
)
}
if (status === 'monitoring') {
return (
} else if (status === 'monitoring') {
indicator = (
<span
className={cn('inline-flex h-3 w-3 shrink-0 items-center justify-center', className)}
title={resolvedTitle}
{...rest}
>
<Radio className="size-3 text-yellow-500" aria-hidden="true" />
<Activity className="size-3 text-yellow-500" aria-hidden="true" />
</span>
)
}
if (status === 'interrupted') {
return (
} else if (status === 'interrupted') {
indicator = (
<span
className={cn('inline-flex h-3 w-3 shrink-0 items-center justify-center', className)}
title={resolvedTitle}
{...rest}
>
<span className="block size-1.5 rounded-full bg-red-500" />
</span>
)
}
if (status === 'permission') {
return (
} else if (status === 'permission') {
indicator = (
<span
className={cn('inline-flex h-3 w-3 shrink-0 items-center justify-center', className)}
title={resolvedTitle}
{...rest}
>
<AgentQuestionIcon className="size-3" />
</span>
)
} else {
indicator = (
<span
className={cn('inline-flex h-3 w-3 shrink-0 items-center justify-center', className)}
{...rest}
>
<span
className={cn(
'block size-2 rounded-full',
status === 'done' || status === 'active'
? // Green dot for both hook-reported 'done' and the heuristic
// 'active' (terminal open, quiet). Working uses a yellow
// ring above; 'inactive' stays grey.
'bg-emerald-500'
: 'bg-neutral-500/40'
)}
/>
</span>
)
}
return (
<span
className={cn('inline-flex h-3 w-3 shrink-0 items-center justify-center', className)}
title={resolvedTitle}
{...rest}
>
<span
className={cn(
'block size-2 rounded-full',
status === 'done' || status === 'active'
? // Green dot for both hook-reported 'done' and the heuristic
// 'active' (terminal open, quiet). Working uses a yellow
// ring above; 'inactive' stays grey.
'bg-emerald-500'
: 'bg-neutral-500/40'
)}
/>
</span>
<StateIndicatorTooltip label={tooltipLabel} side={tooltipSide}>
{indicator}
</StateIndicatorTooltip>
)
})
@@ -168,7 +168,7 @@ function makeHostedReview(overrides: Partial<HostedReviewInfo> = {}): HostedRevi
}
}
function expectParentBodyIsHoverTrigger(markup: string): void {
function expectIdentityBodyIsHoverTrigger(markup: string): void {
const surfaceTag = markup.match(/<div[^>]*data-worktree-card-surface="true"[^>]*>/)?.[0]
const triggerTag = markup.match(/<div[^>]*data-worktree-card-hover-trigger=""[^>]*>/)?.[0]
@@ -236,7 +236,7 @@ describe('WorktreeCard compact hover details', () => {
)
expect(markup).toContain('data-worktree-title-inline-rename=""')
expectParentBodyIsHoverTrigger(markup)
expectIdentityBodyIsHoverTrigger(markup)
expect(markup).toContain('data-hover-open-delay="100"')
expect(markup).toContain('PR #456')
expect(markup).toContain('Fix stale GH PR')
@@ -288,7 +288,7 @@ describe('WorktreeCard compact hover details', () => {
)
expect(markup).toContain('data-hover-open-delay="100"')
expectParentBodyIsHoverTrigger(markup)
expectIdentityBodyIsHoverTrigger(markup)
expect(markup).toContain('Issue #123')
expect(markup).toContain('Linear ENG-123')
expect(markup).toContain('Reviewer handoff note')
@@ -397,7 +397,7 @@ describe('WorktreeCard compact hover details', () => {
expect(markup).toContain('Human title')
})
it('uses one whole-card hover even when detailed metadata icons are visible when new card style is on', async () => {
it('uses one identity hover even when detailed metadata icons are visible when new card style is on', async () => {
settings = { compactWorktreeCards: false, experimentalNewWorktreeCardStyle: true }
worktreeCardProperties = ['status', 'issue', 'linear-issue', 'comment', 'ports']
const { default: WorktreeCard } = await import('./WorktreeCard')
@@ -417,12 +417,12 @@ describe('WorktreeCard compact hover details', () => {
expect(markup).toContain('Workspace metadata')
expect(markup).not.toContain('data-worktree-card-meta-row=""')
expectParentBodyIsHoverTrigger(markup)
expectIdentityBodyIsHoverTrigger(markup)
expect(markup.match(/data-hover-open-delay="100"/g)).toHaveLength(1)
expect(markup).toContain('Reviewer handoff note')
})
it('keeps long workspace and branch identity in whole-card hover details when the branch row is hidden', async () => {
it('keeps long workspace and branch identity in hover details when the branch row is hidden', async () => {
settings = { compactWorktreeCards: false, experimentalNewWorktreeCardStyle: true }
worktreeCardProperties = ['status', 'comment']
const { default: WorktreeCard } = await import('./WorktreeCard')
@@ -440,13 +440,13 @@ describe('WorktreeCard compact hover details', () => {
)
expect(markup).not.toContain('data-worktree-card-meta-row=""')
expectParentBodyIsHoverTrigger(markup)
expectIdentityBodyIsHoverTrigger(markup)
expect(markup).toContain('[Bug]: Hold-to-talk speech-to-text option no longer works')
expect(markup).toContain('bug-hold-to-talk-speech-to-text-option-no-longer-works')
expect(markup).toContain('Reviewer handoff note')
})
it('repeats a long workspace title inside the whole-card hover when branch is already visible', async () => {
it('repeats a long workspace title inside the identity hover when branch is already visible', async () => {
settings = { compactWorktreeCards: false, experimentalNewWorktreeCardStyle: true }
worktreeCardProperties = ['status', 'branch', 'comment']
const longTitle =
@@ -461,13 +461,13 @@ describe('WorktreeCard compact hover details', () => {
/>
)
expectParentBodyIsHoverTrigger(markup)
expectIdentityBodyIsHoverTrigger(markup)
expect(markup.match(new RegExp(longTitle, 'g'))).toHaveLength(2)
expect(markup).toContain('feature/local-branch')
expect(markup).toContain('Reviewer handoff note')
})
it('uses whole-card hover for identity-only new card worktrees with branch row visible', async () => {
it('uses identity hover for identity-only new card worktrees with branch row visible', async () => {
settings = { compactWorktreeCards: false, experimentalNewWorktreeCardStyle: true }
worktreeCardProperties = ['status', 'branch']
const { default: WorktreeCard } = await import('./WorktreeCard')
@@ -481,7 +481,7 @@ describe('WorktreeCard compact hover details', () => {
)
expect(markup).toContain('data-worktree-card-meta-row=""')
expectParentBodyIsHoverTrigger(markup)
expectIdentityBodyIsHoverTrigger(markup)
expect(markup.match(/data-hover-open-delay="100"/g)).toHaveLength(1)
expect(markup.match(/Readable identity only/g)).toHaveLength(2)
expect(markup).toContain('feature/local-branch')
@@ -502,7 +502,7 @@ describe('WorktreeCard compact hover details', () => {
/>
)
expectParentBodyIsHoverTrigger(markup)
expectIdentityBodyIsHoverTrigger(markup)
expect(markup.match(/feature\/local-branch/g)).toHaveLength(3)
})
@@ -605,6 +605,28 @@ describe('WorktreeCard compact hover details', () => {
)
})
it('keeps status and agent tooltip targets outside the worktree details hover trigger', async () => {
settings = { compactWorktreeCards: false, experimentalNewWorktreeCardStyle: true }
worktreeCardProperties = ['status', 'inline-agents']
agentActivityDisplayMode = 'compact'
mockInlineAgentRows = [{} as DashboardAgentRowData]
const { default: WorktreeCard } = await import('./WorktreeCard')
const markup = renderToStaticMarkup(
<WorktreeCard worktree={makeWorktree()} repo={makeRepo()} isActive={false} />
)
const statusIndex = markup.indexOf('data-worktree-card-status-slot=""')
const triggerIndex = markup.indexOf('data-worktree-card-hover-trigger=""')
const hoverContentIndex = markup.indexOf('data-hover-card-content=""')
const agentsIndex = markup.indexOf('data-worktree-agents=""')
expectIdentityBodyIsHoverTrigger(markup)
expect(statusIndex).toBeGreaterThanOrEqual(0)
expect(statusIndex).toBeLessThan(triggerIndex)
expect(hoverContentIndex).toBeGreaterThan(triggerIndex)
expect(agentsIndex).toBeGreaterThan(hoverContentIndex)
})
it('preserves the aggregate cache timer when compact inline agents are enabled but absent', async () => {
settings = { compactWorktreeCards: false, experimentalNewWorktreeCardStyle: true }
worktreeCardProperties = ['status', 'inline-agents']
@@ -643,7 +665,7 @@ describe('WorktreeCard compact hover details', () => {
const hoverContentIndex = markup.indexOf('data-hover-card-content=""')
const childIndex = markup.indexOf('data-lineage-child-card=""')
expectParentBodyIsHoverTrigger(markup)
expectIdentityBodyIsHoverTrigger(markup)
expect(markup).toContain('data-worktree-lineage-children=""')
expect(markup).toContain('group/worktree-card')
expect(markup).not.toContain('group relative flex cursor-pointer')
@@ -5,6 +5,7 @@ import { createRoot, type Root } from 'react-dom/client'
import { renderToStaticMarkup } from 'react-dom/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData'
import { TooltipProvider } from '@/components/ui/tooltip'
import type * as ActivateTabAndFocusPaneModule from '@/lib/activate-tab-and-focus-pane'
import { makePaneKey } from '../../../../shared/stable-pane-id'
@@ -395,7 +396,11 @@ describe('WorktreeCardAgents activation', () => {
const { default: WorktreeCardAgents } = await import('./WorktreeCardAgents')
await act(async () => {
root.render(<WorktreeCardAgents worktreeId="wt-1" />)
root.render(
<TooltipProvider>
<WorktreeCardAgents worktreeId="wt-1" />
</TooltipProvider>
)
})
const row = host.querySelector('.compact-agent-row')
expect(row).toBeInstanceOf(HTMLElement)
@@ -771,7 +771,9 @@ describe('WorktreeCardAgents', () => {
const markup = renderToStaticMarkup(<WorktreeCardAgents worktreeId="wt-1" />)
const iconTitles = [...markup.matchAll(/title="([^"]+)"/g)].map((match) => match[1])
// Variety icons stay identity-free; the state label belongs to the shared tooltip.
expect(iconTitles).toEqual([])
expect(markup).toContain('>Working<')
expect(markup).not.toContain('>5 working<')
expect(markup).toContain('>+2<')
})
@@ -9,8 +9,10 @@ const mocks = vi.hoisted(() => ({
}))
vi.mock('@/components/ui/tooltip', () => ({
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipContent: ({ children }: { children: ReactNode }) => <>{children}</>,
Tooltip: ({ children }: { children: ReactNode }) => <span data-tooltip-root="">{children}</span>,
TooltipContent: ({ children }: { children: ReactNode }) => (
<span data-tooltip-content="">{children}</span>
),
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>
}))
@@ -171,6 +173,7 @@ describe('WorktreeCardStatusSlot', () => {
expect(markup).toContain('Active · Mark as unread')
expect(markup).toContain('bg-emerald-500')
expect(markup.match(/data-tooltip-root/g)).toHaveLength(1)
})
it('keeps the quiet active dot ahead of PR status by default', () => {
@@ -212,6 +215,7 @@ describe('WorktreeCardStatusSlot', () => {
expect(markup).toContain('size-[13px] translate-x-px')
expect(markup).toContain('text-rose-500/85')
expect(markup).not.toContain('bg-emerald-500')
expect(markup).not.toContain('data-tooltip-root')
})
it('uses the unified compact review glyph for GitLab MR status', () => {
@@ -277,7 +281,7 @@ describe('WorktreeCardStatusSlot', () => {
expect(markup).not.toContain('bg-neutral-500/40')
})
it('uses a branch icon with branch-only tooltip copy by default', () => {
it('uses a branch icon with branch-only accessible copy by default', () => {
const markup = renderToStaticMarkup(
<WorktreeCardStatusSlot
worktreeId="wt-1"
@@ -298,9 +302,10 @@ describe('WorktreeCardStatusSlot', () => {
expect(markup).toContain('size-[13px] translate-x-px text-muted-foreground/70')
expect(markup).toContain('text-muted-foreground/70')
expect(markup).not.toContain('bg-emerald-500')
expect(markup).not.toContain('data-tooltip-root')
})
it('uses context-aware branch or folder path tooltip copy', () => {
it('uses context-aware branch or folder path accessible copy', () => {
const markup = renderToStaticMarkup(
<WorktreeCardStatusSlot
worktreeId="wt-1"
@@ -318,6 +323,7 @@ describe('WorktreeCardStatusSlot', () => {
expect(markup).toContain('Branch or folder path')
expect(markup).toContain('lucide-git-branch')
expect(markup).not.toContain('data-tooltip-root')
})
it('keeps the quiet dot when the row has no branch identity', () => {
@@ -338,6 +344,7 @@ describe('WorktreeCardStatusSlot', () => {
expect(markup).toContain('Active')
expect(markup).toContain('bg-emerald-500')
expect(markup).not.toContain('lucide-git-branch')
expect(markup).not.toContain('data-tooltip-root')
})
it('keeps working activity ahead of PR status in new card style', () => {
@@ -359,6 +366,8 @@ describe('WorktreeCardStatusSlot', () => {
expect(markup).toContain('Working')
expect(markup).toContain('inline-flex size-5 items-center justify-center')
expect(markup).toContain('border-yellow-500')
expect(markup).toContain('data-tooltip-root')
expect(markup).toContain('data-tooltip-content="">Working')
expect(markup).not.toContain('PR checks: Failed')
})
@@ -381,6 +390,8 @@ describe('WorktreeCardStatusSlot', () => {
expect(markup).toContain('Needs permission')
expect(markup).toContain('lucide-message-circle-question-mark')
expect(markup).toContain('text-agent-question')
expect(markup).toContain('data-tooltip-root')
expect(markup).toContain('data-tooltip-content="">Needs permission')
expect(markup).not.toContain('PR checks: Failed')
})
@@ -433,6 +444,7 @@ describe('WorktreeCardStatusSlot', () => {
expect(markup).not.toContain('lucide-bell')
expect(markup).not.toContain('text-amber-500')
expect(markup).not.toContain('bg-emerald-500')
expect(markup).not.toContain('data-tooltip-root')
})
it('overlays an unread badge on the branch icon in new card style', () => {
@@ -461,5 +473,6 @@ describe('WorktreeCardStatusSlot', () => {
expect(markup).not.toContain('lucide-bell')
expect(markup).not.toContain('text-amber-500')
expect(markup).not.toContain('bg-emerald-500')
expect(markup).not.toContain('data-tooltip-root')
})
})
@@ -63,7 +63,7 @@ function overlayNewCardUnreadStatus(
)
}
function getReviewStatusTooltip(review: WorktreeCardPrDisplay): string {
function getReviewStatusLabel(review: WorktreeCardPrDisplay): string {
const label = getReviewLabel(review)
if (review.state === 'merged') {
return `${label}: Merged`
@@ -115,57 +115,44 @@ export function WorktreeCardStatusSlot({
QUIET_REVIEW_REPLACEABLE_STATUSES.has(status)
const passiveStatusLabel =
canShowReviewStatus && prDisplay
? getReviewStatusTooltip(prDisplay)
? getReviewStatusLabel(prDisplay)
: canShowBranchStatus
? (branchIdentityLabel ?? getDefaultBranchIdentityLabel())
: statusLabel
const passiveStatusTooltip =
const passiveStatusAnnouncement =
newCardStyle && isUnread ? `${passiveStatusLabel} · Unread` : passiveStatusLabel
// Why: working and permission already own the new-card status lane, but
// unread state should still surface in tooltip/sr-only copy and reappear afterward.
// unread state should still surface to assistive technology and reappear afterward.
const showNewCardUnreadAlert =
newCardStyle && isUnread && showStatus && status !== 'working' && status !== 'permission'
const reviewStatusIconClassName = compactReviewAndBranchStatusIconClassName
const branchStatusIcon = <GitBranch className={branchStatusIconClassName} aria-hidden="true" />
const passiveStatus =
canShowReviewStatus && prDisplay ? (
<Tooltip>
<TooltipTrigger asChild>
<span className={cn('inline-flex size-5 items-center justify-center p-0.5', className)}>
<ReviewIcon
review={prDisplay}
className={reviewStatusIconClassName}
variant="generic"
/>
<span className="sr-only">{passiveStatusTooltip}</span>
</span>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
<span>{passiveStatusTooltip}</span>
</TooltipContent>
</Tooltip>
<span className={cn('inline-flex size-5 items-center justify-center p-0.5', className)}>
<ReviewIcon review={prDisplay} className={reviewStatusIconClassName} variant="generic" />
<span className="sr-only">{passiveStatusAnnouncement}</span>
</span>
) : canShowBranchStatus ? (
<Tooltip>
<TooltipTrigger asChild>
<span className={cn('inline-flex size-5 items-center justify-center p-0.5', className)}>
{branchStatusIcon}
<span className="sr-only">{passiveStatusTooltip}</span>
</span>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
<span>{passiveStatusTooltip}</span>
</TooltipContent>
</Tooltip>
<span className={cn('inline-flex size-5 items-center justify-center p-0.5', className)}>
{branchStatusIcon}
<span className="sr-only">{passiveStatusAnnouncement}</span>
</span>
) : newCardStyle && showStatus ? (
<>
<span className={cn('inline-flex size-5 items-center justify-center', className)}>
<StatusIndicator status={status} aria-hidden="true" />
<StatusIndicator status={status} aria-hidden="true" tooltipSide="right" />
</span>
<span className="sr-only">{passiveStatusTooltip}</span>
<span className="sr-only">{passiveStatusAnnouncement}</span>
</>
) : (
<>
<StatusIndicator status={status} aria-hidden="true" className={className} />
<StatusIndicator
status={status}
aria-hidden="true"
className={className}
tooltipSide="right"
/>
<span className="sr-only">{statusLabel}</span>
</>
)
@@ -216,7 +203,7 @@ export function WorktreeCardStatusSlot({
{branchStatusIcon}
</span>
) : showStatus ? (
<StatusIndicator status={status} aria-hidden="true" />
<StatusIndicator status={status} aria-hidden="true" showTooltip={false} />
) : (
<span className="sr-only">{actionLabel}</span>
)
@@ -227,6 +214,7 @@ export function WorktreeCardStatusSlot({
<StatusIndicator
status={status}
aria-hidden="true"
showTooltip={false}
className="transition-opacity group-hover/unread:opacity-0 group-focus-within/unread:opacity-0"
/>
<Bell className="absolute size-3 text-muted-foreground/40 opacity-0 transition-opacity group-hover/unread:opacity-100 group-focus-within/unread:opacity-100" />
@@ -1,6 +1,7 @@
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import { TooltipProvider } from '@/components/ui/tooltip'
import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData'
import { CompactAgentRow, getCompactAgentSecondary } from './worktree-card-compact-agent-row'
import { getAgentDotState, summarizeAgents } from './worktree-card-agent-summary'
@@ -33,6 +34,16 @@ function monitoringAgent(): DashboardAgentRowData {
}
}
function orderedTitles(markup: string): string[] {
return [...markup.matchAll(/\stitle="([^"]*)"/g)].map((match) => match[1])
}
function renderCompactAgentRow(props: React.ComponentProps<typeof CompactAgentRow>): string {
return renderToStaticMarkup(
createElement(TooltipProvider, null, createElement(CompactAgentRow, props))
)
}
describe('worktree card agent summary', () => {
it('presents passive working as monitoring', () => {
const agent = monitoringAgent()
@@ -46,9 +57,7 @@ describe('worktree card agent summary', () => {
const agent = monitoringAgent()
agent.entry.prompt = 'Run background checks'
const markup = renderToStaticMarkup(
createElement(CompactAgentRow, { agent, now: 2000, onActivate: vi.fn() })
)
const markup = renderCompactAgentRow({ agent, now: 2000, onActivate: vi.fn() })
expect(markup).toContain('title="Monitoring background tasks - Run background checks"')
expect(markup).toMatch(
@@ -56,6 +65,30 @@ describe('worktree card agent summary', () => {
)
})
it('hands the whole row to the send-target reason, and only then', () => {
const agent = monitoringAgent()
agent.entry.prompt = 'Run background checks'
const disabled = renderCompactAgentRow({
agent,
now: 2000,
onActivate: vi.fn(),
sendTargetStatus: 'disabled',
sendTargetDisabledReason: 'Agent needs permission'
})
// The dot sits inside the row, so its own state title would shadow the reason on hover.
expect(orderedTitles(disabled)).toEqual(['Agent needs permission', 'Claude'])
const eligible = renderCompactAgentRow({ agent, now: 2000, onActivate: vi.fn() })
expect(orderedTitles(eligible)).toEqual([
'Claude',
'Monitoring background tasks - Run background checks'
])
expect(eligible).toContain('data-slot="tooltip-trigger"')
})
it('lists interrupted outcomes before clean completions', () => {
const done = monitoringAgent()
done.state = 'done'
@@ -200,13 +200,22 @@ export const CompactAgentRow = React.memo(function CompactAgentRow({
) : reserveDisclosureGutter ? (
<span className="size-4 shrink-0" aria-hidden />
) : null}
<AgentStateDot state={dotState} size="sm" />
{/* Why: the row's actionable disabled reason must win on every hit area. */}
<AgentStateDot
state={dotState}
size="sm"
title={sendTargetDisabledReason ? null : undefined}
tooltipSide="right"
/>
{!hideIcon && (
<span className="inline-flex shrink-0" title={formatAgentTypeLabel(agent.agentType)}>
<AgentIcon agent={agentTypeToIconAgent(agent.agentType)} size={13} />
</span>
)}
<span className="min-w-0 flex-1 truncate">
<span
className="min-w-0 flex-1 truncate"
title={sendTargetDisabledReason ? undefined : rowTitle}
>
{/* Why: the selected-row fill is strong enough to wash out the dimmed
prompt/secondary text, so lift both toward full foreground when focused. */}
<span className={isFocusedPane ? 'text-foreground' : 'text-muted-foreground/90'}>
@@ -278,7 +287,7 @@ export const CompactAgentRow = React.memo(function CompactAgentRow({
role={agent.lineage ? 'treeitem' : undefined}
aria-level={agent.lineage ? agent.lineage.depth + 1 : undefined}
aria-expanded={hasChildDisclosure ? childAgentsExpanded : undefined}
title={sendTargetDisabledReason ?? rowTitle}
title={sendTargetDisabledReason}
>
{rowBody}
</div>
@@ -150,7 +150,7 @@ export function CompactAgentSummaryButton({
key={group.state}
className="inline-flex min-w-0 shrink-0 items-center gap-0.5 rounded-sm bg-worktree-sidebar/70 px-1 py-0.5"
>
<AgentStateDot state={group.state} size="sm" />
<AgentStateDot state={group.state} size="sm" tooltipSide="right" />
{/* Why: same-state agent identities read as one status cluster;
overlapping them saves width without merging different states. */}
<span className="inline-flex shrink-0 items-center -space-x-0.5 pl-0.5">
@@ -3,6 +3,8 @@ import React from 'react'
import { cn } from '@/lib/utils'
import { WorktreeCardHeader } from './worktree-card-header'
import { WorktreeCardMetaRow } from './worktree-card-meta-row'
import { WorktreeCardDetailsHover } from './WorktreeCardMeta'
import { WorktreeCardPortsDetails } from './WorktreeCardPorts'
import type { WorktreeCardPresentation } from './worktree-card-presentation'
import { WorktreeCardSecondaryRows } from './worktree-card-secondary-rows'
import { WorktreeCardStatusSlot } from './WorktreeCardStatusSlot'
@@ -26,10 +28,91 @@ export function WorktreeCardParentContent({
handleToggleUnreadQuick,
statusLaneReview,
branchIdentityDisplay,
showInlineAgentList
showInlineAgentList,
titleRenaming,
isDeleting,
hoverIssue,
hoverLinearIssue,
hoverJiraIssue,
hoverReview,
hoverComment,
metaAutomationProvenance,
metaCliProvenance,
workspacePorts,
detailsHoverControl,
handleRenameTitle,
handleEditIssue,
handleEditComment,
handleOpenGitHubIssueInOrca,
linearIssue,
handleOpenLinearIssueInOrca,
handleOpenReviewInOrca,
handleOpenAutomation,
handleOpenAutomationRun,
hasExplicitLinkedReview,
handleUnlinkReview
} = card
const { titleOnlyCard, parentContentMarginLeft, showCombinedStatusSlot, showUnreadQuickAction } =
presentation
const {
titleOnlyCard,
parentContentMarginLeft,
showCombinedStatusSlot,
showUnreadQuickAction,
hasHoverDetails,
hoverBranchName,
hoverWorkspaceTitle
} = presentation
const identityContent = (
<div
className="group/worktree-card flex w-full min-w-0 flex-col gap-1.5"
data-worktree-card-hover-trigger=""
>
<WorktreeCardHeader card={card} presentation={presentation} />
{presentation.hasMetaRow && <WorktreeCardMetaRow card={card} presentation={presentation} />}
</div>
)
// Why: status glyphs and agent rows own their tooltips; only identity content should open the larger details card.
const identityContentWithHover =
hasHoverDetails && !titleRenaming ? (
<WorktreeCardDetailsHover
issue={hoverIssue}
linearIssue={hoverLinearIssue}
jiraIssue={hoverJiraIssue}
review={hoverReview}
comment={hoverComment}
automationProvenance={metaAutomationProvenance}
cliProvenance={metaCliProvenance}
branchName={hoverBranchName}
workspaceTitle={hoverWorkspaceTitle}
workspaceTitleRenameDisabled={isDeleting || affiliateListMode}
detailsAfter={
workspacePorts.length > 0 ? <WorktreeCardPortsDetails ports={workspacePorts} /> : null
}
openDelay={100}
hoverControl={detailsHoverControl}
onRenameWorkspaceTitle={affiliateListMode ? undefined : handleRenameTitle}
onEditIssue={affiliateListMode ? undefined : handleEditIssue}
onEditComment={affiliateListMode ? undefined : handleEditComment}
onOpenGitHubIssueInOrca={
hoverIssue && 'url' in hoverIssue && hoverIssue.url
? handleOpenGitHubIssueInOrca
: undefined
}
onOpenLinearIssueInOrca={linearIssue?.url ? handleOpenLinearIssueInOrca : undefined}
onOpenReviewInOrca={
hoverReview?.url && hoverReview.provider === 'github' ? handleOpenReviewInOrca : undefined
}
onOpenAutomation={affiliateListMode ? undefined : handleOpenAutomation}
onOpenAutomationRun={affiliateListMode ? undefined : handleOpenAutomationRun}
onUnlinkReview={
!affiliateListMode && hasExplicitLinkedReview ? handleUnlinkReview : undefined
}
>
{identityContent}
</WorktreeCardDetailsHover>
) : (
identityContent
)
return (
<div
@@ -76,9 +159,7 @@ export function WorktreeCardParentContent({
: 'overflow-hidden'
)}
>
{/* Header row: Title */}
<WorktreeCardHeader card={card} presentation={presentation} />
{presentation.hasMetaRow && <WorktreeCardMetaRow card={card} presentation={presentation} />}
{identityContentWithHover}
<WorktreeCardSecondaryRows card={card} presentation={presentation} />
</div>
</div>
@@ -4,8 +4,6 @@ import { LoaderCircle } from 'lucide-react'
import { cn } from '@/lib/utils'
import { AutoRenameFailedDialog } from './AutoRenameFailedDialog'
import WorktreeContextMenu from './WorktreeContextMenu'
import { WorktreeCardDetailsHover } from './WorktreeCardMeta'
import { WorktreeCardPortsDetails } from './WorktreeCardPorts'
import { WorktreeCardParentContent } from './worktree-card-parent-content'
import { buildWorktreeCardPresentation } from './worktree-card-presentation'
import type { WorktreeCardController } from './use-worktree-card-controller'
@@ -38,83 +36,13 @@ export function WorktreeCardSurface({ card }: { card: WorktreeCardController }):
handleDragStart,
handleDragEnd,
handleContextMenuSelect,
hoverIssue,
hoverLinearIssue,
hoverJiraIssue,
hoverReview,
hoverComment,
metaAutomationProvenance,
metaCliProvenance,
workspacePorts,
detailsHoverControl,
handleRenameTitle,
handleEditIssue,
handleEditComment,
handleOpenGitHubIssueInOrca,
linearIssue,
handleOpenLinearIssueInOrca,
handleOpenReviewInOrca,
handleOpenAutomation,
handleOpenAutomationRun,
hasExplicitLinkedReview,
handleUnlinkReview,
showRenameErrorDialog,
setShowRenameErrorDialog
} = card
const { titleOnlyCard, hasHoverDetails, hoverBranchName, hoverWorkspaceTitle, cardStyle } =
presentation
const { titleOnlyCard, cardStyle } = presentation
const parentCardContent = <WorktreeCardParentContent card={card} presentation={presentation} />
const parentHoverTriggerBody = (
<div className="group/worktree-card w-full min-w-0" data-worktree-card-hover-trigger="">
{parentCardContent}
</div>
)
const parentCardBodyWithHoverDetails =
hasHoverDetails && !titleRenaming ? (
<WorktreeCardDetailsHover
issue={hoverIssue}
linearIssue={hoverLinearIssue}
jiraIssue={hoverJiraIssue}
review={hoverReview}
comment={hoverComment}
automationProvenance={metaAutomationProvenance}
cliProvenance={metaCliProvenance}
branchName={hoverBranchName}
workspaceTitle={hoverWorkspaceTitle}
workspaceTitleRenameDisabled={isDeleting || affiliateListMode}
detailsAfter={
workspacePorts.length > 0 ? <WorktreeCardPortsDetails ports={workspacePorts} /> : null
}
openDelay={100}
hoverControl={detailsHoverControl}
onRenameWorkspaceTitle={affiliateListMode ? undefined : handleRenameTitle}
onEditIssue={affiliateListMode ? undefined : handleEditIssue}
onEditComment={affiliateListMode ? undefined : handleEditComment}
onOpenGitHubIssueInOrca={
hoverIssue && 'url' in hoverIssue && hoverIssue.url
? handleOpenGitHubIssueInOrca
: undefined
}
onOpenLinearIssueInOrca={linearIssue?.url ? handleOpenLinearIssueInOrca : undefined}
onOpenReviewInOrca={
hoverReview?.url && hoverReview.provider === 'github' ? handleOpenReviewInOrca : undefined
}
onOpenAutomation={affiliateListMode ? undefined : handleOpenAutomation}
onOpenAutomationRun={affiliateListMode ? undefined : handleOpenAutomationRun}
// Why: branch lookup can surface a review without persisted metadata; only unlink when explicitly linked.
onUnlinkReview={
!affiliateListMode && hasExplicitLinkedReview ? handleUnlinkReview : undefined
}
>
{parentHoverTriggerBody}
</WorktreeCardDetailsHover>
) : (
parentHoverTriggerBody
)
const cardBody = (
<div
className={cn(
@@ -163,7 +91,7 @@ export function WorktreeCardSurface({ card }: { card: WorktreeCardController }):
</div>
</div>
)}
{parentCardBodyWithHoverDetails}
{parentCardContent}
{newCardStyle && lineageChildren ? (
<div
@@ -1,3 +1,5 @@
// @vitest-environment happy-dom
import { describe, expect, it } from 'vitest'
import type { WorktreeLineage } from '../../../../shared/worktree/lineage-types'
import type { Worktree } from '../../../../shared/worktree/types'
@@ -44,6 +46,20 @@ describe('getWorktreeLineageDropTargetId', () => {
expect(getWorktreeLineageDropTargetId({ container, target, pointerY: 150 })).toBeNull()
})
it.each(['status', 'agent'] as const)(
'keeps the %s region in the lineage nesting hit zone',
(targetRole) => {
const { container, target } = makeTarget({
worktreeId: 'parent',
top: 100,
bottom: 200,
targetRole
})
expect(getWorktreeLineageDropTargetId({ container, target, pointerY: 150 })).toBe('parent')
}
)
})
describe('getReorderedWorktreeIdsToUnnest', () => {
@@ -168,24 +184,30 @@ function makeTarget(args: {
top: number
bottom: number
contained?: boolean
targetRole?: 'identity' | 'status' | 'agent'
}): {
container: HTMLElement
target: Element
} {
const row = {
getAttribute: (name: string) => (name === 'data-worktree-drag-id' ? args.worktreeId : null)
} as HTMLElement
const content = {
getBoundingClientRect: () => ({ top: args.top, bottom: args.bottom }),
closest: (selector: string) => (selector === '[data-worktree-drag-id]' ? row : null)
} as HTMLElement
const target = {
closest: (selector: string) =>
selector === '[data-worktree-card-hover-trigger]' ? content : null
} as Element
const container = document.createElement('div')
const row = document.createElement('div')
row.setAttribute('data-worktree-drag-id', args.worktreeId)
const content = document.createElement('div')
content.setAttribute('data-worktree-card-parent-content', '')
content.getBoundingClientRect = () => ({ top: args.top, bottom: args.bottom }) as DOMRect
const status = document.createElement('div')
const identity = document.createElement('div')
identity.setAttribute('data-worktree-card-hover-trigger', '')
const agent = document.createElement('div')
content.append(status, identity, agent)
row.append(content)
container.append(row)
const targetByRole = { status, identity, agent }
const target = targetByRole[args.targetRole ?? 'identity']
const contained = args.contained ?? true
const container = {
contains: (element: Element) => contained && (element === content || element === row)
} as HTMLElement
if (!contained) {
container.removeChild(row)
}
return { container, target }
}
@@ -2,7 +2,7 @@ import type { WorktreeLineage } from '../../../../shared/worktree/lineage-types'
import type { Worktree } from '../../../../shared/worktree/types'
import { getLineageRenderInfo } from './worktree-lineage-projection'
const WORKTREE_CARD_CONTENT_TARGET_SELECTOR = '[data-worktree-card-hover-trigger]'
const WORKTREE_CARD_CONTENT_TARGET_SELECTOR = '[data-worktree-card-parent-content]'
const WORKTREE_DRAG_ROW_SELECTOR = '[data-worktree-drag-id]'
const LINEAGE_DROP_ZONE_RATIO = 0.4
@@ -1,18 +1,21 @@
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it } from 'vitest'
import { TooltipProvider } from '@/components/ui/tooltip'
import { TerminalTabLeadingIcon } from './TerminalTabLeadingIcon'
import type { TerminalTabActivityStatus } from './terminal-tab-activity-status'
/** Render one activity status through the production leading-icon component. */
function renderStatus(status: TerminalTabActivityStatus): string {
return renderToStaticMarkup(
<TerminalTabLeadingIcon
agent="codex"
activityStatus={status}
shell={undefined}
showUnreadActivity={false}
isActive={false}
/>
<TooltipProvider>
<TerminalTabLeadingIcon
agent="codex"
activityStatus={status}
shell={undefined}
showUnreadActivity={false}
isActive={false}
/>
</TooltipProvider>
)
}