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