mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user