mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
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:
+53
-12
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user