mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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')}"`
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user