feat: make titlebar agent status configurable (#526)

Add a `showTitlebarAgentActivity` setting under Appearance > Titlebar
that lets users toggle the agent activity badge in the titlebar.

- Add `showTitlebarAgentActivity` to GlobalSettings (default: true)
- Add toggle in AppearancePane under a new "Titlebar" sub-section
- Upgrade badge from Tooltip to HoverCard with per-worktree breakdown
- Show dimmed idle state when no agents are active
- Fix badge/tab overlap when sidebar is closed (min-w-0 + conditional shrink-0)
- Extract countWorkingAgentsForTab helper for reuse by per-worktree counter
This commit is contained in:
Jinjing
2026-04-11 23:52:21 -07:00
committed by GitHub
parent 206eb50ab2
commit fd33d9cc2d
6 changed files with 236 additions and 34 deletions
+53 -12
View File
@@ -9,6 +9,7 @@ import { syncZoomCSSVar } from '@/lib/ui-zoom'
import { Toaster } from '@/components/ui/sonner'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { useAppStore } from './store'
import { useShallow } from 'zustand/react/shallow'
import { useIpcEvents } from './hooks/useIpcEvents'
import Sidebar from './components/Sidebar'
import Terminal from './components/Terminal'
@@ -29,7 +30,10 @@ import {
import { useGlobalFileDrop } from './hooks/useGlobalFileDrop'
import { registerUpdaterBeforeUnloadBypass } from './lib/updater-beforeunload'
import { buildWorkspaceSessionPayload } from './lib/workspace-session'
import { countWorkingAgents } from './lib/agent-status'
import { countWorkingAgents, countWorkingAgentsPerWorktree } from './lib/agent-status'
import { activateAndRevealWorktree } from './lib/worktree-activation'
import { HoverCard, HoverCardTrigger, HoverCardContent } from '@/components/ui/hover-card'
import { findWorktreeById } from '@/store/slices/worktree-helpers'
const isMac = navigator.userAgent.includes('Mac')
@@ -68,6 +72,15 @@ function App(): React.JSX.Element {
runtimePaneTitlesByTabId: s.runtimePaneTitlesByTabId
})
)
const agentCountByWorktree = useAppStore(
useShallow((s) =>
countWorkingAgentsPerWorktree({
tabsByWorktree: s.tabsByWorktree,
runtimePaneTitlesByTabId: s.runtimePaneTitlesByTabId
})
)
)
const worktreesByRepo = useAppStore((s) => s.worktreesByRepo)
const expandedPaneByTabId = useAppStore((s) => s.expandedPaneByTabId)
const canExpandPaneByTabId = useAppStore((s) => s.canExpandPaneByTabId)
const terminalLayoutsByTabId = useAppStore((s) => s.terminalLayoutsByTabId)
@@ -501,7 +514,7 @@ function App(): React.JSX.Element {
tabs start exactly where the sidebar ends, creating a clean vertical
alignment between the sidebar edge and the first tab. */}
<div
className="flex items-center shrink-0 overflow-hidden"
className={`flex items-center overflow-hidden${showSidebar && sidebarOpen ? ' shrink-0' : ' min-w-0'}`}
style={{ width: showSidebar && sidebarOpen ? sidebarWidth : undefined }}
>
<div className={isMac && !isFullScreen ? 'titlebar-traffic-light-pad' : 'pl-2'} />
@@ -524,22 +537,50 @@ function App(): React.JSX.Element {
</Tooltip>
)}
<div className="titlebar-title">Orca</div>
{activeAgentCount > 0 ? (
<Tooltip>
<TooltipTrigger asChild>
{settings?.showTitlebarAgentActivity !== false ? (
<HoverCard openDelay={200} closeDelay={100}>
<HoverCardTrigger asChild>
<span
className="titlebar-agent-badge"
className={`titlebar-agent-badge${activeAgentCount === 0 ? ' titlebar-agent-badge-idle' : ''}`}
aria-label={`${activeAgentCount} ${activeAgentCount === 1 ? 'agent' : 'agents'} active`}
>
<span className="titlebar-agent-badge-dot" aria-hidden />
<span
className={`titlebar-agent-badge-dot${activeAgentCount === 0 ? ' titlebar-agent-badge-dot-idle' : ''}`}
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>
</HoverCardTrigger>
<HoverCardContent side="bottom" sideOffset={6} className="titlebar-agent-hovercard">
<div className="titlebar-agent-hovercard-header">
{activeAgentCount === 0
? 'No agents active'
: `${activeAgentCount} ${activeAgentCount === 1 ? 'agent' : 'agents'} active`}
</div>
{activeAgentCount > 0 && (
<div className="titlebar-agent-hovercard-list">
{Object.entries(agentCountByWorktree).map(([worktreeId, count]) => {
const wt = findWorktreeById(worktreesByRepo, worktreeId)
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>
)}
</HoverCardContent>
</HoverCard>
) : null}
</div>
{/* Why: keep the center titlebar slot mounted even when tabs are hidden.
+78
View File
@@ -352,6 +352,11 @@
font-weight: 600;
line-height: 1;
white-space: nowrap;
cursor: pointer;
}
.titlebar-agent-badge-idle {
opacity: 0.55;
}
.titlebar-agent-badge-dot {
@@ -363,6 +368,11 @@
box-shadow: 0 0 0 3px color-mix(in srgb, #16a34a 18%, transparent);
}
.titlebar-agent-badge-dot-idle {
background: var(--muted-foreground);
box-shadow: none;
}
.titlebar-agent-badge-count {
min-width: 1ch;
}
@@ -372,6 +382,74 @@
font-weight: 500;
}
/* ─── Agent hover card (titlebar badge popover) ───────────────────── */
.titlebar-agent-hovercard {
width: auto;
min-width: 180px;
max-width: 280px;
padding: 8px 0;
}
.titlebar-agent-hovercard-header {
padding: 0 12px 6px;
font-size: 11px;
font-weight: 500;
color: var(--muted-foreground);
border-bottom: 1px solid var(--border);
margin-bottom: 4px;
}
.titlebar-agent-hovercard-list {
display: flex;
flex-direction: column;
}
.titlebar-agent-hovercard-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 5px 12px;
background: none;
border: none;
color: var(--foreground);
font-size: 12px;
text-align: left;
cursor: pointer;
border-radius: 0;
width: 100%;
}
.titlebar-agent-hovercard-row:hover {
background: var(--accent);
}
.titlebar-agent-hovercard-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.titlebar-agent-hovercard-count {
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: var(--muted-foreground);
font-variant-numeric: tabular-nums;
}
.titlebar-agent-hovercard-dot {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 999px;
background: #16a34a;
}
.titlebar-icon-button {
-webkit-app-region: no-drag;
background: none;
@@ -27,6 +27,11 @@ export const APPEARANCE_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
title: 'Open Right Sidebar by Default',
description: 'Automatically expand the file explorer panel when creating a new worktree.',
keywords: ['layout', 'file explorer', 'sidebar']
},
{
title: 'Titlebar Agent Activity',
description: 'Show the number of active agents in the titlebar.',
keywords: ['titlebar', 'agent', 'badge', 'active', 'count', 'status']
}
]
@@ -41,7 +46,8 @@ export function AppearancePane({
const zoomOutLabel = isMac ? '⌘-' : 'Ctrl -'
const themeEntries = APPEARANCE_PANE_SEARCH_ENTRIES.slice(0, 1)
const zoomEntries = APPEARANCE_PANE_SEARCH_ENTRIES.slice(1, 2)
const layoutEntries = APPEARANCE_PANE_SEARCH_ENTRIES.slice(2)
const layoutEntries = APPEARANCE_PANE_SEARCH_ENTRIES.slice(2, 3)
const titlebarEntries = APPEARANCE_PANE_SEARCH_ENTRIES.slice(3)
const visibleSections = [
matchesSettingsSearch(searchQuery, themeEntries) ? (
@@ -139,6 +145,48 @@ export function AppearancePane({
</button>
</SearchableSetting>
</section>
) : null,
matchesSettingsSearch(searchQuery, titlebarEntries) ? (
<section key="titlebar" className="space-y-4">
<div className="space-y-1">
<h3 className="text-sm font-semibold">Titlebar</h3>
<p className="text-xs text-muted-foreground">
Control what appears in the application titlebar.
</p>
</div>
<SearchableSetting
title="Titlebar Agent Activity"
description="Show the number of active agents in the titlebar."
keywords={['titlebar', 'agent', 'badge', 'active', 'count', 'status']}
className="flex items-center justify-between gap-4 px-1 py-2"
>
<div className="space-y-0.5">
<Label>Titlebar Agent Activity</Label>
<p className="text-xs text-muted-foreground">
Show the number of active agents in the titlebar.
</p>
</div>
<button
role="switch"
aria-checked={settings.showTitlebarAgentActivity}
onClick={() =>
updateSettings({
showTitlebarAgentActivity: !settings.showTitlebarAgentActivity
})
}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
settings.showTitlebarAgentActivity ? 'bg-foreground' : 'bg-muted-foreground/30'
}`}
>
<span
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
settings.showTitlebarAgentActivity ? 'translate-x-4' : 'translate-x-0.5'
}`}
/>
</button>
</SearchableSetting>
</section>
) : null
].filter(Boolean)
+53 -21
View File
@@ -27,29 +27,61 @@ export function countWorkingAgents({
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
}
count += countWorkingAgentsForTab(tab, runtimePaneTitlesByTabId)
}
}
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>>
): number {
let count = 0
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
}
}
return count
}
// 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
}
+1
View File
@@ -106,6 +106,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
terminalScrollbackBytes: 10_000_000,
openLinksInApp: true,
rightSidebarOpenByDefault: true,
showTitlebarAgentActivity: true,
notifications: getDefaultNotificationSettings(),
diffDefaultView: 'inline',
promptCacheTimerEnabled: false,
+2
View File
@@ -403,6 +403,8 @@ export type GlobalSettings = {
* until the user explicitly wants worktree-scoped in-app browsing. */
openLinksInApp: boolean
rightSidebarOpenByDefault: boolean
/** Whether to show the live agent activity count badge in the titlebar. */
showTitlebarAgentActivity: boolean
diffDefaultView: 'inline' | 'side-by-side'
notifications: NotificationSettings
/** When true, a countdown timer is shown after a Claude agent becomes idle,