diff --git a/mobile/src/components/AgentSpinner.tsx b/mobile/src/components/AgentSpinner.tsx index a5e81ec07a9..c05e5d2a489 100644 --- a/mobile/src/components/AgentSpinner.tsx +++ b/mobile/src/components/AgentSpinner.tsx @@ -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 ( - + ) } diff --git a/mobile/src/components/AgentStateDot.tsx b/mobile/src/components/AgentStateDot.tsx index 4d596b7e094..1637296d900 100644 --- a/mobile/src/components/AgentStateDot.tsx +++ b/mobile/src/components/AgentStateDot.tsx @@ -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 ( - + ) } diff --git a/mobile/src/components/agent-monitoring-indicators.test.ts b/mobile/src/components/agent-monitoring-indicators.test.ts index 749d2636b98..eefa2137f14 100644 --- a/mobile/src/components/agent-monitoring-indicators.test.ts +++ b/mobile/src/components/agent-monitoring-indicators.test.ts @@ -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 }) diff --git a/src/renderer/src/components/AgentStateDot.test.ts b/src/renderer/src/components/AgentStateDot.test.ts index 6304b1cfd68..ab84e4562d8 100644 --- a/src/renderer/src/components/AgentStateDot.test.ts +++ b/src/renderer/src/components/AgentStateDot.test.ts @@ -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 + 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')}"` + ) + }) }) diff --git a/src/renderer/src/components/AgentStateDot.tsx b/src/renderer/src/components/AgentStateDot.tsx index 4f361d01f87..bcb44a8e726 100644 --- a/src/renderer/src/components/AgentStateDot.tsx +++ b/src/renderer/src/components/AgentStateDot.tsx @@ -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 = ( ) - } - - if (state === 'monitoring') { - return ( + } else if (state === 'monitoring') { + indicator = ( - ) - } - - 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 = ( ) - } - - if (state === 'permission' || state === 'waiting') { - return ( + } else if (state === 'permission' || state === 'waiting') { + indicator = ( ) + } else { + indicator = ( + + + + ) } return ( - - - + + {indicator} + ) }) diff --git a/src/renderer/src/components/StateIndicatorTooltip.test.tsx b/src/renderer/src/components/StateIndicatorTooltip.test.tsx new file mode 100644 index 00000000000..70bb8272425 --- /dev/null +++ b/src/renderer/src/components/StateIndicatorTooltip.test.tsx @@ -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 }) => ( + {children} + ), + TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}, + TooltipContent: ({ children, side }: { children: ReactNode; side: string }) => ( + + {children} + + ) +})) + +describe('StateIndicatorTooltip', () => { + it('uses Orca tooltip chrome with an explicit 200ms delay', () => { + const markup = renderToStaticMarkup( + + + + ) + + 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( + + + + ) + + 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( + + + + ) + + expect(markup).toBe('') + }) +}) diff --git a/src/renderer/src/components/StateIndicatorTooltip.tsx b/src/renderer/src/components/StateIndicatorTooltip.tsx new file mode 100644 index 00000000000..d4deaf2406e --- /dev/null +++ b/src/renderer/src/components/StateIndicatorTooltip.tsx @@ -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['side'] + +export function StateIndicatorTooltip({ + label, + side = 'top', + children +}: { + label: string | null + side?: StateIndicatorTooltipSide + children: ReactElement +}): React.JSX.Element { + if (label === null) { + return children + } + + return ( + + {children} + + {label} + + + ) +} diff --git a/src/renderer/src/components/WorktreeJumpPalette.recent-tabs.test.tsx b/src/renderer/src/components/WorktreeJumpPalette.recent-tabs.test.tsx index 43a77e30556..2975beddc0f 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.recent-tabs.test.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.recent-tabs.test.tsx @@ -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( '[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 () => { diff --git a/src/renderer/src/components/activity/ActivityPrototypePage.thread-grouping.test.ts b/src/renderer/src/components/activity/ActivityPrototypePage.thread-grouping.test.ts index e7cfcd94e57..72a1b6d3355 100644 --- a/src/renderer/src/components/activity/ActivityPrototypePage.thread-grouping.test.ts +++ b/src/renderer/src/components/activity/ActivityPrototypePage.thread-grouping.test.ts @@ -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() diff --git a/src/renderer/src/components/activity/ActivityPrototypePage.tsx b/src/renderer/src/components/activity/ActivityPrototypePage.tsx index 623e41d4c89..dcc068d88f8 100644 --- a/src/renderer/src/components/activity/ActivityPrototypePage.tsx +++ b/src/renderer/src/components/activity/ActivityPrototypePage.tsx @@ -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 ( - + diff --git a/src/renderer/src/components/cmd-j/palette-live-status.test.tsx b/src/renderer/src/components/cmd-j/palette-live-status.test.tsx index d0a9ea6b619..a2656104d87 100644 --- a/src/renderer/src/components/cmd-j/palette-live-status.test.tsx +++ b/src/renderer/src/components/cmd-j/palette-live-status.test.tsx @@ -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 +): React.JSX.Element { + return ( + + + + ) +} + let testRoot: Root let testContainer: HTMLDivElement @@ -79,6 +90,13 @@ function dotLabels(): string[] { ) } +function expectStyledStatusTooltip(label: string): void { + const trigger = testContainer.querySelector('[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') }) diff --git a/src/renderer/src/components/cmd-j/palette-live-status.tsx b/src/renderer/src/components/cmd-j/palette-live-status.tsx index 377b1527343..e19780a3602 100644 --- a/src/renderer/src/components/cmd-j/palette-live-status.tsx +++ b/src/renderer/src/components/cmd-j/palette-live-status.tsx @@ -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 ( - - {fallback} - + ) } @@ -281,5 +280,5 @@ function RecentTabAttentionBadgeGlyph({ return } // Why: AgentStateDot owns working/permission/done glyphs app-wide (spinner / ? / check). - return + return } diff --git a/src/renderer/src/components/dashboard-popout/agent-map-render-test-harness.tsx b/src/renderer/src/components/dashboard-popout/agent-map-render-test-harness.tsx index e428d43f5f6..e6b84a0a20f 100644 --- a/src/renderer/src/components/dashboard-popout/agent-map-render-test-harness.tsx +++ b/src/renderer/src/components/dashboard-popout/agent-map-render-test-harness.tsx @@ -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 } ) } diff --git a/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx b/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx index 9a95a6293e8..a32056df297 100644 --- a/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx +++ b/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx @@ -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') diff --git a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx index 8bc76da2709..6063aa32e4d 100644 --- a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx +++ b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx @@ -253,7 +253,7 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ className="inline-flex shrink-0 items-center justify-center" aria-label={dotTooltipLabel} > - + diff --git a/src/renderer/src/components/editor/ReviewNotesSendMenuContent.test.tsx b/src/renderer/src/components/editor/ReviewNotesSendMenuContent.test.tsx index 1ae74985923..5f900b4cfd0 100644 --- a/src/renderer/src/components/editor/ReviewNotesSendMenuContent.test.tsx +++ b/src/renderer/src/components/editor/ReviewNotesSendMenuContent.test.tsx @@ -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() }) diff --git a/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx b/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx index adde4669ef4..0800be5b224 100644 --- a/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx +++ b/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx @@ -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" > - + {/* Why: the ancestor's actionable disabled reason must win on every hit area. */} + diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionSubagents.test.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionSubagents.test.tsx index 1aa6ac3a5bb..24bc826e973 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionSubagents.test.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionSubagents.test.tsx @@ -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>() +function SessionSubagentsSection( + props: ComponentProps +): JSX.Element { + return ( + + + + ) +} + 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() + 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( + ) : null} diff --git a/src/renderer/src/components/sidebar/StatusIndicator.test.ts b/src/renderer/src/components/sidebar/StatusIndicator.test.ts index c7ebd3a59ec..1c238615628 100644 --- a/src/renderer/src/components/sidebar/StatusIndicator.test.ts +++ b/src/renderer/src/components/sidebar/StatusIndicator.test.ts @@ -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') + }) }) diff --git a/src/renderer/src/components/sidebar/StatusIndicator.tsx b/src/renderer/src/components/sidebar/StatusIndicator.tsx index 26493bb7d40..fdce0b62f83 100644 --- a/src/renderer/src/components/sidebar/StatusIndicator.tsx +++ b/src/renderer/src/components/sidebar/StatusIndicator.tsx @@ -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, 'title'> & { status: Status + showTooltip?: boolean + tooltipSide?: StateIndicatorTooltipSide } +const AGENT_STATUS_TOOLTIP_STATUSES = new Set([ + '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 = ( ) - } - - if (status === 'monitoring') { - return ( + } else if (status === 'monitoring') { + indicator = ( - ) - } - - if (status === 'interrupted') { - return ( + } else if (status === 'interrupted') { + indicator = ( ) - } - - if (status === 'permission') { - return ( + } else if (status === 'permission') { + indicator = ( ) + } else { + indicator = ( + + + + ) } return ( - - - + + {indicator} + ) }) diff --git a/src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx index a3097460ea7..3fa1c7b053c 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx @@ -168,7 +168,7 @@ function makeHostedReview(overrides: Partial = {}): HostedRevi } } -function expectParentBodyIsHoverTrigger(markup: string): void { +function expectIdentityBodyIsHoverTrigger(markup: string): void { const surfaceTag = markup.match(/]*data-worktree-card-surface="true"[^>]*>/)?.[0] const triggerTag = markup.match(/]*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( + + ) + 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') diff --git a/src/renderer/src/components/sidebar/WorktreeCardAgents.activation.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardAgents.activation.test.tsx index 90ac7cdf38c..5af1a13a884 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardAgents.activation.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardAgents.activation.test.tsx @@ -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() + root.render( + + + + ) }) const row = host.querySelector('.compact-agent-row') expect(row).toBeInstanceOf(HTMLElement) diff --git a/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx index d5591b9e9d5..4bf3a8dcd17 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx @@ -771,7 +771,9 @@ describe('WorktreeCardAgents', () => { const markup = renderToStaticMarkup() 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<') }) diff --git a/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.test.tsx index b9a32ddeb8f..9baed34e995 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.test.tsx @@ -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 }) => {children}, + TooltipContent: ({ children }: { children: ReactNode }) => ( + {children} + ), 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( { 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( { 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') }) }) diff --git a/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.tsx b/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.tsx index 9f8dc175652..72b04b22b1f 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.tsx @@ -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 = ) : showStatus ? ( -