diff --git a/src/renderer/src/components/sidebar/WorktreeCard.quick-actions.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.quick-actions.test.tsx index d543de8b77f..63b37973a07 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.quick-actions.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.quick-actions.test.tsx @@ -149,7 +149,7 @@ describe('WorktreeCard quick actions', () => { expect(markup).toContain('aria-label="Delete workspace"') }) - it('shows sleep as the top-right quick action for a workspace with live activity', () => { + it('does not replace sleep with delete for a workspace with live activity', () => { const worktree = makeWorktree() tabsByWorktree = { [worktree.id]: [{ id: 'tab-1' }] } ptyIdsByTabId = { 'tab-1': ['pty-1'] } @@ -158,6 +158,30 @@ describe('WorktreeCard quick actions', () => { ) - expect(markup).toContain('aria-label="Sleep workspace"') + expect(markup).not.toContain('aria-label="Sleep workspace"') + expect(markup).not.toContain('aria-label="Delete workspace"') + }) + + it('does not show sleep as the top-right quick action for an active workspace', () => { + const worktree = makeWorktree() + tabsByWorktree = { [worktree.id]: [{ id: 'tab-1' }] } + ptyIdsByTabId = { 'tab-1': ['pty-1'] } + + const markup = renderToStaticMarkup( + + ) + + expect(markup).not.toContain('aria-label="Sleep workspace"') + expect(markup).not.toContain('aria-label="Delete workspace"') + }) + + it('does not show delete when the workspace is current but not selected in the sidebar', () => { + const worktree = makeWorktree() + + const markup = renderToStaticMarkup( + + ) + + expect(markup).not.toContain('aria-label="Delete workspace"') }) }) diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx index bc333e31e23..e6bf514960e 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx @@ -11,7 +11,6 @@ import { ChevronDown, GitMerge, LoaderCircle, - Moon, Server, ServerOff, Trash2, @@ -47,14 +46,12 @@ import { getWorkspacePortsByWorktreeId } from '@/lib/workspace-port-groups' import { hasActiveWorkspaceActivity } from '@/lib/worktree-activity-state' import { installWindowVisibilityInterval, isWindowVisible } from '@/lib/window-visibility-interval' import { runWorktreeDelete } from './delete-worktree-flow' -import { runSleepWorktree } from './sleep-worktree-flow' -import { getWorkspaceQuickActionKind } from './worktree-card-quick-action' -import { useMacOptionKeyPressed } from './mac-option-key-state' type WorktreeCardProps = { worktree: Worktree repo: Repo | undefined isActive: boolean + isCurrentWorktree?: boolean isActiveSurface?: boolean isMultiSelected?: boolean selectedWorktrees?: readonly Worktree[] @@ -90,6 +87,7 @@ const WorktreeCard = React.memo(function WorktreeCard({ worktree, repo, isActive, + isCurrentWorktree = isActive, isActiveSurface = isActive, isMultiSelected = false, selectedWorktrees, @@ -162,7 +160,6 @@ const WorktreeCard = React.memo(function WorktreeCard({ }) const isSshDisconnected = sshStatus != null && sshStatus !== 'connected' const [showDisconnectedDialog, setShowDisconnectedDialog] = useState(false) - const isMacOptionPressed = useMacOptionKeyPressed() // Why: on restart the previously-active worktree is auto-restored without a // click, so the dialog never opens. Auto-show it for the active card when SSH @@ -414,30 +411,20 @@ const WorktreeCard = React.memo(function WorktreeCard({ }, [worktree.id, worktree.isUnread, updateWorktreeMeta] ) - const quickActionKind = getWorkspaceQuickActionKind({ - hasActiveActivity, - isDeletable: !worktree.isMainWorktree, - isInactive: !hasActiveActivity, - isMacOptionPressed - }) + // Why: deleting the active/current workspace or one with live activity is a + // disruptive hover action; keep the quick action delete-only and passive. + const showDeleteQuickAction = + !isCurrentWorktree && !hasActiveActivity && !worktree.isMainWorktree const handleWorkspaceQuickAction = useCallback( (event: React.MouseEvent) => { event.preventDefault() event.stopPropagation() - if (quickActionKind === 'sleep') { - void runSleepWorktree(worktree.id) - } else if (quickActionKind === 'delete') { + if (showDeleteQuickAction) { runWorktreeDelete(worktree.id) } }, - [quickActionKind, worktree.id] + [showDeleteQuickAction, worktree.id] ) - const quickActionLabel = - quickActionKind === 'sleep' - ? 'Sleep workspace' - : quickActionKind === 'delete' - ? 'Delete workspace' - : '' const unreadTooltip = worktree.isUnread ? 'Mark read' : 'Mark unread' const childWorkspaceLabel = `${lineageChildCount} child ${ @@ -696,7 +683,7 @@ const WorktreeCard = React.memo(function WorktreeCard({ )} - {quickActionKind && !isDeleting && ( + {showDeleteQuickAction && !isDeleting && (
@@ -708,21 +695,15 @@ const WorktreeCard = React.memo(function WorktreeCard({ className={cn( 'inline-flex size-4 items-center justify-center rounded bg-transparent opacity-0 transition-colors transition-opacity', 'group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100', - quickActionKind === 'delete' - ? 'text-muted-foreground hover:bg-transparent hover:text-foreground focus-visible:bg-transparent focus-visible:text-foreground' - : 'text-muted-foreground hover:bg-transparent hover:text-foreground focus-visible:bg-transparent focus-visible:text-foreground' + 'text-muted-foreground hover:bg-transparent hover:text-foreground focus-visible:bg-transparent focus-visible:text-foreground' )} - aria-label={quickActionLabel} + aria-label="Delete workspace" > - {quickActionKind === 'delete' ? ( - - ) : ( - - )} + - {quickActionKind === 'delete' ? 'Delete workspace' : 'Sleep workspace'} + Delete workspace
diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 4eb0aae9241..83fd63393ff 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -227,6 +227,7 @@ const SIDEBAR_POINTER_DRAG_THRESHOLD_PX = 4 type VirtualizedWorktreeViewportProps = { rows: Row[] activeWorktreeId: string | null + currentWorktreeId: string | null groupBy: WorktreeGroupBy repoGroupOrdering: RepoGroupOrdering toggleGroup: (key: string) => void @@ -519,6 +520,7 @@ function getVirtualRowKey(element: Element): string | null { const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewport({ rows, activeWorktreeId, + currentWorktreeId, groupBy, repoGroupOrdering, toggleGroup, @@ -2120,6 +2122,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp worktree={itemRow.worktree} repo={itemRow.repo} isActive={activeWorktreeId === itemRow.worktree.id} + isCurrentWorktree={currentWorktreeId === itemRow.worktree.id} // Why: a child-active parent should look active without // running active-card side effects such as SSH reconnect UI. isActiveSurface={forceActiveSurface || activeWorktreeId === itemRow.worktree.id} @@ -3034,6 +3037,7 @@ const WorktreeList = React.memo(function WorktreeList({ key={viewportResetKey} rows={rows} activeWorktreeId={selectedSidebarWorktreeId} + currentWorktreeId={activeWorktreeId} groupBy={groupBy} repoGroupOrdering={repoGroupOrdering} toggleGroup={toggleGroup} diff --git a/src/renderer/src/components/sidebar/mac-option-key-state.test.ts b/src/renderer/src/components/sidebar/mac-option-key-state.test.ts deleted file mode 100644 index a5f7f0ae2d7..00000000000 --- a/src/renderer/src/components/sidebar/mac-option-key-state.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' - -type StoredListener = (event: KeyboardEvent) => void - -function createWindowStub(): { - addEventListener: ReturnType - removeEventListener: ReturnType - dispatch: (type: string, event: KeyboardEvent) => void -} { - const listeners = new Map>() - return { - addEventListener: vi.fn((type: string, listener: StoredListener) => { - const bucket = listeners.get(type) ?? new Set() - bucket.add(listener) - listeners.set(type, bucket) - }), - removeEventListener: vi.fn((type: string, listener: StoredListener) => { - listeners.get(type)?.delete(listener) - }), - dispatch: (type, event) => { - for (const listener of listeners.get(type) ?? []) { - listener(event) - } - } - } -} - -describe('mac option key state', () => { - afterEach(() => { - vi.unstubAllGlobals() - vi.resetModules() - }) - - it('shares one window listener set across subscribers and only notifies on value changes', async () => { - const windowStub = createWindowStub() - vi.stubGlobal('navigator', { userAgent: 'Macintosh' }) - vi.stubGlobal('window', windowStub) - const { getMacOptionKeySnapshot, subscribeMacOptionKey } = - await import('./mac-option-key-state') - const first = vi.fn() - const second = vi.fn() - - const unsubscribeFirst = subscribeMacOptionKey(first) - const unsubscribeSecond = subscribeMacOptionKey(second) - - expect(windowStub.addEventListener).toHaveBeenCalledTimes(3) - windowStub.dispatch('keydown', { altKey: true } as KeyboardEvent) - expect(getMacOptionKeySnapshot()).toBe(true) - expect(first).toHaveBeenCalledTimes(1) - expect(second).toHaveBeenCalledTimes(1) - - windowStub.dispatch('keydown', { altKey: true } as KeyboardEvent) - expect(first).toHaveBeenCalledTimes(1) - expect(second).toHaveBeenCalledTimes(1) - - unsubscribeFirst() - expect(windowStub.removeEventListener).not.toHaveBeenCalled() - windowStub.dispatch('keyup', { altKey: false } as KeyboardEvent) - expect(first).toHaveBeenCalledTimes(1) - expect(second).toHaveBeenCalledTimes(2) - - unsubscribeSecond() - expect(windowStub.removeEventListener).toHaveBeenCalledTimes(3) - expect(getMacOptionKeySnapshot()).toBe(false) - }) - - it('does not attach keyboard listeners on non-mac platforms', async () => { - const windowStub = createWindowStub() - vi.stubGlobal('navigator', { userAgent: 'Windows' }) - vi.stubGlobal('window', windowStub) - const { getMacOptionKeySnapshot, subscribeMacOptionKey } = - await import('./mac-option-key-state') - - const unsubscribe = subscribeMacOptionKey(vi.fn()) - - expect(windowStub.addEventListener).not.toHaveBeenCalled() - expect(getMacOptionKeySnapshot()).toBe(false) - unsubscribe() - }) -}) diff --git a/src/renderer/src/components/sidebar/mac-option-key-state.ts b/src/renderer/src/components/sidebar/mac-option-key-state.ts deleted file mode 100644 index 10096eec064..00000000000 --- a/src/renderer/src/components/sidebar/mac-option-key-state.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { useSyncExternalStore } from 'react' - -type OptionKeyListener = () => void - -let optionPressed = false -const listeners = new Set() -let disposeWindowListeners: (() => void) | null = null - -function isMacPlatform(): boolean { - return typeof navigator !== 'undefined' && navigator.userAgent.includes('Mac') -} - -function setOptionPressed(nextPressed: boolean): void { - if (optionPressed === nextPressed) { - return - } - optionPressed = nextPressed - for (const listener of listeners) { - listener() - } -} - -function startWindowListeners(): void { - if (disposeWindowListeners || !isMacPlatform() || typeof window === 'undefined') { - return - } - - const handleKeyChange = (event: KeyboardEvent): void => setOptionPressed(event.altKey) - const handleWindowBlur = (): void => setOptionPressed(false) - window.addEventListener('keydown', handleKeyChange, true) - window.addEventListener('keyup', handleKeyChange, true) - window.addEventListener('blur', handleWindowBlur) - disposeWindowListeners = () => { - window.removeEventListener('keydown', handleKeyChange, true) - window.removeEventListener('keyup', handleKeyChange, true) - window.removeEventListener('blur', handleWindowBlur) - } -} - -export function subscribeMacOptionKey(listener: OptionKeyListener): () => void { - if (!isMacPlatform()) { - return () => undefined - } - listeners.add(listener) - startWindowListeners() - return () => { - listeners.delete(listener) - if (listeners.size > 0) { - return - } - disposeWindowListeners?.() - disposeWindowListeners = null - setOptionPressed(false) - } -} - -export function getMacOptionKeySnapshot(): boolean { - return isMacPlatform() ? optionPressed : false -} - -export function useMacOptionKeyPressed(): boolean { - // Why: the sidebar can render dozens of cards. One shared external store - // avoids a global key listener per card and only re-renders on Option flips. - return useSyncExternalStore(subscribeMacOptionKey, getMacOptionKeySnapshot, () => false) -} diff --git a/src/renderer/src/components/sidebar/worktree-card-quick-action.ts b/src/renderer/src/components/sidebar/worktree-card-quick-action.ts deleted file mode 100644 index f5bad051e2b..00000000000 --- a/src/renderer/src/components/sidebar/worktree-card-quick-action.ts +++ /dev/null @@ -1,21 +0,0 @@ -export type WorktreeCardQuickActionKind = 'sleep' | 'delete' | null - -export function getWorkspaceQuickActionKind({ - hasActiveActivity, - isDeletable, - isInactive, - isMacOptionPressed -}: { - hasActiveActivity: boolean - isDeletable: boolean - isInactive: boolean - isMacOptionPressed: boolean -}): WorktreeCardQuickActionKind { - if (isInactive) { - return isDeletable ? 'delete' : null - } - if (hasActiveActivity) { - return isMacOptionPressed && isDeletable ? 'delete' : 'sleep' - } - return null -} diff --git a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx index 33ca8ea7a10..1d9193a5f25 100644 --- a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx @@ -11,7 +11,6 @@ import { ChevronRight, LoaderCircle, MemoryStick, - Moon, RotateCw, Terminal, Trash2, @@ -35,7 +34,6 @@ import { installWindowVisibilityInterval } from '@/lib/window-visibility-interva import { useAppStore } from '../../store' import { useWorktreeMap } from '../../store/selectors' import { runWorktreeDelete } from '../sidebar/delete-worktree-flow' -import { runSleepWorktree } from '../sidebar/sleep-worktree-flow' import { useDaemonActions, DaemonActionDialog } from '../shared/useDaemonActions' import type { AppMemory, UsageValues, Worktree } from '../../../../shared/types' import { ORPHAN_WORKTREE_ID } from '../../../../shared/constants' @@ -394,20 +392,20 @@ function SessionRow({ function WorktreeRow({ worktree, storeRecord, + activeWorktreeId, isCollapsed, onToggle, onNavigate, - onSleep, onDelete, onKillSession, navigateToTab }: { worktree: UnifiedWorktreeRow storeRecord: Worktree | null + activeWorktreeId: string | null isCollapsed: boolean onToggle: () => void onNavigate: () => void - onSleep: () => void onDelete: () => void onKillSession: (session: UnifiedSessionRow) => void navigateToTab: (tabId: string, paneKey: string | null) => void @@ -420,10 +418,12 @@ function WorktreeRow({ const isSynthetic = worktree.worktreeId === ORPHAN_WORKTREE_ID || worktree.repoId === UNATTRIBUTED_REPO_ID const isNavigable = !isSynthetic - // Why: Sleep / Delete affordances act on a sidebar worktree record; without + // Why: Delete acts on a sidebar worktree record; without // one (synthesized SSH rows whose worktreeId isn't in worktreeById, or - // synthetic buckets) we hide them but keep the row clickable for navigation. - const showWorktreeActions = !isSynthetic && storeRecord !== null + // synthetic buckets), or for the active worktree, we hide it but keep the + // row clickable for navigation. + const showWorktreeActions = + !isSynthetic && storeRecord !== null && worktree.worktreeId !== activeWorktreeId const isMainWorktree = storeRecord?.isMainWorktree ?? false const rowLabel = storeRecord?.displayName?.trim() || worktree.worktreeName @@ -481,25 +481,6 @@ function WorktreeRow({ {showWorktreeActions && (
- - - - - - Sleep — close all panels in this workspace to free memory. - -