Split resource usage status responsibilities (#17244)

* Split speech session lifecycle

* Split terminal output scheduler pipeline

* Split mobile browser pane modules

* Prune resolved max-lines suppressions

* Split pane tree equalization logic

* Extract mobile troubleshoot screen styles

* Split external automation manager

* Split main window service attachments

* Split hosted review creation checks

* Split automation dispatch event handling

* Split settings navigation metadata

* Split daemon initialization lifecycle

* Split GitLab item dialog

* Split relay dispatcher layers

* Split mobile host screen

* Retarget mobile view settings source test

* Split runtime file client layers

* Split ports panel layers

* Split runtime environments pane layers

* Split local PTY provider responsibilities

* Split CDP bridge responsibilities

* Split relay Git handler responsibilities

* Track moved relay Git fetch audit

* Split Linear item drawer responsibilities

* Split telemetry event schema responsibilities

* Split resource usage status responsibilities

* Fix F3-speech for #17123

* Fix F1-cycle for #17131

* Fix F4-navtest for #17157

* Fix F2-allowlist for #17161
This commit is contained in:
Neil
2026-08-29 20:20:13 -07:00
committed by GitHub
parent 23009d9345
commit 80a6bd67ec
15 changed files with 2089 additions and 1619 deletions
-1
View File
@@ -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
@@ -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')
]
@@ -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', () => {
File diff suppressed because it is too large Load Diff
@@ -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')
@@ -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<SetStateAction<UnifiedSessionRow | null>>
killing: boolean
runKillConfirmed: () => Promise<void>
}): React.JSX.Element {
return (
<Dialog
open={killConfirm !== null}
onOpenChange={(next) => {
if (next) {
return
}
if (killing) {
return
}
setKillConfirm(null)
}}
>
<DialogContent
className="max-w-md"
showCloseButton={!killing}
onPointerDownOutside={(e) => {
if (killing) {
e.preventDefault()
}
}}
onEscapeKeyDown={(e) => {
if (killing) {
e.preventDefault()
}
}}
>
<DialogHeader>
<DialogTitle className="text-sm">
{translate(
'auto.components.status.bar.ResourceUsageStatusSegment.e9a5d3c2b1f0',
'Kill {{value0}}?',
{
value0:
killConfirm?.label ??
translate(
'auto.components.status.bar.ResourceUsageStatusSegment.138b99bd80',
'this session'
)
}
)}
</DialogTitle>
<DialogDescription className="text-xs">
{translate(
'auto.components.status.bar.ResourceUsageStatusSegment.67c4ecda49',
"Force-quits this terminal. Any unsaved work in the pane is lost. This can't be undone."
)}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setKillConfirm(null)} disabled={killing}>
{translate(
'auto.components.status.bar.ResourceUsageStatusSegment.946d9f94d0',
'Cancel'
)}
</Button>
<Button variant="destructive" onClick={() => void runKillConfirmed()} disabled={killing}>
{killing ? <LoaderCircle className="size-4 animate-spin" /> : null}
{killing
? translate(
'auto.components.status.bar.ResourceUsageStatusSegment.41ae4fa725',
'Killing…'
)
: translate(
'auto.components.status.bar.ResourceUsageStatusSegment.b10695d6ce',
'Kill session'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -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 (
<svg
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
aria-hidden
preserveAspectRatio="none"
>
<polyline
points={points}
fill="none"
strokeWidth={1}
strokeLinecap="round"
strokeLinejoin="round"
className="stroke-muted-foreground/70"
/>
</svg>
)
}
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 (
<div
className={cn(
METRIC_COLUMNS_CLS,
textCls,
muted ? 'text-muted-foreground/50' : 'text-muted-foreground'
)}
>
<span className={CPU_COLUMN_CLS}>{formatMetricCpu(cpu)}</span>
<span className={MEM_COLUMN_CLS}>{formatMetricMemory(memory)}</span>
</div>
)
}
function AppSubRow({ label, values }: { label: string; values: UsageValues }): React.JSX.Element {
return (
<div className="px-3 py-1.5 pl-6 flex items-center justify-between gap-2">
<span className="text-[11px] text-muted-foreground truncate">{label}</span>
<div className="flex items-center gap-2 shrink-0">
<MetricPair cpu={values.cpu} memory={values.memory} size="small" />
<span className={ROW_TRAILING_GUTTER_CLS} aria-hidden />
</div>
</div>
)
}
export function AppSection({
app,
isCollapsed,
onToggle
}: {
app: AppMemory
isCollapsed: boolean
onToggle: () => void
}): React.JSX.Element {
return (
<div className="border-t border-border/50">
<div className="flex items-center">
<button
type="button"
onClick={onToggle}
className="pl-2 py-2 pr-0.5 transition-colors hover:bg-muted/50"
aria-label={
isCollapsed
? translate(
'auto.components.status.bar.ResourceUsageStatusSegment.e419d27083',
'Expand Orca'
)
: translate(
'auto.components.status.bar.ResourceUsageStatusSegment.53dd5560ae',
'Collapse Orca'
)
}
aria-expanded={!isCollapsed}
>
{isCollapsed ? (
<ChevronRight className="h-3 w-3 text-muted-foreground" />
) : (
<ChevronDown className="h-3 w-3 text-muted-foreground" />
)}
</button>
<div className="flex-1 min-w-0 py-2 pr-3 flex items-center justify-between">
<span className="text-[11px] font-semibold uppercase tracking-wide truncate text-muted-foreground">
{translate('auto.components.status.bar.ResourceUsageStatusSegment.288a4dd177', 'Orca')}
</span>
<div className="flex items-center gap-2 shrink-0">
<Sparkline samples={app.history} />
<MetricPair cpu={app.cpu} memory={app.memory} />
<span className={ROW_TRAILING_GUTTER_CLS} aria-hidden />
</div>
</div>
</div>
{!isCollapsed && (
<div className="border-t border-border/30">
<AppSubRow
label={translate(
'auto.components.status.bar.ResourceUsageStatusSegment.81cd37af99',
'Main'
)}
values={app.main}
/>
<AppSubRow
label={translate(
'auto.components.status.bar.ResourceUsageStatusSegment.d406915b78',
'Renderer'
)}
values={app.renderer}
/>
{(app.other.cpu > 0 || app.other.memory > 0) && (
<AppSubRow
label={translate(
'auto.components.status.bar.ResourceUsageStatusSegment.0f9e50eb07',
'Other'
)}
values={app.other}
/>
)}
</div>
)}
</div>
)
}
@@ -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<SetStateAction<SortOption>>
memoryMetricCopy: ReturnType<typeof getResourceMemoryMetricCopy>
collapsedRepos: Set<string>
toggleRepo: (repoId: string) => void
collapsedWorktrees: Set<string>
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<SetStateAction<boolean>>
daemonUnreachable: boolean
}): React.JSX.Element {
return (
<div ref={setPopoverBodyNode} tabIndex={-1} className="flex h-[420px] flex-col outline-none">
{(unifiedRepos.length > 0 || resourceSnapshot) && (
<div className="flex items-center justify-between px-3 py-1 bg-muted/30 border-b border-border/50 text-[10px] uppercase tracking-wide shrink-0">
<button
type="button"
onClick={() => 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')}
</button>
<div className="flex items-center gap-2 shrink-0">
<div className={cn(METRIC_COLUMNS_CLS, 'text-[10px]')}>
<button
type="button"
onClick={() => 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'
)}
</button>
<button
type="button"
onClick={() => 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}
</button>
</div>
{/* Why: empty trailing gutter keeps CPU/Memory header cells aligned with rows that reserve this width for the kill-X. */}
<span className={ROW_TRAILING_GUTTER_CLS} aria-hidden />
</div>
</div>
)}
<div className="flex-1 overflow-y-auto scrollbar-sleek">
{unifiedRepos.length > 0 && (
<ResourceTree
repos={unifiedRepos}
sortOption={sortOption}
collapsedRepos={collapsedRepos}
toggleRepo={toggleRepo}
collapsedWorktrees={collapsedWorktrees}
activeWorktreeId={activeWorktreeId}
toggleWorktree={toggleWorktree}
navigateToWorktree={navigateToWorktree}
navigateToTab={navigateToTab}
onDelete={deleteWorktree}
onKillSession={handleKillSession}
/>
)}
{unifiedRepos.length === 0 && resourceSnapshot && (
<div className="px-3 py-4 text-center text-xs text-muted-foreground">
{translate(
'auto.components.status.bar.ResourceUsageStatusSegment.27a74f91f0',
'Nothing running right now'
)}
</div>
)}
{resourceSnapshot && (
<AppSection
app={resourceSnapshot.app}
isCollapsed={appCollapsed}
onToggle={() => setAppCollapsed((v) => !v)}
/>
)}
{!resourceSnapshot && !daemonUnreachable && (
<div className="px-3 py-4 text-center text-xs text-muted-foreground">
{translate(
'auto.components.status.bar.ResourceUsageStatusSegment.888dad8c55',
'Loading…'
)}
</div>
)}
</div>
</div>
)
}
export function renderResourceUsagePopoverFooter({
handleOpenWorkspaceCleanup,
orphanCount,
handleKillOrphans
}: {
handleOpenWorkspaceCleanup: () => void
orphanCount: number
handleKillOrphans: () => Promise<void>
}): React.JSX.Element {
return (
<div className="border-t border-border/50 px-3 py-2 shrink-0">
<button
type="button"
onClick={handleOpenWorkspaceCleanup}
className="relative 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"
>
<span className="min-w-0 truncate px-4 text-center">
{translate(
'auto.components.status.bar.ResourceUsageStatusSegment.92924a14e3',
'Clean up workspaces'
)}
</span>
<ChevronRight className="absolute right-2.5 size-3.5 text-muted-foreground" aria-hidden />
</button>
{orphanCount > 0 ? (
<button
type="button"
onClick={() => 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 }
)}
</button>
) : null}
</div>
)
}
@@ -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 (
<div className="flex items-center justify-between gap-2 border-b border-border px-3 py-1.5">
<div className="flex min-w-0 items-center gap-1.5 text-[11px] font-medium text-foreground">
<MemoryStick className="size-3 shrink-0 text-muted-foreground" />
<span className="truncate">
{translate('auto.components.status.bar.StatusBar.d1e1a7a6bf', 'Resource Manager')}
</span>
</div>
<div className="flex items-center gap-0.5">
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => 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"
>
<RotateCw className="size-3" />
</button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6}>
{translate(
'auto.components.status.bar.ResourceUsageStatusSegment.c9382662bb',
'Restart daemon'
)}
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => 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"
>
<Trash2 className="size-3" />
</button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6}>
{translate(
'auto.components.status.bar.ResourceUsageStatusSegment.bd19fd7a59',
'Kill all sessions'
)}
</TooltipContent>
</Tooltip>
</div>
</div>
)
}
export function renderDaemonUnreachableBanner({
daemonActions
}: {
daemonActions: DaemonActionsApi
}): React.JSX.Element {
return (
<div className="flex items-start gap-2 border-b border-border bg-yellow-500/10 px-3 py-2 text-[11px] text-foreground">
<AlertTriangle className="mt-0.5 size-3 shrink-0 text-yellow-500" />
<div className="flex-1">
<div className="font-medium">
{translate(
'auto.components.status.bar.ResourceUsageStatusSegment.f8e0d794b4',
'Daemon is not responding'
)}
</div>
<div className="text-muted-foreground">
{translate(
'auto.components.status.bar.ResourceUsageStatusSegment.f85af9cda6',
'Resource snapshots and terminal sessions are unavailable.'
)}
</div>
</div>
<Button
variant="outline"
size="sm"
className="shrink-0"
onClick={() => daemonActions.setPending('restart')}
disabled={daemonActions.isBusy}
>
<RotateCw className="mr-1 size-3" />
{translate('auto.components.status.bar.ResourceUsageStatusSegment.93b0de3c21', 'Restart')}
</Button>
</div>
)
}
export function renderSessionsOnlyErrorBanner(): React.JSX.Element {
return (
<div
className="flex items-center gap-2 border-b border-border bg-muted/40 px-3 py-1.5 text-[11px] text-muted-foreground"
role="status"
>
<AlertTriangle className="size-3 shrink-0 text-yellow-500" />
<span>
{translate(
'auto.components.status.bar.ResourceUsageStatusSegment.e7cf14ec78',
'Terminal sessions unavailable. The list may be stale.'
)}
</span>
</div>
)
}
export function renderResourceUsageSummary({
totalCpu,
totalMemory,
memoryMetricCopy,
commitBadgeLabel,
commitMetricCopy,
commitToneClass,
orphanCount
}: {
totalCpu: number
totalMemory: number
memoryMetricCopy: ReturnType<typeof getResourceMemoryMetricCopy>
commitBadgeLabel: string | null
commitMetricCopy: ReturnType<typeof getResourceCommitMetricCopy> | null
commitToneClass: string | null
orphanCount: number
}): React.JSX.Element {
return (
<div className="px-3 py-2 border-b border-border flex items-baseline justify-between gap-3 text-xs tabular-nums">
<div className="flex items-baseline gap-3 min-w-0">
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<span
tabIndex={0}
className="font-medium text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:rounded"
>
{formatCpu(totalCpu)}
</span>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6} className="z-[70] max-w-xs">
{translate(
'auto.components.status.bar.ResourceUsageStatusSegment.1fedf94eae',
'Combined CPU load. Values above 100% mean more than one core is working at once.'
)}
</TooltipContent>
</Tooltip>
<span className="text-muted-foreground/50">·</span>
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<span
tabIndex={0}
className="font-medium text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:rounded"
>
{formatMemory(totalMemory)}{' '}
<span className="font-normal text-muted-foreground">
{memoryMetricCopy.summaryLabel}
</span>
</span>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6} className="z-[70] max-w-xs">
{memoryMetricCopy.description}
</TooltipContent>
</Tooltip>
{commitBadgeLabel && commitMetricCopy && (
<>
<span className="text-muted-foreground/50">·</span>
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<span
tabIndex={0}
className={cn(
'font-medium focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:rounded',
commitToneClass ?? 'text-foreground'
)}
>
{commitBadgeLabel}{' '}
<span className="font-normal text-muted-foreground">
{commitMetricCopy.summaryLabel}
</span>
</span>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6} className="z-[70] max-w-xs">
{commitMetricCopy.description}
</TooltipContent>
</Tooltip>
</>
)}
</div>
{orphanCount > 0 && (
<span className="shrink-0 text-yellow-500" aria-live="polite">
{orphanCount === 1
? translate(
'auto.components.status.bar.ResourceUsageStatusSegment.30ff2c3c31',
'{{value0}} orphan',
{ value0: orphanCount }
)
: translate(
'auto.components.status.bar.ResourceUsageStatusSegment.b8f4a2c1d0e3',
'{{value0}} orphans',
{ value0: orphanCount }
)}
</span>
)}
</div>
)
}
@@ -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<string>
toggleRepo: (repoId: string) => void
collapsedWorktrees: Set<string>
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 (
<WorktreeRow
key={wt.worktreeId}
worktree={wt}
storeRecord={storeRecord}
activeWorktreeId={activeWorktreeId}
isCollapsed={collapsedWorktrees.has(wt.worktreeId)}
onToggle={() => 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 (
<div key={group.repoId} className="border-b border-border/50 last:border-b-0">
<div className="flex items-center">
<button
type="button"
onClick={() => 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 ? (
<ChevronRight className="h-3 w-3 text-muted-foreground" />
) : (
<ChevronDown className="h-3 w-3 text-muted-foreground" />
)}
</button>
<div className="flex-1 min-w-0 py-2 pr-3 flex items-center justify-between gap-2">
<span className="flex items-center gap-1.5 min-w-0">
<span className="text-[11px] font-semibold uppercase tracking-wide truncate text-muted-foreground">
{group.repoName}
</span>
{group.hasRemoteChildren && (
<span className="shrink-0 text-[9px] uppercase tracking-wide text-muted-foreground/70">
{translate(
'auto.components.status.bar.ResourceUsageStatusSegment.21cacb16d1',
'· remote'
)}
</span>
)}
</span>
<div className="flex items-center gap-2 shrink-0">
<MetricPair cpu={group.cpu} memory={group.memory} />
<span className={ROW_TRAILING_GUTTER_CLS} aria-hidden />
</div>
</div>
</div>
{!repoCollapsed && (
<div className="border-t border-border/30">{group.worktrees.map(renderWorktree)}</div>
)}
</div>
)
})}
</>
)
}
@@ -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 (
<div
className={cn(
'group/sessrow flex items-center gap-2 pl-10 pr-3 py-1.5',
clickable && 'cursor-pointer hover:bg-accent/40'
)}
onClick={clickable ? handleClick : undefined}
role={clickable ? 'button' : undefined}
tabIndex={clickable ? 0 : -1}
onKeyDown={
clickable
? (e) => {
if (isResourceSessionActivationKey(e.key)) {
e.preventDefault()
handleClick()
}
}
: undefined
}
data-worktree-id={worktreeId}
>
<span
className={cn(
'size-1.5 shrink-0 rounded-full',
session.bound ? 'bg-emerald-500' : 'bg-muted-foreground/40'
)}
/>
<span className="text-[11px] text-muted-foreground truncate min-w-0 flex-1">
{session.label}
</span>
<MetricPair cpu={session.cpu} memory={session.memory} size="small" />
{/* 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. */}
<span className={ROW_TRAILING_GUTTER_CLS}>
<button
type="button"
onClick={(e) => {
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 }
)}
>
<X className="size-3" />
</button>
</span>
</div>
)
}
function BrowserRow({ browser }: { browser: BrowserWorkspace }): React.JSX.Element {
const label = browser.title?.trim() || browser.label?.trim() || browser.url
return (
<div className="flex items-center gap-2 pl-10 pr-3 py-1.5">
<Globe className="size-3 shrink-0 text-muted-foreground" aria-hidden />
<span className="min-w-0 flex-1 truncate text-[11px] text-muted-foreground">{label}</span>
<MetricPair cpu={null} memory={null} size="small" />
<span className={ROW_TRAILING_GUTTER_CLS} aria-hidden />
</div>
)
}
// ─── 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 (
<div className="border-b border-border/20 last:border-b-0">
<div className="group/wtrow flex items-center ml-2 transition-colors hover:bg-muted/60">
{hasResources ? (
<button
type="button"
onClick={onToggle}
className="pl-2 py-2 pr-0.5 shrink-0"
aria-label={
isCollapsed
? translate(
'auto.components.status.bar.ResourceUsageStatusSegment.c4a8968bdd',
'Expand workspace'
)
: translate(
'auto.components.status.bar.ResourceUsageStatusSegment.bbcd9b7b85',
'Collapse workspace'
)
}
>
{isCollapsed ? (
<ChevronRight className="h-3 w-3 text-muted-foreground" />
) : (
<ChevronDown className="h-3 w-3 text-muted-foreground" />
)}
</button>
) : (
<span
className="pl-2 py-2 pr-0.5 shrink-0 w-[calc(0.5rem+0.75rem+0.125rem)]"
aria-hidden
/>
)}
<button
type="button"
onClick={onNavigate}
aria-label={translate(
'auto.components.status.bar.ResourceUsageStatusSegment.d659d71d2d',
'Resume workspace {{value0}}',
{ value0: rowLabel }
)}
className="flex-1 min-w-0 py-2 pr-2 pl-1 text-left flex items-center gap-1.5"
disabled={!isNavigable}
>
<span className="text-xs font-medium truncate">{rowLabel}</span>
{/* Why: gate the chip on SSH connectionId, not missing data — warm-reattached local PTYs land here with hasLocalSamples=false. */}
{worktree.isRemote && (
<span className="shrink-0 text-[9px] uppercase tracking-wide text-muted-foreground/70">
{translate(
'auto.components.status.bar.ResourceUsageStatusSegment.21cacb16d1',
'· remote'
)}
</span>
)}
</button>
<div className="flex items-center gap-2 shrink-0 pr-3">
<div className="relative">
{/* Why: no-hover devices show the action overlay by default, so the sparkline yields there just like on hover. */}
<span
className={cn(
'block transition-opacity',
showWorktreeActions &&
'group-hover/wtrow:opacity-0 group-hover/wtrow:pointer-events-none group-focus-within/wtrow:opacity-0 group-focus-within/wtrow:pointer-events-none [@media(hover:none)]:opacity-0 [@media(hover:none)]:pointer-events-none'
)}
aria-hidden={showWorktreeActions ? undefined : true}
>
<Sparkline samples={worktree.history} />
</span>
{showWorktreeActions && (
<div className="absolute inset-0 flex items-center justify-end gap-0.5 can-hover:opacity-0 can-hover:pointer-events-none transition-opacity group-hover/wtrow:opacity-100 group-hover/wtrow:pointer-events-auto group-focus-within/wtrow:opacity-100 group-focus-within/wtrow:pointer-events-auto">
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<button
type="button"
onClick={onDelete}
disabled={isMainWorktree}
aria-label={translate(
'auto.components.status.bar.ResourceUsageStatusSegment.16bc3c998a',
'Delete workspace {{value0}}',
{ value0: rowLabel }
)}
className={cn(
'p-0.5 rounded text-muted-foreground transition-colors',
isMainWorktree
? 'opacity-40 cursor-not-allowed'
: 'hover:bg-destructive/10 hover:text-destructive'
)}
>
<Trash2 className="size-3" />
</button>
</TooltipTrigger>
<TooltipContent
side="top"
sideOffset={4}
className="z-[70] max-w-[200px] text-pretty"
>
{isMainWorktree
? translate(
'auto.components.status.bar.ResourceUsageStatusSegment.946724a70a',
'The main workspace cannot be deleted.'
)
: translate(
'auto.components.status.bar.ResourceUsageStatusSegment.a82253b458',
'Delete workspace.'
)}
</TooltipContent>
</Tooltip>
</div>
)}
</div>
<MetricPair cpu={worktree.cpu} memory={worktree.memory} />
<span className={ROW_TRAILING_GUTTER_CLS} aria-hidden />
</div>
</div>
{!isCollapsed &&
worktree.sessions.map((session) => (
<SessionRow
key={session.sessionId}
session={session}
worktreeId={worktree.worktreeId}
onNavigate={navigateToTab}
onKill={onKillSession}
/>
))}
{!isCollapsed &&
worktree.browsers.map((browser) => <BrowserRow key={browser.id} browser={browser} />)}
</div>
)
}
@@ -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 (
<Tooltip delayDuration={150}>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<button
type="button"
{...STATUS_BAR_CONTEXT_MENU_EXEMPT_PROPS}
className="relative inline-flex items-center gap-1.5 cursor-pointer rounded px-1 py-0.5 hover:bg-accent/70"
aria-label={
daemonUnreachable
? translate(
'auto.components.status.bar.ResourceUsageStatusSegment.59f178fe11',
'{{value0}}, daemon unreachable',
{ value0: resourceManagerAriaLabel }
)
: resourceManagerAriaLabel
}
>
{spaceScanReady ? (
<span
className="absolute -right-0.5 -top-0.5 size-1.5 rounded-full bg-primary"
aria-hidden="true"
/>
) : null}
<MemoryStick className="size-3 text-muted-foreground" />
{!iconOnly && (
<>
{/* Tint only: the number stays the resident sum it has always been,
and the tooltip names the commit figure that raised the tone. */}
<span
className={cn(
'text-[11px] font-medium tabular-nums',
commitToneClass ?? 'text-muted-foreground'
)}
>
{memBadgeLabel}
</span>
<span className="text-muted-foreground/50">·</span>
<Terminal className="size-3 text-muted-foreground" />
<span className="text-[11px] tabular-nums text-muted-foreground">
{triggerSessionCount}
{orphanCount > 0 && (
<span className="text-yellow-500 ml-0.5">({orphanCount})</span>
)}
</span>
</>
)}
{iconOnly && triggerSessionCount > 0 && (
<span className="text-[11px] tabular-nums text-muted-foreground">
{triggerSessionCount}
</span>
)}
{daemonUnreachable && (
<AlertTriangle
className="size-3 text-yellow-500"
aria-label={translate(
'auto.components.status.bar.ResourceUsageStatusSegment.ca95d077db',
'Daemon unreachable'
)}
/>
)}
</button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6}>
<div className="space-y-0.5">
{resourceManagerTooltipLines.map((line) => (
<div key={line.id} className={line.emphasized ? 'text-primary' : ''}>
{line.text}
</div>
))}
</div>
</TooltipContent>
</Tooltip>
)
}
@@ -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<SetStateAction<Set<string>>>
setCollapsedWorktrees: Dispatch<SetStateAction<Set<string>>>
tabsByWorktree: AppState['tabsByWorktree']
setOpen: Dispatch<SetStateAction<boolean>>
setActiveView: AppState['setActiveView']
openModal: AppState['openModal']
openSpacePage: AppState['openSpacePage']
refreshSessions: () => Promise<void>
removeSession: (sessionId: string) => void
removeSessions: (sessionIds: ReadonlySet<string>) => void
sessions: readonly DaemonSession[]
resourceSessionBindings: ResourceSessionBindingInputs
workspaceSessionReady: boolean
killConfirm: UnifiedSessionRow | null
setKillConfirm: Dispatch<SetStateAction<UnifiedSessionRow | null>>
setKilling: Dispatch<SetStateAction<boolean>>
mountedRef: MutableRefObject<boolean>
cancelPopoverBodyFocusFrame: () => void
popoverBodyRef: MutableRefObject<HTMLDivElement | null>
popoverBodyFocusFrameRef: MutableRefObject<number | null>
}) {
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 <body>; 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
}
}
@@ -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<string, string>()
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<string, string | null>()
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<string, boolean>()
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
}
}
@@ -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<SortOption>('memory')
const [collapsedRepos, setCollapsedRepos] = useState<Set<string>>(new Set())
const [collapsedWorktrees, setCollapsedWorktrees] = useState<Set<string>>(new Set())
const [appCollapsed, setAppCollapsed] = useState(true)
const {
sessionInventory,
sessionsError,
refreshSessions,
clearSessionsError,
removeSession,
removeSessions
} = useResourceSessionInventory(workspaceSessionReady)
const sessions = sessionInventory.sessions
const [killConfirm, setKillConfirm] = useState<UnifiedSessionRow | null>(null)
const [killing, setKilling] = useState(false)
const [spaceScanSnapshot, setSpaceScanSnapshot] = useState<ResourceUsageSpaceScanSnapshot>(
() => ({
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<ResourceSessionBindingInputs>(
() => ({
ptyIdsByTabId,
tabsByWorktree,
terminalLayoutsByTabId,
deferredSshSessionIdsByTabId,
workspaceSessionReady
}),
[
ptyIdsByTabId,
tabsByWorktree,
terminalLayoutsByTabId,
deferredSshSessionIdsByTabId,
workspaceSessionReady
]
)
// Why: after a kill unmounts the session, focus would fall to <body>; park a ref on the popover body to restore it stably for keyboard users.
const popoverBodyRef = useRef<HTMLDivElement | null>(null)
const popoverBodyFocusFrameRef = useRef<number | null>(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
}
}