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