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) +}