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 <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-05-05 14:31:36 -04:00
co-authored by Orca
parent 5ce9c3b481
commit 913174a45c
10 changed files with 918 additions and 813 deletions
@@ -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.'
}
]
@@ -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&apos;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
&ldquo;Process exited&rdquo; and can be reopened immediately. Legacy-protocol sessions
from a previous app version are preserved. This can&apos;t be undone.
</>
),
confirmLabel: 'Restart daemon',
busyLabel: 'Restarting…'
}
case 'killOne':
return {
title: 'Kill this session?',
description: (
<>
Force-quits{' '}
<span className="font-medium text-foreground">{confirm.session.sessionId}</span>. Any
unsaved work in that pane is lost. This can&apos;t be undone.
</>
),
confirmLabel: 'Kill session',
busyLabel: 'Killing…'
}
return {
title: 'Kill this session?',
description: (
<>
Force-quits <span className="font-medium text-foreground">{confirm.session.sessionId}</span>
. Any unsaved work in that pane is lost. This can&apos;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 (
<section className="space-y-4">
@@ -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' ? (
<LoaderCircle className="animate-spin" />
) : (
<Trash2 />
@@ -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' ? (
<LoaderCircle className="animate-spin" />
) : (
<RotateCw />
@@ -581,6 +495,7 @@ export function ManageSessionsSection(): React.JSX.Element {
) : null}
</DialogContent>
</Dialog>
<DaemonActionDialog api={daemonActions} />
</section>
)
}
@@ -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<void>
runKillAll: () => Promise<void>
runConfirmed: () => void
}
export function useDaemonActions(callbacks?: DaemonActionCallbacks): DaemonActionsApi {
const [pending, setPending] = useState<PendingConfirm>(null)
const [busyKind, setBusyKind] = useState<DaemonActionKind | null>(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
&ldquo;Process exited&rdquo; and can be reopened immediately. Legacy-protocol sessions
from a previous app version are preserved. This can&apos;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&apos;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 (
<Dialog
open={pending !== null}
onOpenChange={(open) => {
if (open) {
return
}
if (isBusy) {
return
}
setPending(null)
}}
>
<DialogContent
className="max-w-md"
showCloseButton={!isBusy}
onPointerDownOutside={(e) => {
if (isBusy) {
e.preventDefault()
}
}}
onEscapeKeyDown={(e) => {
if (isBusy) {
e.preventDefault()
}
}}
>
{copy ? (
<>
<DialogHeader>
<DialogTitle className="text-sm">{copy.title}</DialogTitle>
<DialogDescription className="text-xs">
{copy.description}
{extraDescription ? <div className="mt-2">{extraDescription}</div> : null}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setPending(null)} disabled={isBusy}>
Cancel
</Button>
<Button variant="destructive" onClick={runConfirmed} disabled={isBusy}>
{isBusy ? <LoaderCircle className="size-4 animate-spin" /> : null}
{isBusy && busyKind === pending ? copy.busyLabel : copy.confirmLabel}
</Button>
</DialogFooter>
</>
) : null}
</DialogContent>
</Dialog>
)
}
@@ -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 (
<div
className={`flex items-center gap-2 px-2 py-1.5 rounded ${
tabId ? 'cursor-pointer hover:bg-accent/60' : ''
}`}
onClick={tabId ? () => onNavigate(tabId) : undefined}
>
<span
className={`size-1.5 shrink-0 rounded-full ${isBound ? 'bg-emerald-500' : 'bg-muted-foreground/40'}`}
/>
<div className="min-w-0 flex-1">
<div className="truncate text-[12px] font-medium font-mono">
{sessionLabel(session, tabTitle)}
</div>
</div>
{!isBound && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onKill(session.id)
}}
className="shrink-0 rounded p-0.5 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
aria-label={`Kill session ${session.id}`}
>
<Trash2 className="size-3" />
</button>
)}
</div>
)
}
export function SessionsStatusSegment({
compact: _compact,
iconOnly
}: {
compact: boolean
iconOnly: boolean
}): React.JSX.Element {
const [sessions, setSessions] = useState<DaemonSession[]>([])
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<string, string>()
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<string, string>()
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<string, (typeof tabsByWorktree)[string][number]>()
for (const tabs of Object.values(tabsByWorktree)) {
for (const tab of tabs) {
tabById.set(tab.id, tab)
}
}
const map = new Map<string, string>()
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 (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<button
type="button"
className="inline-flex items-center gap-1.5 cursor-pointer rounded px-1 py-0.5 hover:bg-accent/70"
aria-label="Terminal sessions"
>
<Terminal className="size-3 text-muted-foreground" />
{!iconOnly && (
<span className="text-[11px] tabular-nums">
{sessions.length}
{orphanCount > 0 && <span className="text-yellow-500 ml-0.5">({orphanCount})</span>}
</span>
)}
{iconOnly && sessions.length > 0 && (
<span className="text-[11px] tabular-nums">{sessions.length}</span>
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="end" sideOffset={8} className="w-[260px]">
<div className="px-2 pt-1.5 pb-1 text-[10px] font-medium uppercase tracking-[0.08em] text-muted-foreground">
Terminal Sessions ({sessions.length})
</div>
{sessions.length === 0 ? (
<div className="px-2 py-3 text-center text-[11px] text-muted-foreground">
No active sessions
</div>
) : (
<div className="max-h-[240px] overflow-y-auto scrollbar-sleek">
{[...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 (
<SessionRow
key={s.id}
session={s}
isBound={workspaceSessionReady && boundPtyIds.has(s.id)}
tabId={tabId}
tabTitle={ptyIdToTabTitle.get(s.id)}
onKill={handleKill}
onNavigate={handleNavigate}
/>
)
})}
</div>
)}
{orphanCount > 0 && (
<>
<DropdownMenuSeparator />
<div className="px-2 py-2">
<button
type="button"
onClick={() => void handleKillOrphans()}
className="inline-flex w-full items-center justify-center rounded-md border border-border/70 px-2.5 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-accent/60"
>
Kill {orphanCount} Orphan{orphanCount > 1 ? 's' : ''}
</button>
</div>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -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 {
<div className="flex items-center gap-3">
<UpdateStatusSegment compact={compact} iconOnly={iconOnly} />
{sidekickEnabled && <SidekickStatusSegment />}
{showMemory && <MemoryStatusSegment compact={compact} iconOnly={iconOnly} />}
{showSessions && <SessionsStatusSegment compact={compact} iconOnly={iconOnly} />}
{showResourceUsage && <ResourceUsageStatusSegment compact={compact} iconOnly={iconOnly} />}
{showSsh && <SshStatusSegment compact={compact} iconOnly={iconOnly} />}
</div>
@@ -901,18 +890,11 @@ function StatusBarInner(): React.JSX.Element | null {
SSH Status
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={statusBarItems.includes('sessions')}
onCheckedChange={() => toggleStatusBarItem('sessions')}
checked={statusBarItems.includes('resource-usage')}
onCheckedChange={() => toggleStatusBarItem('resource-usage')}
>
<Terminal className="size-3.5" />
Terminal Sessions
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={statusBarItems.includes('memory')}
onCheckedChange={() => toggleStatusBarItem('memory')}
>
<MemoryStickIcon className="size-3.5" />
Memory
<Activity className="size-3.5" />
Resource Usage
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>
+9 -1
View File
@@ -4,18 +4,26 @@ import type { MemorySnapshot } from '../../../../shared/types'
export type MemorySlice = {
memorySnapshot: MemorySnapshot | null
memorySnapshotError: string | null
fetchMemorySnapshot: () => Promise<void>
}
export const createMemorySlice: StateCreator<AppState, [], [], MemorySlice> = (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)
})
}
}
})
+17 -1
View File
@@ -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<AppState, [], [], UISlice> = (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
+1 -2
View File
@@ -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
+1 -8
View File
@@ -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'