mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
feat(status-bar): restructure memory/CPU popover and add row actions (#1190)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -22,26 +22,28 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { useAppStore } from '@/store'
|
||||
import { useRepoById } from '@/store/selectors'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { Worktree } from '../../../../shared/types'
|
||||
import { isFolderRepo } from '../../../../shared/repo-kind'
|
||||
import { runWorktreeDeleteWithToast } from './delete-worktree-flow'
|
||||
import { runWorktreeDelete } from './delete-worktree-flow'
|
||||
import { runSleepWorktree } from './sleep-worktree-flow'
|
||||
|
||||
type Props = {
|
||||
worktree: Worktree
|
||||
children: React.ReactNode
|
||||
contentClassName?: string
|
||||
}
|
||||
|
||||
const CLOSE_ALL_CONTEXT_MENUS_EVENT = 'orca-close-all-context-menus'
|
||||
|
||||
const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ worktree, children }: Props) {
|
||||
const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
|
||||
worktree,
|
||||
children,
|
||||
contentClassName
|
||||
}: Props) {
|
||||
const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta)
|
||||
const openModal = useAppStore((s) => s.openModal)
|
||||
const repo = useRepoById(worktree.repoId)
|
||||
const skipDeleteConfirm = useAppStore((s) => s.settings?.skipDeleteWorktreeConfirm ?? false)
|
||||
const shutdownWorktreeTerminals = useAppStore((s) => s.shutdownWorktreeTerminals)
|
||||
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
|
||||
const setActiveWorktree = useAppStore((s) => s.setActiveWorktree)
|
||||
const clearWorktreeDeleteState = useAppStore((s) => s.clearWorktreeDeleteState)
|
||||
const deleteState = useAppStore((s) => s.deleteStateByWorktreeId[worktree.id])
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const [menuPoint, setMenuPoint] = useState({ x: 0, y: 0 })
|
||||
@@ -101,20 +103,12 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ worktree,
|
||||
}, [worktree.id, worktree.displayName, worktree.linkedIssue, worktree.comment, openModal])
|
||||
|
||||
const handleCloseTerminals = useCallback(async () => {
|
||||
// Why: shutting down the currently active worktree while its TerminalPane
|
||||
// is still visible causes a visible "reboot" flicker and can crash the
|
||||
// pane. clearTransientTerminalState nulls each tab's ptyId in place
|
||||
// without bumping generation, so TerminalPane stays mounted while its
|
||||
// PTYs are being killed; PTY exit callbacks then race against the live
|
||||
// xterm instance. Boot the user to the landing page FIRST so the visible
|
||||
// surface is detached before the async teardown runs.
|
||||
if (activeWorktreeId === worktree.id) {
|
||||
setActiveWorktree(null)
|
||||
}
|
||||
await shutdownWorktreeTerminals(worktree.id)
|
||||
}, [worktree.id, shutdownWorktreeTerminals, activeWorktreeId, setActiveWorktree])
|
||||
await runSleepWorktree(worktree.id)
|
||||
}, [worktree.id])
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
// Folder mode handled inline because it routes to a different modal;
|
||||
// standard delete delegates to the shared runWorktreeDelete helper.
|
||||
setMenuOpen(false)
|
||||
if (isFolder) {
|
||||
// Why: folder mode reuses the worktree row UI for a synthetic root entry,
|
||||
@@ -126,30 +120,12 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ worktree,
|
||||
})
|
||||
return
|
||||
}
|
||||
clearWorktreeDeleteState(worktree.id)
|
||||
// Why: when the user has opted into skipping the confirmation, jump
|
||||
// straight to the same delete-with-toast flow the dialog would run on
|
||||
// confirm. The force-delete fallback still surfaces through the toast's
|
||||
// "Force Delete" action, so the user never silently loses dirty work —
|
||||
// they just skip the redundant "are you sure?" step for clean deletes.
|
||||
// The dialog stays the entry point for the main worktree (guarded at the
|
||||
// DropdownMenuItem level) and for any worktree that becomes unavailable
|
||||
// mid-action, because those cases produce dialog-specific UI.
|
||||
if (skipDeleteConfirm && !worktree.isMainWorktree) {
|
||||
runWorktreeDeleteWithToast(worktree.id, worktree.displayName)
|
||||
return
|
||||
}
|
||||
openModal('delete-worktree', { worktreeId: worktree.id })
|
||||
}, [
|
||||
worktree.id,
|
||||
worktree.repoId,
|
||||
worktree.displayName,
|
||||
worktree.isMainWorktree,
|
||||
clearWorktreeDeleteState,
|
||||
isFolder,
|
||||
openModal,
|
||||
skipDeleteConfirm
|
||||
])
|
||||
// Why delegate to runWorktreeDelete: keeps the skip-confirm vs. modal
|
||||
// decision tree (and its rationale) in one place shared with the memory
|
||||
// popover's inline Delete action. Folder mode short-circuits above
|
||||
// because the confirm-remove-folder modal is unique to this caller.
|
||||
runWorktreeDelete(worktree.id)
|
||||
}, [worktree.id, worktree.repoId, worktree.displayName, isFolder, openModal])
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -175,7 +151,7 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ worktree,
|
||||
style={{ left: menuPoint.x, top: menuPoint.y }}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-52" sideOffset={0} align="start">
|
||||
<DropdownMenuContent className={cn('w-52', contentClassName)} sideOffset={0} align="start">
|
||||
<DropdownMenuItem onSelect={handleOpenInFinder} disabled={isDeleting}>
|
||||
<FolderOpen className="size-3.5" />
|
||||
Open in Finder
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { toast } from 'sonner'
|
||||
import { useAppStore } from '@/store'
|
||||
import { getWorktreeMapFromState } from '@/store/selectors'
|
||||
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
|
||||
import { getDeleteWorktreeToastCopy } from './delete-worktree-toast'
|
||||
|
||||
@@ -73,3 +74,43 @@ export function runWorktreeDeleteWithToast(worktreeId: string, worktreeName: str
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared funnel for the standard (non-folder) delete decision tree, called
|
||||
* from both WorktreeContextMenu and MemoryStatusSegment. Mirrors the
|
||||
* `runSleepWorktree` pattern: reads state imperatively so the helper can be
|
||||
* invoked from any handler without plumbing selectors through props, then
|
||||
* branches on the user's `skipDeleteWorktreeConfirm` preference — either
|
||||
* running the delete immediately with toast feedback, or opening the
|
||||
* confirmation modal.
|
||||
*
|
||||
* Why folder mode is handled at the call site: folder-repo removal branches
|
||||
* to a different modal (`confirm-remove-folder`) and the folder-vs-git
|
||||
* determination requires the full Worktree record's repoId. Keeping that
|
||||
* decision adjacent to the caller (rather than branching inside this helper)
|
||||
* avoids bleeding folder-mode concerns into what is otherwise a simple
|
||||
* skip-confirm-vs-modal decision, and lets the context menu short-circuit
|
||||
* before ever entering this funnel.
|
||||
*
|
||||
* The main-worktree / missing-record guard here is defense-in-depth — the
|
||||
* caller is responsible for disabling UI when this is known ahead of time,
|
||||
* but we still refuse to act if the record disappeared between render and
|
||||
* click (e.g. a concurrent delete or state reset).
|
||||
*/
|
||||
export function runWorktreeDelete(worktreeId: string): void {
|
||||
const state = useAppStore.getState()
|
||||
const target = getWorktreeMapFromState(state).get(worktreeId) ?? null
|
||||
// Guard: main worktrees cannot be deleted, and a missing record means the
|
||||
// worktree was removed out from under us — either way, no-op silently
|
||||
// rather than opening a modal with stale/invalid context.
|
||||
if (!target || target.isMainWorktree) {
|
||||
return
|
||||
}
|
||||
state.clearWorktreeDeleteState(worktreeId)
|
||||
const skipConfirm = state.settings?.skipDeleteWorktreeConfirm ?? false
|
||||
if (skipConfirm) {
|
||||
runWorktreeDeleteWithToast(worktreeId, target.displayName)
|
||||
return
|
||||
}
|
||||
state.openModal('delete-worktree', { worktreeId })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { toast } from 'sonner'
|
||||
import { useAppStore } from '@/store'
|
||||
|
||||
/**
|
||||
* Shared "sleep worktree" flow (close all panels to free memory / CPU)
|
||||
* used by WorktreeContextMenu and MemoryStatusSegment's per-row hover action.
|
||||
*
|
||||
* Why this is a module helper rather than inlined at each call site: the guard
|
||||
* that clears `activeWorktreeId` before tearing down terminals isn't optional
|
||||
* polish — shutting down the active worktree while its TerminalPane is still
|
||||
* visible causes a visible "reboot" flicker and can crash the pane (PTY exit
|
||||
* callbacks race against the live xterm instance). See the original comment
|
||||
* in WorktreeContextMenu's handleCloseTerminals for the full reasoning.
|
||||
* Centralizing the sequence here keeps that safety invariant in one place so
|
||||
* a new caller can't accidentally skip it.
|
||||
*/
|
||||
export async function runSleepWorktree(worktreeId: string): Promise<void> {
|
||||
const { activeWorktreeId, setActiveWorktree, shutdownWorktreeTerminals } = useAppStore.getState()
|
||||
if (activeWorktreeId === worktreeId) {
|
||||
setActiveWorktree(null)
|
||||
}
|
||||
try {
|
||||
await shutdownWorktreeTerminals(worktreeId)
|
||||
} catch (err) {
|
||||
// Why: callers are fire-and-forget; surface the failure as a toast and
|
||||
// otherwise continue — the active-worktree reset already happened so we
|
||||
// don't leave the UI in a stale state.
|
||||
toast.error('Failed to sleep workspace', {
|
||||
description: err instanceof Error ? err.message : String(err)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,25 +2,22 @@
|
||||
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). */
|
||||
import React, { memo, useEffect, useMemo, useState } from 'react'
|
||||
import { ArrowDownWideNarrow, ChevronDown, ChevronRight, MemoryStick } from 'lucide-react'
|
||||
import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { ChevronDown, ChevronRight, MemoryStick, Moon, Trash2 } from 'lucide-react'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
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 type {
|
||||
AppMemory,
|
||||
SessionMemory,
|
||||
TerminalTab,
|
||||
UsageValues,
|
||||
Worktree,
|
||||
WorktreeMemory
|
||||
} from '../../../../shared/types'
|
||||
import { ORPHAN_WORKTREE_ID } from '../../../../shared/constants'
|
||||
@@ -31,12 +28,6 @@ const POLL_MS = 2_000
|
||||
|
||||
type SortOption = 'memory' | 'cpu' | 'name'
|
||||
|
||||
const SORT_LABELS: Record<SortOption, string> = {
|
||||
memory: 'Memory',
|
||||
cpu: 'CPU',
|
||||
name: 'Name'
|
||||
}
|
||||
|
||||
const METRIC_COLUMNS_CLS = 'flex items-center shrink-0 tabular-nums'
|
||||
const CPU_COLUMN_CLS = 'w-12 text-right'
|
||||
const MEM_COLUMN_CLS = 'w-16 text-right'
|
||||
@@ -144,14 +135,21 @@ function bucketByRepo(worktrees: WorktreeMemory[]): RepoGroup[] {
|
||||
return [...map.values()]
|
||||
}
|
||||
|
||||
function sortWorktreesBy(list: WorktreeMemory[], sort: SortOption): WorktreeMemory[] {
|
||||
function sortWorktreesBy(
|
||||
list: WorktreeMemory[],
|
||||
sort: SortOption,
|
||||
labelFor: (wt: WorktreeMemory) => string
|
||||
): WorktreeMemory[] {
|
||||
const copy = [...list]
|
||||
if (sort === 'memory') {
|
||||
copy.sort((a, b) => b.memory - a.memory)
|
||||
} else if (sort === 'cpu') {
|
||||
copy.sort((a, b) => b.cpu - a.cpu)
|
||||
} else {
|
||||
copy.sort((a, b) => a.worktreeName.localeCompare(b.worktreeName))
|
||||
// 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
|
||||
}
|
||||
@@ -251,41 +249,7 @@ const Sparkline = memo(SparklineImpl, (a, b) => {
|
||||
return true
|
||||
})
|
||||
|
||||
// ─── Leaf UI: metric chip + row ─────────────────────────────────────
|
||||
|
||||
function MetricChip({
|
||||
label,
|
||||
value,
|
||||
tooltip
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
tooltip?: string
|
||||
}): React.JSX.Element {
|
||||
const body = (
|
||||
<div className="min-w-0 px-1 py-0.5">
|
||||
<span className="block text-[10px] text-muted-foreground uppercase tracking-wide whitespace-nowrap">
|
||||
{label}
|
||||
</span>
|
||||
<span className="block text-base leading-5 font-medium tabular-nums whitespace-nowrap text-foreground">
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
if (!tooltip) {
|
||||
return body
|
||||
}
|
||||
return (
|
||||
<Tooltip delayDuration={150}>
|
||||
<TooltipTrigger asChild>{body}</TooltipTrigger>
|
||||
{/* Why z-[70]: parent PopoverContent stacks at z-[60]; the default
|
||||
tooltip z-50 would render behind it. */}
|
||||
<TooltipContent side="top" sideOffset={6} className="z-[70] max-w-xs">
|
||||
{tooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
// ─── Leaf UI: metric row ────────────────────────────────────────────
|
||||
|
||||
function MetricPair({
|
||||
cpu,
|
||||
@@ -307,20 +271,49 @@ function MetricPair({
|
||||
|
||||
// ─── Section: app (main / renderer / other) ─────────────────────────
|
||||
|
||||
function AppSection({ app }: { app: AppMemory }): React.JSX.Element {
|
||||
function AppSection({
|
||||
app,
|
||||
isCollapsed,
|
||||
onToggle
|
||||
}: {
|
||||
app: AppMemory
|
||||
isCollapsed: boolean
|
||||
onToggle: () => void
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<div className="border-b border-border/50">
|
||||
<div className="px-3 py-2 flex items-center justify-between">
|
||||
<span className="text-xs font-medium truncate">Orca App</span>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Sparkline samples={app.history} />
|
||||
<MetricPair cpu={app.cpu} memory={app.memory} />
|
||||
<div className="border-t border-border/50">
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="pl-2 py-2 pr-0.5 transition-colors hover:bg-muted/50"
|
||||
aria-label={isCollapsed ? 'Expand Orca' : 'Collapse Orca'}
|
||||
aria-expanded={!isCollapsed}
|
||||
>
|
||||
{isCollapsed ? (
|
||||
<ChevronRight className="h-3 w-3 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
<div className="flex-1 min-w-0 py-2 pr-3 flex items-center justify-between">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide truncate text-muted-foreground">
|
||||
Orca
|
||||
</span>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Sparkline samples={app.history} />
|
||||
<MetricPair cpu={app.cpu} memory={app.memory} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AppSubRow label="Main" values={app.main} />
|
||||
<AppSubRow label="Renderer" values={app.renderer} />
|
||||
{(app.other.cpu > 0 || app.other.memory > 0) && (
|
||||
<AppSubRow label="Other" values={app.other} />
|
||||
{!isCollapsed && (
|
||||
<div className="border-t border-border/30">
|
||||
<AppSubRow label="Main" values={app.main} />
|
||||
<AppSubRow label="Renderer" values={app.renderer} />
|
||||
{(app.other.cpu > 0 || app.other.memory > 0) && (
|
||||
<AppSubRow label="Other" values={app.other} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
@@ -344,7 +337,9 @@ function WorktreeSection({
|
||||
toggleRepo,
|
||||
collapsedWorktrees,
|
||||
toggleWorktree,
|
||||
navigateToWorktree
|
||||
navigateToWorktree,
|
||||
onSleep,
|
||||
onDelete
|
||||
}: {
|
||||
worktrees: WorktreeMemory[]
|
||||
sortOption: SortOption
|
||||
@@ -353,6 +348,8 @@ function WorktreeSection({
|
||||
collapsedWorktrees: Set<string>
|
||||
toggleWorktree: (worktreeId: string) => void
|
||||
navigateToWorktree: (worktreeId: string) => void
|
||||
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
|
||||
@@ -361,17 +358,59 @@ function WorktreeSection({
|
||||
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) => ({
|
||||
...group,
|
||||
worktrees: sortWorktreesBy(group.worktrees, sortOption)
|
||||
worktrees: sortWorktreesBy(group.worktrees, sortOption, labelFor)
|
||||
})),
|
||||
[worktrees, sortOption]
|
||||
[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 => {
|
||||
const storeRecord = worktreeById.get(wt.worktreeId) ?? null
|
||||
return (
|
||||
<WorktreeRow
|
||||
key={wt.worktreeId}
|
||||
worktree={wt}
|
||||
storeRecord={storeRecord}
|
||||
isCollapsed={collapsedWorktrees.has(wt.worktreeId)}
|
||||
onToggle={() => toggleWorktree(wt.worktreeId)}
|
||||
onNavigate={() => navigateToWorktree(wt.worktreeId)}
|
||||
onSleep={() => onSleep(wt.worktreeId)}
|
||||
onDelete={() => onDelete(wt.worktreeId)}
|
||||
tabsByWorktree={tabsByWorktree}
|
||||
runtimePaneTitlesByTabId={runtimePaneTitlesByTabId}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (singleRepo) {
|
||||
return <>{repoGroups[0].worktrees.map(renderWorktree)}</>
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{repoGroups.map((group) => {
|
||||
@@ -400,19 +439,7 @@ function WorktreeSection({
|
||||
</div>
|
||||
|
||||
{!repoCollapsed && (
|
||||
<div className="border-t border-border/30">
|
||||
{group.worktrees.map((wt) => (
|
||||
<WorktreeRow
|
||||
key={wt.worktreeId}
|
||||
worktree={wt}
|
||||
isCollapsed={collapsedWorktrees.has(wt.worktreeId)}
|
||||
onToggle={() => toggleWorktree(wt.worktreeId)}
|
||||
onNavigate={() => navigateToWorktree(wt.worktreeId)}
|
||||
tabsByWorktree={tabsByWorktree}
|
||||
runtimePaneTitlesByTabId={runtimePaneTitlesByTabId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="border-t border-border/30">{group.worktrees.map(renderWorktree)}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
@@ -423,30 +450,44 @@ function WorktreeSection({
|
||||
|
||||
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<string, TerminalTab[]>
|
||||
runtimePaneTitlesByTabId: Record<string, Record<number, string>>
|
||||
}): 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
|
||||
|
||||
return (
|
||||
<div className="border-b border-border/20 last:border-b-0">
|
||||
<div className="flex items-center ml-2">
|
||||
{hasSessions && (
|
||||
<div className="group/wtrow flex items-center ml-2 transition-colors hover:bg-muted/60">
|
||||
{hasSessions ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="pl-2 py-2 pr-0.5 transition-colors shrink-0 hover:bg-muted/60"
|
||||
aria-label={isCollapsed ? 'Expand worktree' : 'Collapse worktree'}
|
||||
className="pl-2 py-2 pr-0.5 shrink-0"
|
||||
aria-label={isCollapsed ? 'Expand workspace' : 'Collapse workspace'}
|
||||
>
|
||||
{isCollapsed ? (
|
||||
<ChevronRight className="h-3 w-3 text-muted-foreground" />
|
||||
@@ -454,22 +495,94 @@ function WorktreeRow({
|
||||
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
// 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.
|
||||
<span
|
||||
className="pl-2 py-2 pr-0.5 shrink-0 w-[calc(0.5rem+0.75rem+0.125rem)]"
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNavigate}
|
||||
aria-label={`Open worktree ${worktree.worktreeName}`}
|
||||
className={cn(
|
||||
'flex-1 min-w-0 py-2 pr-3 flex items-center justify-between transition-colors hover:bg-muted/60',
|
||||
hasSessions ? 'pl-1' : 'pl-3'
|
||||
)}
|
||||
aria-label={`Open workspace ${rowLabel}`}
|
||||
className="flex-1 min-w-0 py-2 pr-2 pl-1 text-left"
|
||||
>
|
||||
<span className="text-xs font-medium truncate min-w-0 mr-2">{worktree.worktreeName}</span>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Sparkline samples={worktree.history} />
|
||||
<MetricPair cpu={worktree.cpu} memory={worktree.memory} />
|
||||
</div>
|
||||
<span className="text-xs font-medium truncate block">{rowLabel}</span>
|
||||
</button>
|
||||
<div className="flex items-center gap-2 shrink-0 pr-3">
|
||||
{/* 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. */}
|
||||
<div className="relative">
|
||||
<span
|
||||
className={cn(
|
||||
'block transition-opacity',
|
||||
showActions &&
|
||||
'group-hover/wtrow:opacity-0 group-hover/wtrow:pointer-events-none group-focus-within/wtrow:opacity-0 group-focus-within/wtrow:pointer-events-none'
|
||||
)}
|
||||
aria-hidden={showActions ? undefined : true}
|
||||
>
|
||||
<Sparkline samples={worktree.history} />
|
||||
</span>
|
||||
{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.
|
||||
<div className="absolute inset-0 flex items-center justify-end gap-0.5 opacity-0 pointer-events-none transition-opacity group-hover/wtrow:opacity-100 group-hover/wtrow:pointer-events-auto group-focus-within/wtrow:opacity-100 group-focus-within/wtrow:pointer-events-auto">
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSleep}
|
||||
aria-label={`Sleep workspace ${rowLabel}`}
|
||||
className="p-0.5 rounded text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
>
|
||||
<Moon className="size-3" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
sideOffset={4}
|
||||
className="z-[70] max-w-[200px] text-pretty"
|
||||
>
|
||||
Sleep — close all panels in this workspace to free memory.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
disabled={isMainWorktree}
|
||||
aria-label={`Delete workspace ${rowLabel}`}
|
||||
className={cn(
|
||||
'p-0.5 rounded text-muted-foreground transition-colors',
|
||||
isMainWorktree
|
||||
? 'opacity-40 cursor-not-allowed'
|
||||
: 'hover:bg-destructive/10 hover:text-destructive'
|
||||
)}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
sideOffset={4}
|
||||
className="z-[70] max-w-[200px] text-pretty"
|
||||
>
|
||||
{isMainWorktree ? 'The main workspace cannot be deleted.' : 'Delete workspace.'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<MetricPair cpu={worktree.cpu} memory={worktree.memory} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isCollapsed &&
|
||||
@@ -494,6 +607,8 @@ function WorktreeRow({
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
// Why: wrap real rows in the shared context menu so right-click exposes
|
||||
}
|
||||
|
||||
// ─── Segment (top-level) ────────────────────────────────────────────
|
||||
@@ -509,11 +624,23 @@ export function MemoryStatusSegment({
|
||||
}): React.JSX.Element {
|
||||
const snapshot = useAppStore((s) => s.memorySnapshot)
|
||||
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 [open, setOpen] = useState(false)
|
||||
const [sortOption, setSortOption] = useState<SortOption>('memory')
|
||||
const [collapsedRepos, setCollapsedRepos] = useState<Set<string>>(new Set())
|
||||
const [collapsedWorktrees, setCollapsedWorktrees] = useState<Set<string>>(new Set())
|
||||
// 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)
|
||||
|
||||
// Why: only poll while the popover is open. When closed, the badge shows
|
||||
// whatever value was last fetched — good enough for a passive indicator
|
||||
@@ -543,7 +670,11 @@ export function MemoryStatusSegment({
|
||||
}
|
||||
}, [snapshot])
|
||||
|
||||
const toggleRepo = (repoId: string): void => {
|
||||
// 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 toggleRepo = useCallback((repoId: string): void => {
|
||||
setCollapsedRepos((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(repoId)) {
|
||||
@@ -553,9 +684,9 @@ export function MemoryStatusSegment({
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
const toggleWorktree = (worktreeId: string): void => {
|
||||
const toggleWorktree = useCallback((worktreeId: string): void => {
|
||||
setCollapsedWorktrees((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(worktreeId)) {
|
||||
@@ -565,9 +696,11 @@ export function MemoryStatusSegment({
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
const navigateToWorktree = (worktreeId: string): void => {
|
||||
// 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)
|
||||
@@ -581,7 +714,21 @@ export function MemoryStatusSegment({
|
||||
return
|
||||
}
|
||||
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)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
@@ -607,66 +754,122 @@ export function MemoryStatusSegment({
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<PopoverContent side="top" align="end" sideOffset={8} className="w-[26rem] p-0">
|
||||
<div className="p-3 border-b border-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
Memory & CPU
|
||||
</h4>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[11px] text-muted-foreground hover:bg-muted transition-colors"
|
||||
aria-label="Sort worktrees"
|
||||
<PopoverContent
|
||||
side="top"
|
||||
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. */}
|
||||
{snapshot && (
|
||||
<div className="px-3 py-2 border-b border-border flex items-baseline gap-3 text-xs tabular-nums">
|
||||
<Tooltip delayDuration={200}>
|
||||
<TooltipTrigger asChild>
|
||||
{/* Why tabIndex on span triggers: Radix Tooltip needs a
|
||||
focusable child for keyboard reveal; plain <span> isn't
|
||||
focusable by default. */}
|
||||
<span
|
||||
tabIndex={0}
|
||||
className="font-medium text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:rounded"
|
||||
>
|
||||
<ArrowDownWideNarrow className="h-3.5 w-3.5" />
|
||||
<span>{SORT_LABELS[sortOption]}</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
{/* Why z-[70]: PopoverContent is z-[60]; the default dropdown
|
||||
z-50 would render behind it. */}
|
||||
<DropdownMenuContent align="end" className="w-40 z-[70]">
|
||||
<DropdownMenuRadioGroup
|
||||
value={sortOption}
|
||||
onValueChange={(value) => {
|
||||
if (value === 'memory' || value === 'cpu' || value === 'name') {
|
||||
setSortOption(value)
|
||||
}
|
||||
}}
|
||||
{formatCpu(totalCpu)}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={6} className="z-[70] max-w-xs">
|
||||
Combined CPU load. Values above 100% mean more than one core is working at once.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<span className="text-muted-foreground/50">·</span>
|
||||
<Tooltip delayDuration={200}>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
tabIndex={0}
|
||||
className="font-medium text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:rounded"
|
||||
>
|
||||
<DropdownMenuRadioItem value="memory">Memory</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="cpu">CPU</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="name">Name</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{formatMemory(totalMemory)}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={6} className="z-[70] max-w-xs">
|
||||
Resident memory held by Orca plus the processes under each worktree's
|
||||
terminals.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<span className="text-muted-foreground/50">·</span>
|
||||
<Tooltip delayDuration={200}>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
tabIndex={0}
|
||||
className="text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:rounded"
|
||||
>
|
||||
{formatPercent(hostShare)} of system RAM
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={6} className="z-[70] max-w-xs">
|
||||
How much of this machine's physical RAM the Orca-tracked processes are sitting
|
||||
on.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{snapshot && (
|
||||
<div className="mt-2 grid grid-cols-3 gap-2">
|
||||
<MetricChip
|
||||
label="CPU"
|
||||
value={formatCpu(totalCpu)}
|
||||
tooltip="Combined CPU load across the Orca app and the shell subtrees Orca launched. Values above 100% mean more than one core is working at once."
|
||||
/>
|
||||
<MetricChip
|
||||
label="Memory"
|
||||
value={formatMemory(totalMemory)}
|
||||
tooltip="Resident memory held by the Orca app plus the processes under each worktree's terminals. A number that only climbs usually points at a worktree keeping something alive."
|
||||
/>
|
||||
<MetricChip
|
||||
label="% of system RAM"
|
||||
value={formatPercent(hostShare)}
|
||||
tooltip="How much of this machine's physical RAM the Orca-tracked processes are sitting on. A low number here while the system feels slow means the pressure is coming from something else."
|
||||
/>
|
||||
{/* 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 && (
|
||||
<div className="flex items-center justify-between px-3 py-1 bg-muted/30 border-b border-border/50 text-[10px] uppercase tracking-wide">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSortOption('name')}
|
||||
className={cn(
|
||||
'hover:text-foreground transition-colors',
|
||||
sortOption === 'name' ? 'font-semibold text-foreground' : 'text-muted-foreground/80'
|
||||
)}
|
||||
aria-pressed={sortOption === 'name'}
|
||||
>
|
||||
Name
|
||||
</button>
|
||||
<div className={cn(METRIC_COLUMNS_CLS, 'text-[10px]')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSortOption('cpu')}
|
||||
className={cn(
|
||||
CPU_COLUMN_CLS,
|
||||
'hover:text-foreground transition-colors',
|
||||
sortOption === 'cpu'
|
||||
? 'font-semibold text-foreground'
|
||||
: 'text-muted-foreground/80'
|
||||
)}
|
||||
aria-pressed={sortOption === 'cpu'}
|
||||
>
|
||||
CPU
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSortOption('memory')}
|
||||
className={cn(
|
||||
MEM_COLUMN_CLS,
|
||||
'hover:text-foreground transition-colors',
|
||||
sortOption === 'memory'
|
||||
? 'font-semibold text-foreground'
|
||||
: 'text-muted-foreground/80'
|
||||
)}
|
||||
aria-pressed={sortOption === 'memory'}
|
||||
>
|
||||
Memory
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="max-h-[50vh] overflow-y-auto scrollbar-sleek">
|
||||
{snapshot && <AppSection app={snapshot.app} />}
|
||||
|
||||
{snapshot && snapshot.worktrees.length > 0 && (
|
||||
<WorktreeSection
|
||||
worktrees={snapshot.worktrees}
|
||||
@@ -676,6 +879,8 @@ export function MemoryStatusSegment({
|
||||
collapsedWorktrees={collapsedWorktrees}
|
||||
toggleWorktree={toggleWorktree}
|
||||
navigateToWorktree={navigateToWorktree}
|
||||
onSleep={handleSleep}
|
||||
onDelete={deleteWorktree}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -685,6 +890,17 @@ export function MemoryStatusSegment({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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 && (
|
||||
<AppSection
|
||||
app={snapshot.app}
|
||||
isCollapsed={appCollapsed}
|
||||
onToggle={() => setAppCollapsed((v) => !v)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!snapshot && (
|
||||
<div className="px-3 py-4 text-center text-xs text-muted-foreground">Loading…</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user