diff --git a/src/renderer/src/components/activity/activity-thread-hover-card.tsx b/src/renderer/src/components/activity/activity-thread-hover-card.tsx index 9ffd287cb1d..3158416005b 100644 --- a/src/renderer/src/components/activity/activity-thread-hover-card.tsx +++ b/src/renderer/src/components/activity/activity-thread-hover-card.tsx @@ -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 ( event.stopPropagation()}> - + void + onAuxClickCapture: (event: React.MouseEvent) => void detailsHoverControl: ReturnType 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} > = { 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({ request, - onClose + onClose, + onPointerDownOutside, + onOpenSettings }: LinkActionPopoverProps): React.JSX.Element { const openSettingsPage = useAppStore((state) => state.openSettingsPage) const openSettingsTarget = useAppStore((state) => state.openSettingsTarget) @@ -113,6 +117,7 @@ export function LinkActionPopover({ const openTerminalLinkSettings = (): void => { onClose() + onOpenSettings?.() openSettingsTarget({ pane: 'browser', repoId: null, @@ -139,6 +144,7 @@ export function LinkActionPopover({ onOpenAutoFocus={(event) => event.preventDefault()} onCloseAutoFocus={(event) => event.preventDefault()} onEscapeKeyDown={() => request.restoreFocus()} + onPointerDownOutside={onPointerDownOutside} >
{ 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', () => { diff --git a/src/renderer/src/components/sidebar/WorktreeCardMeta.tsx b/src/renderer/src/components/sidebar/WorktreeCardMeta.tsx index f0137bf2da0..ca5ca00d329 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardMeta.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardMeta.tsx @@ -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} > event.stopPropagation()}> - + event.stopPropagation()} onDoubleClick={(event) => event.stopPropagation()} + onAuxClickCapture={onAuxClickCapture} > {showIdentityHeader && ( diff --git a/src/renderer/src/components/sidebar/use-workspace-reference-link-actions.ts b/src/renderer/src/components/sidebar/use-workspace-reference-link-actions.ts index ff49f2fac2a..76316698d6c 100644 --- a/src/renderer/src/components/sidebar/use-workspace-reference-link-actions.ts +++ b/src/renderer/src/components/sidebar/use-workspace-reference-link-actions.ts @@ -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(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 } } diff --git a/src/renderer/src/components/sidebar/worktree-card-details-hover-state.test.tsx b/src/renderer/src/components/sidebar/worktree-card-details-hover-state.test.tsx index d985c2d7897..e139ee284fa 100644 --- a/src/renderer/src/components/sidebar/worktree-card-details-hover-state.test.tsx +++ b/src/renderer/src/components/sidebar/worktree-card-details-hover-state.test.tsx @@ -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 @@ -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() + }) }) diff --git a/src/renderer/src/components/sidebar/worktree-card-details-hover-state.ts b/src/renderer/src/components/sidebar/worktree-card-details-hover-state.ts index e17c12b7a26..997d2cbb4ac 100644 --- a/src/renderer/src/components/sidebar/worktree-card-details-hover-state.ts +++ b/src/renderer/src/components/sidebar/worktree-card-details-hover-state.ts @@ -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(null) + const [openMenu, setOpenMenuState] = useState(null) + const openMenuRef = useRef(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 } }