fix(sidebar): keep the workspace hover card open behind link actions (#33409)

* fix(sidebar): keep the workspace hover card open behind link actions and system-browser opens

* fix(sidebar): scope the unfocused hover hold to external opens and fix chooser dismissal edge cases

* perf(sidebar): register the refocus listener only while a hover hold is set

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
OrcaWin
2026-10-24 23:07:54 -07:00
committed by GitHub
co-authored by OrcaWin
parent e1066973f2
commit 4633fa9d07
7 changed files with 205 additions and 39 deletions
@@ -55,10 +55,8 @@ export function ActivityThreadHoverCard({
}: ActivityThreadHoverCardProps): React.JSX.Element {
const detailsHoverControl = useWorktreeCardDetailsHoverControl()
const open = detailsHoverControl.hoverOpen && !suppressed
const { linkActionRequest, onOpenLink, closeLinkActions } = useWorkspaceReferenceLinkActions(
thread.worktree.id,
detailsHoverControl.closeHover
)
const { linkActionPopoverProps, onOpenLink, onAuxClickCapture } =
useWorkspaceReferenceLinkActions(thread.worktree.id, detailsHoverControl)
return (
<HoverCard
@@ -68,7 +66,7 @@ export function ActivityThreadHoverCard({
closeDelay={closeDelay}
>
<span className="contents" onClick={(event) => event.stopPropagation()}>
<LinkActionPopover request={linkActionRequest} onClose={closeLinkActions} />
<LinkActionPopover {...linkActionPopoverProps} />
</span>
<ActivityThreadHoverCardTrigger
open={open}
@@ -80,6 +78,7 @@ export function ActivityThreadHoverCard({
<ActivityThreadHoverCardContent
thread={thread}
onOpenLink={onOpenLink}
onAuxClickCapture={onAuxClickCapture}
detailsHoverControl={detailsHoverControl}
onJumpToWorkspace={onJumpToWorkspace}
canJumpToWorkspace={canJumpToWorkspace}
@@ -92,12 +91,14 @@ export function ActivityThreadHoverCard({
function ActivityThreadHoverCardContent({
thread,
onOpenLink,
onAuxClickCapture,
detailsHoverControl,
onJumpToWorkspace,
canJumpToWorkspace
}: {
thread: AgentPaneThread
onOpenLink: (url: string, event: React.MouseEvent) => void
onAuxClickCapture: (event: React.MouseEvent) => void
detailsHoverControl: ReturnType<typeof useWorktreeCardDetailsHoverControl>
onJumpToWorkspace?: (thread: AgentPaneThread) => void
canJumpToWorkspace?: boolean
@@ -222,6 +223,7 @@ function ActivityThreadHoverCardContent({
{...{ [WORKTREE_NATIVE_CONTEXT_MENU_ATTR]: '' }}
onClick={(event) => event.stopPropagation()}
onDoubleClick={(event) => event.stopPropagation()}
onAuxClickCapture={onAuxClickCapture}
>
<SelectedTextCopyMenu className="space-y-3">
<ActivityThreadHoverCardSummary
@@ -14,6 +14,8 @@ import type { LinkAction, LinkActionRequest } from './link-action-request'
type LinkActionPopoverProps<TRequest extends LinkActionRequest> = {
request: TRequest | null
onClose: (dismissed?: TRequest) => void
onPointerDownOutside?: (event: Event) => void
onOpenSettings?: () => void
}
function ActionRow({
@@ -54,7 +56,9 @@ function ActionRow({
/** Shared by the terminal and native chat: pick where a clicked link opens. */
export function LinkActionPopover<TRequest extends LinkActionRequest>({
request,
onClose
onClose,
onPointerDownOutside,
onOpenSettings
}: LinkActionPopoverProps<TRequest>): React.JSX.Element {
const openSettingsPage = useAppStore((state) => state.openSettingsPage)
const openSettingsTarget = useAppStore((state) => state.openSettingsTarget)
@@ -113,6 +117,7 @@ export function LinkActionPopover<TRequest extends LinkActionRequest>({
const openTerminalLinkSettings = (): void => {
onClose()
onOpenSettings?.()
openSettingsTarget({
pane: 'browser',
repoId: null,
@@ -139,6 +144,7 @@ export function LinkActionPopover<TRequest extends LinkActionRequest>({
onOpenAutoFocus={(event) => event.preventDefault()}
onCloseAutoFocus={(event) => event.preventDefault()}
onEscapeKeyDown={() => request.restoreFocus()}
onPointerDownOutside={onPointerDownOutside}
>
<div className="mb-0.5 flex items-center gap-1 overflow-hidden border-b border-border px-1.5 py-0.5 font-mono text-xs text-muted-foreground">
<span
@@ -173,7 +173,7 @@ describe('workspace linked references in hover details', () => {
expect(screen.queryByText('Used for checks')).toBeNull()
expect(screen.getAllByLabelText('Checks: Passing')).toHaveLength(1)
})
it('offers Orca link destinations without activating the parent and dismisses hover', () => {
it('offers Orca link destinations without activating the parent or dismissing hover', () => {
const openUrl = vi.fn()
Object.defineProperty(window, 'api', { configurable: true, value: { shell: { openUrl } } })
const parentClick = vi.fn()
@@ -189,7 +189,7 @@ describe('workspace linked references in hover details', () => {
expect(screen.getByRole('button', { name: /System Browser/ })).toBeTruthy()
expect(parentClick).not.toHaveBeenCalled()
expect(container.querySelector('[data-hover-open]')?.getAttribute('data-hover-open')).toBe(
'false'
'true'
)
})
it('opens link management from either section header and dismisses hover', () => {
@@ -89,6 +89,7 @@ export function WorktreeCardDetailsHover({
hoverControl
}: WorktreeCardDetailsHoverProps): React.JSX.Element {
const internalHoverControl = useWorktreeCardDetailsHoverControl()
const detailsHoverControl = hoverControl ?? internalHoverControl
const {
hoverOpen,
issueMenuOpen,
@@ -97,11 +98,9 @@ export function WorktreeCardDetailsHover({
handleIssueMenuOpenChange,
handleReviewMenuOpenChange,
closeHover
} = hoverControl ?? internalHoverControl
const { linkActionRequest, onOpenLink, closeLinkActions } = useWorkspaceReferenceLinkActions(
workspace?.id,
closeHover
)
} = detailsHoverControl
const { linkActionPopoverProps, onOpenLink, onAuxClickCapture } =
useWorkspaceReferenceLinkActions(workspace?.id, detailsHoverControl)
const [workspaceTitleEditing, setWorkspaceTitleEditing] = React.useState(false)
const pendingWorkspaceTitleCloseRef = React.useRef(false)
const handleWorkspaceTitleEditingChange = React.useCallback(
@@ -191,7 +190,7 @@ export function WorktreeCardDetailsHover({
closeDelay={closeDelay}
>
<span className="contents" onClick={(event) => event.stopPropagation()}>
<LinkActionPopover request={linkActionRequest} onClose={closeLinkActions} />
<LinkActionPopover {...linkActionPopoverProps} />
</span>
<HoverCardTrigger
asChild
@@ -207,6 +206,7 @@ export function WorktreeCardDetailsHover({
{...{ [WORKTREE_NATIVE_CONTEXT_MENU_ATTR]: '' }}
onClick={(event) => event.stopPropagation()}
onDoubleClick={(event) => event.stopPropagation()}
onAuxClickCapture={onAuxClickCapture}
>
<SelectedTextCopyMenu className="space-y-3">
{showIdentityHeader && (
@@ -1,29 +1,47 @@
import { useState, type MouseEvent } from 'react'
import type { LinkActionRequest } from '@/components/link-actions/link-action-request'
import type { LinkAction, LinkActionRequest } from '@/components/link-actions/link-action-request'
import { handleNativeChatWebLink } from '@/components/native-chat/native-chat-web-link-actions'
import { resolveNativeChatHttpLinkRouting } from '@/components/native-chat/native-chat-link-target-actions'
import { nativeChatPlainLinkClickBehavior } from '@/components/native-chat/native-chat-link-click-behavior'
import { isTerminalLinkDirectActivation } from '@/components/terminal-pane/terminal-link-activation'
import { httpLinkDirectClickDestination } from '@/lib/http-link-destinations'
import { useAppStore } from '@/store'
import type { WorktreeCardDetailsHoverControl } from './worktree-card-details-hover-state'
export function useWorkspaceReferenceLinkActions(
workspaceId: string | undefined,
closeHover: () => void
{ closeHover, handleLinkActionsOpenChange, holdOpenUntilRefocus }: WorktreeCardDetailsHoverControl
) {
const [linkActionRequest, setLinkActionRequest] = useState<LinkActionRequest | null>(null)
// Why: only an Orca-browser open moves the user elsewhere in the app; a
// system-browser open should leave the card where it was.
const closeHoverOnInternalRun = (action: LinkAction): LinkAction => ({
...action,
run: () => {
if (action.external) {
holdOpenUntilRefocus()
} else {
closeHover()
}
return action.run()
}
})
const onOpenLink = (url: string, event: MouseEvent): void => {
if (!workspaceId) {
event.preventDefault()
event.stopPropagation()
holdOpenUntilRefocus()
void window.api.shell.openUrl(url)
closeHover()
return
}
const state = useAppStore.getState()
const anchor = event.currentTarget
const plainClickBehavior = nativeChatPlainLinkClickBehavior(state.settings)
handleNativeChatWebLink(event, url, {
const routing = resolveNativeChatHttpLinkRouting(state, workspaceId)
let requested = false
const consumed = handleNativeChatWebLink(event, url, {
worktreeId: workspaceId,
...resolveNativeChatHttpLinkRouting(state, workspaceId),
...routing,
actionsEnabled: plainClickBehavior === 'actions',
plainClickBehavior,
restoreFocus: () => {
@@ -31,9 +49,54 @@ export function useWorkspaceReferenceLinkActions(
anchor.focus({ preventScroll: true })
}
},
request: setLinkActionRequest
request: (request) => {
requested = true
handleLinkActionsOpenChange(true)
setLinkActionRequest({
...request,
primary: closeHoverOnInternalRun(request.primary),
alternate: request.alternate && closeHoverOnInternalRun(request.alternate),
secondaryActions: request.secondaryActions?.map(closeHoverOnInternalRun)
})
}
})
closeHover()
if (requested) {
return
}
const destination = !consumed
? 'system'
: isTerminalLinkDirectActivation(event)
? httpLinkDirectClickDestination(routing.destinations, event.shiftKey)
: routing.destinations.primary
if (destination === 'system') {
holdOpenUntilRefocus()
} else {
closeHover()
}
}
return { linkActionRequest, onOpenLink, closeLinkActions: () => setLinkActionRequest(null) }
const closeLinkActions = (): void => {
setLinkActionRequest(null)
handleLinkActionsOpenChange(false)
}
const linkActionPopoverProps = {
request: linkActionRequest,
onClose: closeLinkActions,
// Why: a click on the card itself only dismisses the chooser; anywhere else dismisses both.
onPointerDownOutside: (event: Event): void => {
if (
!(event.target instanceof Element) ||
!event.target.closest('[data-slot="hover-card-content"]')
) {
closeHover()
}
},
onOpenSettings: closeHover
}
// Why: middle-click takes the anchor's native target=_blank path to the system browser.
const onAuxClickCapture = (event: MouseEvent): void => {
if (event.button === 1 && event.target instanceof Element && event.target.closest('a[href]')) {
holdOpenUntilRefocus()
}
}
return { linkActionPopoverProps, onOpenLink, onAuxClickCapture }
}
@@ -2,7 +2,7 @@
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { useWorktreeCardDetailsHoverControl } from './worktree-card-details-hover-state'
type HoverControlSnapshot = ReturnType<typeof useWorktreeCardDetailsHoverControl>
@@ -105,4 +105,55 @@ describe('useWorktreeCardDetailsHoverControl', () => {
expect(control?.hoverOpen).toBe(false)
expect(control?.reviewMenuOpen).toBe(false)
})
it('keeps a menu that opened in the same click that dismissed the link chooser', () => {
mountProbe()
act(() => {
control?.handleHoverOpenChange(true)
control?.handleLinkActionsOpenChange(true)
control?.handleReviewMenuOpenChange(true)
control?.handleLinkActionsOpenChange(false)
})
expect(control?.reviewMenuOpen).toBe(true)
})
it('keeps the card open behind the link chooser after the pointer moves onto it', () => {
mountProbe()
act(() => {
control?.handleHoverOpenChange(true)
control?.handleLinkActionsOpenChange(true)
control?.handleHoverOpenChange(false)
control?.handleLinkActionsOpenChange(false)
})
expect(control?.hoverOpen).toBe(true)
})
it('holds the card open while unfocused only after an external link open', () => {
mountProbe()
const hasFocus = vi.spyOn(document, 'hasFocus').mockReturnValue(false)
act(() => {
control?.handleHoverOpenChange(true)
control?.handleHoverOpenChange(false)
})
expect(control?.hoverOpen).toBe(false)
act(() => {
control?.handleHoverOpenChange(true)
control?.holdOpenUntilRefocus()
control?.handleHoverOpenChange(false)
})
expect(control?.hoverOpen).toBe(true)
act(() => {
window.dispatchEvent(new Event('focus'))
control?.handleHoverOpenChange(false)
})
expect(control?.hoverOpen).toBe(false)
hasFocus.mockRestore()
})
})
@@ -1,38 +1,80 @@
import { useCallback, useRef, useState } from 'react'
type WorktreeCardDetailMenu = 'issue' | 'review'
type WorktreeCardDetailMenu = 'issue' | 'review' | 'link-actions'
export function useWorktreeCardDetailsHoverControl() {
const [open, setOpen] = useState(false)
const [openMenu, setOpenMenu] = useState<WorktreeCardDetailMenu | null>(null)
const [openMenu, setOpenMenuState] = useState<WorktreeCardDetailMenu | null>(null)
const openMenuRef = useRef<WorktreeCardDetailMenu | null>(null)
const pendingHoverCloseRef = useRef(false)
const holdOpenUntilRefocusRef = useRef(false)
const setOpenMenu = useCallback((menu: WorktreeCardDetailMenu | null) => {
openMenuRef.current = menu
setOpenMenuState(menu)
}, [])
const closeHover = useCallback(() => {
pendingHoverCloseRef.current = false
holdOpenUntilRefocusRef.current = false
setOpenMenu(null)
setOpen(false)
}, [setOpenMenu])
const handleHoverOpenChange = useCallback((next: boolean) => {
// Why: portaled detail menus sit outside HoverCardContent — keep the card
// mounted until the menu closes so the menu items stay clickable.
if (openMenuRef.current) {
pendingHoverCloseRef.current = !next
return
}
// Why: the system browser opened from this card takes focus and the
// pointer; keep the card for when the user comes back.
if (!next && holdOpenUntilRefocusRef.current && !document.hasFocus()) {
return
}
pendingHoverCloseRef.current = false
if (!next) {
holdOpenUntilRefocusRef.current = false
}
setOpen(next)
}, [])
const handleHoverOpenChange = useCallback(
(next: boolean) => {
// Why: portaled detail menus sit outside HoverCardContent — keep the card
// mounted until the menu closes so the menu items stay clickable.
if (openMenu) {
pendingHoverCloseRef.current = !next
const setDetailMenuOpen = useCallback(
(menu: WorktreeCardDetailMenu, next: boolean) => {
if (next) {
setOpenMenu(menu)
return
}
pendingHoverCloseRef.current = false
setOpen(next)
// Why: another menu may have opened in the same click that dismissed this one.
if (openMenuRef.current !== menu) {
return
}
setOpenMenu(null)
if (pendingHoverCloseRef.current) {
pendingHoverCloseRef.current = false
// Why: the link-action popover anchors on the card, so leaving the card
// for the popover is not a request to dismiss it.
if (menu !== 'link-actions') {
setOpen(false)
}
}
},
[openMenu]
[setOpenMenu]
)
const setDetailMenuOpen = useCallback((menu: WorktreeCardDetailMenu, next: boolean) => {
setOpenMenu(next ? menu : null)
if (!next && pendingHoverCloseRef.current) {
pendingHoverCloseRef.current = false
setOpen(false)
const holdOpenUntilRefocus = useCallback(() => {
if (holdOpenUntilRefocusRef.current) {
return
}
holdOpenUntilRefocusRef.current = true
window.addEventListener(
'focus',
() => {
holdOpenUntilRefocusRef.current = false
},
{ once: true }
)
}, [])
return {
@@ -42,6 +84,8 @@ export function useWorktreeCardDetailsHoverControl() {
handleHoverOpenChange,
handleIssueMenuOpenChange: (next: boolean) => setDetailMenuOpen('issue', next),
handleReviewMenuOpenChange: (next: boolean) => setDetailMenuOpen('review', next),
handleLinkActionsOpenChange: (next: boolean) => setDetailMenuOpen('link-actions', next),
holdOpenUntilRefocus,
closeHover
}
}