mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
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:
@@ -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')
|
||||
]
|
||||
|
||||
+1
-1
@@ -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
+4
-3
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user