From 93637e54c99bcb70611ce67cfc5080bb433374a1 Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Sat, 23 May 2026 21:12:15 -0700
Subject: [PATCH] Highlight focused agent rows (#2733)
* Highlight focused agent status dots
* Highlight focused agent rows
---
.../dashboard/DashboardAgentRow.test.tsx | 22 +++
.../dashboard/DashboardAgentRow.tsx | 9 +-
.../sidebar/WorktreeCardAgents.test.tsx | 38 ++++-
.../components/sidebar/WorktreeCardAgents.tsx | 3 +
.../focused-agent-row-highlight.test.ts | 149 ++++++++++++++++++
.../sidebar/focused-agent-row-highlight.ts | 70 ++++++++
6 files changed, 287 insertions(+), 4 deletions(-)
create mode 100644 src/renderer/src/components/sidebar/focused-agent-row-highlight.test.ts
create mode 100644 src/renderer/src/components/sidebar/focused-agent-row-highlight.ts
diff --git a/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx b/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx
index 9769b5bb4..cffacb9e7 100644
--- a/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx
+++ b/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx
@@ -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(
+
+
+
+ )
+
+ 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)
diff --git a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx
index dc25b2706..100181d91 100644
--- a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx
+++ b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx
@@ -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}
diff --git a/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx
index a260ce42b..25542908d 100644
--- a/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx
+++ b/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx
@@ -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 } }) => (
-
{agent.paneKey}
+ default: ({ agent, isFocusedPane }: { agent: { paneKey: string }; isFocusedPane?: boolean }) => (
+
+ {agent.paneKey}
+
)
}))
+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()
+
+ 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')
diff --git a/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx b/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx
index e660634ac..391bcf212 100644
--- a/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx
+++ b/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx
@@ -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}
/>
))}
diff --git a/src/renderer/src/components/sidebar/focused-agent-row-highlight.test.ts b/src/renderer/src/components/sidebar/focused-agent-row-highlight.test.ts
new file mode 100644
index 000000000..1f5c87eb6
--- /dev/null
+++ b/src/renderer/src/components/sidebar/focused-agent-row-highlight.test.ts
@@ -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 {
+ 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)
+ })
+})
diff --git a/src/renderer/src/components/sidebar/focused-agent-row-highlight.ts b/src/renderer/src/components/sidebar/focused-agent-row-highlight.ts
new file mode 100644
index 000000000..8ddc911ca
--- /dev/null
+++ b/src/renderer/src/components/sidebar/focused-agent-row-highlight.ts
@@ -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)
+}