From 0ad3fda39725d007bd0023f7f7cbbdf3c749e304 Mon Sep 17 00:00:00 2001
From: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Date: Mon, 13 Apr 2026 22:49:15 -0700
Subject: [PATCH] fix: harden active agent hovercard (#621)
---
src/renderer/src/App.tsx | 101 +++++++++++++-----
src/renderer/src/assets/main.css | 48 ++++++++-
.../use-terminal-pane-global-effects.ts | 26 ++++-
src/renderer/src/constants/terminal.ts | 6 ++
.../src/lib/agent-status-count.test.ts | 28 ++++-
src/renderer/src/lib/agent-status.test.ts | 17 +++
src/renderer/src/lib/agent-status.ts | 84 ++++++++++-----
.../src/store/slices/worktrees.test.ts | 25 +++++
src/renderer/src/store/slices/worktrees.ts | 11 ++
src/shared/agent-detection.ts | 36 +++++++
10 files changed, 326 insertions(+), 56 deletions(-)
diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx
index 92ed4143f01..9d56cf4ccba 100644
--- a/src/renderer/src/App.tsx
+++ b/src/renderer/src/App.tsx
@@ -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 {
{activeAgentCount > 0 && (
- {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 (
-
+
+
+ {agents.map((agent, index) => (
+
+ ))}
+
)
})}
diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css
index ed25c5cf739..b8d9ebec511 100644
--- a/src/renderer/src/assets/main.css
+++ b/src/renderer/src/assets/main.css
@@ -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;
diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts
index 9feba48ef35..c8fdab9bd41 100644
--- a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts
+++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts
@@ -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).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
diff --git a/src/renderer/src/constants/terminal.ts b/src/renderer/src/constants/terminal.ts
index 4e476a67050..7b2f379ebb0 100644
--- a/src/renderer/src/constants/terminal.ts
+++ b/src/renderer/src/constants/terminal.ts
@@ -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
+}
diff --git a/src/renderer/src/lib/agent-status-count.test.ts b/src/renderer/src/lib/agent-status-count.test.ts
index 7821de88c35..f90ae33aed2 100644
--- a/src/renderer/src/lib/agent-status-count.test.ts
+++ b/src/renderer/src/lib/agent-status-count.test.ts
@@ -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 {
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 }
+ ]
+ }
+ })
+ })
+})
diff --git a/src/renderer/src/lib/agent-status.test.ts b/src/renderer/src/lib/agent-status.test.ts
index 31d1dd4fe31..ee3cb338912 100644
--- a/src/renderer/src/lib/agent-status.test.ts
+++ b/src/renderer/src/lib/agent-status.test.ts
@@ -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,
diff --git a/src/renderer/src/lib/agent-status.ts b/src/renderer/src/lib/agent-status.ts
index 8982de36f25..e5ca887a68a 100644
--- a/src/renderer/src/lib/agent-status.ts
+++ b/src/renderer/src/lib/agent-status.ts
@@ -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
runtimePaneTitlesByTabId: Record>
}
+export type WorkingAgentEntry = {
+ label: string
+ status: AgentStatus
+ tabId: string
+ paneId: number | null
+}
+
+export type WorktreeAgents = {
+ agents: WorkingAgentEntry[]
+}
+
+export function getWorkingAgentsPerWorktree({
+ tabsByWorktree,
+ runtimePaneTitlesByTabId
+}: CountWorkingAgentsArgs): Record {
+ const result: Record = {}
+
+ 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 {
- const result: Record = {}
-
- 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>
diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts
index 32c497add0e..5e9ae761736 100644
--- a/src/renderer/src/store/slices/worktrees.test.ts
+++ b/src/renderer/src/store/slices/worktrees.test.ts
@@ -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)
+
+ 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)
+
+ await store.getState().fetchWorktrees('repo1')
+
+ expect(store.getState().worktreesByRepo.repo1).toEqual([])
+ expect(store.getState().sortEpoch).toBe(8)
+ })
})
describe('removeWorktree state cleanup', () => {
diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts
index f69a49da1e6..cb3d891c2eb 100644
--- a/src/renderer/src/store/slices/worktrees.ts
+++ b/src/renderer/src/store/slices/worktrees.ts
@@ -51,6 +51,17 @@ export const createWorktreeSlice: StateCreator
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
diff --git a/src/shared/agent-detection.ts b/src/shared/agent-detection.ts
index 0cad21f47b6..0ab82631040 100644
--- a/src/shared/agent-detection.ts
+++ b/src/shared/agent-detection.ts
@@ -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