diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt
index 8bcdaf5c943..bc4de12092a 100644
--- a/config/max-lines-baseline.txt
+++ b/config/max-lines-baseline.txt
@@ -41,7 +41,6 @@ inline src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx
inline src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx
inline src/renderer/src/components/settings/AccountsPane.tsx
inline src/renderer/src/components/settings/Settings.tsx
-inline src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx
inline src/renderer/src/components/status-bar/StatusBar.tsx
inline src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx
inline src/renderer/src/components/terminal-pane/TerminalPane.tsx
diff --git a/src/renderer/src/components/hover-reveal-touch-action-visibility.test.ts b/src/renderer/src/components/hover-reveal-touch-action-visibility.test.ts
index f77c27f2aff..77aca89c326 100644
--- a/src/renderer/src/components/hover-reveal-touch-action-visibility.test.ts
+++ b/src/renderer/src/components/hover-reveal-touch-action-visibility.test.ts
@@ -36,7 +36,7 @@ const HOVER_REVEAL_FILES = [
resolve(__dirname, 'sidebar/worktree-list/rows/HostSectionHeader.tsx'),
resolve(__dirname, 'sidebar/worktree-list/rows/SectionHeader.tsx'),
resolve(__dirname, 'sidebar/worktree-list/rows/item-row.tsx'),
- resolve(__dirname, 'status-bar/ResourceUsageStatusSegment.tsx'),
+ resolve(__dirname, 'status-bar/resource-usage-session-rows.tsx'),
resolve(__dirname, 'status-bar/ports-status-popover-rows.tsx'),
resolve(__dirname, 'tab-bar/TabBarQuickCommandsMenu.tsx')
]
diff --git a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.session-polling.test.ts b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.session-polling.test.ts
index 8dc4a515a23..efbd31a191c 100644
--- a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.session-polling.test.ts
+++ b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.session-polling.test.ts
@@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
-const SOURCE_PATH = resolve(__dirname, 'ResourceUsageStatusSegment.tsx')
+const SOURCE_PATH = resolve(__dirname, 'use-resource-usage-status-controller.ts')
const INVENTORY_HOOK_PATH = resolve(__dirname, 'use-resource-session-inventory.ts')
describe('ResourceUsageStatusSegment session inventory', () => {
diff --git a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx
index 5a66e5214a6..e0830f9aafc 100644
--- a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx
+++ b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx
@@ -1,727 +1,23 @@
-/* eslint-disable max-lines -- Why: one status-bar segment co-locates sparkline, worktree tree, session list, daemon actions, and kill-confirm; see docs/resource-usage-merge-spec.md. */
-import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
-import {
- AlertTriangle,
- ChevronDown,
- ChevronRight,
- Globe,
- LoaderCircle,
- MemoryStick,
- RotateCw,
- Terminal,
- Trash2,
- X
-} from 'lucide-react'
-import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
-import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
-import { Button } from '@/components/ui/button'
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle
-} from '@/components/ui/dialog'
-import { cn } from '@/lib/utils'
-import { useMountedRef } from '@/hooks/useMountedRef'
-import { activateAndRevealWorktree } from '@/lib/worktree-activation'
-import { activateTabAndFocusPane } from '@/lib/activate-tab-and-focus-pane'
-import { useAppStore } from '../../store'
-import { getAllWorktreesFromState, useWorktreeMap } from '../../store/selectors'
-import { runWorktreeDelete } from '../sidebar/delete-worktree-flow'
-import { useDaemonActions, DaemonActionDialog } from '../shared/useDaemonActions'
-import type { BrowserWorkspace } from '../../../../shared/browser-workspace-types'
-import type { AppMemory, UsageValues } from '../../../../shared/process-stats-types'
-import type { Worktree } from '../../../../shared/worktree/types'
-import { ORPHAN_WORKTREE_ID } from '../../../../shared/constants'
-import { getRepoExecutionHostId, parseExecutionHostId } from '../../../../shared/execution-host'
-import { mergeSnapshotAndSessions, UNATTRIBUTED_REPO_ID } from './mergeSnapshotAndSessions'
-import type {
- Metric,
- UnifiedProjectGroup,
- UnifiedSessionRow,
- UnifiedWorktreeRow
-} from './resource-usage-merge-types'
-import { WorkspaceSpaceCompactPanel } from './WorkspaceSpaceCompactPanel'
+import React from 'react'
+import { Popover, PopoverContent } from '@/components/ui/popover'
+import { DaemonActionDialog } from '../shared/useDaemonActions'
import { STATUS_BAR_CONTEXT_MENU_EXEMPT_PROPS } from './status-bar-context-menu-policy'
+import { useResourceUsageStatusController } from './use-resource-usage-status-controller'
+import { renderResourceUsageStatusTrigger } from './resource-usage-status-trigger'
import {
- isResourceSessionActivationKey,
- navigateResourceSessionToTab
-} from './resource-session-navigation'
+ renderDaemonUnreachableBanner,
+ renderResourceUsagePopoverHeader,
+ renderResourceUsageSummary,
+ renderSessionsOnlyErrorBanner
+} from './resource-usage-popover-summary'
import {
- getResourceUsageAllWorktrees,
- getResourceUsageBrowserTabsByWorktree,
- getResourceUsageDeferredSshSessionIdsByTabId,
- getResourceUsagePtyIdsByTabId,
- getResourceUsageRepos,
- getResourceUsageRuntimePaneTitlesByTabId,
- getResourceUsageTerminalLayoutsByTabId,
- getResourceUsageTabsByWorktree
-} from './resource-usage-open-slices'
-import {
- resolveResourceUsageSpaceScanReady,
- type ResourceUsageSpaceScanSnapshot
-} from './resource-usage-space-scan-ready'
-import {
- getResourceManagerAriaLabel,
- getResourceManagerTooltipLines
-} from './resource-manager-terminal-copy'
-import {
- getCommitPressureToneClass,
- getResourceCommitMetricCopy,
- getResourceMemoryMetricCopy
-} from './resource-memory-metric-copy'
-import { requiresKillConfirmation } from './resource-session-kill-confirmation'
-import { resolveResourceManagerWorktreeTarget } from './resource-manager-worktree-target'
-import {
- countUnboundDaemonSessions,
- selectUnboundDaemonSessions,
- type ResourceSessionBindingInputs
-} from './resource-session-bindings'
-import { useResourceSessionInventory } from './use-resource-session-inventory'
-import { translate } from '@/i18n/i18n'
+ renderResourceUsagePopoverBody,
+ renderResourceUsagePopoverFooter
+} from './resource-usage-popover-body'
+import { renderResourceUsageKillDialog } from './resource-usage-kill-dialog'
+import { WorkspaceSpaceCompactPanel } from './WorkspaceSpaceCompactPanel'
-const POLL_MS = 2_000
-
-type SortOption = 'memory' | 'cpu' | 'name'
-
-const METRIC_COLUMNS_CLS = 'flex items-center shrink-0 tabular-nums'
-const CPU_COLUMN_CLS = 'w-12 text-right'
-const MEM_COLUMN_CLS = 'w-16 text-right'
-// Why: every row and the header reserve this trailing gutter so CPU/Memory columns align whether or not the row has a kill-X.
-const ROW_TRAILING_GUTTER_CLS = 'w-5 shrink-0 flex items-center justify-end'
-
-// ─── Formatters ─────────────────────────────────────────────────────
-
-function formatMemory(bytes: number): string {
- if (bytes < 1024 * 1024) {
- return `${Math.round(bytes / 1024)} KB`
- }
- if (bytes < 1024 * 1024 * 1024) {
- return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
- }
- return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`
-}
-
-function formatCpu(percent: number): string {
- return `${percent.toFixed(1)}%`
-}
-
-function formatMetricCpu(value: Metric): string {
- return value === null ? '—' : formatCpu(value)
-}
-
-function formatMetricMemory(value: Metric): string {
- return value === null ? '—' : formatMemory(value)
-}
-
-// ─── Sparkline ──────────────────────────────────────────────────────
-
-type SparklineProps = {
- samples: number[]
- width?: number
- height?: number
-}
-
-function SparklineImpl({ samples, width = 48, height = 14 }: SparklineProps): React.JSX.Element {
- const points = useMemo(() => {
- const safe = Array.isArray(samples) ? samples : []
- if (safe.length < 2) {
- const midY = (height / 2).toFixed(1)
- return `0,${midY} ${width},${midY}`
- }
-
- let min = safe[0]
- let max = safe[0]
- for (const v of safe) {
- if (v < min) {
- min = v
- }
- if (v > max) {
- max = v
- }
- }
- const range = max - min || 1
- const stepX = width / (safe.length - 1)
-
- const out: string[] = []
- for (let i = 0; i < safe.length; i++) {
- const x = (i * stepX).toFixed(1)
- const y = (height - ((safe[i] - min) / range) * height).toFixed(1)
- out.push(`${x},${y}`)
- }
- return out.join(' ')
- }, [samples, width, height])
-
- return (
-
-
-
- )
-}
-
-const Sparkline = memo(SparklineImpl, (a, b) => {
- if (a.width !== b.width || a.height !== b.height) {
- return false
- }
- const sa = Array.isArray(a.samples) ? a.samples : []
- const sb = Array.isArray(b.samples) ? b.samples : []
- if (sa === sb) {
- return true
- }
- if (sa.length !== sb.length) {
- return false
- }
- for (let i = 0; i < sa.length; i++) {
- if (sa[i] !== sb[i]) {
- return false
- }
- }
- return true
-})
-
-// ─── Leaf UI: metric row ────────────────────────────────────────────
-
-function MetricPair({
- cpu,
- memory,
- size = 'base'
-}: {
- cpu: Metric
- memory: Metric
- size?: 'base' | 'small'
-}): React.JSX.Element {
- const textCls = size === 'small' ? 'text-[11px]' : 'text-xs'
- const muted = cpu === null && memory === null
- return (
-
- {formatMetricCpu(cpu)}
- {formatMetricMemory(memory)}
-
- )
-}
-
-function AppSubRow({ label, values }: { label: string; values: UsageValues }): React.JSX.Element {
- return (
-
- )
-}
-
-function AppSection({
- app,
- isCollapsed,
- onToggle
-}: {
- app: AppMemory
- isCollapsed: boolean
- onToggle: () => void
-}): React.JSX.Element {
- return (
-
-
-
- {isCollapsed ? (
-
- ) : (
-
- )}
-
-
-
- {translate('auto.components.status.bar.ResourceUsageStatusSegment.288a4dd177', 'Orca')}
-
-
-
-
-
-
-
-
- {!isCollapsed && (
-
-
-
- {(app.other.cpu > 0 || app.other.memory > 0) && (
-
- )}
-
- )}
-
- )
-}
-
-// ─── Sorting ────────────────────────────────────────────────────────
-
-function compareMetricDesc(a: Metric, b: Metric): number {
- // Why: null metrics (remote rows) sort last regardless of direction so they don't pollute the "biggest consumers" view.
- if (a === null && b === null) {
- return 0
- }
- if (a === null) {
- return 1
- }
- if (b === null) {
- return -1
- }
- return b - a
-}
-
-function sortWorktrees(list: UnifiedWorktreeRow[], sort: SortOption): UnifiedWorktreeRow[] {
- const copy = [...list]
- if (sort === 'memory') {
- copy.sort((a, b) => compareMetricDesc(a.memory, b.memory))
- } else if (sort === 'cpu') {
- copy.sort((a, b) => compareMetricDesc(a.cpu, b.cpu))
- } else {
- copy.sort((a, b) => a.worktreeName.localeCompare(b.worktreeName))
- }
- return copy
-}
-
-function sortProjectGroups(groups: UnifiedProjectGroup[], sort: SortOption): UnifiedProjectGroup[] {
- const copy = [...groups]
- if (sort === 'memory') {
- copy.sort((a, b) => compareMetricDesc(a.memory, b.memory))
- } else if (sort === 'cpu') {
- copy.sort((a, b) => compareMetricDesc(a.cpu, b.cpu))
- } else {
- copy.sort((a, b) => a.repoName.localeCompare(b.repoName))
- }
- return copy
-}
-
-// ─── Session row ────────────────────────────────────────────────────
-
-// Exported (with WorktreeRow) for row-level regression tests pinning the kill affordance and remote-chip presentation.
-export function SessionRow({
- session,
- worktreeId,
- onNavigate,
- onKill
-}: {
- session: UnifiedSessionRow
- worktreeId: string
- onNavigate: (tabId: string, paneKey: string | null) => void
- onKill: (session: UnifiedSessionRow) => void
-}): React.JSX.Element {
- const clickable = session.tabId !== null && session.bound
- const handleClick = (): void => {
- if (clickable && session.tabId) {
- onNavigate(session.tabId, session.paneKey)
- }
- }
-
- return (
- {
- if (isResourceSessionActivationKey(e.key)) {
- e.preventDefault()
- handleClick()
- }
- }
- : undefined
- }
- data-worktree-id={worktreeId}
- >
-
-
- {session.label}
-
-
- {/* Why: kill X sits in the shared gutter for column alignment; bound rows reveal it on hover/focus, orphan rows always show it as reclaimable. */}
-
- {
- e.stopPropagation()
- onKill(session)
- }}
- className={cn(
- 'rounded p-0.5 text-muted-foreground transition-opacity hover:bg-destructive/10 hover:text-destructive',
- session.bound &&
- 'can-hover:opacity-0 group-hover/sessrow:opacity-100 group-focus-within/sessrow:opacity-100 focus-visible:opacity-100'
- )}
- aria-label={translate(
- 'auto.components.status.bar.ResourceUsageStatusSegment.fa6d36758d',
- 'Kill session {{value0}}',
- { value0: session.sessionId }
- )}
- >
-
-
-
-
- )
-}
-
-function BrowserRow({ browser }: { browser: BrowserWorkspace }): React.JSX.Element {
- const label = browser.title?.trim() || browser.label?.trim() || browser.url
- return (
-
-
- {label}
-
-
-
- )
-}
-
-// ─── Worktree row ───────────────────────────────────────────────────
-
-export function WorktreeRow({
- worktree,
- storeRecord,
- activeWorktreeId,
- isCollapsed,
- onToggle,
- onNavigate,
- onDelete,
- onKillSession,
- navigateToTab
-}: {
- worktree: UnifiedWorktreeRow
- storeRecord: Worktree | null
- activeWorktreeId: string | null
- isCollapsed: boolean
- onToggle: () => void
- onNavigate: () => void
- onDelete: () => void
- onKillSession: (session: UnifiedSessionRow) => void
- navigateToTab: (tabId: string, paneKey: string | null) => void
-}): React.JSX.Element {
- const hasResources = worktree.sessions.length > 0 || worktree.browsers.length > 0
- // Why: synthetic buckets (orphan/unattributed) have no sidebar target to reveal; real and SSH-resolved worktrees stay navigable.
- const isSynthetic =
- worktree.worktreeId === ORPHAN_WORKTREE_ID || worktree.repoId === UNATTRIBUTED_REPO_ID
- const isNavigable = !isSynthetic
- // Why: Delete needs a sidebar worktree record; hidden for synthetic/SSH-only rows and the active worktree, but the row stays navigable.
- const showWorktreeActions =
- !isSynthetic && storeRecord !== null && worktree.worktreeId !== activeWorktreeId
- const isMainWorktree = storeRecord?.isMainWorktree ?? false
- const rowLabel = storeRecord?.displayName?.trim() || worktree.worktreeName
-
- return (
-
-
- {hasResources ? (
-
- {isCollapsed ? (
-
- ) : (
-
- )}
-
- ) : (
-
- )}
-
- {rowLabel}
- {/* Why: gate the chip on SSH connectionId, not missing data — warm-reattached local PTYs land here with hasLocalSamples=false. */}
- {worktree.isRemote && (
-
- {translate(
- 'auto.components.status.bar.ResourceUsageStatusSegment.21cacb16d1',
- '· remote'
- )}
-
- )}
-
-
-
- {/* Why: no-hover devices show the action overlay by default, so the sparkline yields there just like on hover. */}
-
-
-
- {showWorktreeActions && (
-
-
-
-
-
-
-
-
- {isMainWorktree
- ? translate(
- 'auto.components.status.bar.ResourceUsageStatusSegment.946724a70a',
- 'The main workspace cannot be deleted.'
- )
- : translate(
- 'auto.components.status.bar.ResourceUsageStatusSegment.a82253b458',
- 'Delete workspace.'
- )}
-
-
-
- )}
-
-
-
-
-
-
- {!isCollapsed &&
- worktree.sessions.map((session) => (
-
- ))}
- {!isCollapsed &&
- worktree.browsers.map((browser) =>
)}
-
- )
-}
-
-// ─── Repo + worktree tree ───────────────────────────────────────────
-
-function ResourceTree({
- repos,
- sortOption,
- collapsedRepos,
- toggleRepo,
- collapsedWorktrees,
- activeWorktreeId,
- toggleWorktree,
- navigateToWorktree,
- navigateToTab,
- onDelete,
- onKillSession
-}: {
- repos: UnifiedProjectGroup[]
- sortOption: SortOption
- collapsedRepos: Set
- toggleRepo: (repoId: string) => void
- collapsedWorktrees: Set
- activeWorktreeId: string | null
- toggleWorktree: (worktreeId: string) => void
- navigateToWorktree: (worktreeId: string) => void
- navigateToTab: (tabId: string, paneKey: string | null) => void
- onDelete: (worktreeId: string) => void
- onKillSession: (session: UnifiedSessionRow) => void
-}): React.JSX.Element {
- const worktreeById = useWorktreeMap()
-
- const sortedRepos = useMemo(() => {
- const grouped = sortProjectGroups(repos, sortOption)
- return grouped.map((repo) => ({
- ...repo,
- worktrees: sortWorktrees(repo.worktrees, sortOption)
- }))
- }, [repos, sortOption])
-
- const renderWorktree = (wt: UnifiedWorktreeRow): React.JSX.Element => {
- const storeRecord = worktreeById.get(wt.worktreeId) ?? null
- return (
- toggleWorktree(wt.worktreeId)}
- onNavigate={() => navigateToWorktree(wt.worktreeId)}
- onDelete={() => onDelete(wt.worktreeId)}
- onKillSession={onKillSession}
- navigateToTab={navigateToTab}
- />
- )
- }
-
- if (sortedRepos.length === 1) {
- return <>{sortedRepos[0].worktrees.map(renderWorktree)}>
- }
-
- return (
- <>
- {sortedRepos.map((group) => {
- const repoCollapsed = collapsedRepos.has(group.repoId)
- return (
-
-
-
toggleRepo(group.repoId)}
- className="pl-2 py-2 pr-0.5 transition-colors hover:bg-muted/50"
- aria-label={
- repoCollapsed
- ? translate(
- 'auto.components.status.bar.ResourceUsageStatusSegment.b12e31dfcb',
- 'Expand repo'
- )
- : translate(
- 'auto.components.status.bar.ResourceUsageStatusSegment.73a3fd68a9',
- 'Collapse repo'
- )
- }
- >
- {repoCollapsed ? (
-
- ) : (
-
- )}
-
-
-
-
- {group.repoName}
-
- {group.hasRemoteChildren && (
-
- {translate(
- 'auto.components.status.bar.ResourceUsageStatusSegment.21cacb16d1',
- '· remote'
- )}
-
- )}
-
-
-
-
-
-
-
-
- {!repoCollapsed && (
-
{group.worktrees.map(renderWorktree)}
- )}
-
- )
- })}
- >
- )
-}
+export { SessionRow, WorktreeRow } from './resource-usage-session-rows'
// ─── Top-level segment ──────────────────────────────────────────────
@@ -731,425 +27,50 @@ export function ResourceUsageStatusSegment({
compact?: boolean
iconOnly: boolean
}): React.JSX.Element {
- const snapshot = useAppStore((s) => s.memorySnapshot)
- const memorySnapshotError = useAppStore((s) => s.memorySnapshotError)
- const fetchSnapshot = useAppStore((s) => s.fetchMemorySnapshot)
- const workspaceSessionReady = useAppStore((s) => s.workspaceSessionReady)
- const setActiveView = useAppStore((s) => s.setActiveView)
- const openModal = useAppStore((s) => s.openModal)
- const openSpacePage = useAppStore((s) => s.openSpacePage)
- const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction)
- const activeView = useAppStore((s) => s.activeView)
- const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
- const workspaceSpaceScannedAt = useAppStore((s) => s.workspaceSpaceAnalysis?.scannedAt ?? null)
- const workspaceSpaceScanning = useAppStore((s) => s.workspaceSpaceScanning)
-
- const [open, setOpen] = useState(false)
- const [sortOption, setSortOption] = useState('memory')
- const [collapsedRepos, setCollapsedRepos] = useState>(new Set())
- const [collapsedWorktrees, setCollapsedWorktrees] = useState>(new Set())
- const [appCollapsed, setAppCollapsed] = useState(true)
+ const controller = useResourceUsageStatusController()
const {
- sessionInventory,
- sessionsError,
- refreshSessions,
- clearSessionsError,
- removeSession,
- removeSessions
- } = useResourceSessionInventory(workspaceSessionReady)
- const sessions = sessionInventory.sessions
- const [killConfirm, setKillConfirm] = useState(null)
- const [killing, setKilling] = useState(false)
- const [spaceScanSnapshot, setSpaceScanSnapshot] = useState(
- () => ({
- ready: false,
- previousScanning: workspaceSpaceScanning,
- lastSeenScannedAt: workspaceSpaceScannedAt
- })
- )
- // Why: tab titles churn on every keystroke; subscribe to those maps only while open so closed badges don't rerender.
- const runtimePaneTitlesByTabId = useAppStore((s) =>
- getResourceUsageRuntimePaneTitlesByTabId(s, open)
- )
- const repos = useAppStore((s) => getResourceUsageRepos(s, open))
- const allWorktrees = useAppStore((s) => getResourceUsageAllWorktrees(s, open))
- const tabsByWorktree = useAppStore((s) => getResourceUsageTabsByWorktree(s, open))
- const browserTabsByWorktree = useAppStore((s) => getResourceUsageBrowserTabsByWorktree(s, open))
- // Why: full binding maps stay behind open sentinels so unchanged counts don't rerender the closed segment.
- const ptyIdsByTabId = useAppStore((s) => getResourceUsagePtyIdsByTabId(s, open))
- const terminalLayoutsByTabId = useAppStore((s) => getResourceUsageTerminalLayoutsByTabId(s, open))
- // Why: sessions awaiting SSH reattach are live on the remote host with no other binding.
- const deferredSshSessionIdsByTabId = useAppStore((s) =>
- getResourceUsageDeferredSshSessionIdsByTabId(s, open)
- )
- const resourceSnapshot = snapshot
- // Why: ptyIdsByTabId tracks mounted/live panes only; Resource Manager reads restored wake hints only for classification.
- const resourceSessionBindings = useMemo(
- () => ({
- ptyIdsByTabId,
- tabsByWorktree,
- terminalLayoutsByTabId,
- deferredSshSessionIdsByTabId,
- workspaceSessionReady
- }),
- [
- ptyIdsByTabId,
- tabsByWorktree,
- terminalLayoutsByTabId,
- deferredSshSessionIdsByTabId,
- workspaceSessionReady
- ]
- )
-
- // Why: after a kill unmounts the session, focus would fall to ; park a ref on the popover body to restore it stably for keyboard users.
- const popoverBodyRef = useRef(null)
- const popoverBodyFocusFrameRef = useRef(null)
- const mountedRef = useMountedRef()
-
- const cancelPopoverBodyFocusFrame = useCallback((): void => {
- if (popoverBodyFocusFrameRef.current === null) {
- return
- }
- cancelAnimationFrame(popoverBodyFocusFrameRef.current)
- popoverBodyFocusFrameRef.current = null
- }, [])
-
- const setPopoverBodyNode = useCallback(
- (node: HTMLDivElement | null): void => {
- // Why: the queued post-kill focus is only valid while the popover body exists.
- if (!node) {
- cancelPopoverBodyFocusFrame()
- }
- popoverBodyRef.current = node
- },
- [cancelPopoverBodyFocusFrame]
- )
-
- const daemonActions = useDaemonActions({
- onRestartSettled: () => {
- clearSessionsError()
- void fetchSnapshot()
- void refreshSessions()
- }
- })
-
- // Why: Space scans can finish after the user closes the full page/popover; the status-bar trigger becomes the handoff point.
- const nextSpaceScanSnapshot = resolveResourceUsageSpaceScanReady({
- snapshot: spaceScanSnapshot,
open,
- activeView,
- scannedAt: workspaceSpaceScannedAt,
- scanning: workspaceSpaceScanning
- })
- if (
- nextSpaceScanSnapshot.ready !== spaceScanSnapshot.ready ||
- nextSpaceScanSnapshot.previousScanning !== spaceScanSnapshot.previousScanning ||
- nextSpaceScanSnapshot.lastSeenScannedAt !== spaceScanSnapshot.lastSeenScannedAt
- ) {
- // Why: guarded render-time state update (no ref mutation during render); React can safely retry it before commit.
- setSpaceScanSnapshot(nextSpaceScanSnapshot)
- }
- const spaceScanReady = nextSpaceScanSnapshot.ready
-
- // Why: seed RAM after session restore so the closed chip does not require a
- // click; the session-inventory hook independently seeds daemon PTYs.
- useEffect(() => {
- if (workspaceSessionReady) {
- void fetchSnapshot()
- }
- }, [workspaceSessionReady, fetchSnapshot])
-
- // Poll memory only while the popover is open. Session inventory is still
- // explicit-on-open/action/seed (not a closed interval) because full
- // listSessions can pause input with large preserved-session sets.
- useEffect(() => {
- if (!open) {
- return
- }
- void fetchSnapshot()
- void refreshSessions()
- // Why: only memory polls on an interval; session inventory is explicit on open/action since it's expensive with many terminals.
- const memTimer = window.setInterval(() => {
- void fetchSnapshot()
- }, POLL_MS)
- return () => {
- window.clearInterval(memTimer)
- }
- }, [open, fetchSnapshot, refreshSessions])
-
- useEffect(() => {
- if (!open) {
- clearSessionsError()
- }
- }, [open, clearSessionsError])
-
- const repoDisplayNameById = useMemo(() => {
- const map = new Map()
- for (const repo of repos) {
- const display = repo.displayName?.trim()
- if (display) {
- map.set(repo.id, display)
- }
- }
- return map
- }, [repos])
-
- // Why: non-null connectionId is the only honest "remote" signal (SSH PTYs run remote); build from the store, not a missing memory sample.
- const repoConnectionIdById = useMemo(() => {
- const map = new Map()
- for (const repo of repos) {
- map.set(repo.id, repo.connectionId ?? null)
- }
- return map
- }, [repos])
-
- // Why: runtime-hosted repos have no local daemon samples or killable sessions; this map drives their per-row exclusion in the merge.
- const repoRuntimeScopedById = useMemo(() => {
- const map = new Map()
- for (const repo of repos) {
- const parsed = parseExecutionHostId(getRepoExecutionHostId(repo))
- map.set(repo.id, parsed?.kind === 'runtime')
- }
- return map
- }, [repos])
-
- const worktreeById = useMemo(
- () => new Map(allWorktrees.map((worktree) => [worktree.id, worktree])),
- [allWorktrees]
- )
-
- // Why: skip the merge when closed; the always-mounted segment recomputing on every keystroke-driven store mutation made the app laggy.
- const unifiedRepos = useMemo(
- () =>
- open
- ? mergeSnapshotAndSessions(resourceSnapshot, sessions, {
- // Why spread: the rows and the bulk selector must classify from the identical binding
- // inputs. Re-listing them here let the row path miss deferred SSH sessions, so their
- // single-row kill skipped confirmation while bulk cleanup correctly spared them (#8459).
- ...resourceSessionBindings,
- runtimePaneTitlesByTabId,
- repoDisplayNameById,
- repoConnectionIdById,
- repoRuntimeScopedById,
- browserTabsByWorktree,
- worktreeById
- })
- : [],
- [
- open,
- resourceSnapshot,
- sessions,
- resourceSessionBindings,
- runtimePaneTitlesByTabId,
- repoDisplayNameById,
- repoConnectionIdById,
- repoRuntimeScopedById,
- browserTabsByWorktree,
- worktreeById
- ]
- )
-
- // Why: orphan detection needs daemon inventory; keep it open-only so the closed badge never triggers a background global session scan.
- const orphanCount = useMemo(() => {
- if (!open || !workspaceSessionReady) {
- return 0
- }
- return countUnboundDaemonSessions(sessions, resourceSessionBindings)
- }, [open, sessions, resourceSessionBindings, workspaceSessionReady])
-
- // Why: open and closed badges share the same daemon inventory cache. The old
- // closed path used boundPtyIds (wake hints) and inflated the chip to 60+.
- const triggerSessionCount = sessionInventory.count
-
- const memoryMetricCopy = getResourceMemoryMetricCopy(
- resourceSnapshot?.processMemoryMetric ?? 'rss'
- )
- // Why null-not-zero: a host that cannot read commit (every Unix host, and any
- // host older than the field) must render nothing here, never "0 B committed".
- const commitMetricCopy = resourceSnapshot?.processCommitMetric
- ? getResourceCommitMetricCopy()
- : null
- const { totalMemory, totalCpu, memBadgeLabel, totalPrivateMemory, commitToneClass } =
- useMemo(() => {
- const memory = resourceSnapshot?.totalMemory ?? 0
- const cpu = resourceSnapshot?.totalCpu ?? 0
- const privateMemory = resourceSnapshot?.totalPrivateMemory
- return {
- totalMemory: memory,
- totalCpu: cpu,
- memBadgeLabel: resourceSnapshot ? formatMemory(memory) : '—',
- totalPrivateMemory: privateMemory,
- commitToneClass: getCommitPressureToneClass({
- privateMemory,
- hostTotalMemory: resourceSnapshot?.host.totalMemory ?? 0
- })
- }
- }, [resourceSnapshot])
- const commitBadgeLabel =
- commitMetricCopy && totalPrivateMemory !== undefined ? formatMemory(totalPrivateMemory) : null
-
- // Why: memorySnapshotError null means "succeeded" OR "never fetched"; a sessions failure before any snapshot still counts as daemon-unreachable.
- const daemonUnreachable = sessionsError && (memorySnapshotError !== null || snapshot === null)
- // Why: sessions IPC can fail while snapshot IPC works; flag it so the empty session list isn't mistaken for healthy.
- const sessionsOnlyError = sessionsError && memorySnapshotError === null
- const resourceManagerTooltipLines = getResourceManagerTooltipLines({
- memoryLabel: resourceSnapshot
- ? [
- `${memBadgeLabel} · ${memoryMetricCopy.summaryLabel}`,
- commitBadgeLabel && commitMetricCopy
- ? `${commitBadgeLabel} ${commitMetricCopy.summaryLabel}`
- : null
- ]
- .filter(Boolean)
- .join(' · ')
- : memBadgeLabel,
- sessionCount: triggerSessionCount,
- spaceScanReady
- })
- const resourceManagerAriaLabel = getResourceManagerAriaLabel({
- sessionCount: triggerSessionCount,
- spaceScanReady
- })
-
- const toggleRepo = useCallback((repoId: string): void => {
- setCollapsedRepos((prev) => {
- const next = new Set(prev)
- if (next.has(repoId)) {
- next.delete(repoId)
- } else {
- next.add(repoId)
- }
- return next
- })
- }, [])
-
- const toggleWorktree = useCallback((worktreeId: string): void => {
- setCollapsedWorktrees((prev) => {
- const next = new Set(prev)
- if (next.has(worktreeId)) {
- next.delete(worktreeId)
- } else {
- next.add(worktreeId)
- }
- return next
- })
- }, [])
-
- // Why: keep popover open on worktree navigation so users can browse; onFocusOutside suppresses the bound-row focus transfer.
- const navigateToWorktree = useCallback((worktreeId: string): void => {
- if (worktreeId === ORPHAN_WORKTREE_ID || worktreeId.startsWith(`${UNATTRIBUTED_REPO_ID}::`)) {
- return
- }
- const target = resolveResourceManagerWorktreeTarget(
- worktreeId,
- getAllWorktreesFromState(useAppStore.getState())
- )
- if (!target) {
- return
- }
- activateAndRevealWorktree(worktreeId, { executionHostId: target.hostId })
- }, [])
-
- const navigateToTab = useCallback(
- (tabId: string, paneKey: string | null) => {
- navigateResourceSessionToTab(tabId, paneKey, {
- tabsByWorktree,
- setOpen,
- setActiveView,
- activateAndRevealWorktree,
- activateTabAndFocusPane
- })
- },
- [tabsByWorktree, setActiveView]
- )
-
- const deleteWorktree = useCallback((worktreeId: string): void => {
- const target = resolveResourceManagerWorktreeTarget(
- worktreeId,
- getAllWorktreesFromState(useAppStore.getState())
- )
- if (!target) {
- return
- }
- setOpen(false)
- runWorktreeDelete(worktreeId, { expectedHostId: target.hostId })
- }, [])
-
- const handleOpenWorkspaceCleanup = useCallback((): void => {
- setOpen(false)
- queueMicrotask(() => openModal('workspace-cleanup'))
- }, [openModal])
-
- const handleKillSession = useCallback(
- (session: UnifiedSessionRow): void => {
- if (!requiresKillConfirmation(session)) {
- removeSession(session.sessionId)
- // Why: await the kill before refreshing, else the refresh re-reads the daemon list before the kill lands and re-adds the row.
- void (async () => {
- try {
- await window.api.pty.kill(session.sessionId)
- } catch {
- /* already dead */
- }
- await refreshSessions()
- })()
- return
- }
- setKillConfirm(session)
- },
- [refreshSessions, removeSession]
- )
-
- const handleKillOrphans = useCallback(async () => {
- if (!workspaceSessionReady) {
- return
- }
- // Why the shared selector: the button's count comes from the same function, so the set killed
- // is exactly the set advertised. Filtering separately here is how live sessions got killed.
- const orphans = selectUnboundDaemonSessions(sessions, resourceSessionBindings)
- if (orphans.length === 0) {
- return
- }
- // Why: optimistic removal so rows disappear immediately instead of waiting for the next daemon-side list refresh.
- const orphanIds = new Set(orphans.map((s) => s.id))
- removeSessions(orphanIds)
- await Promise.allSettled(orphans.map((s) => window.api.pty.kill(s.id)))
- void refreshSessions()
- }, [sessions, resourceSessionBindings, workspaceSessionReady, refreshSessions, removeSessions])
-
- const runKillConfirmed = useCallback(async () => {
- if (!killConfirm) {
- return
- }
- const target = killConfirm
- setKilling(true)
- // Why: optimistic removal avoids a flash where the dialog closes but the killed row lingers until the next list refresh.
- removeSession(target.sessionId)
- try {
- await window.api.pty.kill(target.sessionId)
- } catch {
- /* already dead — fall through */
- } finally {
- if (mountedRef.current) {
- setKilling(false)
- setKillConfirm(null)
- // Why: killed row unmounts and focus would drop to ; park it on the popover body so keyboard users stay in the list.
- cancelPopoverBodyFocusFrame()
- if (popoverBodyRef.current) {
- popoverBodyFocusFrameRef.current = requestAnimationFrame(() => {
- popoverBodyFocusFrameRef.current = null
- popoverBodyRef.current?.focus()
- })
- }
- void refreshSessions()
- }
- }
- }, [cancelPopoverBodyFocusFrame, killConfirm, mountedRef, refreshSessions, removeSession])
-
- const openSpaceResults = useCallback((): void => {
- setOpen(false)
- openSpacePage()
- }, [openSpacePage])
+ setOpen,
+ sortOption,
+ setSortOption,
+ collapsedRepos,
+ collapsedWorktrees,
+ appCollapsed,
+ setAppCollapsed,
+ activeWorktreeId,
+ killConfirm,
+ setKillConfirm,
+ killing,
+ setPopoverBodyNode,
+ daemonActions,
+ resourceSnapshot,
+ spaceScanReady,
+ recordFeatureInteraction,
+ unifiedRepos,
+ orphanCount,
+ triggerSessionCount,
+ memoryMetricCopy,
+ commitMetricCopy,
+ totalMemory,
+ totalCpu,
+ memBadgeLabel,
+ commitToneClass,
+ commitBadgeLabel,
+ daemonUnreachable,
+ sessionsOnlyError,
+ resourceManagerTooltipLines,
+ resourceManagerAriaLabel,
+ toggleRepo,
+ toggleWorktree,
+ navigateToWorktree,
+ navigateToTab,
+ deleteWorktree,
+ handleOpenWorkspaceCleanup,
+ handleKillSession,
+ handleKillOrphans,
+ runKillConfirmed,
+ openSpaceResults
+ } = controller
return (
-
-
-
-
- {spaceScanReady ? (
-
- ) : null}
-
- {!iconOnly && (
- <>
- {/* Tint only: the number stays the resident sum it has always been,
- and the tooltip names the commit figure that raised the tone. */}
-
- {memBadgeLabel}
-
- ·
-
-
- {triggerSessionCount}
- {orphanCount > 0 && (
- ({orphanCount})
- )}
-
- >
- )}
- {iconOnly && triggerSessionCount > 0 && (
-
- {triggerSessionCount}
-
- )}
- {daemonUnreachable && (
-
- )}
-
-
-
-
-
- {resourceManagerTooltipLines.map((line) => (
-
- {line.text}
-
- ))}
-
-
-
+ {renderResourceUsageStatusTrigger({
+ daemonUnreachable,
+ resourceManagerAriaLabel,
+ spaceScanReady,
+ iconOnly,
+ commitToneClass,
+ memBadgeLabel,
+ triggerSessionCount,
+ orphanCount,
+ resourceManagerTooltipLines
+ })}
event.preventDefault()}
>
-
-
-
-
- {translate('auto.components.status.bar.StatusBar.d1e1a7a6bf', 'Resource Manager')}
-
-
-
-
-
-
- daemonActions.setPending('restart')}
- disabled={daemonActions.isBusy}
- aria-label={translate(
- 'auto.components.status.bar.ResourceUsageStatusSegment.c9382662bb',
- 'Restart daemon'
- )}
- className="inline-flex size-6 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
- >
-
-
-
-
- {translate(
- 'auto.components.status.bar.ResourceUsageStatusSegment.c9382662bb',
- 'Restart daemon'
- )}
-
-
-
-
- daemonActions.setPending('killAll')}
- disabled={daemonActions.isBusy}
- aria-label={translate(
- 'auto.components.status.bar.ResourceUsageStatusSegment.bd19fd7a59',
- 'Kill all sessions'
- )}
- className="inline-flex size-6 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive disabled:opacity-40"
- >
-
-
-
-
- {translate(
- 'auto.components.status.bar.ResourceUsageStatusSegment.bd19fd7a59',
- 'Kill all sessions'
- )}
-
-
-
-
-
- {daemonUnreachable && (
-
-
-
-
- {translate(
- 'auto.components.status.bar.ResourceUsageStatusSegment.f8e0d794b4',
- 'Daemon is not responding'
- )}
-
-
- {translate(
- 'auto.components.status.bar.ResourceUsageStatusSegment.f85af9cda6',
- 'Resource snapshots and terminal sessions are unavailable.'
- )}
-
-
-
daemonActions.setPending('restart')}
- disabled={daemonActions.isBusy}
- >
-
- {translate(
- 'auto.components.status.bar.ResourceUsageStatusSegment.93b0de3c21',
- 'Restart'
- )}
-
-
- )}
-
- {!daemonUnreachable && sessionsOnlyError && (
-
-
-
- {translate(
- 'auto.components.status.bar.ResourceUsageStatusSegment.e7cf14ec78',
- 'Terminal sessions unavailable. The list may be stale.'
- )}
-
-
- )}
-
- {resourceSnapshot && (
-
-
-
-
-
- {formatCpu(totalCpu)}
-
-
-
- {translate(
- 'auto.components.status.bar.ResourceUsageStatusSegment.1fedf94eae',
- 'Combined CPU load. Values above 100% mean more than one core is working at once.'
- )}
-
-
- ·
-
-
-
- {formatMemory(totalMemory)}{' '}
-
- {memoryMetricCopy.summaryLabel}
-
-
-
-
- {memoryMetricCopy.description}
-
-
- {commitBadgeLabel && commitMetricCopy && (
- <>
- ·
-
-
-
- {commitBadgeLabel}{' '}
-
- {commitMetricCopy.summaryLabel}
-
-
-
-
- {commitMetricCopy.description}
-
-
- >
- )}
-
- {orphanCount > 0 && (
-
- {orphanCount === 1
- ? translate(
- 'auto.components.status.bar.ResourceUsageStatusSegment.30ff2c3c31',
- '{{value0}} orphan',
- { value0: orphanCount }
- )
- : translate(
- 'auto.components.status.bar.ResourceUsageStatusSegment.b8f4a2c1d0e3',
- '{{value0}} orphans',
- { value0: orphanCount }
- )}
-
- )}
-
- )}
-
+ {renderResourceUsagePopoverHeader({ daemonActions })}
+ {daemonUnreachable && renderDaemonUnreachableBanner({ daemonActions })}
+ {!daemonUnreachable && sessionsOnlyError && renderSessionsOnlyErrorBanner()}
+ {resourceSnapshot &&
+ renderResourceUsageSummary({
+ totalCpu,
+ totalMemory,
+ memoryMetricCopy,
+ commitBadgeLabel,
+ commitMetricCopy,
+ commitToneClass,
+ orphanCount
+ })}
{/* Why: fixed 420px height so the popover doesn't jump as worktrees expand/collapse or sessions change; inner tree owns its scroll. */}
-
- {(unifiedRepos.length > 0 || resourceSnapshot) && (
-
-
setSortOption('name')}
- className={cn(
- 'hover:text-foreground transition-colors',
- sortOption === 'name'
- ? 'font-semibold text-foreground'
- : 'text-muted-foreground/80'
- )}
- aria-pressed={sortOption === 'name'}
- >
- {translate(
- 'auto.components.status.bar.ResourceUsageStatusSegment.2aa2de6cb9',
- 'Name'
- )}
-
-
-
- setSortOption('cpu')}
- className={cn(
- CPU_COLUMN_CLS,
- 'hover:text-foreground transition-colors',
- sortOption === 'cpu'
- ? 'font-semibold text-foreground'
- : 'text-muted-foreground/80'
- )}
- aria-pressed={sortOption === 'cpu'}
- >
- {translate(
- 'auto.components.status.bar.ResourceUsageStatusSegment.298f4be7f2',
- 'CPU'
- )}
-
- setSortOption('memory')}
- className={cn(
- MEM_COLUMN_CLS,
- 'hover:text-foreground transition-colors',
- sortOption === 'memory'
- ? 'font-semibold text-foreground'
- : 'text-muted-foreground/80'
- )}
- aria-pressed={sortOption === 'memory'}
- >
- {memoryMetricCopy.columnLabel}
-
-
- {/* Why: empty trailing gutter keeps CPU/Memory header cells aligned with rows that reserve this width for the kill-X. */}
-
-
-
- )}
-
-
- {unifiedRepos.length > 0 && (
-
- )}
-
- {unifiedRepos.length === 0 && resourceSnapshot && (
-
- {translate(
- 'auto.components.status.bar.ResourceUsageStatusSegment.27a74f91f0',
- 'Nothing running right now'
- )}
-
- )}
-
- {resourceSnapshot && (
-
setAppCollapsed((v) => !v)}
- />
- )}
-
- {!resourceSnapshot && !daemonUnreachable && (
-
- {translate(
- 'auto.components.status.bar.ResourceUsageStatusSegment.888dad8c55',
- 'Loading…'
- )}
-
- )}
-
-
-
-
-
-
- {translate(
- 'auto.components.status.bar.ResourceUsageStatusSegment.92924a14e3',
- 'Clean up workspaces'
- )}
-
-
-
- {orphanCount > 0 ? (
- void handleKillOrphans()}
- className="mt-2 inline-flex w-full items-center justify-center rounded-md border border-border/70 px-2.5 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-accent/60"
- >
- {orphanCount === 1
- ? translate(
- 'auto.components.status.bar.ResourceUsageStatusSegment.c7e3b1a0d9f2',
- 'End {{value0}} orphan terminal',
- { value0: orphanCount }
- )
- : translate(
- 'auto.components.status.bar.ResourceUsageStatusSegment.d8f4c2b1e0a3',
- 'End {{value0}} orphan terminals',
- { value0: orphanCount }
- )}
-
- ) : null}
-
-
+ {renderResourceUsagePopoverBody({
+ setPopoverBodyNode,
+ unifiedRepos,
+ resourceSnapshot,
+ sortOption,
+ setSortOption,
+ memoryMetricCopy,
+ collapsedRepos,
+ toggleRepo,
+ collapsedWorktrees,
+ activeWorktreeId,
+ toggleWorktree,
+ navigateToWorktree,
+ navigateToTab,
+ deleteWorktree,
+ handleKillSession,
+ appCollapsed,
+ setAppCollapsed,
+ daemonUnreachable
+ })}
+ {renderResourceUsagePopoverFooter({
+ handleOpenWorkspaceCleanup,
+ orphanCount,
+ handleKillOrphans
+ })}
{/* Why: hoisted to a sibling of PopoverContent — nested, the Dialog unmounts with the popover mid-interaction and the kill-confirm flow disappears. */}
- {
- if (next) {
- return
- }
- if (killing) {
- return
- }
- setKillConfirm(null)
- }}
- >
- {
- if (killing) {
- e.preventDefault()
- }
- }}
- onEscapeKeyDown={(e) => {
- if (killing) {
- e.preventDefault()
- }
- }}
- >
-
-
- {translate(
- 'auto.components.status.bar.ResourceUsageStatusSegment.e9a5d3c2b1f0',
- 'Kill {{value0}}?',
- {
- value0:
- killConfirm?.label ??
- translate(
- 'auto.components.status.bar.ResourceUsageStatusSegment.138b99bd80',
- 'this session'
- )
- }
- )}
-
-
- {translate(
- 'auto.components.status.bar.ResourceUsageStatusSegment.67c4ecda49',
- "Force-quits this terminal. Any unsaved work in the pane is lost. This can't be undone."
- )}
-
-
-
- setKillConfirm(null)} disabled={killing}>
- {translate(
- 'auto.components.status.bar.ResourceUsageStatusSegment.946d9f94d0',
- 'Cancel'
- )}
-
- void runKillConfirmed()}
- disabled={killing}
- >
- {killing ? : null}
- {killing
- ? translate(
- 'auto.components.status.bar.ResourceUsageStatusSegment.41ae4fa725',
- 'Killing…'
- )
- : translate(
- 'auto.components.status.bar.ResourceUsageStatusSegment.b10695d6ce',
- 'Kill session'
- )}
-
-
-
-
+ {renderResourceUsageKillDialog({
+ killConfirm,
+ setKillConfirm,
+ killing,
+ runKillConfirmed
+ })}
)
diff --git a/src/renderer/src/components/status-bar/resource-session-classification-parity.test.ts b/src/renderer/src/components/status-bar/resource-session-classification-parity.test.ts
index 723dc4e81a8..fafe3cc155b 100644
--- a/src/renderer/src/components/status-bar/resource-session-classification-parity.test.ts
+++ b/src/renderer/src/components/status-bar/resource-session-classification-parity.test.ts
@@ -2,7 +2,8 @@ import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
-const SEGMENT_PATH = resolve(__dirname, 'ResourceUsageStatusSegment.tsx')
+const CONTROLLER_PATH = resolve(__dirname, 'use-resource-usage-status-controller.ts')
+const DERIVED_MODEL_PATH = resolve(__dirname, 'use-resource-usage-derived-model.ts')
/**
* Both destructive paths — bulk "kill orphans" and a single row's kill — must classify from the
@@ -12,7 +13,7 @@ const SEGMENT_PATH = resolve(__dirname, 'ResourceUsageStatusSegment.tsx')
*/
describe('resource session classification parity', () => {
it('feeds the row merge the same binding inputs as the bulk selector', () => {
- const source = readFileSync(SEGMENT_PATH, 'utf8')
+ const source = readFileSync(DERIVED_MODEL_PATH, 'utf8')
const mergeCall = source.slice(
source.indexOf('mergeSnapshotAndSessions(resourceSnapshot'),
source.indexOf('worktreeById\n })')
@@ -32,7 +33,7 @@ describe('resource session classification parity', () => {
})
it('keeps every binding source in the one object both paths read', () => {
- const source = readFileSync(SEGMENT_PATH, 'utf8')
+ const source = readFileSync(CONTROLLER_PATH, 'utf8')
const bindings = source.slice(
source.indexOf('const resourceSessionBindings = useMemo'),
source.indexOf('const popoverBodyRef')
diff --git a/src/renderer/src/components/status-bar/resource-usage-kill-dialog.tsx b/src/renderer/src/components/status-bar/resource-usage-kill-dialog.tsx
new file mode 100644
index 00000000000..58ec6707aa0
--- /dev/null
+++ b/src/renderer/src/components/status-bar/resource-usage-kill-dialog.tsx
@@ -0,0 +1,98 @@
+import React, { type Dispatch, type SetStateAction } from 'react'
+import { LoaderCircle } from 'lucide-react'
+import { Button } from '@/components/ui/button'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle
+} from '@/components/ui/dialog'
+import { translate } from '@/i18n/i18n'
+import type { UnifiedSessionRow } from './resource-usage-merge-types'
+
+export function renderResourceUsageKillDialog({
+ killConfirm,
+ setKillConfirm,
+ killing,
+ runKillConfirmed
+}: {
+ killConfirm: UnifiedSessionRow | null
+ setKillConfirm: Dispatch>
+ killing: boolean
+ runKillConfirmed: () => Promise
+}): React.JSX.Element {
+ return (
+ {
+ if (next) {
+ return
+ }
+ if (killing) {
+ return
+ }
+ setKillConfirm(null)
+ }}
+ >
+ {
+ if (killing) {
+ e.preventDefault()
+ }
+ }}
+ onEscapeKeyDown={(e) => {
+ if (killing) {
+ e.preventDefault()
+ }
+ }}
+ >
+
+
+ {translate(
+ 'auto.components.status.bar.ResourceUsageStatusSegment.e9a5d3c2b1f0',
+ 'Kill {{value0}}?',
+ {
+ value0:
+ killConfirm?.label ??
+ translate(
+ 'auto.components.status.bar.ResourceUsageStatusSegment.138b99bd80',
+ 'this session'
+ )
+ }
+ )}
+
+
+ {translate(
+ 'auto.components.status.bar.ResourceUsageStatusSegment.67c4ecda49',
+ "Force-quits this terminal. Any unsaved work in the pane is lost. This can't be undone."
+ )}
+
+
+
+ setKillConfirm(null)} disabled={killing}>
+ {translate(
+ 'auto.components.status.bar.ResourceUsageStatusSegment.946d9f94d0',
+ 'Cancel'
+ )}
+
+ void runKillConfirmed()} disabled={killing}>
+ {killing ? : null}
+ {killing
+ ? translate(
+ 'auto.components.status.bar.ResourceUsageStatusSegment.41ae4fa725',
+ 'Killing…'
+ )
+ : translate(
+ 'auto.components.status.bar.ResourceUsageStatusSegment.b10695d6ce',
+ 'Kill session'
+ )}
+
+
+
+
+ )
+}
diff --git a/src/renderer/src/components/status-bar/resource-usage-metrics.tsx b/src/renderer/src/components/status-bar/resource-usage-metrics.tsx
new file mode 100644
index 00000000000..b0309d5f917
--- /dev/null
+++ b/src/renderer/src/components/status-bar/resource-usage-metrics.tsx
@@ -0,0 +1,230 @@
+import React, { memo, useMemo } from 'react'
+import { ChevronDown, ChevronRight } from 'lucide-react'
+import { cn } from '@/lib/utils'
+import { translate } from '@/i18n/i18n'
+import type { AppMemory, UsageValues } from '../../../../shared/process-stats-types'
+import type { Metric } from './resource-usage-merge-types'
+
+export const METRIC_COLUMNS_CLS = 'flex items-center shrink-0 tabular-nums'
+export const CPU_COLUMN_CLS = 'w-12 text-right'
+export const MEM_COLUMN_CLS = 'w-16 text-right'
+// Why: every row and the header reserve this trailing gutter so CPU/Memory columns align whether or not the row has a kill-X.
+export const ROW_TRAILING_GUTTER_CLS = 'w-5 shrink-0 flex items-center justify-end'
+
+// ─── Formatters ─────────────────────────────────────────────────────
+
+export function formatMemory(bytes: number): string {
+ if (bytes < 1024 * 1024) {
+ return `${Math.round(bytes / 1024)} KB`
+ }
+ if (bytes < 1024 * 1024 * 1024) {
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
+ }
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`
+}
+
+export function formatCpu(percent: number): string {
+ return `${percent.toFixed(1)}%`
+}
+
+function formatMetricCpu(value: Metric): string {
+ return value === null ? '—' : formatCpu(value)
+}
+
+function formatMetricMemory(value: Metric): string {
+ return value === null ? '—' : formatMemory(value)
+}
+
+// ─── Sparkline ──────────────────────────────────────────────────────
+
+type SparklineProps = {
+ samples: number[]
+ width?: number
+ height?: number
+}
+
+function SparklineImpl({ samples, width = 48, height = 14 }: SparklineProps): React.JSX.Element {
+ const points = useMemo(() => {
+ const safe = Array.isArray(samples) ? samples : []
+ if (safe.length < 2) {
+ const midY = (height / 2).toFixed(1)
+ return `0,${midY} ${width},${midY}`
+ }
+
+ let min = safe[0]
+ let max = safe[0]
+ for (const v of safe) {
+ if (v < min) {
+ min = v
+ }
+ if (v > max) {
+ max = v
+ }
+ }
+ const range = max - min || 1
+ const stepX = width / (safe.length - 1)
+
+ const out: string[] = []
+ for (let i = 0; i < safe.length; i++) {
+ const x = (i * stepX).toFixed(1)
+ const y = (height - ((safe[i] - min) / range) * height).toFixed(1)
+ out.push(`${x},${y}`)
+ }
+ return out.join(' ')
+ }, [samples, width, height])
+
+ return (
+
+
+
+ )
+}
+
+export const Sparkline = memo(SparklineImpl, (a, b) => {
+ if (a.width !== b.width || a.height !== b.height) {
+ return false
+ }
+ const sa = Array.isArray(a.samples) ? a.samples : []
+ const sb = Array.isArray(b.samples) ? b.samples : []
+ if (sa === sb) {
+ return true
+ }
+ if (sa.length !== sb.length) {
+ return false
+ }
+ for (let i = 0; i < sa.length; i++) {
+ if (sa[i] !== sb[i]) {
+ return false
+ }
+ }
+ return true
+})
+
+// ─── Leaf UI: metric row ────────────────────────────────────────────
+
+export function MetricPair({
+ cpu,
+ memory,
+ size = 'base'
+}: {
+ cpu: Metric
+ memory: Metric
+ size?: 'base' | 'small'
+}): React.JSX.Element {
+ const textCls = size === 'small' ? 'text-[11px]' : 'text-xs'
+ const muted = cpu === null && memory === null
+ return (
+
+ {formatMetricCpu(cpu)}
+ {formatMetricMemory(memory)}
+
+ )
+}
+
+function AppSubRow({ label, values }: { label: string; values: UsageValues }): React.JSX.Element {
+ return (
+
+ )
+}
+
+export function AppSection({
+ app,
+ isCollapsed,
+ onToggle
+}: {
+ app: AppMemory
+ isCollapsed: boolean
+ onToggle: () => void
+}): React.JSX.Element {
+ return (
+
+
+
+ {isCollapsed ? (
+
+ ) : (
+
+ )}
+
+
+
+ {translate('auto.components.status.bar.ResourceUsageStatusSegment.288a4dd177', 'Orca')}
+
+
+
+
+
+
+
+
+ {!isCollapsed && (
+
+
+
+ {(app.other.cpu > 0 || app.other.memory > 0) && (
+
+ )}
+
+ )}
+
+ )
+}
diff --git a/src/renderer/src/components/status-bar/resource-usage-popover-body.tsx b/src/renderer/src/components/status-bar/resource-usage-popover-body.tsx
new file mode 100644
index 00000000000..9bc3b81611d
--- /dev/null
+++ b/src/renderer/src/components/status-bar/resource-usage-popover-body.tsx
@@ -0,0 +1,204 @@
+import React, { type Dispatch, type SetStateAction } from 'react'
+import { ChevronRight } from 'lucide-react'
+import { cn } from '@/lib/utils'
+import { translate } from '@/i18n/i18n'
+import type { MemorySnapshot } from '../../../../shared/process-stats-types'
+import type { UnifiedProjectGroup, UnifiedSessionRow } from './resource-usage-merge-types'
+import type { SortOption } from './resource-usage-resource-tree'
+import { ResourceTree } from './resource-usage-resource-tree'
+import {
+ AppSection,
+ CPU_COLUMN_CLS,
+ MEM_COLUMN_CLS,
+ METRIC_COLUMNS_CLS,
+ ROW_TRAILING_GUTTER_CLS
+} from './resource-usage-metrics'
+import type { getResourceMemoryMetricCopy } from './resource-memory-metric-copy'
+
+export function renderResourceUsagePopoverBody({
+ setPopoverBodyNode,
+ unifiedRepos,
+ resourceSnapshot,
+ sortOption,
+ setSortOption,
+ memoryMetricCopy,
+ collapsedRepos,
+ toggleRepo,
+ collapsedWorktrees,
+ activeWorktreeId,
+ toggleWorktree,
+ navigateToWorktree,
+ navigateToTab,
+ deleteWorktree,
+ handleKillSession,
+ appCollapsed,
+ setAppCollapsed,
+ daemonUnreachable
+}: {
+ setPopoverBodyNode: (node: HTMLDivElement | null) => void
+ unifiedRepos: UnifiedProjectGroup[]
+ resourceSnapshot: MemorySnapshot | null
+ sortOption: SortOption
+ setSortOption: Dispatch>
+ memoryMetricCopy: ReturnType
+ collapsedRepos: Set
+ toggleRepo: (repoId: string) => void
+ collapsedWorktrees: Set
+ activeWorktreeId: string | null
+ toggleWorktree: (worktreeId: string) => void
+ navigateToWorktree: (worktreeId: string) => void
+ navigateToTab: (tabId: string, paneKey: string | null) => void
+ deleteWorktree: (worktreeId: string) => void
+ handleKillSession: (session: UnifiedSessionRow) => void
+ appCollapsed: boolean
+ setAppCollapsed: Dispatch>
+ daemonUnreachable: boolean
+}): React.JSX.Element {
+ return (
+
+ {(unifiedRepos.length > 0 || resourceSnapshot) && (
+
+
setSortOption('name')}
+ className={cn(
+ 'hover:text-foreground transition-colors',
+ sortOption === 'name' ? 'font-semibold text-foreground' : 'text-muted-foreground/80'
+ )}
+ aria-pressed={sortOption === 'name'}
+ >
+ {translate('auto.components.status.bar.ResourceUsageStatusSegment.2aa2de6cb9', 'Name')}
+
+
+
+ setSortOption('cpu')}
+ className={cn(
+ CPU_COLUMN_CLS,
+ 'hover:text-foreground transition-colors',
+ sortOption === 'cpu'
+ ? 'font-semibold text-foreground'
+ : 'text-muted-foreground/80'
+ )}
+ aria-pressed={sortOption === 'cpu'}
+ >
+ {translate(
+ 'auto.components.status.bar.ResourceUsageStatusSegment.298f4be7f2',
+ 'CPU'
+ )}
+
+ setSortOption('memory')}
+ className={cn(
+ MEM_COLUMN_CLS,
+ 'hover:text-foreground transition-colors',
+ sortOption === 'memory'
+ ? 'font-semibold text-foreground'
+ : 'text-muted-foreground/80'
+ )}
+ aria-pressed={sortOption === 'memory'}
+ >
+ {memoryMetricCopy.columnLabel}
+
+
+ {/* Why: empty trailing gutter keeps CPU/Memory header cells aligned with rows that reserve this width for the kill-X. */}
+
+
+
+ )}
+
+
+ {unifiedRepos.length > 0 && (
+
+ )}
+
+ {unifiedRepos.length === 0 && resourceSnapshot && (
+
+ {translate(
+ 'auto.components.status.bar.ResourceUsageStatusSegment.27a74f91f0',
+ 'Nothing running right now'
+ )}
+
+ )}
+
+ {resourceSnapshot && (
+
setAppCollapsed((v) => !v)}
+ />
+ )}
+
+ {!resourceSnapshot && !daemonUnreachable && (
+
+ {translate(
+ 'auto.components.status.bar.ResourceUsageStatusSegment.888dad8c55',
+ 'Loading…'
+ )}
+
+ )}
+
+
+ )
+}
+
+export function renderResourceUsagePopoverFooter({
+ handleOpenWorkspaceCleanup,
+ orphanCount,
+ handleKillOrphans
+}: {
+ handleOpenWorkspaceCleanup: () => void
+ orphanCount: number
+ handleKillOrphans: () => Promise
+}): React.JSX.Element {
+ return (
+
+
+
+ {translate(
+ 'auto.components.status.bar.ResourceUsageStatusSegment.92924a14e3',
+ 'Clean up workspaces'
+ )}
+
+
+
+ {orphanCount > 0 ? (
+ void handleKillOrphans()}
+ className="mt-2 inline-flex w-full items-center justify-center rounded-md border border-border/70 px-2.5 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-accent/60"
+ >
+ {orphanCount === 1
+ ? translate(
+ 'auto.components.status.bar.ResourceUsageStatusSegment.c7e3b1a0d9f2',
+ 'End {{value0}} orphan terminal',
+ { value0: orphanCount }
+ )
+ : translate(
+ 'auto.components.status.bar.ResourceUsageStatusSegment.d8f4c2b1e0a3',
+ 'End {{value0}} orphan terminals',
+ { value0: orphanCount }
+ )}
+
+ ) : null}
+
+ )
+}
diff --git a/src/renderer/src/components/status-bar/resource-usage-popover-summary.tsx b/src/renderer/src/components/status-bar/resource-usage-popover-summary.tsx
new file mode 100644
index 00000000000..6901ae19a0f
--- /dev/null
+++ b/src/renderer/src/components/status-bar/resource-usage-popover-summary.tsx
@@ -0,0 +1,226 @@
+import React from 'react'
+import { AlertTriangle, MemoryStick, RotateCw, Trash2 } from 'lucide-react'
+import { Button } from '@/components/ui/button'
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
+import { cn } from '@/lib/utils'
+import { translate } from '@/i18n/i18n'
+import type { DaemonActionsApi } from '../shared/useDaemonActions'
+import type {
+ getResourceCommitMetricCopy,
+ getResourceMemoryMetricCopy
+} from './resource-memory-metric-copy'
+import { formatCpu, formatMemory } from './resource-usage-metrics'
+
+export function renderResourceUsagePopoverHeader({
+ daemonActions
+}: {
+ daemonActions: DaemonActionsApi
+}): React.JSX.Element {
+ return (
+
+
+
+
+ {translate('auto.components.status.bar.StatusBar.d1e1a7a6bf', 'Resource Manager')}
+
+
+
+
+
+
+ daemonActions.setPending('restart')}
+ disabled={daemonActions.isBusy}
+ aria-label={translate(
+ 'auto.components.status.bar.ResourceUsageStatusSegment.c9382662bb',
+ 'Restart daemon'
+ )}
+ className="inline-flex size-6 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
+ >
+
+
+
+
+ {translate(
+ 'auto.components.status.bar.ResourceUsageStatusSegment.c9382662bb',
+ 'Restart daemon'
+ )}
+
+
+
+
+ daemonActions.setPending('killAll')}
+ disabled={daemonActions.isBusy}
+ aria-label={translate(
+ 'auto.components.status.bar.ResourceUsageStatusSegment.bd19fd7a59',
+ 'Kill all sessions'
+ )}
+ className="inline-flex size-6 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive disabled:opacity-40"
+ >
+
+
+
+
+ {translate(
+ 'auto.components.status.bar.ResourceUsageStatusSegment.bd19fd7a59',
+ 'Kill all sessions'
+ )}
+
+
+
+
+ )
+}
+
+export function renderDaemonUnreachableBanner({
+ daemonActions
+}: {
+ daemonActions: DaemonActionsApi
+}): React.JSX.Element {
+ return (
+
+
+
+
+ {translate(
+ 'auto.components.status.bar.ResourceUsageStatusSegment.f8e0d794b4',
+ 'Daemon is not responding'
+ )}
+
+
+ {translate(
+ 'auto.components.status.bar.ResourceUsageStatusSegment.f85af9cda6',
+ 'Resource snapshots and terminal sessions are unavailable.'
+ )}
+
+
+
daemonActions.setPending('restart')}
+ disabled={daemonActions.isBusy}
+ >
+
+ {translate('auto.components.status.bar.ResourceUsageStatusSegment.93b0de3c21', 'Restart')}
+
+
+ )
+}
+
+export function renderSessionsOnlyErrorBanner(): React.JSX.Element {
+ return (
+
+
+
+ {translate(
+ 'auto.components.status.bar.ResourceUsageStatusSegment.e7cf14ec78',
+ 'Terminal sessions unavailable. The list may be stale.'
+ )}
+
+
+ )
+}
+
+export function renderResourceUsageSummary({
+ totalCpu,
+ totalMemory,
+ memoryMetricCopy,
+ commitBadgeLabel,
+ commitMetricCopy,
+ commitToneClass,
+ orphanCount
+}: {
+ totalCpu: number
+ totalMemory: number
+ memoryMetricCopy: ReturnType
+ commitBadgeLabel: string | null
+ commitMetricCopy: ReturnType | null
+ commitToneClass: string | null
+ orphanCount: number
+}): React.JSX.Element {
+ return (
+
+
+
+
+
+ {formatCpu(totalCpu)}
+
+
+
+ {translate(
+ 'auto.components.status.bar.ResourceUsageStatusSegment.1fedf94eae',
+ 'Combined CPU load. Values above 100% mean more than one core is working at once.'
+ )}
+
+
+ ·
+
+
+
+ {formatMemory(totalMemory)}{' '}
+
+ {memoryMetricCopy.summaryLabel}
+
+
+
+
+ {memoryMetricCopy.description}
+
+
+ {commitBadgeLabel && commitMetricCopy && (
+ <>
+ ·
+
+
+
+ {commitBadgeLabel}{' '}
+
+ {commitMetricCopy.summaryLabel}
+
+
+
+
+ {commitMetricCopy.description}
+
+
+ >
+ )}
+
+ {orphanCount > 0 && (
+
+ {orphanCount === 1
+ ? translate(
+ 'auto.components.status.bar.ResourceUsageStatusSegment.30ff2c3c31',
+ '{{value0}} orphan',
+ { value0: orphanCount }
+ )
+ : translate(
+ 'auto.components.status.bar.ResourceUsageStatusSegment.b8f4a2c1d0e3',
+ '{{value0}} orphans',
+ { value0: orphanCount }
+ )}
+
+ )}
+
+ )
+}
diff --git a/src/renderer/src/components/status-bar/resource-usage-resource-tree.tsx b/src/renderer/src/components/status-bar/resource-usage-resource-tree.tsx
new file mode 100644
index 00000000000..4f4c57cd07b
--- /dev/null
+++ b/src/renderer/src/components/status-bar/resource-usage-resource-tree.tsx
@@ -0,0 +1,173 @@
+import React, { useMemo } from 'react'
+import { ChevronDown, ChevronRight } from 'lucide-react'
+import { translate } from '@/i18n/i18n'
+import { useWorktreeMap } from '../../store/selectors'
+import type {
+ Metric,
+ UnifiedProjectGroup,
+ UnifiedSessionRow,
+ UnifiedWorktreeRow
+} from './resource-usage-merge-types'
+import { MetricPair, ROW_TRAILING_GUTTER_CLS } from './resource-usage-metrics'
+import { WorktreeRow } from './resource-usage-session-rows'
+
+export type SortOption = 'memory' | 'cpu' | 'name'
+
+// ─── Sorting ────────────────────────────────────────────────────────
+
+function compareMetricDesc(a: Metric, b: Metric): number {
+ // Why: null metrics (remote rows) sort last regardless of direction so they don't pollute the "biggest consumers" view.
+ if (a === null && b === null) {
+ return 0
+ }
+ if (a === null) {
+ return 1
+ }
+ if (b === null) {
+ return -1
+ }
+ return b - a
+}
+
+function sortWorktrees(list: UnifiedWorktreeRow[], sort: SortOption): UnifiedWorktreeRow[] {
+ const copy = [...list]
+ if (sort === 'memory') {
+ copy.sort((a, b) => compareMetricDesc(a.memory, b.memory))
+ } else if (sort === 'cpu') {
+ copy.sort((a, b) => compareMetricDesc(a.cpu, b.cpu))
+ } else {
+ copy.sort((a, b) => a.worktreeName.localeCompare(b.worktreeName))
+ }
+ return copy
+}
+
+function sortProjectGroups(groups: UnifiedProjectGroup[], sort: SortOption): UnifiedProjectGroup[] {
+ const copy = [...groups]
+ if (sort === 'memory') {
+ copy.sort((a, b) => compareMetricDesc(a.memory, b.memory))
+ } else if (sort === 'cpu') {
+ copy.sort((a, b) => compareMetricDesc(a.cpu, b.cpu))
+ } else {
+ copy.sort((a, b) => a.repoName.localeCompare(b.repoName))
+ }
+ return copy
+}
+
+// ─── Repo + worktree tree ───────────────────────────────────────────
+
+export function ResourceTree({
+ repos,
+ sortOption,
+ collapsedRepos,
+ toggleRepo,
+ collapsedWorktrees,
+ activeWorktreeId,
+ toggleWorktree,
+ navigateToWorktree,
+ navigateToTab,
+ onDelete,
+ onKillSession
+}: {
+ repos: UnifiedProjectGroup[]
+ sortOption: SortOption
+ collapsedRepos: Set
+ toggleRepo: (repoId: string) => void
+ collapsedWorktrees: Set
+ activeWorktreeId: string | null
+ toggleWorktree: (worktreeId: string) => void
+ navigateToWorktree: (worktreeId: string) => void
+ navigateToTab: (tabId: string, paneKey: string | null) => void
+ onDelete: (worktreeId: string) => void
+ onKillSession: (session: UnifiedSessionRow) => void
+}): React.JSX.Element {
+ const worktreeById = useWorktreeMap()
+
+ const sortedRepos = useMemo(() => {
+ const grouped = sortProjectGroups(repos, sortOption)
+ return grouped.map((repo) => ({
+ ...repo,
+ worktrees: sortWorktrees(repo.worktrees, sortOption)
+ }))
+ }, [repos, sortOption])
+
+ const renderWorktree = (wt: UnifiedWorktreeRow): React.JSX.Element => {
+ const storeRecord = worktreeById.get(wt.worktreeId) ?? null
+ return (
+ toggleWorktree(wt.worktreeId)}
+ onNavigate={() => navigateToWorktree(wt.worktreeId)}
+ onDelete={() => onDelete(wt.worktreeId)}
+ onKillSession={onKillSession}
+ navigateToTab={navigateToTab}
+ />
+ )
+ }
+
+ if (sortedRepos.length === 1) {
+ return <>{sortedRepos[0].worktrees.map(renderWorktree)}>
+ }
+
+ return (
+ <>
+ {sortedRepos.map((group) => {
+ const repoCollapsed = collapsedRepos.has(group.repoId)
+ return (
+
+
+
toggleRepo(group.repoId)}
+ className="pl-2 py-2 pr-0.5 transition-colors hover:bg-muted/50"
+ aria-label={
+ repoCollapsed
+ ? translate(
+ 'auto.components.status.bar.ResourceUsageStatusSegment.b12e31dfcb',
+ 'Expand repo'
+ )
+ : translate(
+ 'auto.components.status.bar.ResourceUsageStatusSegment.73a3fd68a9',
+ 'Collapse repo'
+ )
+ }
+ >
+ {repoCollapsed ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {group.repoName}
+
+ {group.hasRemoteChildren && (
+
+ {translate(
+ 'auto.components.status.bar.ResourceUsageStatusSegment.21cacb16d1',
+ '· remote'
+ )}
+
+ )}
+
+
+
+
+
+
+
+
+ {!repoCollapsed && (
+
{group.worktrees.map(renderWorktree)}
+ )}
+
+ )
+ })}
+ >
+ )
+}
diff --git a/src/renderer/src/components/status-bar/resource-usage-session-rows.tsx b/src/renderer/src/components/status-bar/resource-usage-session-rows.tsx
new file mode 100644
index 00000000000..3efa332c294
--- /dev/null
+++ b/src/renderer/src/components/status-bar/resource-usage-session-rows.tsx
@@ -0,0 +1,266 @@
+import React from 'react'
+import { ChevronDown, ChevronRight, Globe, Trash2, X } from 'lucide-react'
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
+import { cn } from '@/lib/utils'
+import { translate } from '@/i18n/i18n'
+import type { BrowserWorkspace } from '../../../../shared/browser-workspace-types'
+import { ORPHAN_WORKTREE_ID } from '../../../../shared/constants'
+import type { Worktree } from '../../../../shared/worktree/types'
+import { UNATTRIBUTED_REPO_ID } from './mergeSnapshotAndSessions'
+import type { UnifiedSessionRow, UnifiedWorktreeRow } from './resource-usage-merge-types'
+import { MetricPair, ROW_TRAILING_GUTTER_CLS, Sparkline } from './resource-usage-metrics'
+import { isResourceSessionActivationKey } from './resource-session-navigation'
+
+// ─── Session row ────────────────────────────────────────────────────
+
+// Exported (with WorktreeRow) for row-level regression tests pinning the kill affordance and remote-chip presentation.
+export function SessionRow({
+ session,
+ worktreeId,
+ onNavigate,
+ onKill
+}: {
+ session: UnifiedSessionRow
+ worktreeId: string
+ onNavigate: (tabId: string, paneKey: string | null) => void
+ onKill: (session: UnifiedSessionRow) => void
+}): React.JSX.Element {
+ const clickable = session.tabId !== null && session.bound
+ const handleClick = (): void => {
+ if (clickable && session.tabId) {
+ onNavigate(session.tabId, session.paneKey)
+ }
+ }
+
+ return (
+ {
+ if (isResourceSessionActivationKey(e.key)) {
+ e.preventDefault()
+ handleClick()
+ }
+ }
+ : undefined
+ }
+ data-worktree-id={worktreeId}
+ >
+
+
+ {session.label}
+
+
+ {/* Why: kill X sits in the shared gutter for column alignment; bound rows reveal it on hover/focus, orphan rows always show it as reclaimable. */}
+
+ {
+ e.stopPropagation()
+ onKill(session)
+ }}
+ className={cn(
+ 'rounded p-0.5 text-muted-foreground transition-opacity hover:bg-destructive/10 hover:text-destructive',
+ session.bound &&
+ 'can-hover:opacity-0 group-hover/sessrow:opacity-100 group-focus-within/sessrow:opacity-100 focus-visible:opacity-100'
+ )}
+ aria-label={translate(
+ 'auto.components.status.bar.ResourceUsageStatusSegment.fa6d36758d',
+ 'Kill session {{value0}}',
+ { value0: session.sessionId }
+ )}
+ >
+
+
+
+
+ )
+}
+
+function BrowserRow({ browser }: { browser: BrowserWorkspace }): React.JSX.Element {
+ const label = browser.title?.trim() || browser.label?.trim() || browser.url
+ return (
+
+
+ {label}
+
+
+
+ )
+}
+
+// ─── Worktree row ───────────────────────────────────────────────────
+
+export function WorktreeRow({
+ worktree,
+ storeRecord,
+ activeWorktreeId,
+ isCollapsed,
+ onToggle,
+ onNavigate,
+ onDelete,
+ onKillSession,
+ navigateToTab
+}: {
+ worktree: UnifiedWorktreeRow
+ storeRecord: Worktree | null
+ activeWorktreeId: string | null
+ isCollapsed: boolean
+ onToggle: () => void
+ onNavigate: () => void
+ onDelete: () => void
+ onKillSession: (session: UnifiedSessionRow) => void
+ navigateToTab: (tabId: string, paneKey: string | null) => void
+}): React.JSX.Element {
+ const hasResources = worktree.sessions.length > 0 || worktree.browsers.length > 0
+ // Why: synthetic buckets (orphan/unattributed) have no sidebar target to reveal; real and SSH-resolved worktrees stay navigable.
+ const isSynthetic =
+ worktree.worktreeId === ORPHAN_WORKTREE_ID || worktree.repoId === UNATTRIBUTED_REPO_ID
+ const isNavigable = !isSynthetic
+ // Why: Delete needs a sidebar worktree record; hidden for synthetic/SSH-only rows and the active worktree, but the row stays navigable.
+ const showWorktreeActions =
+ !isSynthetic && storeRecord !== null && worktree.worktreeId !== activeWorktreeId
+ const isMainWorktree = storeRecord?.isMainWorktree ?? false
+ const rowLabel = storeRecord?.displayName?.trim() || worktree.worktreeName
+
+ return (
+
+
+ {hasResources ? (
+
+ {isCollapsed ? (
+
+ ) : (
+
+ )}
+
+ ) : (
+
+ )}
+
+ {rowLabel}
+ {/* Why: gate the chip on SSH connectionId, not missing data — warm-reattached local PTYs land here with hasLocalSamples=false. */}
+ {worktree.isRemote && (
+
+ {translate(
+ 'auto.components.status.bar.ResourceUsageStatusSegment.21cacb16d1',
+ '· remote'
+ )}
+
+ )}
+
+
+
+ {/* Why: no-hover devices show the action overlay by default, so the sparkline yields there just like on hover. */}
+
+
+
+ {showWorktreeActions && (
+
+
+
+
+
+
+
+
+ {isMainWorktree
+ ? translate(
+ 'auto.components.status.bar.ResourceUsageStatusSegment.946724a70a',
+ 'The main workspace cannot be deleted.'
+ )
+ : translate(
+ 'auto.components.status.bar.ResourceUsageStatusSegment.a82253b458',
+ 'Delete workspace.'
+ )}
+
+
+
+ )}
+
+
+
+
+
+
+ {!isCollapsed &&
+ worktree.sessions.map((session) => (
+
+ ))}
+ {!isCollapsed &&
+ worktree.browsers.map((browser) =>
)}
+
+ )
+}
diff --git a/src/renderer/src/components/status-bar/resource-usage-status-trigger.tsx b/src/renderer/src/components/status-bar/resource-usage-status-trigger.tsx
new file mode 100644
index 00000000000..c5cb328b2ef
--- /dev/null
+++ b/src/renderer/src/components/status-bar/resource-usage-status-trigger.tsx
@@ -0,0 +1,105 @@
+import React from 'react'
+import { AlertTriangle, MemoryStick, Terminal } from 'lucide-react'
+import { PopoverTrigger } from '@/components/ui/popover'
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
+import { cn } from '@/lib/utils'
+import { translate } from '@/i18n/i18n'
+import { STATUS_BAR_CONTEXT_MENU_EXEMPT_PROPS } from './status-bar-context-menu-policy'
+
+export function renderResourceUsageStatusTrigger({
+ daemonUnreachable,
+ resourceManagerAriaLabel,
+ spaceScanReady,
+ iconOnly,
+ commitToneClass,
+ memBadgeLabel,
+ triggerSessionCount,
+ orphanCount,
+ resourceManagerTooltipLines
+}: {
+ daemonUnreachable: boolean
+ resourceManagerAriaLabel: string
+ spaceScanReady: boolean
+ iconOnly: boolean
+ commitToneClass: string | null
+ memBadgeLabel: string
+ triggerSessionCount: number
+ orphanCount: number
+ resourceManagerTooltipLines: { id: string; text: string; emphasized: boolean }[]
+}): React.JSX.Element {
+ return (
+
+
+
+
+ {spaceScanReady ? (
+
+ ) : null}
+
+ {!iconOnly && (
+ <>
+ {/* Tint only: the number stays the resident sum it has always been,
+ and the tooltip names the commit figure that raised the tone. */}
+
+ {memBadgeLabel}
+
+ ·
+
+
+ {triggerSessionCount}
+ {orphanCount > 0 && (
+ ({orphanCount})
+ )}
+
+ >
+ )}
+ {iconOnly && triggerSessionCount > 0 && (
+
+ {triggerSessionCount}
+
+ )}
+ {daemonUnreachable && (
+
+ )}
+
+
+
+
+
+ {resourceManagerTooltipLines.map((line) => (
+
+ {line.text}
+
+ ))}
+
+
+
+ )
+}
diff --git a/src/renderer/src/components/status-bar/use-resource-usage-actions.ts b/src/renderer/src/components/status-bar/use-resource-usage-actions.ts
new file mode 100644
index 00000000000..bbd9223611f
--- /dev/null
+++ b/src/renderer/src/components/status-bar/use-resource-usage-actions.ts
@@ -0,0 +1,231 @@
+import { useCallback, type Dispatch, type MutableRefObject, type SetStateAction } from 'react'
+import { activateAndRevealWorktree } from '@/lib/worktree-activation'
+import { activateTabAndFocusPane } from '@/lib/activate-tab-and-focus-pane'
+import { useAppStore } from '../../store'
+import type { AppState } from '../../store/types'
+import { getAllWorktreesFromState } from '../../store/selectors'
+import { runWorktreeDelete } from '../sidebar/delete-worktree-flow'
+import { ORPHAN_WORKTREE_ID } from '../../../../shared/constants'
+import { UNATTRIBUTED_REPO_ID } from './mergeSnapshotAndSessions'
+import type { DaemonSession, UnifiedSessionRow } from './resource-usage-merge-types'
+import type { ResourceSessionBindingInputs } from './resource-session-bindings'
+import { selectUnboundDaemonSessions } from './resource-session-bindings'
+import { navigateResourceSessionToTab } from './resource-session-navigation'
+import { requiresKillConfirmation } from './resource-session-kill-confirmation'
+import { resolveResourceManagerWorktreeTarget } from './resource-manager-worktree-target'
+
+export function useResourceUsageActions({
+ setCollapsedRepos,
+ setCollapsedWorktrees,
+ tabsByWorktree,
+ setOpen,
+ setActiveView,
+ openModal,
+ openSpacePage,
+ refreshSessions,
+ removeSession,
+ removeSessions,
+ sessions,
+ resourceSessionBindings,
+ workspaceSessionReady,
+ killConfirm,
+ setKillConfirm,
+ setKilling,
+ mountedRef,
+ cancelPopoverBodyFocusFrame,
+ popoverBodyRef,
+ popoverBodyFocusFrameRef
+}: {
+ setCollapsedRepos: Dispatch>>
+ setCollapsedWorktrees: Dispatch>>
+ tabsByWorktree: AppState['tabsByWorktree']
+ setOpen: Dispatch>
+ setActiveView: AppState['setActiveView']
+ openModal: AppState['openModal']
+ openSpacePage: AppState['openSpacePage']
+ refreshSessions: () => Promise
+ removeSession: (sessionId: string) => void
+ removeSessions: (sessionIds: ReadonlySet) => void
+ sessions: readonly DaemonSession[]
+ resourceSessionBindings: ResourceSessionBindingInputs
+ workspaceSessionReady: boolean
+ killConfirm: UnifiedSessionRow | null
+ setKillConfirm: Dispatch>
+ setKilling: Dispatch>
+ mountedRef: MutableRefObject
+ cancelPopoverBodyFocusFrame: () => void
+ popoverBodyRef: MutableRefObject
+ popoverBodyFocusFrameRef: MutableRefObject
+}) {
+ const toggleRepo = useCallback(
+ (repoId: string): void => {
+ setCollapsedRepos((prev) => {
+ const next = new Set(prev)
+ if (next.has(repoId)) {
+ next.delete(repoId)
+ } else {
+ next.add(repoId)
+ }
+ return next
+ })
+ },
+ [setCollapsedRepos]
+ )
+
+ const toggleWorktree = useCallback(
+ (worktreeId: string): void => {
+ setCollapsedWorktrees((prev) => {
+ const next = new Set(prev)
+ if (next.has(worktreeId)) {
+ next.delete(worktreeId)
+ } else {
+ next.add(worktreeId)
+ }
+ return next
+ })
+ },
+ [setCollapsedWorktrees]
+ )
+
+ // Why: keep popover open on worktree navigation so users can browse; onFocusOutside suppresses the bound-row focus transfer.
+ const navigateToWorktree = useCallback((worktreeId: string): void => {
+ if (worktreeId === ORPHAN_WORKTREE_ID || worktreeId.startsWith(`${UNATTRIBUTED_REPO_ID}::`)) {
+ return
+ }
+ const target = resolveResourceManagerWorktreeTarget(
+ worktreeId,
+ getAllWorktreesFromState(useAppStore.getState())
+ )
+ if (!target) {
+ return
+ }
+ activateAndRevealWorktree(worktreeId, { executionHostId: target.hostId })
+ }, [])
+
+ const navigateToTab = useCallback(
+ (tabId: string, paneKey: string | null) => {
+ navigateResourceSessionToTab(tabId, paneKey, {
+ tabsByWorktree,
+ setOpen,
+ setActiveView,
+ activateAndRevealWorktree,
+ activateTabAndFocusPane
+ })
+ },
+ [tabsByWorktree, setOpen, setActiveView]
+ )
+
+ const deleteWorktree = useCallback(
+ (worktreeId: string): void => {
+ const target = resolveResourceManagerWorktreeTarget(
+ worktreeId,
+ getAllWorktreesFromState(useAppStore.getState())
+ )
+ if (!target) {
+ return
+ }
+ setOpen(false)
+ runWorktreeDelete(worktreeId, { expectedHostId: target.hostId })
+ },
+ [setOpen]
+ )
+
+ const handleOpenWorkspaceCleanup = useCallback((): void => {
+ setOpen(false)
+ queueMicrotask(() => openModal('workspace-cleanup'))
+ }, [openModal, setOpen])
+
+ const handleKillSession = useCallback(
+ (session: UnifiedSessionRow): void => {
+ if (!requiresKillConfirmation(session)) {
+ removeSession(session.sessionId)
+ // Why: await the kill before refreshing, else the refresh re-reads the daemon list before the kill lands and re-adds the row.
+ void (async () => {
+ try {
+ await window.api.pty.kill(session.sessionId)
+ } catch {
+ /* already dead */
+ }
+ await refreshSessions()
+ })()
+ return
+ }
+ setKillConfirm(session)
+ },
+ [refreshSessions, removeSession, setKillConfirm]
+ )
+
+ const handleKillOrphans = useCallback(async () => {
+ if (!workspaceSessionReady) {
+ return
+ }
+ // Why the shared selector: the button's count comes from the same function, so the set killed
+ // is exactly the set advertised. Filtering separately here is how live sessions got killed.
+ const orphans = selectUnboundDaemonSessions(sessions, resourceSessionBindings)
+ if (orphans.length === 0) {
+ return
+ }
+ // Why: optimistic removal so rows disappear immediately instead of waiting for the next daemon-side list refresh.
+ const orphanIds = new Set(orphans.map((s) => s.id))
+ removeSessions(orphanIds)
+ await Promise.allSettled(orphans.map((s) => window.api.pty.kill(s.id)))
+ void refreshSessions()
+ }, [sessions, resourceSessionBindings, workspaceSessionReady, refreshSessions, removeSessions])
+
+ const runKillConfirmed = useCallback(async () => {
+ if (!killConfirm) {
+ return
+ }
+ const target = killConfirm
+ setKilling(true)
+ // Why: optimistic removal avoids a flash where the dialog closes but the killed row lingers until the next list refresh.
+ removeSession(target.sessionId)
+ try {
+ await window.api.pty.kill(target.sessionId)
+ } catch {
+ /* already dead — fall through */
+ } finally {
+ if (mountedRef.current) {
+ setKilling(false)
+ setKillConfirm(null)
+ // Why: killed row unmounts and focus would drop to ; park it on the popover body so keyboard users stay in the list.
+ cancelPopoverBodyFocusFrame()
+ if (popoverBodyRef.current) {
+ popoverBodyFocusFrameRef.current = requestAnimationFrame(() => {
+ popoverBodyFocusFrameRef.current = null
+ popoverBodyRef.current?.focus()
+ })
+ }
+ void refreshSessions()
+ }
+ }
+ }, [
+ cancelPopoverBodyFocusFrame,
+ killConfirm,
+ mountedRef,
+ popoverBodyFocusFrameRef,
+ popoverBodyRef,
+ refreshSessions,
+ removeSession,
+ setKillConfirm,
+ setKilling
+ ])
+
+ const openSpaceResults = useCallback((): void => {
+ setOpen(false)
+ openSpacePage()
+ }, [openSpacePage, setOpen])
+
+ return {
+ toggleRepo,
+ toggleWorktree,
+ navigateToWorktree,
+ navigateToTab,
+ deleteWorktree,
+ handleOpenWorkspaceCleanup,
+ handleKillSession,
+ handleKillOrphans,
+ runKillConfirmed,
+ openSpaceResults
+ }
+}
diff --git a/src/renderer/src/components/status-bar/use-resource-usage-derived-model.ts b/src/renderer/src/components/status-bar/use-resource-usage-derived-model.ts
new file mode 100644
index 00000000000..6cf5c780351
--- /dev/null
+++ b/src/renderer/src/components/status-bar/use-resource-usage-derived-model.ts
@@ -0,0 +1,196 @@
+import { useMemo } from 'react'
+import type { AppState } from '../../store/types'
+import type { MemorySnapshot } from '../../../../shared/process-stats-types'
+import type { Worktree } from '../../../../shared/worktree/types'
+import { getRepoExecutionHostId, parseExecutionHostId } from '../../../../shared/execution-host'
+import { mergeSnapshotAndSessions } from './mergeSnapshotAndSessions'
+import type { DaemonSession } from './resource-usage-merge-types'
+import type { ResourceSessionBindingInputs } from './resource-session-bindings'
+import { countUnboundDaemonSessions } from './resource-session-bindings'
+import {
+ getResourceManagerAriaLabel,
+ getResourceManagerTooltipLines
+} from './resource-manager-terminal-copy'
+import {
+ getCommitPressureToneClass,
+ getResourceCommitMetricCopy,
+ getResourceMemoryMetricCopy
+} from './resource-memory-metric-copy'
+import { formatMemory } from './resource-usage-metrics'
+
+export function useResourceUsageDerivedModel({
+ open,
+ resourceSnapshot,
+ sessions,
+ resourceSessionBindings,
+ runtimePaneTitlesByTabId,
+ repos,
+ allWorktrees,
+ browserTabsByWorktree,
+ workspaceSessionReady,
+ sessionCount,
+ sessionsError,
+ memorySnapshotError,
+ snapshot,
+ spaceScanReady
+}: {
+ open: boolean
+ resourceSnapshot: MemorySnapshot | null
+ sessions: readonly DaemonSession[]
+ resourceSessionBindings: ResourceSessionBindingInputs
+ runtimePaneTitlesByTabId: AppState['runtimePaneTitlesByTabId']
+ repos: AppState['repos']
+ allWorktrees: Worktree[]
+ browserTabsByWorktree: AppState['browserTabsByWorktree']
+ workspaceSessionReady: boolean
+ sessionCount: number
+ sessionsError: boolean
+ memorySnapshotError: string | null
+ snapshot: MemorySnapshot | null
+ spaceScanReady: boolean
+}) {
+ const repoDisplayNameById = useMemo(() => {
+ const map = new Map()
+ for (const repo of repos) {
+ const display = repo.displayName?.trim()
+ if (display) {
+ map.set(repo.id, display)
+ }
+ }
+ return map
+ }, [repos])
+
+ // Why: non-null connectionId is the only honest "remote" signal (SSH PTYs run remote); build from the store, not a missing memory sample.
+ const repoConnectionIdById = useMemo(() => {
+ const map = new Map()
+ for (const repo of repos) {
+ map.set(repo.id, repo.connectionId ?? null)
+ }
+ return map
+ }, [repos])
+
+ // Why: runtime-hosted repos have no local daemon samples or killable sessions; this map drives their per-row exclusion in the merge.
+ const repoRuntimeScopedById = useMemo(() => {
+ const map = new Map()
+ for (const repo of repos) {
+ const parsed = parseExecutionHostId(getRepoExecutionHostId(repo))
+ map.set(repo.id, parsed?.kind === 'runtime')
+ }
+ return map
+ }, [repos])
+
+ const worktreeById = useMemo(
+ () => new Map(allWorktrees.map((worktree) => [worktree.id, worktree])),
+ [allWorktrees]
+ )
+
+ // Why: skip the merge when closed; the always-mounted segment recomputing on every keystroke-driven store mutation made the app laggy.
+ const unifiedRepos = useMemo(
+ () =>
+ open
+ ? mergeSnapshotAndSessions(resourceSnapshot, sessions, {
+ // Why spread: the rows and the bulk selector must classify from the identical binding
+ // inputs. Re-listing them here let the row path miss deferred SSH sessions, so their
+ // single-row kill skipped confirmation while bulk cleanup correctly spared them (#8459).
+ ...resourceSessionBindings,
+ runtimePaneTitlesByTabId,
+ repoDisplayNameById,
+ repoConnectionIdById,
+ repoRuntimeScopedById,
+ browserTabsByWorktree,
+ worktreeById
+ })
+ : [],
+ [
+ open,
+ resourceSnapshot,
+ sessions,
+ resourceSessionBindings,
+ runtimePaneTitlesByTabId,
+ repoDisplayNameById,
+ repoConnectionIdById,
+ repoRuntimeScopedById,
+ browserTabsByWorktree,
+ worktreeById
+ ]
+ )
+
+ // Why: orphan detection needs daemon inventory; keep it open-only so the closed badge never triggers a background global session scan.
+ const orphanCount = useMemo(() => {
+ if (!open || !workspaceSessionReady) {
+ return 0
+ }
+ return countUnboundDaemonSessions(sessions, resourceSessionBindings)
+ }, [open, sessions, resourceSessionBindings, workspaceSessionReady])
+
+ // Why: open and closed badges share the same daemon inventory cache. The old
+ // closed path used boundPtyIds (wake hints) and inflated the chip to 60+.
+ const triggerSessionCount = sessionCount
+
+ const memoryMetricCopy = getResourceMemoryMetricCopy(
+ resourceSnapshot?.processMemoryMetric ?? 'rss'
+ )
+ // Why null-not-zero: a host that cannot read commit (every Unix host, and any
+ // host older than the field) must render nothing here, never "0 B committed".
+ const commitMetricCopy = resourceSnapshot?.processCommitMetric
+ ? getResourceCommitMetricCopy()
+ : null
+ const { totalMemory, totalCpu, memBadgeLabel, totalPrivateMemory, commitToneClass } =
+ useMemo(() => {
+ const memory = resourceSnapshot?.totalMemory ?? 0
+ const cpu = resourceSnapshot?.totalCpu ?? 0
+ const privateMemory = resourceSnapshot?.totalPrivateMemory
+ return {
+ totalMemory: memory,
+ totalCpu: cpu,
+ memBadgeLabel: resourceSnapshot ? formatMemory(memory) : '—',
+ totalPrivateMemory: privateMemory,
+ commitToneClass: getCommitPressureToneClass({
+ privateMemory,
+ hostTotalMemory: resourceSnapshot?.host.totalMemory ?? 0
+ })
+ }
+ }, [resourceSnapshot])
+ const commitBadgeLabel =
+ commitMetricCopy && totalPrivateMemory !== undefined ? formatMemory(totalPrivateMemory) : null
+
+ // Why: memorySnapshotError null means "succeeded" OR "never fetched"; a sessions failure before any snapshot still counts as daemon-unreachable.
+ const daemonUnreachable = sessionsError && (memorySnapshotError !== null || snapshot === null)
+ // Why: sessions IPC can fail while snapshot IPC works; flag it so the empty session list isn't mistaken for healthy.
+ const sessionsOnlyError = sessionsError && memorySnapshotError === null
+ const resourceManagerTooltipLines = getResourceManagerTooltipLines({
+ memoryLabel: resourceSnapshot
+ ? [
+ `${memBadgeLabel} · ${memoryMetricCopy.summaryLabel}`,
+ commitBadgeLabel && commitMetricCopy
+ ? `${commitBadgeLabel} ${commitMetricCopy.summaryLabel}`
+ : null
+ ]
+ .filter(Boolean)
+ .join(' · ')
+ : memBadgeLabel,
+ sessionCount: triggerSessionCount,
+ spaceScanReady
+ })
+ const resourceManagerAriaLabel = getResourceManagerAriaLabel({
+ sessionCount: triggerSessionCount,
+ spaceScanReady
+ })
+
+ return {
+ unifiedRepos,
+ orphanCount,
+ triggerSessionCount,
+ memoryMetricCopy,
+ commitMetricCopy,
+ totalMemory,
+ totalCpu,
+ memBadgeLabel,
+ commitToneClass,
+ commitBadgeLabel,
+ daemonUnreachable,
+ sessionsOnlyError,
+ resourceManagerTooltipLines,
+ resourceManagerAriaLabel
+ }
+}
diff --git a/src/renderer/src/components/status-bar/use-resource-usage-status-controller.ts b/src/renderer/src/components/status-bar/use-resource-usage-status-controller.ts
new file mode 100644
index 00000000000..9dd9c22666d
--- /dev/null
+++ b/src/renderer/src/components/status-bar/use-resource-usage-status-controller.ts
@@ -0,0 +1,241 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { useMountedRef } from '@/hooks/useMountedRef'
+import { useAppStore } from '../../store'
+import { useDaemonActions } from '../shared/useDaemonActions'
+import type { UnifiedSessionRow } from './resource-usage-merge-types'
+import type { ResourceSessionBindingInputs } from './resource-session-bindings'
+import type { SortOption } from './resource-usage-resource-tree'
+import {
+ getResourceUsageAllWorktrees,
+ getResourceUsageBrowserTabsByWorktree,
+ getResourceUsageDeferredSshSessionIdsByTabId,
+ getResourceUsagePtyIdsByTabId,
+ getResourceUsageRepos,
+ getResourceUsageRuntimePaneTitlesByTabId,
+ getResourceUsageTerminalLayoutsByTabId,
+ getResourceUsageTabsByWorktree
+} from './resource-usage-open-slices'
+import {
+ resolveResourceUsageSpaceScanReady,
+ type ResourceUsageSpaceScanSnapshot
+} from './resource-usage-space-scan-ready'
+import { useResourceSessionInventory } from './use-resource-session-inventory'
+import { useResourceUsageActions } from './use-resource-usage-actions'
+import { useResourceUsageDerivedModel } from './use-resource-usage-derived-model'
+
+const POLL_MS = 2_000
+
+export function useResourceUsageStatusController() {
+ const snapshot = useAppStore((s) => s.memorySnapshot)
+ const memorySnapshotError = useAppStore((s) => s.memorySnapshotError)
+ const fetchSnapshot = useAppStore((s) => s.fetchMemorySnapshot)
+ const workspaceSessionReady = useAppStore((s) => s.workspaceSessionReady)
+ const setActiveView = useAppStore((s) => s.setActiveView)
+ const openModal = useAppStore((s) => s.openModal)
+ const openSpacePage = useAppStore((s) => s.openSpacePage)
+ const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction)
+ const activeView = useAppStore((s) => s.activeView)
+ const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
+ const workspaceSpaceScannedAt = useAppStore((s) => s.workspaceSpaceAnalysis?.scannedAt ?? null)
+ const workspaceSpaceScanning = useAppStore((s) => s.workspaceSpaceScanning)
+
+ const [open, setOpen] = useState(false)
+ const [sortOption, setSortOption] = useState('memory')
+ const [collapsedRepos, setCollapsedRepos] = useState>(new Set())
+ const [collapsedWorktrees, setCollapsedWorktrees] = useState>(new Set())
+ const [appCollapsed, setAppCollapsed] = useState(true)
+ const {
+ sessionInventory,
+ sessionsError,
+ refreshSessions,
+ clearSessionsError,
+ removeSession,
+ removeSessions
+ } = useResourceSessionInventory(workspaceSessionReady)
+ const sessions = sessionInventory.sessions
+ const [killConfirm, setKillConfirm] = useState(null)
+ const [killing, setKilling] = useState(false)
+ const [spaceScanSnapshot, setSpaceScanSnapshot] = useState(
+ () => ({
+ ready: false,
+ previousScanning: workspaceSpaceScanning,
+ lastSeenScannedAt: workspaceSpaceScannedAt
+ })
+ )
+ // Why: tab titles churn on every keystroke; subscribe to those maps only while open so closed badges don't rerender.
+ const runtimePaneTitlesByTabId = useAppStore((s) =>
+ getResourceUsageRuntimePaneTitlesByTabId(s, open)
+ )
+ const repos = useAppStore((s) => getResourceUsageRepos(s, open))
+ const allWorktrees = useAppStore((s) => getResourceUsageAllWorktrees(s, open))
+ const tabsByWorktree = useAppStore((s) => getResourceUsageTabsByWorktree(s, open))
+ const browserTabsByWorktree = useAppStore((s) => getResourceUsageBrowserTabsByWorktree(s, open))
+ // Why: full binding maps stay behind open sentinels so unchanged counts don't rerender the closed segment.
+ const ptyIdsByTabId = useAppStore((s) => getResourceUsagePtyIdsByTabId(s, open))
+ const terminalLayoutsByTabId = useAppStore((s) => getResourceUsageTerminalLayoutsByTabId(s, open))
+ // Why: sessions awaiting SSH reattach are live on the remote host with no other binding.
+ const deferredSshSessionIdsByTabId = useAppStore((s) =>
+ getResourceUsageDeferredSshSessionIdsByTabId(s, open)
+ )
+ const resourceSnapshot = snapshot
+ // Why: ptyIdsByTabId tracks mounted/live panes only; Resource Manager reads restored wake hints only for classification.
+ const resourceSessionBindings = useMemo(
+ () => ({
+ ptyIdsByTabId,
+ tabsByWorktree,
+ terminalLayoutsByTabId,
+ deferredSshSessionIdsByTabId,
+ workspaceSessionReady
+ }),
+ [
+ ptyIdsByTabId,
+ tabsByWorktree,
+ terminalLayoutsByTabId,
+ deferredSshSessionIdsByTabId,
+ workspaceSessionReady
+ ]
+ )
+
+ // Why: after a kill unmounts the session, focus would fall to ; park a ref on the popover body to restore it stably for keyboard users.
+ const popoverBodyRef = useRef(null)
+ const popoverBodyFocusFrameRef = useRef(null)
+ const mountedRef = useMountedRef()
+
+ const cancelPopoverBodyFocusFrame = useCallback((): void => {
+ if (popoverBodyFocusFrameRef.current === null) {
+ return
+ }
+ cancelAnimationFrame(popoverBodyFocusFrameRef.current)
+ popoverBodyFocusFrameRef.current = null
+ }, [])
+
+ const setPopoverBodyNode = useCallback(
+ (node: HTMLDivElement | null): void => {
+ // Why: the queued post-kill focus is only valid while the popover body exists.
+ if (!node) {
+ cancelPopoverBodyFocusFrame()
+ }
+ popoverBodyRef.current = node
+ },
+ [cancelPopoverBodyFocusFrame]
+ )
+
+ const daemonActions = useDaemonActions({
+ onRestartSettled: () => {
+ clearSessionsError()
+ void fetchSnapshot()
+ void refreshSessions()
+ }
+ })
+
+ // Why: Space scans can finish after the user closes the full page/popover; the status-bar trigger becomes the handoff point.
+ const nextSpaceScanSnapshot = resolveResourceUsageSpaceScanReady({
+ snapshot: spaceScanSnapshot,
+ open,
+ activeView,
+ scannedAt: workspaceSpaceScannedAt,
+ scanning: workspaceSpaceScanning
+ })
+ if (
+ nextSpaceScanSnapshot.ready !== spaceScanSnapshot.ready ||
+ nextSpaceScanSnapshot.previousScanning !== spaceScanSnapshot.previousScanning ||
+ nextSpaceScanSnapshot.lastSeenScannedAt !== spaceScanSnapshot.lastSeenScannedAt
+ ) {
+ // Why: guarded render-time state update (no ref mutation during render); React can safely retry it before commit.
+ setSpaceScanSnapshot(nextSpaceScanSnapshot)
+ }
+ const spaceScanReady = nextSpaceScanSnapshot.ready
+
+ // Why: seed RAM after session restore so the closed chip does not require a
+ // click; the session-inventory hook independently seeds daemon PTYs.
+ useEffect(() => {
+ if (workspaceSessionReady) {
+ void fetchSnapshot()
+ }
+ }, [workspaceSessionReady, fetchSnapshot])
+
+ // Poll memory only while the popover is open. Session inventory is still
+ // explicit-on-open/action/seed (not a closed interval) because full
+ // listSessions can pause input with large preserved-session sets.
+ useEffect(() => {
+ if (!open) {
+ return
+ }
+ void fetchSnapshot()
+ void refreshSessions()
+ // Why: only memory polls on an interval; session inventory is explicit on open/action since it's expensive with many terminals.
+ const memTimer = window.setInterval(() => {
+ void fetchSnapshot()
+ }, POLL_MS)
+ return () => {
+ window.clearInterval(memTimer)
+ }
+ }, [open, fetchSnapshot, refreshSessions])
+
+ useEffect(() => {
+ if (!open) {
+ clearSessionsError()
+ }
+ }, [open, clearSessionsError])
+
+ const derived = useResourceUsageDerivedModel({
+ open,
+ resourceSnapshot,
+ sessions,
+ resourceSessionBindings,
+ runtimePaneTitlesByTabId,
+ repos,
+ allWorktrees,
+ browserTabsByWorktree,
+ workspaceSessionReady,
+ sessionCount: sessionInventory.count,
+ sessionsError,
+ memorySnapshotError,
+ snapshot,
+ spaceScanReady
+ })
+ const actions = useResourceUsageActions({
+ setCollapsedRepos,
+ setCollapsedWorktrees,
+ tabsByWorktree,
+ setOpen,
+ setActiveView,
+ openModal,
+ openSpacePage,
+ refreshSessions,
+ removeSession,
+ removeSessions,
+ sessions,
+ resourceSessionBindings,
+ workspaceSessionReady,
+ killConfirm,
+ setKillConfirm,
+ setKilling,
+ mountedRef,
+ cancelPopoverBodyFocusFrame,
+ popoverBodyRef,
+ popoverBodyFocusFrameRef
+ })
+
+ return {
+ open,
+ setOpen,
+ sortOption,
+ setSortOption,
+ collapsedRepos,
+ collapsedWorktrees,
+ appCollapsed,
+ setAppCollapsed,
+ activeWorktreeId,
+ killConfirm,
+ setKillConfirm,
+ killing,
+ setPopoverBodyNode,
+ daemonActions,
+ resourceSnapshot,
+ spaceScanReady,
+ recordFeatureInteraction,
+ ...derived,
+ ...actions
+ }
+}