From fd33d9cc2d8684dd082ea551151bd05ee68c4d2f Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sat, 11 Apr 2026 23:52:21 -0700 Subject: [PATCH] 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 --- src/renderer/src/App.tsx | 65 +++++++++++++--- src/renderer/src/assets/main.css | 78 +++++++++++++++++++ .../components/settings/AppearancePane.tsx | 50 +++++++++++- src/renderer/src/lib/agent-status.ts | 74 +++++++++++++----- src/shared/constants.ts | 1 + src/shared/types.ts | 2 + 6 files changed, 236 insertions(+), 34 deletions(-) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 5b3c9aeae8d..c240e5a1c25 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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. */}
@@ -524,22 +537,50 @@ function App(): React.JSX.Element { )}
Orca
- {activeAgentCount > 0 ? ( - - + {settings?.showTitlebarAgentActivity !== false ? ( + + - + {activeAgentCount} active - - - {`${activeAgentCount} ${activeAgentCount === 1 ? 'agent' : 'agents'} active across all worktrees`} - - + + +
+ {activeAgentCount === 0 + ? 'No agents active' + : `${activeAgentCount} ${activeAgentCount === 1 ? 'agent' : 'agents'} active`} +
+ {activeAgentCount > 0 && ( +
+ {Object.entries(agentCountByWorktree).map(([worktreeId, count]) => { + const wt = findWorktreeById(worktreesByRepo, worktreeId) + return ( + + ) + })} +
+ )} +
+ ) : null}
{/* Why: keep the center titlebar slot mounted even when tabs are hidden. diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index bbc3e20c5f7..3738668ccef 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -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; diff --git a/src/renderer/src/components/settings/AppearancePane.tsx b/src/renderer/src/components/settings/AppearancePane.tsx index 5df3d70b323..ab854434b9c 100644 --- a/src/renderer/src/components/settings/AppearancePane.tsx +++ b/src/renderer/src/components/settings/AppearancePane.tsx @@ -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({ + ) : null, + matchesSettingsSearch(searchQuery, titlebarEntries) ? ( +
+
+

Titlebar

+

+ Control what appears in the application titlebar. +

+
+ + +
+ +

+ Show the number of active agents in the titlebar. +

+
+ +
+
) : null ].filter(Boolean) diff --git a/src/renderer/src/lib/agent-status.ts b/src/renderer/src/lib/agent-status.ts index 2ff9d2c7ac3..8982de36f25 100644 --- a/src/renderer/src/lib/agent-status.ts +++ b/src/renderer/src/lib/agent-status.ts @@ -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 { + 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> +): 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 +} diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 49db23cf776..98c0813c5eb 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -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, diff --git a/src/shared/types.ts b/src/shared/types.ts index 2be2a745e71..ca7c8726440 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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,