Highlight focused agent rows (#2733)

* Highlight focused agent status dots

* Highlight focused agent rows
This commit is contained in:
Neil 2026-05-23 21:12:15 -07:00 committed by GitHub
parent d2406ada87
commit 93637e54c9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 287 additions and 4 deletions

View File

@ -92,6 +92,28 @@ function tokenCount(markup: string, token: string): number {
}
describe('DashboardAgentRow', () => {
it('uses the hover background as the focused-pane row highlight', () => {
const markup = renderToStaticMarkup(
<TooltipProvider>
<DashboardAgentRow
agent={makeAgent()}
onDismiss={vi.fn()}
onActivate={vi.fn()}
now={NOW}
hideIdentityIcon
hideExpand
isFocusedPane
/>
</TooltipProvider>
)
expect(markup).toContain('data-focused-agent-pane="true"')
expect(classTokens(markup)).toContain('hover:bg-black/[0.06]')
expect(classTokens(markup)).toContain('dark:hover:bg-accent/30')
expect(classTokens(markup)).toContain('bg-black/[0.06]')
expect(classTokens(markup)).toContain('dark:bg-accent/30')
})
it('scopes the timestamp and dismiss hover swap to the row-owned group', () => {
const markup = renderRow(makeAgent())
const classes = hoverSwapClasses(markup)

View File

@ -110,6 +110,8 @@ type Props = {
*/
hideIdentityIcon?: boolean
hideExpand?: boolean
/** Reuse the row's hover tint to show the focused terminal pane's agent. */
isFocusedPane?: boolean
}
const DashboardAgentRow = React.memo(function DashboardAgentRow({
@ -120,7 +122,8 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({
isUnvisited = false,
stateDotSize = 'md',
hideIdentityIcon = false,
hideExpand = false
hideExpand = false,
isFocusedPane = false
}: Props) {
const [expanded, setExpanded] = useState(false)
// Why: stop propagation so clicking the X doesn't also fire the worktree
@ -236,8 +239,10 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({
// near-nothing because accent (#f5f5f5) is already ~white. Use a
// black alpha overlay in light mode (mirrors WorktreeCard.tsx's
// active-state pattern) so the lift is symmetric across themes.
'cursor-pointer rounded-sm hover:bg-black/[0.06] dark:hover:bg-accent/30'
'cursor-pointer rounded-sm hover:bg-black/[0.06] dark:hover:bg-accent/30',
isFocusedPane && 'bg-black/[0.06] dark:bg-accent/30'
)}
data-focused-agent-pane={isFocusedPane ? 'true' : undefined}
title={tsParts.length > 0 ? tsParts.join(' • ') : undefined}
role={participatesInLineage ? 'treeitem' : undefined}
aria-level={participatesInLineage ? (lineage?.depth ?? 0) + 1 : undefined}

View File

@ -10,6 +10,7 @@ let mockAgents = [
}
}
]
let mockFocusedAgentPaneKey: string | null = null
vi.mock('@/store', () => ({
useAppStore: (selector: (state: unknown) => unknown) =>
@ -30,11 +31,17 @@ vi.mock('@/components/dashboard/useNow', () => ({
}))
vi.mock('@/components/dashboard/DashboardAgentRow', () => ({
default: ({ agent }: { agent: { paneKey: string } }) => (
<div data-testid="agent-row">{agent.paneKey}</div>
default: ({ agent, isFocusedPane }: { agent: { paneKey: string }; isFocusedPane?: boolean }) => (
<div data-testid="agent-row" data-focused={isFocusedPane ? 'true' : 'false'}>
{agent.paneKey}
</div>
)
}))
vi.mock('./focused-agent-row-highlight', () => ({
useFocusedAgentPaneKey: vi.fn(() => mockFocusedAgentPaneKey)
}))
describe('WorktreeCardAgents', () => {
beforeEach(() => {
vi.clearAllMocks()
@ -47,6 +54,7 @@ describe('WorktreeCardAgents', () => {
}
}
]
mockFocusedAgentPaneKey = null
})
it('renders rows in a labeled group without the removed per-card toggle header', async () => {
@ -61,6 +69,32 @@ describe('WorktreeCardAgents', () => {
expect(markup).not.toContain('aria-expanded')
})
it('marks only the focused agent row', async () => {
mockFocusedAgentPaneKey = 'tab-1:2'
mockAgents = [
{
paneKey: 'tab-1:1',
tab: { id: 'tab-1' },
entry: {
stateStartedAt: 1000
}
},
{
paneKey: 'tab-1:2',
tab: { id: 'tab-1' },
entry: {
stateStartedAt: 1000
}
}
]
const { default: WorktreeCardAgents } = await import('./WorktreeCardAgents')
const markup = renderToStaticMarkup(<WorktreeCardAgents worktreeId="wt-1" />)
expect(markup).toContain('data-focused="false">tab-1:1')
expect(markup).toContain('data-focused="true">tab-1:2')
})
it('does not render the labeled wrapper when there are no agent rows', async () => {
mockAgents = []
const { default: WorktreeCardAgents } = await import('./WorktreeCardAgents')

View File

@ -9,6 +9,7 @@ import { cn } from '@/lib/utils'
import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData'
import { parsePaneKey } from '../../../../shared/stable-pane-id'
import { dismissStaleAgentRowByKey } from '../terminal-pane/stale-agent-row'
import { useFocusedAgentPaneKey } from './focused-agent-row-highlight'
type Props = {
worktreeId: string
@ -53,6 +54,7 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({
}: BodyProps) {
const dropAgentStatus = useAppStore((s) => s.dropAgentStatus)
const dismissRetainedAgent = useAppStore((s) => s.dismissRetainedAgent)
const focusedAgentPaneKey = useFocusedAgentPaneKey(worktreeId)
// Why: subscribe to the ack map reference (Object.is equality) and derive
// per-agent unvisited flags locally. Keeps the inline list's bold/mute
@ -159,6 +161,7 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({
// Keep the identity glyph (Claude/Gemini/…) so users can tell
// agents apart at a glance within a worktree.
hideExpand
isFocusedPane={agent.paneKey === focusedAgentPaneKey}
/>
</div>
))}

View File

@ -0,0 +1,149 @@
import { describe, expect, it, vi } from 'vitest'
import type {
AgentStatusEntry,
MigrationUnsupportedPtyEntry
} from '../../../../shared/agent-status-types'
import { makePaneKey } from '../../../../shared/stable-pane-id'
import type { TerminalTab } from '../../../../shared/types'
import {
getFocusedAgentPaneKeyForWorktree,
type FocusedAgentRowHighlightState
} from './focused-agent-row-highlight'
vi.mock('@/lib/agent-status', () => ({
isExplicitAgentStatusFresh: vi.fn(
(entry: AgentStatusEntry, now: number, staleAfterMs: number) =>
now - entry.updatedAt <= staleAfterMs
)
}))
const WORKTREE_ID = 'repo-1::/worktree'
const OTHER_WORKTREE_ID = 'repo-1::/other'
const TAB_ID = 'tab-1'
const OTHER_TAB_ID = 'tab-2'
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
const OTHER_LEAF_ID = '22222222-2222-4222-8222-222222222222'
const PANE_KEY = makePaneKey(TAB_ID, LEAF_ID)
const OTHER_PANE_KEY = makePaneKey(TAB_ID, OTHER_LEAF_ID)
function makeTab(id: string, worktreeId = WORKTREE_ID): TerminalTab {
return {
id,
worktreeId,
ptyId: 'pty-1',
title: 'bash',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 0
}
}
function makeAgentStatusEntry(paneKey: string, updatedAt = 1_000): AgentStatusEntry {
return {
paneKey,
state: 'working',
prompt: '',
updatedAt,
stateStartedAt: updatedAt,
stateHistory: []
}
}
function makeUnsupportedEntry(paneKey: string): MigrationUnsupportedPtyEntry {
return {
ptyId: 'pty-unsupported',
worktreeId: WORKTREE_ID,
tabId: TAB_ID,
leafId: LEAF_ID,
paneKey,
reason: 'legacy-numeric-pane-key',
source: 'local',
updatedAt: 1_000
}
}
function makeState(
overrides: Partial<FocusedAgentRowHighlightState> = {}
): FocusedAgentRowHighlightState {
return {
activeWorktreeId: WORKTREE_ID,
activeTabType: 'terminal',
activeTabId: TAB_ID,
tabsByWorktree: {
[WORKTREE_ID]: [makeTab(TAB_ID)],
[OTHER_WORKTREE_ID]: [makeTab(OTHER_TAB_ID, OTHER_WORKTREE_ID)]
},
terminalLayoutsByTabId: {
[TAB_ID]: {
root: { type: 'leaf', leafId: LEAF_ID },
activeLeafId: LEAF_ID,
expandedLeafId: null
}
},
agentStatusByPaneKey: {},
retainedAgentsByPaneKey: {},
migrationUnsupportedByPtyId: {},
...overrides
}
}
describe('getFocusedAgentPaneKeyForWorktree', () => {
it('returns the focused pane key when that pane has a fresh live agent status', () => {
const state = makeState({
agentStatusByPaneKey: {
[PANE_KEY]: makeAgentStatusEntry(PANE_KEY)
}
})
expect(getFocusedAgentPaneKeyForWorktree(state, WORKTREE_ID, 2_000)).toBe(PANE_KEY)
})
it('does not return another split pane in the same terminal tab', () => {
const state = makeState({
agentStatusByPaneKey: {
[OTHER_PANE_KEY]: makeAgentStatusEntry(OTHER_PANE_KEY)
}
})
expect(getFocusedAgentPaneKeyForWorktree(state, WORKTREE_ID, 2_000)).toBeNull()
})
it('does not highlight while another surface type is active', () => {
const state = makeState({
activeTabType: 'editor',
agentStatusByPaneKey: {
[PANE_KEY]: makeAgentStatusEntry(PANE_KEY)
}
})
expect(getFocusedAgentPaneKeyForWorktree(state, WORKTREE_ID, 2_000)).toBeNull()
})
it('returns retained agent row pane keys for the focused pane', () => {
const entry = makeAgentStatusEntry(PANE_KEY)
const state = makeState({
retainedAgentsByPaneKey: {
[PANE_KEY]: {
entry,
tab: makeTab(TAB_ID),
worktreeId: WORKTREE_ID,
agentType: 'codex',
startedAt: 1_000
}
}
})
expect(getFocusedAgentPaneKeyForWorktree(state, WORKTREE_ID, 2_000)).toBe(PANE_KEY)
})
it('returns migration-unsupported agent row pane keys for the focused pane', () => {
const state = makeState({
migrationUnsupportedByPtyId: {
'pty-unsupported': makeUnsupportedEntry(PANE_KEY)
}
})
expect(getFocusedAgentPaneKeyForWorktree(state, WORKTREE_ID, 2_000)).toBe(PANE_KEY)
})
})

View File

@ -0,0 +1,70 @@
import { useAppStore } from '@/store'
import type { AppState } from '@/store/types'
import { isExplicitAgentStatusFresh } from '@/lib/agent-status'
import {
AGENT_STATUS_STALE_AFTER_MS,
type AgentStatusEntry
} from '../../../../shared/agent-status-types'
import { isTerminalLeafId, makePaneKey } from '../../../../shared/stable-pane-id'
export type FocusedAgentRowHighlightState = Pick<
AppState,
| 'activeWorktreeId'
| 'activeTabType'
| 'activeTabId'
| 'tabsByWorktree'
| 'terminalLayoutsByTabId'
| 'agentStatusByPaneKey'
| 'retainedAgentsByPaneKey'
| 'migrationUnsupportedByPtyId'
>
export function getFocusedAgentPaneKeyForWorktree(
state: FocusedAgentRowHighlightState,
worktreeId: string,
now = Date.now()
): string | null {
if (state.activeWorktreeId !== worktreeId || state.activeTabType !== 'terminal') {
return null
}
const activeTabId = state.activeTabId
if (!activeTabId) {
return null
}
const activeTabBelongsToWorktree = (state.tabsByWorktree[worktreeId] ?? []).some(
(tab) => tab.id === activeTabId
)
if (!activeTabBelongsToWorktree) {
return null
}
const activeLeafId = state.terminalLayoutsByTabId[activeTabId]?.activeLeafId
if (!activeLeafId || !isTerminalLeafId(activeLeafId)) {
return null
}
const activePaneKey = makePaneKey(activeTabId, activeLeafId)
const liveEntry = state.agentStatusByPaneKey[activePaneKey]
if (liveEntry && isFreshLiveAgent(liveEntry, now)) {
return activePaneKey
}
if (state.retainedAgentsByPaneKey[activePaneKey]?.worktreeId === worktreeId) {
return activePaneKey
}
const hasMigrationUnsupportedRow = Object.values(state.migrationUnsupportedByPtyId).some(
(entry) => entry.paneKey === activePaneKey
)
return hasMigrationUnsupportedRow ? activePaneKey : null
}
export function useFocusedAgentPaneKey(worktreeId: string): string | null {
return useAppStore((state) => getFocusedAgentPaneKeyForWorktree(state, worktreeId))
}
function isFreshLiveAgent(entry: AgentStatusEntry, now: number): boolean {
return isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)
}