mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
fix: harden active agent hovercard (#621)
This commit is contained in:
+77
-24
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
]
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user