fix: remove sleep hover quick actions (#2697)

This commit is contained in:
Jinjing 2026-05-23 12:47:10 -07:00 committed by GitHub
parent 7b0a1faac1
commit cfd758ef73
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 55 additions and 234 deletions

View File

@ -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', () => {
<WorktreeCard worktree={worktree} repo={makeRepo()} isActive={false} />
)
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(
<WorktreeCard worktree={worktree} repo={makeRepo()} isActive />
)
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(
<WorktreeCard worktree={worktree} repo={makeRepo()} isActive={false} isCurrentWorktree />
)
expect(markup).not.toContain('aria-label="Delete workspace"')
})
})

View File

@ -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<HTMLButtonElement>) => {
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({
)}
</div>
{quickActionKind && !isDeleting && (
{showDeleteQuickAction && !isDeleting && (
<div className="ml-auto flex shrink-0 items-center justify-center pr-1.5">
<Tooltip>
<TooltipTrigger asChild>
@ -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' ? (
<Trash2 className="size-3.5" />
) : (
<Moon className="size-3.5" />
)}
<Trash2 className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
{quickActionKind === 'delete' ? 'Delete workspace' : 'Sleep workspace'}
Delete workspace
</TooltipContent>
</Tooltip>
</div>

View File

@ -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}

View File

@ -1,80 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
type StoredListener = (event: KeyboardEvent) => void
function createWindowStub(): {
addEventListener: ReturnType<typeof vi.fn>
removeEventListener: ReturnType<typeof vi.fn>
dispatch: (type: string, event: KeyboardEvent) => void
} {
const listeners = new Map<string, Set<StoredListener>>()
return {
addEventListener: vi.fn((type: string, listener: StoredListener) => {
const bucket = listeners.get(type) ?? new Set<StoredListener>()
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()
})
})

View File

@ -1,65 +0,0 @@
import { useSyncExternalStore } from 'react'
type OptionKeyListener = () => void
let optionPressed = false
const listeners = new Set<OptionKeyListener>()
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)
}

View File

@ -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
}

View File

@ -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({
</span>
{showWorktreeActions && (
<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
@ -555,10 +536,10 @@ function ResourceTree({
collapsedRepos,
toggleRepo,
collapsedWorktrees,
activeWorktreeId,
toggleWorktree,
navigateToWorktree,
navigateToTab,
onSleep,
onDelete,
onKillSession
}: {
@ -567,10 +548,10 @@ function ResourceTree({
collapsedRepos: Set<string>
toggleRepo: (repoId: string) => void
collapsedWorktrees: Set<string>
activeWorktreeId: string | null
toggleWorktree: (worktreeId: string) => void
navigateToWorktree: (worktreeId: string) => void
navigateToTab: (tabId: string, paneKey: string | null) => void
onSleep: (worktreeId: string) => void
onDelete: (worktreeId: string) => void
onKillSession: (session: UnifiedSessionRow) => void
}): React.JSX.Element {
@ -591,10 +572,10 @@ function ResourceTree({
key={wt.worktreeId}
worktree={wt}
storeRecord={storeRecord}
activeWorktreeId={activeWorktreeId}
isCollapsed={collapsedWorktrees.has(wt.worktreeId)}
onToggle={() => toggleWorktree(wt.worktreeId)}
onNavigate={() => navigateToWorktree(wt.worktreeId)}
onSleep={() => onSleep(wt.worktreeId)}
onDelete={() => onDelete(wt.worktreeId)}
onKillSession={onKillSession}
navigateToTab={navigateToTab}
@ -670,6 +651,7 @@ export function ResourceUsageStatusSegment({
const openModal = useAppStore((s) => s.openModal)
const openSpacePage = useAppStore((s) => s.openSpacePage)
const activeView = useAppStore((s) => s.activeView)
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
const workspaceSpaceScannedAt = useAppStore((s) => s.workspaceSpaceAnalysis?.scannedAt ?? null)
const workspaceSpaceScanning = useAppStore((s) => s.workspaceSpaceScanning)
const activeRuntimeEnvironmentId = useAppStore(
@ -986,10 +968,6 @@ export function ResourceUsageStatusSegment({
runWorktreeDelete(worktreeId)
}, [])
const handleSleep = useCallback((id: string): void => {
void runSleepWorktree(id)
}, [])
const handleOpenWorkspaceCleanup = useCallback((): void => {
if (runtimeEnvironmentActive) {
return
@ -1352,10 +1330,10 @@ export function ResourceUsageStatusSegment({
collapsedRepos={collapsedRepos}
toggleRepo={toggleRepo}
collapsedWorktrees={collapsedWorktrees}
activeWorktreeId={activeWorktreeId}
toggleWorktree={toggleWorktree}
navigateToWorktree={navigateToWorktree}
navigateToTab={navigateToTab}
onSleep={handleSleep}
onDelete={deleteWorktree}
onKillSession={handleKillSession}
/>