Highlight focused agent rows (#2733)
* Highlight focused agent status dots * Highlight focused agent rows
This commit is contained in:
parent
d2406ada87
commit
93637e54c9
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
Loading…
Reference in New Issue