fix: close workspace hovercard on context menu (#2347)

This commit is contained in:
Neil 2026-05-19 14:00:37 -07:00 committed by GitHub
parent f010bb8619
commit 73ff6e43dd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 102 additions and 14 deletions

View File

@ -1,4 +1,4 @@
import React, { useCallback, useMemo } from 'react'
import React, { useCallback, useMemo, useRef, useState } from 'react'
import { Pin } from 'lucide-react'
import { useAppStore } from '@/store'
import { Badge } from '@/components/ui/badge'
@ -10,6 +10,7 @@ import type { Repo, Worktree } from '../../../../shared/types'
import WorktreeCard from './WorktreeCard'
import { WorktreeActivityStatusIndicator } from './WorktreeActivityStatusIndicator'
import WorktreeContextMenu from './WorktreeContextMenu'
import { getWorkspaceKanbanDetailsHoverOpenState } from './workspace-kanban-details-hover'
import { writeWorkspaceDragData } from './workspace-status'
type WorkspaceKanbanCardProps = {
@ -106,6 +107,8 @@ function WorkspaceKanbanCompactCard({
}: Omit<WorkspaceKanbanCardProps, 'compact'>): React.JSX.Element {
const deleteState = useAppStore((s) => s.deleteStateByWorktreeId[worktree.id])
const isDeleting = deleteState?.isDeleting ?? false
const [detailsOpen, setDetailsOpen] = useState(false)
const contextMenuOpenRef = useRef(false)
const contextWorktrees = useMemo(
() =>
isSelected && selectedWorktrees && selectedWorktrees.length > 0
@ -150,13 +153,45 @@ function WorkspaceKanbanCompactCard({
[contextWorktrees, isDeleting, isSelected, worktree.id]
)
const handleDetailsOpenChange = useCallback((requestedOpen: boolean) => {
setDetailsOpen(
getWorkspaceKanbanDetailsHoverOpenState({
contextMenuOpen: contextMenuOpenRef.current,
requestedOpen
})
)
}, [])
const handleContextMenuOpenChange = useCallback((open: boolean) => {
contextMenuOpenRef.current = open
if (open) {
// Why: the preview sits beside the compact card, so it should disappear
// as soon as the card's context menu becomes the active surface.
setDetailsOpen(false)
}
}, [])
const handleContextMenuSelect = useCallback(
(event: React.MouseEvent<HTMLElement>) => {
setDetailsOpen(false)
return onContextMenuSelect(event, worktree)
},
[onContextMenuSelect, worktree]
)
return (
<WorktreeContextMenu
worktree={worktree}
selectedWorktrees={contextWorktrees}
onContextMenuSelect={(event) => onContextMenuSelect(event, worktree)}
onContextMenuSelect={handleContextMenuSelect}
onOpenChange={handleContextMenuOpenChange}
>
<HoverCard openDelay={450} closeDelay={100}>
<HoverCard
open={detailsOpen}
onOpenChange={handleDetailsOpenChange}
openDelay={450}
closeDelay={100}
>
<HoverCardTrigger asChild>
<button
type="button"
@ -209,7 +244,12 @@ function WorkspaceKanbanCompactCard({
) : null}
</button>
</HoverCardTrigger>
<HoverCardContent side="right" align="start" sideOffset={8} className="w-72 p-1.5">
<HoverCardContent
side="right"
align="start"
sideOffset={8}
className="w-72 p-1.5 data-[state=closed]:hidden"
>
<WorktreeCard
worktree={worktree}
repo={repo}

View File

@ -46,6 +46,7 @@ type Props = {
contentClassName?: string
selectedWorktrees?: readonly Worktree[]
onContextMenuSelect?: (event: React.MouseEvent<HTMLElement>) => readonly Worktree[]
onOpenChange?: (open: boolean) => void
}
const CLOSE_ALL_CONTEXT_MENUS_EVENT = 'orca-close-all-context-menus'
@ -154,7 +155,8 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
children,
contentClassName,
selectedWorktrees = [worktree],
onContextMenuSelect
onContextMenuSelect,
onOpenChange
}: Props) {
const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta)
const workspaceStatuses = useAppStore((s) => s.workspaceStatuses)
@ -225,11 +227,19 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
const validParentWorktreeId = lineageInfo.state === 'valid' ? lineageInfo.parent.id : null
const hasAnyContextLineage = activeContextWorktrees.some((item) => worktreeLineageById[item.id])
const setMenuOpenState = useCallback(
(open: boolean) => {
setMenuOpen(open)
onOpenChange?.(open)
},
[onOpenChange]
)
useEffect(() => {
const closeMenu = (): void => setMenuOpen(false)
const closeMenu = (): void => setMenuOpenState(false)
window.addEventListener(CLOSE_ALL_CONTEXT_MENUS_EVENT, closeMenu)
return () => window.removeEventListener(CLOSE_ALL_CONTEXT_MENUS_EVENT, closeMenu)
}, [])
}, [setMenuOpenState])
const handleCopyPath = useCallback(() => {
window.api.ui.writeClipboardText(worktree.path)
@ -245,7 +255,7 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
const handleAssignWorkspaceStatus = useCallback(
(status: string) => {
setMenuOpen(false)
setMenuOpenState(false)
void Promise.all(
activeContextWorktrees.map((item) =>
getWorkspaceStatus(item, workspaceStatuses) === status
@ -254,7 +264,7 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
)
)
},
[activeContextWorktrees, updateWorktreeMeta, workspaceStatuses]
[activeContextWorktrees, setMenuOpenState, updateWorktreeMeta, workspaceStatuses]
)
const handleRename = useCallback(() => {
@ -277,14 +287,14 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
const handleCloseTerminals = useCallback(() => {
const worktreeIds = sleepableWorktrees.map((item) => item.id)
setMenuOpen(false)
setMenuOpenState(false)
// Why: Sleep can remount the sidebar when it clears the active workspace.
// Let Radix finish closing the menu first so its focus/portal teardown
// cannot scroll the virtualized list during that remount.
window.setTimeout(() => {
void runSleepWorktrees(worktreeIds)
}, 50)
}, [sleepableWorktrees])
}, [setMenuOpenState, sleepableWorktrees])
const handleDelete = useCallback(() => {
// Folder mode handled inline because it routes to a different modal;
@ -293,7 +303,7 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
scopeRef.current
?.closest('[data-worktree-sidebar]')
?.dispatchEvent(new Event(VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT))
setMenuOpen(false)
setMenuOpenState(false)
// Why: Delete can remove the active row and remount the sidebar. Run it
// after menu close for the same reason as Sleep above.
window.setTimeout(() => {
@ -325,6 +335,7 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
isFolder,
isMultiContext,
openModal,
setMenuOpenState,
worktree.displayName,
worktree.id,
worktree.repoId
@ -389,14 +400,14 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
setContextWorktrees(onContextMenuSelect?.(event) ?? selectedWorktrees)
const bounds = event.currentTarget.getBoundingClientRect()
setMenuPoint({ x: event.clientX - bounds.left, y: event.clientY - bounds.top })
setMenuOpen(true)
setMenuOpenState(true)
}}
onClickCapture={(event) => {
suppressOpeningPointerEvent(event)
}}
>
{children}
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen} modal={false}>
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpenState} modal={false}>
<DropdownMenuTrigger asChild>
<button
aria-hidden

View File

@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest'
import { getWorkspaceKanbanDetailsHoverOpenState } from './workspace-kanban-details-hover'
describe('getWorkspaceKanbanDetailsHoverOpenState', () => {
it('keeps the details hover closed while the context menu is open', () => {
expect(
getWorkspaceKanbanDetailsHoverOpenState({
contextMenuOpen: true,
requestedOpen: true
})
).toBe(false)
})
it('follows hover requests when the context menu is closed', () => {
expect(
getWorkspaceKanbanDetailsHoverOpenState({
contextMenuOpen: false,
requestedOpen: true
})
).toBe(true)
expect(
getWorkspaceKanbanDetailsHoverOpenState({
contextMenuOpen: false,
requestedOpen: false
})
).toBe(false)
})
})

View File

@ -0,0 +1,9 @@
export function getWorkspaceKanbanDetailsHoverOpenState({
contextMenuOpen,
requestedOpen
}: {
contextMenuOpen: boolean
requestedOpen: boolean
}): boolean {
return contextMenuOpen ? false : requestedOpen
}