Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions src/renderer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
import { useGlobalFileDrop } from './hooks/useGlobalFileDrop'
import { registerUpdaterBeforeUnloadBypass } from './lib/updater-beforeunload'
import { buildWorkspaceSessionPayload } from './lib/workspace-session'
import { countWorkingAgents } from './lib/agent-status'

const isMac = navigator.userAgent.includes('Mac')

Expand Down Expand Up @@ -60,6 +61,12 @@ function App(): React.JSX.Element {
const activeRepoId = useAppStore((s) => s.activeRepoId)
const tabsByWorktree = useAppStore((s) => s.tabsByWorktree)
const activeTabId = useAppStore((s) => s.activeTabId)
const activeAgentCount = useAppStore((s) =>
countWorkingAgents({
tabsByWorktree: s.tabsByWorktree,
runtimePaneTitlesByTabId: s.runtimePaneTitlesByTabId
})
)
const expandedPaneByTabId = useAppStore((s) => s.expandedPaneByTabId)
const canExpandPaneByTabId = useAppStore((s) => s.canExpandPaneByTabId)
const terminalLayoutsByTabId = useAppStore((s) => s.terminalLayoutsByTabId)
Expand Down Expand Up @@ -491,6 +498,23 @@ function App(): React.JSX.Element {
</TooltipContent>
</Tooltip>
<div className="titlebar-title">Orca</div>
{activeAgentCount > 0 ? (
<Tooltip>
<TooltipTrigger asChild>
<span
className="titlebar-agent-badge"
aria-label={`${activeAgentCount} ${activeAgentCount === 1 ? 'agent' : 'agents'} active`}
>
<span className="titlebar-agent-badge-dot" aria-hidden />
<span className="titlebar-agent-badge-count">{activeAgentCount}</span>
<span className="titlebar-agent-badge-label">active</span>
</span>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{`${activeAgentCount} ${activeAgentCount === 1 ? 'agent' : 'agents'} active across all worktrees`}
</TooltipContent>
</Tooltip>
) : null}
</div>
{/* Why: keep the center titlebar slot mounted even when tabs are hidden.
Using `hidden` here collapsed the spacer entirely, which let the
Expand Down
35 changes: 35 additions & 0 deletions src/renderer/src/assets/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,41 @@
flex-shrink: 0;
}

.titlebar-agent-badge {
-webkit-app-region: no-drag;
display: inline-flex;
align-items: center;
gap: 6px;
margin-left: 10px;
padding: 3px 8px;
border-radius: 999px;
border: 1px solid color-mix(in srgb, var(--border) 78%, transparent);
background: color-mix(in srgb, var(--accent) 80%, transparent);
color: var(--foreground);
font-size: 11px;
font-weight: 600;
line-height: 1;
white-space: nowrap;
}

.titlebar-agent-badge-dot {
width: 7px;
height: 7px;
border-radius: 999px;
flex-shrink: 0;
background: #16a34a;
box-shadow: 0 0 0 3px color-mix(in srgb, #16a34a 18%, transparent);
}

.titlebar-agent-badge-count {
min-width: 1ch;
}

.titlebar-agent-badge-label {
color: var(--muted-foreground);
font-weight: 500;
}

.titlebar-icon-button {
-webkit-app-region: no-drag;
background: none;
Expand Down
6 changes: 6 additions & 0 deletions src/renderer/src/components/terminal-pane/TerminalPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@
const setTabLayout = useAppStore((store) => store.setTabLayout)
const initialLayoutRef = useRef(savedLayout)
const updateTabTitle = useAppStore((store) => store.updateTabTitle)
const setRuntimePaneTitle = useAppStore((store) => store.setRuntimePaneTitle)
const clearRuntimePaneTitle = useAppStore((store) => store.clearRuntimePaneTitle)
const updateTabPtyId = useAppStore((store) => store.updateTabPtyId)
const clearTabPtyId = useAppStore((store) => store.clearTabPtyId)
const markWorktreeUnread = useAppStore((store) => store.markWorktreeUnread)
Expand Down Expand Up @@ -268,6 +270,8 @@
clearTabPtyId,
consumeSuppressedPtyExit: useAppStore((store) => store.consumeSuppressedPtyExit),
updateTabTitle,
setRuntimePaneTitle,
clearRuntimePaneTitle,
updateTabPtyId,
markWorktreeUnread,
dispatchNotification,
Expand Down Expand Up @@ -323,6 +327,8 @@
clearTabPtyId,
consumeSuppressedPtyExit: useAppStore.getState().consumeSuppressedPtyExit,
updateTabTitle,
setRuntimePaneTitle,
clearRuntimePaneTitle,

Check warning on line 331 in src/renderer/src/components/terminal-pane/TerminalPane.tsx

View workflow job for this annotation

GitHub Actions / verify

eslint-plugin-react-hooks(exhaustive-deps)

React Hook useCallback has missing dependencies: 'clearRuntimePaneTitle', and 'setRuntimePaneTitle'
updateTabPtyId,
markWorktreeUnread,
dispatchNotification,
Expand Down
4 changes: 4 additions & 0 deletions src/renderer/src/components/terminal-pane/pty-connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ type PtyConnectionDeps = {
clearTabPtyId: (tabId: string, ptyId: string) => void
consumeSuppressedPtyExit: (ptyId: string) => boolean
updateTabTitle: (tabId: string, title: string) => void
setRuntimePaneTitle: (tabId: string, paneId: number, title: string) => void
clearRuntimePaneTitle: (tabId: string, paneId: number) => void
updateTabPtyId: (tabId: string, ptyId: string) => void
markWorktreeUnread: (worktreeId: string) => void
dispatchNotification: (event: {
Expand Down Expand Up @@ -49,6 +51,7 @@ export function connectPanePty(
const cacheKey = `${deps.tabId}:${pane.id}`

const onExit = (ptyId: string): void => {
deps.clearRuntimePaneTitle(deps.tabId, pane.id)
deps.clearTabPtyId(deps.tabId, ptyId)
// Why: if the PTY exits abruptly (Ctrl-D, crash, shell termination) without
// first emitting a non-agent title, the cache timer would persist as stale
Expand Down Expand Up @@ -85,6 +88,7 @@ export function connectPanePty(

const onTitleChange = (title: string, rawTitle: string): void => {
manager.setPaneGpuRendering(pane.id, !isGeminiTerminalTitle(rawTitle))
deps.setRuntimePaneTitle(deps.tabId, pane.id, title)
deps.updateTabTitle(deps.tabId, title)

if (!hasConsideredInitialCacheTimerSeed) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ type UseTerminalPaneLifecycleDeps = {
clearTabPtyId: (tabId: string, ptyId: string) => void
consumeSuppressedPtyExit: (ptyId: string) => boolean
updateTabTitle: (tabId: string, title: string) => void
setRuntimePaneTitle: (tabId: string, paneId: number, title: string) => void
clearRuntimePaneTitle: (tabId: string, paneId: number) => void
updateTabPtyId: (tabId: string, ptyId: string) => void
markWorktreeUnread: (worktreeId: string) => void
dispatchNotification: (event: {
Expand Down Expand Up @@ -92,6 +94,8 @@ export function useTerminalPaneLifecycle({
clearTabPtyId,
consumeSuppressedPtyExit,
updateTabTitle,
setRuntimePaneTitle,
clearRuntimePaneTitle,
updateTabPtyId,
markWorktreeUnread,
dispatchNotification,
Expand Down Expand Up @@ -189,6 +193,8 @@ export function useTerminalPaneLifecycle({
clearTabPtyId,
consumeSuppressedPtyExit,
updateTabTitle,
setRuntimePaneTitle,
clearRuntimePaneTitle,
updateTabPtyId,
markWorktreeUnread,
dispatchNotification,
Expand Down Expand Up @@ -252,6 +258,7 @@ export function useTerminalPaneLifecycle({
transport.destroy?.()
paneTransportsRef.current.delete(paneId)
}
clearRuntimePaneTitle(tabId, paneId)
paneFontSizesRef.current.delete(paneId)
pendingWritesRef.current.delete(paneId)
// Clean up pane title state so closed panes don't leave stale entries.
Expand Down
83 changes: 83 additions & 0 deletions src/renderer/src/lib/agent-status-count.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { describe, expect, it } from 'vitest'
import type { TerminalTab } from '../../../shared/types'
import { countWorkingAgents } from './agent-status'

function makeTab(overrides: Partial<TerminalTab> = {}): TerminalTab {
return {
id: 'tab-1',
ptyId: 'pty-1',
worktreeId: 'wt-1',
title: 'Terminal 1',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 0,
...overrides
}
}

describe('countWorkingAgents', () => {
it('counts each live working tab when pane-level titles are unavailable', () => {
expect(
countWorkingAgents({
tabsByWorktree: {
'wt-1': [
makeTab({ id: 'tab-1', title: '⠂ Claude Code' }),
makeTab({ id: 'tab-2', title: '✦ Gemini CLI' })
],
'wt-2': [makeTab({ id: 'tab-3', worktreeId: 'wt-2', title: '⠋ Codex is thinking' })]
},
runtimePaneTitlesByTabId: {}
})
).toBe(3)
})

it('counts working panes separately within the same tab', () => {
expect(
countWorkingAgents({
tabsByWorktree: {
'wt-1': [makeTab({ id: 'tab-1', title: '⠂ Claude Code' })]
},
runtimePaneTitlesByTabId: {
'tab-1': {
1: '⠂ Claude Code',
2: '✦ Gemini CLI',
3: '✳ Claude Code'
}
}
})
).toBe(2)
})

it('ignores non-working or non-live tabs', () => {
expect(
countWorkingAgents({
tabsByWorktree: {
'wt-1': [
makeTab({ id: 'tab-1', title: '✳ Claude Code' }),
makeTab({ id: 'tab-2', title: '✋ Gemini CLI' }),
makeTab({ id: 'tab-3', title: 'bash' }),
makeTab({ id: 'tab-4', title: '⠂ Claude Code', ptyId: null })
]
},
runtimePaneTitlesByTabId: {}
})
).toBe(0)
})

it('prefers pane-level titles over the coarse tab title when available', () => {
expect(
countWorkingAgents({
tabsByWorktree: {
'wt-1': [makeTab({ id: 'tab-1', title: '⠂ Claude Code' })]
},
runtimePaneTitlesByTabId: {
'tab-1': {
1: '✳ Claude Code',
2: 'bash'
}
}
})
).toBe(0)
})
})
43 changes: 43 additions & 0 deletions src/renderer/src/lib/agent-status.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { TerminalTab } from '../../../shared/types'

// Re-export from shared module so existing renderer imports continue to work.
// Why: the main process now needs the same agent detection logic for stat
// tracking. Moving to shared avoids duplicating the detection code.
Expand All @@ -10,3 +12,44 @@ export {
isGeminiTerminalTitle,
isClaudeAgent
} from '../../../shared/agent-detection'
import { detectAgentStatusFromTitle } from '../../../shared/agent-detection'

type CountWorkingAgentsArgs = {
tabsByWorktree: Record<string, TerminalTab[]>
runtimePaneTitlesByTabId: Record<string, Record<number, string>>
}

export function countWorkingAgents({
tabsByWorktree,
runtimePaneTitlesByTabId
}: CountWorkingAgentsArgs): number {
let count = 0

for (const tabs of Object.values(tabsByWorktree)) {
for (const tab of tabs) {
const paneTitles = runtimePaneTitlesByTabId[tab.id]
// Why: split-pane tabs can host multiple concurrent agents, but the
// legacy tab title only reflects the last pane title update that won the
// tab label. Prefer pane-level titles whenever TerminalPane is mounted,
// and fall back to the tab title only for tabs we have not mounted yet
// (for example restored-but-unvisited worktrees).
if (paneTitles && Object.keys(paneTitles).length > 0) {
for (const title of Object.values(paneTitles)) {
if (detectAgentStatusFromTitle(title) === 'working') {
count += 1
}
}
continue
}
// Why: restored session tabs can keep the last agent title even before a
// PTY reconnects (or after the PTY is gone). Count only live PTY-backed
// tab fallbacks so the titlebar matches the sidebar's notion of
// "actively running" instead of surfacing stale pre-shutdown state.
if (tab.ptyId && detectAgentStatusFromTitle(tab.title) === 'working') {
count += 1
}
}
}

return count
}
3 changes: 3 additions & 0 deletions src/renderer/src/store/slices/repos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,13 +140,15 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
const nextTabs = { ...s.tabsByWorktree }
const nextLayouts = { ...s.terminalLayoutsByTabId }
const nextPtyIdsByTabId = { ...s.ptyIdsByTabId }
const nextRuntimePaneTitlesByTabId = { ...s.runtimePaneTitlesByTabId }
const nextSuppressedPtyExitIds = { ...s.suppressedPtyExitIds }
for (const wId of worktreeIds) {
delete nextTabs[wId]
}
for (const tabId of killedTabIds) {
delete nextLayouts[tabId]
delete nextPtyIdsByTabId[tabId]
delete nextRuntimePaneTitlesByTabId[tabId]
}
for (const ptyId of killedPtyIds) {
nextSuppressedPtyExitIds[ptyId] = true
Expand Down Expand Up @@ -174,6 +176,7 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
worktreesByRepo: nextWorktrees,
tabsByWorktree: nextTabs,
ptyIdsByTabId: nextPtyIdsByTabId,
runtimePaneTitlesByTabId: nextRuntimePaneTitlesByTabId,
suppressedPtyExitIds: nextSuppressedPtyExitIds,
terminalLayoutsByTabId: nextLayouts,
activeTabId: s.activeTabId && killedTabIds.has(s.activeTabId) ? null : s.activeTabId,
Expand Down
Loading
Loading