mirror of
https://github.com/stablyai/orca.git
synced 2026-10-13 08:02:52 +00:00
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:
@@ -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'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{' '}
|
||||
<span className="font-medium text-foreground">{confirm.session.sessionId}</span>. 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 <span className="font-medium text-foreground">{confirm.session.sessionId}</span>
|
||||
. 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 (
|
||||
<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
|
||||
“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 (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
+616
-327
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user