fix: harden active agent hovercard (#621)

This commit is contained in:
Jinjing
2026-04-13 22:49:15 -07:00
committed by GitHub
parent 130c3e9697
commit 0ad3fda397
10 changed files with 326 additions and 56 deletions
+77 -24
View File
@@ -1,10 +1,10 @@
/* eslint-disable max-lines */
import { useEffect } from 'react'
import { useEffect, useMemo } from 'react'
import { DEFAULT_STATUS_BAR_ITEMS, DEFAULT_WORKTREE_CARD_PROPERTIES } from '../../shared/constants'
import { isGitRepoKind } from '../../shared/repo-kind'
import { Minimize2, PanelLeft, PanelRight } from 'lucide-react'
import { TOGGLE_TERMINAL_PANE_EXPAND_EVENT } from '@/constants/terminal'
import { FOCUS_TERMINAL_PANE_EVENT, TOGGLE_TERMINAL_PANE_EXPAND_EVENT } from '@/constants/terminal'
import { syncZoomCSSVar } from '@/lib/ui-zoom'
import { toast } from 'sonner'
import { Toaster } from '@/components/ui/sonner'
@@ -31,10 +31,10 @@ import {
import { useGlobalFileDrop } from './hooks/useGlobalFileDrop'
import { registerUpdaterBeforeUnloadBypass } from './lib/updater-beforeunload'
import { buildWorkspaceSessionPayload } from './lib/workspace-session'
import { countWorkingAgents, countWorkingAgentsPerWorktree } from './lib/agent-status'
import { countWorkingAgents, getWorkingAgentsPerWorktree } from './lib/agent-status'
import { activateAndRevealWorktree } from './lib/worktree-activation'
import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover'
import { findWorktreeById } from '@/store/slices/worktree-helpers'
import { findWorktreeById, getRepoIdFromWorktreeId } from '@/store/slices/worktree-helpers'
const isMac = navigator.userAgent.includes('Mac')
@@ -73,13 +73,15 @@ function App(): React.JSX.Element {
runtimePaneTitlesByTabId: s.runtimePaneTitlesByTabId
})
)
const agentCountByWorktree = useAppStore(
useShallow((s) =>
countWorkingAgentsPerWorktree({
tabsByWorktree: s.tabsByWorktree,
runtimePaneTitlesByTabId: s.runtimePaneTitlesByTabId
})
)
const agentInputs = useAppStore(
useShallow((s) => ({
tabsByWorktree: s.tabsByWorktree,
runtimePaneTitlesByTabId: s.runtimePaneTitlesByTabId
}))
)
const workingAgentsPerWorktree = useMemo(
() => getWorkingAgentsPerWorktree(agentInputs),
[agentInputs]
)
const worktreesByRepo = useAppStore((s) => s.worktreesByRepo)
const expandedPaneByTabId = useAppStore((s) => s.expandedPaneByTabId)
@@ -583,21 +585,72 @@ function App(): React.JSX.Element {
</div>
{activeAgentCount > 0 && (
<div className="titlebar-agent-hovercard-list">
{Object.entries(agentCountByWorktree).map(([worktreeId, count]) => {
{Object.entries(workingAgentsPerWorktree).map(([worktreeId, { agents }]) => {
const wt = findWorktreeById(worktreesByRepo, worktreeId)
// Why: when a transient git error causes worktreesByRepo to
// lose a worktree, the raw worktreeId (uuid::path) is not
// useful. Extract a cross-platform path basename as a
// readable fallback.
const sepIdx = worktreeId.indexOf('::')
const pathPart = sepIdx !== -1 ? worktreeId.slice(sepIdx + 2) : worktreeId
const fallbackName = pathPart.split(/[\\/]/).pop() || pathPart
return (
<button
key={worktreeId}
className="titlebar-agent-hovercard-row"
onClick={() => activateAndRevealWorktree(worktreeId)}
>
<span className="titlebar-agent-hovercard-name">
{wt?.displayName ?? worktreeId}
</span>
<span className="titlebar-agent-hovercard-count">
{count} <span className="titlebar-agent-hovercard-dot" />
</span>
</button>
<div key={worktreeId}>
<button
className="titlebar-agent-hovercard-worktree"
onClick={() => {
// Why: if the worktree is missing from worktreesByRepo
// (transient git error cleared the list), refresh the
// repo's worktrees before navigating so the activation
// lookup succeeds instead of silently failing.
if (!wt) {
const repoId = getRepoIdFromWorktreeId(worktreeId)
void useAppStore
.getState()
.fetchWorktrees(repoId)
.then(() => {
activateAndRevealWorktree(worktreeId)
})
return
}
activateAndRevealWorktree(worktreeId)
}}
>
<span className="titlebar-agent-hovercard-name">
{wt?.displayName ?? fallbackName}
</span>
</button>
{agents.map((agent, index) => (
<button
key={index}
className="titlebar-agent-hovercard-agent"
onClick={() => {
activateAndRevealWorktree(worktreeId)
useAppStore.getState().setActiveTab(agent.tabId)
if (agent.paneId !== null) {
// Why: a split-terminal tab can host multiple
// agents. After selecting the tab, wait one
// frame so the active TerminalPane can mount
// and then focus the specific pane the user
// clicked instead of leaving whichever pane
// was previously active highlighted.
requestAnimationFrame(() => {
window.dispatchEvent(
new CustomEvent(FOCUS_TERMINAL_PANE_EVENT, {
detail: { tabId: agent.tabId, paneId: agent.paneId }
})
)
})
}
}}
>
<span className="titlebar-agent-hovercard-agent-label">
{agent.label}
</span>
<span className="titlebar-agent-hovercard-agent-dot" />
</button>
))}
</div>
)
})}
</div>
+43 -5
View File
@@ -355,6 +355,11 @@
cursor: pointer;
}
.titlebar-agent-badge:hover {
background: var(--accent);
border-color: var(--border);
}
.titlebar-agent-badge-idle {
opacity: 0.55;
}
@@ -430,26 +435,59 @@
flex-direction: column;
}
.titlebar-agent-hovercard-row {
.titlebar-agent-hovercard-worktree {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
gap: 8px;
padding: 5px 12px;
background: none;
border: none;
color: var(--foreground);
font-size: 12px;
font-size: 13px;
font-weight: 500;
text-align: left;
cursor: pointer;
border-radius: 0;
width: 100%;
}
.titlebar-agent-hovercard-row:hover {
.titlebar-agent-hovercard-worktree:hover {
background: var(--accent);
}
.titlebar-agent-hovercard-agent {
display: flex;
align-items: center;
justify-content: space-between;
padding: 3px 12px 3px 24px;
font-size: 12px;
color: var(--muted-foreground);
background: none;
border: none;
text-align: left;
width: 100%;
cursor: pointer;
}
.titlebar-agent-hovercard-agent:hover {
background: var(--accent);
color: var(--foreground);
}
.titlebar-agent-hovercard-agent-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.titlebar-agent-hovercard-agent-dot {
width: 6px;
height: 6px;
border-radius: 999px;
flex-shrink: 0;
background: #16a34a;
}
.titlebar-agent-hovercard-name {
overflow: hidden;
text-overflow: ellipsis;
@@ -1,5 +1,9 @@
import { useEffect, useRef } from 'react'
import { TOGGLE_TERMINAL_PANE_EXPAND_EVENT } from '@/constants/terminal'
import {
FOCUS_TERMINAL_PANE_EVENT,
TOGGLE_TERMINAL_PANE_EXPAND_EVENT,
type FocusTerminalPaneDetail
} from '@/constants/terminal'
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
import { shellEscapePath } from './pane-helpers'
import { fitAndFocusPanes, fitPanes } from './pane-helpers'
@@ -136,6 +140,26 @@ export function useTerminalPaneGlobalEffects({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tabId])
useEffect(() => {
const onFocusPane = (event: Event): void => {
const detail = (event as CustomEvent<FocusTerminalPaneDetail | undefined>).detail
if (!detail?.tabId || detail.tabId !== tabId) {
return
}
const manager = managerRef.current
if (!manager) {
return
}
const pane = manager.getPanes().find((candidate) => candidate.id === detail.paneId)
if (!pane) {
return
}
manager.setActivePane(pane.id, { focus: true })
}
window.addEventListener(FOCUS_TERMINAL_PANE_EVENT, onFocusPane)
return () => window.removeEventListener(FOCUS_TERMINAL_PANE_EVENT, onFocusPane)
}, [tabId, managerRef])
useEffect(() => {
if (!isActive) {
return
+6
View File
@@ -1,5 +1,11 @@
export const TOGGLE_TERMINAL_PANE_EXPAND_EVENT = 'orca-toggle-terminal-pane-expand'
export const FOCUS_TERMINAL_PANE_EVENT = 'orca-focus-terminal-pane'
export type ToggleTerminalPaneExpandDetail = {
tabId: string
}
export type FocusTerminalPaneDetail = {
tabId: string
paneId: number
}
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import type { TerminalTab } from '../../../shared/types'
import { countWorkingAgents } from './agent-status'
import { countWorkingAgents, getWorkingAgentsPerWorktree } from './agent-status'
function makeTab(overrides: Partial<TerminalTab> = {}): TerminalTab {
return {
@@ -81,3 +81,29 @@ describe('countWorkingAgents', () => {
).toBe(0)
})
})
describe('getWorkingAgentsPerWorktree', () => {
it('returns per-pane labels and pane ids for split tabs', () => {
expect(
getWorkingAgentsPerWorktree({
tabsByWorktree: {
'wt-1': [makeTab({ id: 'tab-1', title: '⠂ Claude Code' })]
},
runtimePaneTitlesByTabId: {
'tab-1': {
1: '⠂ Claude Code',
2: '✦ Gemini CLI',
3: '✳ Claude Code'
}
}
})
).toEqual({
'wt-1': {
agents: [
{ label: 'Claude Code', status: 'working', tabId: 'tab-1', paneId: 1 },
{ label: 'Gemini CLI', status: 'working', tabId: 'tab-1', paneId: 2 }
]
}
})
})
})
+17
View File
@@ -1,8 +1,13 @@
/* eslint-disable max-lines --
* Why: agent title detection is intentionally table-driven in one place so the
* supported title variants stay readable and regressions are easy to compare.
*/
import { describe, expect, it, vi } from 'vitest'
import {
detectAgentStatusFromTitle,
clearWorkingIndicators,
createAgentStatusTracker,
getAgentLabel,
isGeminiTerminalTitle,
normalizeTerminalTitle
} from './agent-status'
@@ -230,6 +235,18 @@ describe('isGeminiTerminalTitle', () => {
})
})
describe('getAgentLabel', () => {
it('labels Pi working titles as Pi instead of Claude Code', () => {
expect(getAgentLabel('⠋ π - my-project')).toBe('Pi')
})
it('labels supported agent families consistently', () => {
expect(getAgentLabel('✦ Gemini CLI')).toBe('Gemini CLI')
expect(getAgentLabel('⠂ Claude Code')).toBe('Claude Code')
expect(getAgentLabel('⠋ Codex is thinking')).toBe('Codex')
})
})
describe('createAgentStatusTracker', () => {
// --- Claude Code: real captured OSC title sequence (v2.1.86) ---
// CRITICAL: Claude Code changes the title to the TASK DESCRIPTION,
+59 -25
View File
@@ -10,15 +10,72 @@ export {
createAgentStatusTracker,
normalizeTerminalTitle,
isGeminiTerminalTitle,
isClaudeAgent
isClaudeAgent,
getAgentLabel
} from '../../../shared/agent-detection'
import {
type AgentStatus,
detectAgentStatusFromTitle,
getAgentLabel
} from '../../../shared/agent-detection'
import { detectAgentStatusFromTitle } from '../../../shared/agent-detection'
type CountWorkingAgentsArgs = {
tabsByWorktree: Record<string, TerminalTab[]>
runtimePaneTitlesByTabId: Record<string, Record<number, string>>
}
export type WorkingAgentEntry = {
label: string
status: AgentStatus
tabId: string
paneId: number | null
}
export type WorktreeAgents = {
agents: WorkingAgentEntry[]
}
export function getWorkingAgentsPerWorktree({
tabsByWorktree,
runtimePaneTitlesByTabId
}: CountWorkingAgentsArgs): Record<string, WorktreeAgents> {
const result: Record<string, WorktreeAgents> = {}
for (const [worktreeId, tabs] of Object.entries(tabsByWorktree)) {
const agents: WorkingAgentEntry[] = []
for (const tab of tabs) {
const paneTitles = runtimePaneTitlesByTabId[tab.id]
if (paneTitles && Object.keys(paneTitles).length > 0) {
for (const [paneIdStr, title] of Object.entries(paneTitles)) {
if (detectAgentStatusFromTitle(title) === 'working') {
const label = getAgentLabel(title)
if (label) {
agents.push({
label,
status: 'working',
tabId: tab.id,
paneId: Number(paneIdStr)
})
}
}
}
} else if (tab.ptyId && detectAgentStatusFromTitle(tab.title) === 'working') {
const label = getAgentLabel(tab.title)
if (label) {
agents.push({ label, status: 'working', tabId: tab.id, paneId: null })
}
}
}
if (agents.length > 0) {
result[worktreeId] = { agents }
}
}
return result
}
export function countWorkingAgents({
tabsByWorktree,
runtimePaneTitlesByTabId
@@ -34,29 +91,6 @@ export function countWorkingAgents({
return count
}
/**
* Returns a map of worktreeId → number of active agents for that worktree.
* Only includes worktrees with at least one working agent.
*/
export function countWorkingAgentsPerWorktree({
tabsByWorktree,
runtimePaneTitlesByTabId
}: CountWorkingAgentsArgs): Record<string, number> {
const result: Record<string, number> = {}
for (const [worktreeId, tabs] of Object.entries(tabsByWorktree)) {
let count = 0
for (const tab of tabs) {
count += countWorkingAgentsForTab(tab, runtimePaneTitlesByTabId)
}
if (count > 0) {
result[worktreeId] = count
}
}
return result
}
function countWorkingAgentsForTab(
tab: TerminalTab,
runtimePaneTitlesByTabId: Record<string, Record<number, string>>
@@ -128,6 +128,31 @@ describe('fetchWorktrees', () => {
expect(store.getState().worktreesByRepo.repo1).toEqual([refreshed])
expect(store.getState().sortEpoch).toBe(8)
})
it('keeps the last known worktree list when a refresh transiently returns empty', async () => {
const store = createTestStore()
const existing = makeWorktree({ id: 'repo1::/path/wt1', repoId: 'repo1', path: '/path/wt1' })
mockApi.worktrees.list.mockResolvedValue([])
store.setState({ worktreesByRepo: { repo1: [existing] }, sortEpoch: 7 } as Partial<AppState>)
await store.getState().fetchWorktrees('repo1')
expect(store.getState().worktreesByRepo.repo1).toEqual([existing])
expect(store.getState().sortEpoch).toBe(7)
})
it('accepts an empty refresh when the repo had no cached worktrees', async () => {
const store = createTestStore()
mockApi.worktrees.list.mockResolvedValue([])
store.setState({ worktreesByRepo: {}, sortEpoch: 7 } as Partial<AppState>)
await store.getState().fetchWorktrees('repo1')
expect(store.getState().worktreesByRepo.repo1).toEqual([])
expect(store.getState().sortEpoch).toBe(8)
})
})
describe('removeWorktree state cleanup', () => {
@@ -51,6 +51,17 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
return
}
// Why: `git worktree list` can fail transiently (e.g. concurrent git
// operations holding a lock, disk I/O hiccup). The backend catches these
// errors and returns []. Replacing a known-good worktree list with []
// causes tabsByWorktree entries to become orphaned — the agent activity
// badge then shows raw worktree IDs instead of display names, and click-
// to-navigate silently fails because findWorktreeById returns undefined.
// Keep the stale-but-correct data until the next successful refresh.
if (worktrees.length === 0 && current && current.length > 0) {
return
}
set((s) => ({
// Why: active worktrees can change branches entirely from a terminal.
// We refresh that live git identity into renderer state, but only bump
+36
View File
@@ -52,6 +52,12 @@ export function isPiTerminalTitle(title: string): boolean {
return title.startsWith(PI_IDLE_PREFIX)
}
function isPiAgentTitle(title: string): boolean {
return (
isPiTerminalTitle(title) || (containsBrailleSpinner(title) && title.includes(PI_IDLE_PREFIX))
)
}
function containsBrailleSpinner(title: string): boolean {
for (const char of title) {
const codePoint = char.codePointAt(0)
@@ -226,6 +232,36 @@ export function isClaudeAgent(title: string): boolean {
return false
}
export function getAgentLabel(title: string): string | null {
const lower = title.toLowerCase()
if (isGeminiTerminalTitle(title)) {
return 'Gemini CLI'
}
// Why: Pi working titles include a braille spinner prefix, which would be
// mistaken for Claude Code if we checked `isClaudeAgent` first.
if (isPiAgentTitle(title)) {
return 'Pi'
}
// Why: Codex/OpenCode/Aider can also use braille spinner prefixes while
// working. Prefer explicit name matches before Claude's generic spinner
// heuristic so mixed-agent hovercards stay truthful.
if (lower.includes('codex')) {
return 'Codex'
}
if (lower.includes('opencode')) {
return 'OpenCode'
}
if (lower.includes('aider')) {
return 'Aider'
}
if (isClaudeAgent(title)) {
return 'Claude Code'
}
return null
}
export function detectAgentStatusFromTitle(title: string): AgentStatus | null {
if (!title) {
return null