From 913174a45cfa2133b684c0bf35c19a78fc76b0ed Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Mon, 4 May 2026 20:04:27 -0400 Subject: [PATCH] feat(status-bar): consolidate memory + sessions into Resource Usage segment Merges the Memory and Terminal Sessions status-bar segments into a single Resource Usage segment with a Resources / Terminal Sessions tab strip, daemon Restart and Kill-all-sessions actions in the popover footer, and a 'Daemon not responding' banner when both subsystems are unreachable. The new segment is gated by a single 'resource-usage' StatusBarItem that defaults ON; legacy 'memory' and 'sessions' values migrate in place on load so users who had either enabled keep the new segment visible. Daemon restart + kill-all dialog flows are extracted into a shared useDaemonActions hook so the settings pane and status bar stay in sync. Co-authored-by: Orca --- .../components/settings/AppearancePane.tsx | 30 +- .../settings/ManageSessionsSection.tsx | 165 +-- .../components/shared/useDaemonActions.tsx | 207 ++++ ...ent.tsx => ResourceUsageStatusSegment.tsx} | 943 ++++++++++++------ .../status-bar/SessionsStatusSegment.tsx | 310 ------ .../src/components/status-bar/StatusBar.tsx | 36 +- src/renderer/src/store/slices/memory.ts | 10 +- src/renderer/src/store/slices/ui.ts | 18 +- src/shared/constants.ts | 3 +- src/shared/types.ts | 9 +- 10 files changed, 918 insertions(+), 813 deletions(-) create mode 100644 src/renderer/src/components/shared/useDaemonActions.tsx rename src/renderer/src/components/status-bar/{MemoryStatusSegment.tsx => ResourceUsageStatusSegment.tsx} (56%) delete mode 100644 src/renderer/src/components/status-bar/SessionsStatusSegment.tsx diff --git a/src/renderer/src/components/settings/AppearancePane.tsx b/src/renderer/src/components/settings/AppearancePane.tsx index 290abf983cb..295daf5ef97 100644 --- a/src/renderer/src/components/settings/AppearancePane.tsx +++ b/src/renderer/src/components/settings/AppearancePane.tsx @@ -59,18 +59,24 @@ const STATUS_BAR_TOGGLES: readonly { 'Show the active SSH connection. Only visible once an SSH target is configured.' }, { - id: 'sessions', - title: 'Terminal Sessions', - description: 'Show the terminal session count in the status bar.', - keywords: ['status bar', 'terminal', 'sessions', 'count', 'pty'], - toggleDescription: 'Show the number of active terminal sessions across all workspaces.' - }, - { - id: 'memory', - title: 'Memory Monitoring', - description: 'Show memory and CPU usage in the status bar.', - keywords: ['status bar', 'memory', 'ram', 'cpu', 'monitoring', 'usage', 'performance'], - toggleDescription: 'Show total memory and CPU usage. Click it to see a per-workspace breakdown.' + id: 'resource-usage', + title: 'Resource Usage', + description: 'Show CPU, memory, and terminal session indicators in the status bar.', + keywords: [ + 'status bar', + 'resource', + 'usage', + 'memory', + 'ram', + 'cpu', + 'terminal', + 'sessions', + 'pty', + 'monitoring', + 'performance' + ], + toggleDescription: + 'Show CPU, memory, and terminal session counts. Click it for a per-workspace breakdown and daemon controls.' } ] diff --git a/src/renderer/src/components/settings/ManageSessionsSection.tsx b/src/renderer/src/components/settings/ManageSessionsSection.tsx index ffdc4f879a5..8cfd6d82397 100644 --- a/src/renderer/src/components/settings/ManageSessionsSection.tsx +++ b/src/renderer/src/components/settings/ManageSessionsSection.tsx @@ -19,14 +19,11 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip' import { SearchableSetting } from './SearchableSetting' import { useAppStore } from '../../store' import { activateAndRevealWorktree } from '@/lib/worktree-activation' +import { useDaemonActions, DaemonActionDialog } from '../shared/useDaemonActions' -type ConfirmKind = 'killAll' | 'restart' | 'killOne' +type ConfirmKind = 'killOne' -type PendingConfirm = - | { kind: 'killAll' } - | { kind: 'restart' } - | { kind: 'killOne'; session: PtyManagementSession } - | null +type PendingConfirm = { kind: 'killOne'; session: PtyManagementSession } | null // Why: mirror the status-bar SessionsStatusSegment label style — last two // path segments joined by the platform separator, no ellipsis prefix. This @@ -83,46 +80,16 @@ function getConfirmCopy(confirm: PendingConfirm): { if (!confirm) { return null } - switch (confirm.kind) { - case 'killAll': - return { - title: 'Kill all terminal sessions?', - description: ( - <> - This force-quits every running terminal pane across all workspaces. Any unsaved work in - those sessions is lost. The daemon itself keeps running, and new terminals can be opened - immediately. This can't be undone. - - ), - confirmLabel: 'Kill all sessions', - busyLabel: 'Killing…' - } - case 'restart': - return { - title: 'Restart the terminal daemon?', - description: ( - <> - Kills every running terminal pane and restarts the daemon process. Panes show - “Process exited” and can be reopened immediately. Legacy-protocol sessions - from a previous app version are preserved. This can't be undone. - - ), - confirmLabel: 'Restart daemon', - busyLabel: 'Restarting…' - } - case 'killOne': - return { - title: 'Kill this session?', - description: ( - <> - Force-quits{' '} - {confirm.session.sessionId}. Any - unsaved work in that pane is lost. This can't be undone. - - ), - confirmLabel: 'Kill session', - busyLabel: 'Killing…' - } + return { + title: 'Kill this session?', + description: ( + <> + Force-quits {confirm.session.sessionId} + . Any unsaved work in that pane is lost. This can't be undone. + + ), + confirmLabel: 'Kill session', + busyLabel: 'Killing…' } } @@ -235,48 +202,30 @@ export function ManageSessionsSection(): React.JSX.Element { const sessionCount = sessions.length - const handleKillAll = useCallback(async () => { - setBusyKind('killAll') - mutationInFlight.current = true - optimisticRollback.current = sessions - setSessions([]) - try { - const { killedCount, remainingCount } = await window.api.pty.management.killAll() - if (remainingCount > 0 && killedCount > 0) { - toast.warning( - `Killed ${killedCount} of ${killedCount + remainingCount} sessions. ${remainingCount} refused to exit.` - ) - } else if (killedCount > 0) { - toast.success(`Killed ${killedCount} session${killedCount === 1 ? '' : 's'}.`) - } else if (remainingCount === 0) { - toast.info('No sessions running.') - } else { - toast.error(`${remainingCount} session${remainingCount === 1 ? '' : 's'} refused to exit.`) - } - mutationInFlight.current = false - await refresh() - } catch (err) { - // Why: the IPC rejected before the daemon could act — restore the list - // so the user isn't left staring at a blank table that doesn't reflect - // reality. + // Why: shared hook owns the daemon-wide restart + kill-all flows so the + // status-bar segment and this settings pane can't drift on copy, IPC calls, + // or the anti-dismiss guard. We pass lifecycle callbacks to keep the pane's + // optimistic setSessions([]) + rollback pattern local to the component. + const daemonActions = useDaemonActions({ + onKillAllStart: () => { + mutationInFlight.current = true + optimisticRollback.current = sessions + setSessions([]) + }, + onKillAllError: () => { if (isMounted.current && optimisticRollback.current) { setSessions(optimisticRollback.current) } - toast.error('Couldn’t kill sessions.', { - description: err instanceof Error ? err.message : undefined - }) - } finally { + }, + onKillAllSettled: () => { optimisticRollback.current = null mutationInFlight.current = false - if (isMounted.current) { - setBusyKind(null) - // Why: the dialog stays open during the mutation so the user sees the - // spinner + busyLabel and can't dismiss mid-flight (see Dialog guard - // below). Close it here when the op actually finishes. - setConfirm(null) - } + void refresh() + }, + onRestartSettled: () => { + void refresh() } - }, [refresh, sessions]) + }) const handleKillOne = useCallback( async (session: PtyManagementSession) => { @@ -308,54 +257,19 @@ export function ManageSessionsSection(): React.JSX.Element { [refresh] ) - const handleRestart = useCallback(async () => { - setBusyKind('restart') - mutationInFlight.current = true - try { - const { success } = await window.api.pty.management.restart() - if (success) { - toast.success('Daemon restarted.') - } else { - toast.error('Restart failed — check logs.') - } - mutationInFlight.current = false - await refresh() - } catch (err) { - toast.error('Restart failed.', { - description: err instanceof Error ? err.message : undefined - }) - } finally { - mutationInFlight.current = false - if (isMounted.current) { - setBusyKind(null) - setConfirm(null) - } - } - }, [refresh]) - // Why: do NOT clear `confirm` here — the dialog must stay open for the // duration of the mutation so the spinner + busyLabel render and the - // anti-dismiss guard can actually hold. Each handler's `finally` clears + // anti-dismiss guard can actually hold. handleKillOne's `finally` clears // `confirm` when the op resolves. const runConfirmed = useCallback(() => { if (!confirm) { return } - const pending = confirm - switch (pending.kind) { - case 'killAll': - void handleKillAll() - return - case 'restart': - void handleRestart() - return - case 'killOne': - void handleKillOne(pending.session) - } - }, [confirm, handleKillAll, handleKillOne, handleRestart]) + void handleKillOne(confirm.session) + }, [confirm, handleKillOne]) const copy = useMemo(() => getConfirmCopy(confirm), [confirm]) - const isBusy = busyKind !== null + const isBusy = busyKind !== null || daemonActions.isBusy return (
@@ -410,11 +324,11 @@ export function ManageSessionsSection(): React.JSX.Element { variant="ghost" size="icon-xs" disabled={isBusy || sessionCount === 0} - onClick={() => setConfirm({ kind: 'killAll' })} + onClick={() => daemonActions.setPending('killAll')} aria-label="Kill all sessions" className="text-muted-foreground hover:text-destructive" > - {busyKind === 'killAll' ? ( + {daemonActions.busyKind === 'killAll' ? ( ) : ( @@ -431,11 +345,11 @@ export function ManageSessionsSection(): React.JSX.Element { variant="ghost" size="icon-xs" disabled={isBusy} - onClick={() => setConfirm({ kind: 'restart' })} + onClick={() => daemonActions.setPending('restart')} aria-label="Restart daemon" className="text-muted-foreground" > - {busyKind === 'restart' ? ( + {daemonActions.busyKind === 'restart' ? ( ) : ( @@ -581,6 +495,7 @@ export function ManageSessionsSection(): React.JSX.Element { ) : null} +
) } diff --git a/src/renderer/src/components/shared/useDaemonActions.tsx b/src/renderer/src/components/shared/useDaemonActions.tsx new file mode 100644 index 00000000000..b8dd7c91483 --- /dev/null +++ b/src/renderer/src/components/shared/useDaemonActions.tsx @@ -0,0 +1,207 @@ +import React, { useCallback, useState } from 'react' +import { LoaderCircle } from 'lucide-react' +import { toast } from 'sonner' +import { Button } from '../ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '../ui/dialog' + +export type DaemonActionKind = 'restart' | 'killAll' + +export type DaemonActionCallbacks = { + // Why: ManageSessionsSection owns an optimistic setSessions([]) + rollback + // pattern. Exposing lifecycle hooks lets each caller keep the state that + // belongs to it (the settings pane's list; the status bar's badge) instead + // of pulling unrelated concerns into this module. + onKillAllStart?: () => void + onKillAllError?: () => void + onKillAllSettled?: () => void + onRestartSettled?: () => void +} + +type PendingConfirm = DaemonActionKind | null + +export type DaemonActionsApi = { + pending: PendingConfirm + setPending: (kind: PendingConfirm) => void + busyKind: DaemonActionKind | null + isBusy: boolean + runRestart: () => Promise + runKillAll: () => Promise + runConfirmed: () => void +} + +export function useDaemonActions(callbacks?: DaemonActionCallbacks): DaemonActionsApi { + const [pending, setPending] = useState(null) + const [busyKind, setBusyKind] = useState(null) + + const runRestart = useCallback(async () => { + setBusyKind('restart') + try { + const { success } = await window.api.pty.management.restart() + if (success) { + toast.success('Daemon restarted.') + } else { + toast.error('Restart failed — check logs.') + } + } catch (err) { + toast.error('Restart failed.', { + description: err instanceof Error ? err.message : undefined + }) + } finally { + setBusyKind(null) + setPending(null) + callbacks?.onRestartSettled?.() + } + }, [callbacks]) + + const runKillAll = useCallback(async () => { + setBusyKind('killAll') + callbacks?.onKillAllStart?.() + try { + const { killedCount, remainingCount } = await window.api.pty.management.killAll() + if (remainingCount > 0 && killedCount > 0) { + toast.warning( + `Killed ${killedCount} of ${killedCount + remainingCount} sessions. ${remainingCount} refused to exit.` + ) + } else if (killedCount > 0) { + toast.success(`Killed ${killedCount} session${killedCount === 1 ? '' : 's'}.`) + } else if (remainingCount === 0) { + toast.info('No sessions running.') + } else { + toast.error(`${remainingCount} session${remainingCount === 1 ? '' : 's'} refused to exit.`) + } + } catch (err) { + callbacks?.onKillAllError?.() + toast.error('Couldn’t kill sessions.', { + description: err instanceof Error ? err.message : undefined + }) + } finally { + setBusyKind(null) + setPending(null) + callbacks?.onKillAllSettled?.() + } + }, [callbacks]) + + const runConfirmed = useCallback(() => { + if (pending === 'restart') { + void runRestart() + } else if (pending === 'killAll') { + void runKillAll() + } + }, [pending, runRestart, runKillAll]) + + return { + pending, + setPending, + busyKind, + isBusy: busyKind !== null, + runRestart, + runKillAll, + runConfirmed + } +} + +type CopyShape = { + title: string + description: React.ReactNode + confirmLabel: string + busyLabel: string +} + +function getCopy(kind: DaemonActionKind): CopyShape { + if (kind === 'restart') { + return { + title: 'Restart the terminal daemon?', + description: ( + <> + Kills every running terminal pane and restarts the daemon process. Panes show + “Process exited” and can be reopened immediately. Legacy-protocol sessions + from a previous app version are preserved. This can't be undone. + + ), + confirmLabel: 'Restart daemon', + busyLabel: 'Restarting…' + } + } + return { + title: 'Kill all terminal sessions?', + description: ( + <> + This force-quits every running terminal pane across all workspaces. Any unsaved work in + those sessions is lost. The daemon itself keeps running, and new terminals can be opened + immediately. This can't be undone. + + ), + confirmLabel: 'Kill all sessions', + busyLabel: 'Killing…' + } +} + +export function DaemonActionDialog({ + api, + // Why: when mounted under a Popover, we need the confirm to stay open while + // the mutation runs. The caller wires `onOpenChange` here to gate dismissal. + extraDescription +}: { + api: DaemonActionsApi + extraDescription?: React.ReactNode +}): React.JSX.Element { + const { pending, setPending, busyKind, isBusy, runConfirmed } = api + const copy = pending ? getCopy(pending) : null + return ( + { + if (open) { + return + } + if (isBusy) { + return + } + setPending(null) + }} + > + { + if (isBusy) { + e.preventDefault() + } + }} + onEscapeKeyDown={(e) => { + if (isBusy) { + e.preventDefault() + } + }} + > + {copy ? ( + <> + + {copy.title} + + {copy.description} + {extraDescription ?
{extraDescription}
: null} +
+
+ + + + + + ) : null} +
+
+ ) +} diff --git a/src/renderer/src/components/status-bar/MemoryStatusSegment.tsx b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx similarity index 56% rename from src/renderer/src/components/status-bar/MemoryStatusSegment.tsx rename to src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx index 4e314a52fbd..affa2fe7a04 100644 --- a/src/renderer/src/components/status-bar/MemoryStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx @@ -1,17 +1,30 @@ -/* eslint-disable max-lines -- Why: the popover, its sub-sections, the - sparkline, and the formatters are all small pieces that only exist to - serve this one status-bar segment. Keeping them co-located follows the - same convention as the other *StatusSegment.tsx files (see StatusBar.tsx). */ +/* eslint-disable max-lines -- Why: consolidating memory + sessions into one + surface deliberately co-locates the sparkline, worktree tree, session list, + and daemon-action footer so the popover body and badge stay consistent. + Splitting across files would scatter render-state that only exists to + serve this one status-bar segment. See docs/consolidated-resource-usage. */ import React, { memo, useCallback, useEffect, useMemo, useState } from 'react' -import { ChevronDown, ChevronRight, MemoryStick, Moon, Trash2 } from 'lucide-react' +import { + AlertTriangle, + ChevronDown, + ChevronRight, + MemoryStick, + Moon, + RotateCw, + Terminal, + Trash2 +} from 'lucide-react' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { Button } from '@/components/ui/button' import { cn } from '@/lib/utils' import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { useAppStore } from '../../store' import { useWorktreeMap } from '../../store/selectors' import { runWorktreeDelete } from '../sidebar/delete-worktree-flow' import { runSleepWorktree } from '../sidebar/sleep-worktree-flow' +import { useDaemonActions, DaemonActionDialog } from '../shared/useDaemonActions' import type { AppMemory, SessionMemory, @@ -22,16 +35,18 @@ import type { } from '../../../../shared/types' import { ORPHAN_WORKTREE_ID } from '../../../../shared/constants' -// ─── Constants ────────────────────────────────────────────────────── - const POLL_MS = 2_000 +const SESSIONS_POLL_MS = 10_000 type SortOption = 'memory' | 'cpu' | 'name' +type TabKey = 'resources' | 'sessions' 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' +type DaemonSession = { id: string; cwd: string; title: string } + // ─── Formatters ───────────────────────────────────────────────────── function formatMemory(bytes: number): string { @@ -52,7 +67,32 @@ function formatPercent(value: number): string { return `${value.toFixed(0)}%` } -// ─── Session label resolution ─────────────────────────────────────── +function shortCwd(cwd: string): string { + if (!cwd) { + return 'unknown' + } + const separator = cwd.includes('\\') ? '\\' : '/' + const parts = cwd.split(/[\\/]+/).filter(Boolean) + return parts.length > 2 ? parts.slice(-2).join(separator) : cwd +} + +function sessionLabel(session: DaemonSession, tabTitle?: string): string { + if (session.cwd) { + return shortCwd(session.cwd) + } + const sep = session.id.lastIndexOf('@@') + if (sep !== -1) { + const worktreeId = session.id.slice(0, sep) + return shortCwd(worktreeId) + } + if (tabTitle) { + return tabTitle + } + if (session.title) { + return session.title + } + return 'unknown' +} function parsePaneKey(paneKey: string | null): { tabId: string; paneRuntimeId: number } | null { if (!paneKey) { @@ -81,10 +121,6 @@ function sessionRowLabel( const tabIndex = tabs.findIndex((t) => t.id === parsed.tabId) const tab = tabIndex >= 0 ? tabs[tabIndex] : undefined if (tab) { - // Why: mirror the tab bar's label precedence (SortableTab: customTitle - // wins over the live OSC-updated title). Fall through to the runtime - // pane title so split panes stay identifiable, then the saved tab - // title, and finally a stable index-based label. const custom = tab.customTitle?.trim() if (custom) { return custom @@ -146,9 +182,6 @@ function sortWorktreesBy( } else if (sort === 'cpu') { copy.sort((a, b) => b.cpu - a.cpu) } else { - // Why labelFor instead of worktreeName: the row label prefers the - // user-editable displayName over the dirname, so alphabetical order - // needs to match what the user actually sees in the list. copy.sort((a, b) => labelFor(a).localeCompare(labelFor(b))) } return copy @@ -176,8 +209,6 @@ type SparklineProps = { function SparklineImpl({ samples, width = 48, height = 14 }: SparklineProps): React.JSX.Element { const points = useMemo(() => { - // Why: defensive against IPC payload drift during hot-reload — a missing - // or non-array history should render as a flat line, not throw. const safe = Array.isArray(samples) ? samples : [] if (safe.length < 2) { const midY = (height / 2).toFixed(1) @@ -200,7 +231,6 @@ function SparklineImpl({ samples, width = 48, height = 14 }: SparklineProps): Re const out: string[] = [] for (let i = 0; i < safe.length; i++) { const x = (i * stepX).toFixed(1) - // SVG y grows downward, so invert: larger values render higher. const y = (height - ((safe[i] - min) / range) * height).toFixed(1) out.push(`${x},${y}`) } @@ -227,8 +257,6 @@ function SparklineImpl({ samples, width = 48, height = 14 }: SparklineProps): Re ) } -/** Why memo: popover polls every 2s and shallow-equal samples shouldn't - * trigger a full re-scan of the min/max/polyline points for every row. */ const Sparkline = memo(SparklineImpl, (a, b) => { if (a.width !== b.width || a.height !== b.height) { return false @@ -269,7 +297,14 @@ function MetricPair({ ) } -// ─── Section: app (main / renderer / other) ───────────────────────── +function AppSubRow({ label, values }: { label: string; values: UsageValues }): React.JSX.Element { + return ( +
+ {label} + +
+ ) +} function AppSection({ app, @@ -319,17 +354,150 @@ function AppSection({ ) } -function AppSubRow({ label, values }: { label: string; values: UsageValues }): React.JSX.Element { +// ─── Worktree tree ────────────────────────────────────────────────── + +function WorktreeRow({ + worktree, + storeRecord, + isCollapsed, + onToggle, + onNavigate, + onSleep, + onDelete, + tabsByWorktree, + runtimePaneTitlesByTabId +}: { + worktree: WorktreeMemory + storeRecord: Worktree | null + isCollapsed: boolean + onToggle: () => void + onNavigate: () => void + onSleep: () => void + onDelete: () => void + tabsByWorktree: Record + runtimePaneTitlesByTabId: Record> +}): React.JSX.Element { + const hasSessions = worktree.sessions.length > 0 + const showActions = worktree.worktreeId !== ORPHAN_WORKTREE_ID && storeRecord !== null + const isMainWorktree = storeRecord?.isMainWorktree ?? false + const rowLabel = storeRecord?.displayName?.trim() || worktree.worktreeName + return ( -
- {label} - +
+
+ {hasSessions ? ( + + ) : ( + + )} + +
+
+ + + + {showActions && ( +
+ + + + + + Sleep — close all panels in this workspace to free memory. + + + + + + + + {isMainWorktree ? 'The main workspace cannot be deleted.' : 'Delete workspace.'} + + +
+ )} +
+ +
+
+ + {!isCollapsed && + worktree.sessions.map((session) => ( +
+ + {sessionRowLabel( + session, + worktree.worktreeId, + tabsByWorktree, + runtimePaneTitlesByTabId + )} + + +
+ ))}
) } -// ─── Section: worktree tree ───────────────────────────────────────── - function WorktreeSection({ worktrees, sortOption, @@ -351,30 +519,16 @@ function WorktreeSection({ onSleep: (worktreeId: string) => void onDelete: (worktreeId: string) => void }): React.JSX.Element { - // Why: these slices mutate frequently (runtimePaneTitlesByTabId updates on - // every terminal OSC escape). Subscribing inside WorktreeSection — which - // only mounts when the popover is open — prevents those updates from - // re-rendering the always-mounted status-bar segment. const tabsByWorktree = useAppStore((s) => s.tabsByWorktree) const runtimePaneTitlesByTabId = useAppStore((s) => s.runtimePaneTitlesByTabId) - - // Why: WorktreeMemory is a lightweight snapshot; we look the real Worktree - // record up from the store so rows can (a) disable Delete for the main - // worktree, and (b) render the user-editable displayName instead of the - // dirname. Use the shared cached selector so we don't duplicate the - // WeakMap-cached Map the rest of the app already shares. const worktreeById = useWorktreeMap() - // Shared label resolver: prefer displayName, fall back to the dirname - // carried on the memory snapshot. Used for both rendering and alpha-sort. const labelFor = useCallback( (wt: WorktreeMemory): string => worktreeById.get(wt.worktreeId)?.displayName?.trim() || wt.worktreeName, [worktreeById] ) - // Memoize grouping: popover polls every 2s, so without this we'd rebuild - // the Map + arrays on every render even when nothing changed. const repoGroups = useMemo( () => sortRepoGroupsBy(bucketByRepo(worktrees), sortOption).map((group) => ({ @@ -384,9 +538,6 @@ function WorktreeSection({ [worktrees, sortOption, labelFor] ) - // Why: when only one repo is active, the repo header row adds a useless - // level of nesting — the worktrees are the interesting thing. Flatten - // straight to worktree rows in that case. const singleRepo = repoGroups.length === 1 const renderWorktree = (wt: WorktreeMemory): React.JSX.Element => { @@ -448,217 +599,281 @@ function WorktreeSection({ ) } -function WorktreeRow({ - worktree, - storeRecord, - isCollapsed, - onToggle, - onNavigate, - onSleep, - onDelete, - tabsByWorktree, - runtimePaneTitlesByTabId +// ─── Sessions tab ─────────────────────────────────────────────────── + +function SessionsTabPanel({ + sessions, + sessionsError, + onCloseSegment, + onSessionsChanged }: { - worktree: WorktreeMemory - storeRecord: Worktree | null - isCollapsed: boolean - onToggle: () => void - onNavigate: () => void - onSleep: () => void - onDelete: () => void - tabsByWorktree: Record - runtimePaneTitlesByTabId: Record> + sessions: DaemonSession[] + sessionsError: boolean + onCloseSegment: () => void + // Why: kill actions in this panel mutate daemon state but the parent owns + // the polling timer + sessions list. Without an immediate refresh, killed + // rows linger up to the 10s poll interval. Mirror the old SessionsSegment + // behavior of refreshing right after the IPC settles. + onSessionsChanged: () => void }): React.JSX.Element { - const hasSessions = worktree.sessions.length > 0 - // Why: actions are only meaningful for real worktrees — orphan/unknown - // rows are synthetic buckets with no row to act on. - const showActions = worktree.worktreeId !== ORPHAN_WORKTREE_ID && storeRecord !== null - const isMainWorktree = storeRecord?.isMainWorktree ?? false - // Why: Worktree.displayName is the user-editable workspace name (set via - // Rename). Fall back to the dirname-shaped worktreeName from the memory - // snapshot for orphan/unresolved rows that have no store record. - const rowLabel = storeRecord?.displayName?.trim() || worktree.worktreeName + const tabsByWorktree = useAppStore((s) => s.tabsByWorktree) + const ptyIdsByTabId = useAppStore((s) => s.ptyIdsByTabId) + const runtimePaneTitlesByTabId = useAppStore((s) => s.runtimePaneTitlesByTabId) + const workspaceSessionReady = useAppStore((s) => s.workspaceSessionReady) + const setActiveTab = useAppStore((s) => s.setActiveTab) + const setActiveView = useAppStore((s) => s.setActiveView) + + const boundPtyIds = useMemo( + () => new Set(Object.values(ptyIdsByTabId).flat().filter(Boolean)), + [ptyIdsByTabId] + ) + + const ptyIdToTabId = useMemo(() => { + const map = new Map() + for (const [tabId, ptyIds] of Object.entries(ptyIdsByTabId)) { + for (const ptyId of ptyIds) { + map.set(ptyId, tabId) + } + } + return map + }, [ptyIdsByTabId]) + + const tabIdToWorktreeId = useMemo(() => { + const map = new Map() + for (const [worktreeId, tabs] of Object.entries(tabsByWorktree)) { + for (const tab of tabs) { + map.set(tab.id, worktreeId) + } + } + return map + }, [tabsByWorktree]) + + const ptyIdToTabTitle = useMemo(() => { + const tabById = new Map() + for (const tabs of Object.values(tabsByWorktree)) { + for (const tab of tabs) { + tabById.set(tab.id, tab) + } + } + const map = new Map() + for (const [tabId, ptyIds] of Object.entries(ptyIdsByTabId)) { + const tab = tabById.get(tabId) + if (!tab) { + continue + } + const runtimeTitles = runtimePaneTitlesByTabId[tabId] + const liveTitle = runtimeTitles + ? Object.values(runtimeTitles).find((t) => t?.trim()) + : undefined + const title = + tab.customTitle?.trim() || liveTitle?.trim() || tab.title || tab.defaultTitle?.trim() + if (title) { + for (const ptyId of ptyIds) { + map.set(ptyId, title) + } + } + } + return map + }, [ptyIdsByTabId, tabsByWorktree, runtimePaneTitlesByTabId]) + + const orphanCount = workspaceSessionReady + ? sessions.filter((s) => !boundPtyIds.has(s.id)).length + : 0 + + const handleKill = useCallback( + async (id: string) => { + try { + await window.api.pty.kill(id) + } catch { + /* already dead */ + } + onSessionsChanged() + }, + [onSessionsChanged] + ) + + const handleKillOrphans = useCallback(async () => { + if (!workspaceSessionReady) { + return + } + const orphans = sessions.filter((s) => !boundPtyIds.has(s.id)) + await Promise.allSettled(orphans.map((s) => window.api.pty.kill(s.id))) + onSessionsChanged() + }, [sessions, boundPtyIds, workspaceSessionReady, onSessionsChanged]) + + const handleNavigate = useCallback( + (tabId: string) => { + const worktreeId = tabIdToWorktreeId.get(tabId) + if (worktreeId) { + activateAndRevealWorktree(worktreeId) + } + setActiveView('terminal') + setActiveTab(tabId) + onCloseSegment() + }, + [tabIdToWorktreeId, setActiveView, setActiveTab, onCloseSegment] + ) + + if (sessionsError && sessions.length === 0) { + return ( +
+ Terminal sessions unavailable. +
+ ) + } return ( -
-
- {hasSessions ? ( - - ) : ( - // Why this width: matches the chevron button's pl-2 + w-3 + pr-0.5 - // footprint so rows without sessions don't shift horizontally - // relative to rows with sessions. - - )} - -
- {/* Why the relative wrapper + absolute actions: the sparkline - reserves the space so the row width never changes on hover. - The actions fade in on top of the sparkline (which fades out - in the same transition), preventing the layout "jump" that - happened when the sparkline was toggled via display:none. */} -
- - - - {showActions && ( - // Why pointer-events pairing: opacity alone leaves the buttons - // clickable when invisible (touch devices have no hover state), - // so the Delete button can fire on an accidental tap. -
- - +
+
+ Terminal Sessions ({sessions.length}) +
+ {sessions.length === 0 ? ( +
+ No active sessions +
+ ) : ( +
+ {[...sessions] + .sort((a, b) => { + const aBound = workspaceSessionReady && boundPtyIds.has(a.id) ? 0 : 1 + const bBound = workspaceSessionReady && boundPtyIds.has(b.id) ? 0 : 1 + return aBound - bBound + }) + .map((s) => { + const tabId = ptyIdToTabId.get(s.id) ?? null + const isBound = workspaceSessionReady && boundPtyIds.has(s.id) + const title = ptyIdToTabTitle.get(s.id) + return ( +
handleNavigate(tabId) : undefined} + > + +
+
+ {sessionLabel(s, title)} +
+
+ {!isBound && ( - - - Sleep — close all panels in this workspace to free memory. - - - - - - - - {isMainWorktree ? 'The main workspace cannot be deleted.' : 'Delete workspace.'} - - -
- )} -
- + )} +
+ ) + })}
-
- - {!isCollapsed && - worktree.sessions.map((session) => ( - // Why: sessionId can be null/fall back to ptyId; combine with pid - // so two sessions from the same process family don't collide in - // React reconciliation. -
0 && ( +
+
- ))} + Kill {orphanCount} Orphan{orphanCount > 1 ? 's' : ''} + +
+ )}
) - - // Why: wrap real rows in the shared context menu so right-click exposes } -// ─── Segment (top-level) ──────────────────────────────────────────── +// ─── Top-level segment ────────────────────────────────────────────── -export function MemoryStatusSegment({ +export function ResourceUsageStatusSegment({ iconOnly }: { - // Why: `compact` is accepted for uniformity with the other *StatusSegment - // components but is not used — the icon/badge layout already fits inside - // a compact status bar. 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) - // Why: worktree metadata (map, skipDeleteWorktreeConfirm, - // clearWorktreeDeleteState, openModal) is only needed at click time inside - // `deleteWorktree`. This segment is always mounted in the status bar, so - // subscribing to those slices at the top level would cause it (and every - // descendant) to re-render on unrelated worktree metadata churn - // (pin/rename/unread/session). The shared `runWorktreeDelete` helper pulls - // them imperatively via `useAppStore.getState()` instead. + const workspaceSessionReady = useAppStore((s) => s.workspaceSessionReady) + const ptyIdsByTabId = useAppStore((s) => s.ptyIdsByTabId) const [open, setOpen] = useState(false) + const [activeTab, setActiveTab] = useState('resources') const [sortOption, setSortOption] = useState('memory') const [collapsedRepos, setCollapsedRepos] = useState>(new Set()) const [collapsedWorktrees, setCollapsedWorktrees] = useState>(new Set()) - // Why: the Orca app breakdown (Main/Renderer/Other) is a diagnostic detail - // most users don't need to see every time — collapse it by default and - // surface the per-worktree usage, which is what people usually open this - // popover to investigate. const [appCollapsed, setAppCollapsed] = useState(true) + const [sessions, setSessions] = useState([]) + const [sessionsError, setSessionsError] = useState(false) - // Why: only poll while the popover is open. When closed, the badge shows - // whatever value was last fetched — good enough for a passive indicator - // and keeps us from waking the main process every few seconds. + const daemonActions = useDaemonActions({ + onRestartSettled: () => { + // Why: after a restart, clear the stale error state so the error banner + // disappears until the next poll confirms the new state. + setSessionsError(false) + void fetchSnapshot() + void refreshSessions() + }, + onKillAllSettled: () => { + void refreshSessions() + } + }) + + const refreshSessions = useCallback(async () => { + try { + const result = await window.api.pty.listSessions() + setSessions(result) + setSessionsError(false) + } catch { + setSessionsError(true) + } + }, []) + + // Poll memory + sessions when popover is open. Sessions also poll in the + // background at a slower rate so the badge count stays reasonably fresh + // without keeping the Memory IPC hot. useEffect(() => { if (!open) { return } void fetchSnapshot() - const timer = window.setInterval(() => { + void refreshSessions() + const memTimer = window.setInterval(() => { void fetchSnapshot() }, POLL_MS) - return () => window.clearInterval(timer) - }, [open, fetchSnapshot]) + const sessTimer = window.setInterval(() => { + void refreshSessions() + }, SESSIONS_POLL_MS) + return () => { + window.clearInterval(memTimer) + window.clearInterval(sessTimer) + } + }, [open, fetchSnapshot, refreshSessions]) - // Derived values are grouped into one memo so open/sort/collapse state - // changes don't recompute them. - const { totalMemory, totalCpu, hostShare, badgeLabel } = useMemo(() => { + useEffect(() => { + const interval = setInterval(() => void refreshSessions(), SESSIONS_POLL_MS) + void refreshSessions() + return () => clearInterval(interval) + }, [refreshSessions]) + + const boundPtyIds = useMemo( + () => new Set(Object.values(ptyIdsByTabId).flat().filter(Boolean)), + [ptyIdsByTabId] + ) + const orphanCount = workspaceSessionReady + ? sessions.filter((s) => !boundPtyIds.has(s.id)).length + : 0 + + const { totalMemory, totalCpu, hostShare, memBadgeLabel } = useMemo(() => { const memory = snapshot?.totalMemory ?? 0 const cpu = snapshot?.totalCpu ?? 0 const hostTotal = snapshot?.host.totalMemory ?? 0 @@ -666,14 +881,12 @@ export function MemoryStatusSegment({ totalMemory: memory, totalCpu: cpu, hostShare: hostTotal > 0 ? (memory / hostTotal) * 100 : 0, - badgeLabel: snapshot ? formatMemory(memory) : '—' + memBadgeLabel: snapshot ? formatMemory(memory) : '—' } }, [snapshot]) - // Why empty deps: these callbacks only call the state setter returned by - // useState, which React guarantees is stable across renders — so we don't - // need to list it. Wrapping in useCallback keeps the reference stable across - // the 2s polling re-renders so descendants can be memoized downstream. + const daemonUnreachable = sessionsError && memorySnapshotError !== null + const toggleRepo = useCallback((repoId: string): void => { setCollapsedRepos((prev) => { const next = new Set(prev) @@ -698,17 +911,11 @@ export function MemoryStatusSegment({ }) }, []) - // Deps intentionally empty: only uses the stable setOpen setter and - // module-level imports (ORPHAN_WORKTREE_ID, activateAndRevealWorktree). const navigateToWorktree = useCallback((worktreeId: string): void => { - // Orphan bucket has a synthetic id with no real worktree to reveal. if (worktreeId === ORPHAN_WORKTREE_ID) { setOpen(false) return } - // Why: returns false when the worktree has been deleted between - // snapshot capture and this click. Leave the popover open in that - // case so a silent no-op doesn't look like a broken button. const result = activateAndRevealWorktree(worktreeId) if (result === false) { return @@ -716,20 +923,19 @@ export function MemoryStatusSegment({ setOpen(false) }, []) - // Why this thin wrapper: the popover needs to close before the modal/toast - // appears (Radix's outside-pointerdown would otherwise dismiss the dialog). - // The actual decision tree lives in `runWorktreeDelete` so both the popover - // and the sidebar context menu stay in sync. const deleteWorktree = useCallback((worktreeId: string): void => { setOpen(false) runWorktreeDelete(worktreeId) }, []) - // Stable callback so onSleep prop identity doesn't churn across polls. const handleSleep = useCallback((id: string): void => { void runSleepWorktree(id) }, []) + const closeSegment = useCallback(() => { + setOpen(false) + }, []) + return ( @@ -738,19 +944,38 @@ export function MemoryStatusSegment({ - Memory — {badgeLabel} + Resource usage — {memBadgeLabel} · {sessions.length} session + {sessions.length === 1 ? '' : 's'} @@ -759,22 +984,34 @@ export function MemoryStatusSegment({ align="end" sideOffset={8} className="w-[26rem] p-0" - // Why: the first focusable element inside is the CPU/memory/% span - // (tabIndex=0 so the tooltip is keyboard-reachable). Without this, - // Radix auto-focuses that span on open, which triggers its tooltip - // and leaves it stuck until the user mouses over something else. onOpenAutoFocus={(event) => event.preventDefault()} > - {/* Why: the popover trigger already announces "Memory & CPU", so a - heading row is redundant. We lead with the totals — which is what - most people open this for — on a single compact line. */} + {daemonUnreachable && ( +
+ +
+
Daemon is not responding
+
+ Resource snapshots and terminal sessions are unavailable. +
+
+ +
+ )} + {snapshot && (
- {/* Why tabIndex on span triggers: Radix Tooltip needs a - focusable child for keyboard reveal; plain isn't - focusable by default. */} )} - {/* Why click-to-sort on the column headers: the headers already - label the columns, so doubling them up with a separate sort - control was pure redundancy. The active column is bolded so - users can see at a glance which one drives the order. */} - {snapshot && ( -
- -
- - + Resources + + + Terminal Sessions + {orphanCount > 0 && ({orphanCount})} + + + + + {snapshot && ( +
+ +
+ + +
+
+ )} + +
+ {snapshot && snapshot.worktrees.length > 0 && ( + + )} + + {snapshot && snapshot.worktrees.length === 0 && ( +
+ Nothing running right now +
+ )} + + {snapshot && ( + setAppCollapsed((v) => !v)} + /> + )} + + {!snapshot && !daemonUnreachable && ( +
Loading…
+ )}
-
- )} + -
- {snapshot && snapshot.worktrees.length > 0 && ( - + void refreshSessions()} /> - )} + + - {snapshot && snapshot.worktrees.length === 0 && ( -
- Nothing running right now -
- )} - - {/* Why Orca App at the bottom: it's a constant baseline everyone has, - so it's less informative than the per-worktree breakdown. Keep - it available but out of the way. */} - {snapshot && ( - setAppCollapsed((v) => !v)} - /> - )} - - {!snapshot && ( -
Loading…
- )} +
+ +
+ ) } diff --git a/src/renderer/src/components/status-bar/SessionsStatusSegment.tsx b/src/renderer/src/components/status-bar/SessionsStatusSegment.tsx deleted file mode 100644 index 72739372326..00000000000 --- a/src/renderer/src/components/status-bar/SessionsStatusSegment.tsx +++ /dev/null @@ -1,310 +0,0 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react' -import { Terminal, Trash2 } from 'lucide-react' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuSeparator, - DropdownMenuTrigger -} from '@/components/ui/dropdown-menu' -import { useAppStore } from '../../store' -import { activateAndRevealWorktree } from '@/lib/worktree-activation' - -type DaemonSession = { id: string; cwd: string; title: string } - -function shortCwd(cwd: string): string { - if (!cwd) { - return 'unknown' - } - const separator = cwd.includes('\\') ? '\\' : '/' - const parts = cwd.split(/[\\/]+/).filter(Boolean) - return parts.length > 2 ? parts.slice(-2).join(separator) : cwd -} - -function sessionLabel(session: DaemonSession, tabTitle?: string): string { - if (session.cwd) { - return shortCwd(session.cwd) - } - // Why: daemon session IDs use the format `${worktreeId}@@${shortUuid}`. - // When the shell hasn't emitted OSC 7 cwd updates (e.g. Claude Code agents), - // fall back to showing the worktreeId portion so the user can identify which - // worktree the session belongs to. - const sep = session.id.lastIndexOf('@@') - if (sep !== -1) { - const worktreeId = session.id.slice(0, sep) - return shortCwd(worktreeId) - } - // Why: for sessions with legacy numeric IDs (e.g. from LocalPtyProvider) or - // sessions whose shells haven't emitted OSC 7, use the tab title from the - // renderer store — it carries the live terminal title shown in the tab bar. - // Fall back to the daemon-reported title (typically "shell") so orphan - // sessions without a bound tab still display something meaningful. - if (tabTitle) { - return tabTitle - } - if (session.title) { - return session.title - } - return 'unknown' -} - -function SessionRow({ - session, - isBound, - tabId, - tabTitle, - onKill, - onNavigate -}: { - session: DaemonSession - isBound: boolean - tabId: string | null - tabTitle: string | undefined - onKill: (id: string) => void - onNavigate: (tabId: string) => void -}): React.JSX.Element { - return ( -
onNavigate(tabId) : undefined} - > - -
-
- {sessionLabel(session, tabTitle)} -
-
- {!isBound && ( - - )} -
- ) -} - -export function SessionsStatusSegment({ - compact: _compact, - iconOnly -}: { - compact: boolean - iconOnly: boolean -}): React.JSX.Element { - const [sessions, setSessions] = useState([]) - const [open, setOpen] = useState(false) - const tabsByWorktree = useAppStore((s) => s.tabsByWorktree) - const ptyIdsByTabId = useAppStore((s) => s.ptyIdsByTabId) - const runtimePaneTitlesByTabId = useAppStore((s) => s.runtimePaneTitlesByTabId) - const workspaceSessionReady = useAppStore((s) => s.workspaceSessionReady) - const setActiveTab = useAppStore((s) => s.setActiveTab) - const setActiveView = useAppStore((s) => s.setActiveView) - - const boundPtyIds = useMemo( - () => new Set(Object.values(ptyIdsByTabId).flat().filter(Boolean)), - [ptyIdsByTabId] - ) - - // Why: ptyIdsByTabId tracks all ptyIds a tab has ever been associated with - // (including split panes). Build a reverse map so we can navigate from a - // daemon session ID back to the tab that owns it. - const ptyIdToTabId = useMemo(() => { - const map = new Map() - for (const [tabId, ptyIds] of Object.entries(ptyIdsByTabId)) { - for (const ptyId of ptyIds) { - map.set(ptyId, tabId) - } - } - return map - }, [ptyIdsByTabId]) - - const tabIdToWorktreeId = useMemo(() => { - const map = new Map() - for (const [worktreeId, tabs] of Object.entries(tabsByWorktree)) { - for (const tab of tabs) { - map.set(tab.id, worktreeId) - } - } - return map - }, [tabsByWorktree]) - - // Why: the daemon IPC response has sparse metadata (cwd may be null, title is - // hardcoded to "shell"), but the renderer store already knows each tab's live - // title. Build a ptyId→title map so the popover can show the same name as the - // tab bar for bound sessions. - const ptyIdToTabTitle = useMemo(() => { - // Why: build a tabId→tab lookup once up-front instead of doing - // O(totalTabs) .flat().find() per ptyIdsByTabId entry. - const tabById = new Map() - for (const tabs of Object.values(tabsByWorktree)) { - for (const tab of tabs) { - tabById.set(tab.id, tab) - } - } - const map = new Map() - for (const [tabId, ptyIds] of Object.entries(ptyIdsByTabId)) { - const tab = tabById.get(tabId) - if (!tab) { - continue - } - // Why: runtimePaneTitlesByTabId holds the most current OSC-reported title - // for each mounted pane — it updates on every title change regardless of - // which split pane is focused. Prefer the first non-empty runtime pane - // title, then the persisted tab.title (which only updates for the focused - // pane), then the stable defaultTitle. - const runtimeTitles = runtimePaneTitlesByTabId[tabId] - const liveTitle = runtimeTitles - ? Object.values(runtimeTitles).find((t) => t?.trim()) - : undefined - const title = - tab.customTitle?.trim() || liveTitle?.trim() || tab.title || tab.defaultTitle?.trim() - if (title) { - for (const ptyId of ptyIds) { - map.set(ptyId, title) - } - } - } - return map - }, [ptyIdsByTabId, tabsByWorktree, runtimePaneTitlesByTabId]) - - const refresh = useCallback(async () => { - try { - const result = await window.api.pty.listSessions() - setSessions(result) - } catch { - setSessions([]) - } - }, []) - - useEffect(() => { - if (open) { - void refresh() - } - }, [open, refresh]) - - useEffect(() => { - const interval = setInterval(() => void refresh(), 10_000) - void refresh() - return () => clearInterval(interval) - }, [refresh]) - - const orphanCount = workspaceSessionReady - ? sessions.filter((s) => !boundPtyIds.has(s.id)).length - : 0 - - const handleKill = useCallback( - async (id: string) => { - try { - await window.api.pty.kill(id) - } catch { - /* already dead */ - } - await refresh() - }, - [refresh] - ) - - const handleKillOrphans = useCallback(async () => { - if (!workspaceSessionReady) { - return - } - const orphans = sessions.filter((s) => !boundPtyIds.has(s.id)) - await Promise.allSettled(orphans.map((s) => window.api.pty.kill(s.id))) - await refresh() - }, [sessions, boundPtyIds, refresh, workspaceSessionReady]) - - const handleNavigate = useCallback( - (tabId: string) => { - const worktreeId = tabIdToWorktreeId.get(tabId) - if (worktreeId) { - // Why: opening a session from the status bar is another worktree jump, - // so route it through the shared activation path before restoring the - // specific tab inside that worktree. - activateAndRevealWorktree(worktreeId) - } - setActiveView('terminal') - setActiveTab(tabId) - }, - [tabIdToWorktreeId, setActiveView, setActiveTab] - ) - - return ( - - - - - -
- Terminal Sessions ({sessions.length}) -
- {sessions.length === 0 ? ( -
- No active sessions -
- ) : ( -
- {[...sessions] - .sort((a, b) => { - const aBound = workspaceSessionReady && boundPtyIds.has(a.id) ? 0 : 1 - const bBound = workspaceSessionReady && boundPtyIds.has(b.id) ? 0 : 1 - return aBound - bBound - }) - .map((s) => { - const tabId = ptyIdToTabId.get(s.id) ?? null - return ( - - ) - })} -
- )} - {orphanCount > 0 && ( - <> - -
- -
- - )} -
-
- ) -} diff --git a/src/renderer/src/components/status-bar/StatusBar.tsx b/src/renderer/src/components/status-bar/StatusBar.tsx index 55d8c8d56c3..609473a86e8 100644 --- a/src/renderer/src/components/status-bar/StatusBar.tsx +++ b/src/renderer/src/components/status-bar/StatusBar.tsx @@ -1,15 +1,7 @@ /* eslint-disable max-lines -- Why: the status bar keeps provider rendering, interaction menus, and compact-layout behavior together so the hover/click states stay consistent across Claude and Codex. */ -import { - AlertTriangle, - ChevronDown, - ChevronRight, - MemoryStick as MemoryStickIcon, - RefreshCw, - Server, - Terminal -} from 'lucide-react' +import { AlertTriangle, Activity, ChevronDown, ChevronRight, RefreshCw, Server } from 'lucide-react' import React, { useCallback, useEffect, useRef, useState } from 'react' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { @@ -32,9 +24,8 @@ import { ClaudeIcon, GeminiIcon, OpenAIIcon, OpenCodeGoIcon } from './icons' import { formatWindowLabel } from '@/lib/window-label-formatter' import { markLiveCodexSessionsForRestart } from '@/lib/codex-session-restart' import { SshStatusSegment } from './SshStatusSegment' -import { SessionsStatusSegment } from './SessionsStatusSegment' import { UpdateStatusSegment } from './UpdateStatusSegment' -import { MemoryStatusSegment } from './MemoryStatusSegment' +import { ResourceUsageStatusSegment } from './ResourceUsageStatusSegment' import { SidekickStatusSegment } from './SidekickStatusSegment' function getCodexAccountLabel( @@ -774,9 +765,8 @@ function StatusBarInner(): React.JSX.Element | null { const showGemini = gemini !== null && statusBarItems.includes('gemini') const showOpencodeGo = opencodeGo !== null && statusBarItems.includes('opencode-go') const showSsh = statusBarItems.includes('ssh') - const showSessions = statusBarItems.includes('sessions') - const showMemory = statusBarItems.includes('memory') - const anyVisible = showClaude || showCodex || showGemini || showOpencodeGo || showMemory + const showResourceUsage = statusBarItems.includes('resource-usage') + const anyVisible = showClaude || showCodex || showGemini || showOpencodeGo || showResourceUsage const anyFetching = claude?.status === 'fetching' || codex?.status === 'fetching' || @@ -850,8 +840,7 @@ function StatusBarInner(): React.JSX.Element | null {
{sidekickEnabled && } - {showMemory && } - {showSessions && } + {showResourceUsage && } {showSsh && }
@@ -901,18 +890,11 @@ function StatusBarInner(): React.JSX.Element | null { SSH Status toggleStatusBarItem('sessions')} + checked={statusBarItems.includes('resource-usage')} + onCheckedChange={() => toggleStatusBarItem('resource-usage')} > - - Terminal Sessions - - toggleStatusBarItem('memory')} - > - - Memory + + Resource Usage diff --git a/src/renderer/src/store/slices/memory.ts b/src/renderer/src/store/slices/memory.ts index 2349fecdc0a..b24de6d0c89 100644 --- a/src/renderer/src/store/slices/memory.ts +++ b/src/renderer/src/store/slices/memory.ts @@ -4,18 +4,26 @@ import type { MemorySnapshot } from '../../../../shared/types' export type MemorySlice = { memorySnapshot: MemorySnapshot | null + memorySnapshotError: string | null fetchMemorySnapshot: () => Promise } export const createMemorySlice: StateCreator = (set) => ({ memorySnapshot: null, + memorySnapshotError: null, fetchMemorySnapshot: async () => { try { const snapshot = await window.api.memory.getSnapshot() - set({ memorySnapshot: snapshot }) + set({ memorySnapshot: snapshot, memorySnapshotError: null }) } catch (err) { + // Why: the always-on Resource Usage status-bar segment needs to know when + // the snapshot IPC is failing so it can surface a "daemon not responding" + // banner with a Restart CTA. Prior code only console.error'd. console.error('Failed to fetch memory snapshot:', err) + set({ + memorySnapshotError: err instanceof Error ? err.message : String(err) + }) } } }) diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index 455c89b9c9f..934d0b7e48a 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -40,6 +40,22 @@ import { DEFAULT_STATUS_BAR_ITEMS, DEFAULT_WORKTREE_CARD_PROPERTIES } from '../../../../shared/constants' + +// Why: persisted UI state pre-dated the consolidation of `memory` + `sessions` +// into a single `resource-usage` entry. Rewrite legacy ids in place and +// de-duplicate. We leave unknown ids alone so a downgrade→upgrade cycle +// doesn't strip a newer build's ids out of the user's settings. +function migrateStatusBarItems(items: readonly string[] | undefined): StatusBarItem[] { + const source = items ?? DEFAULT_STATUS_BAR_ITEMS + const out: string[] = [] + for (const id of source) { + const mapped = id === 'memory' || id === 'sessions' ? 'resource-usage' : id + if (!out.includes(mapped)) { + out.push(mapped) + } + } + return out as StatusBarItem[] +} import type { OrcaHookScriptKind } from '../../lib/orca-hook-trust' import { DEFAULT_SIDEKICK_ID, isBundledSidekickId } from '../../components/sidekick/sidekick-models' import { revokeCustomSidekickBlobUrl } from '../../components/sidekick/sidekick-blob-cache' @@ -648,7 +664,7 @@ export const createUISlice: StateCreator = (set, get) uiZoomLevel: ui.uiZoomLevel ?? 0, editorFontZoomLevel: ui.editorFontZoomLevel ?? 0, worktreeCardProperties: ui.worktreeCardProperties ?? [...DEFAULT_WORKTREE_CARD_PROPERTIES], - statusBarItems: ui.statusBarItems ?? [...DEFAULT_STATUS_BAR_ITEMS], + statusBarItems: migrateStatusBarItems(ui.statusBarItems), statusBarVisible: ui.statusBarVisible ?? true, // Why: absent → true so existing users see the sidekick the first time // they enable the experimental flag. Only an explicit Hide sidekick diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 7a2075acb63..0f9a3e96326 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -82,8 +82,7 @@ export const DEFAULT_STATUS_BAR_ITEMS: StatusBarItem[] = [ 'gemini', 'opencode-go', 'ssh', - 'sessions', - 'memory' + 'resource-usage' ] /** Synthetic worktree id used by the memory collector to bucket PTYs that diff --git a/src/shared/types.ts b/src/shared/types.ts index f5af7171843..424f56b5d72 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1307,14 +1307,7 @@ export type WorktreeCardProperty = // view options. | 'inline-agents' -export type StatusBarItem = - | 'claude' - | 'codex' - | 'gemini' - | 'opencode-go' - | 'ssh' - | 'sessions' - | 'memory' +export type StatusBarItem = 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'ssh' | 'resource-usage' export type TaskResumeState = { githubMode?: 'items' | 'project'