diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx
index de284fa3e..2d92a3e0b 100644
--- a/src/renderer/src/components/sidebar/WorktreeCard.tsx
+++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx
@@ -45,6 +45,7 @@ import {
import { WorktreeCardPortsDetails, WorktreeCardPortsTrigger } from './WorktreeCardPorts'
import { writeWorkspaceDragData } from './workspace-status'
import { getWorktreeCardPrDisplay } from './worktree-card-pr-display'
+import { useWorktreeCardDetailsHoverControl } from './worktree-card-details-hover-state'
import { getWorkspacePortsByWorktreeId } from '@/lib/workspace-port-groups'
import { RepoBadgeMark } from '@/components/repo/RepoBadgeLabel'
import { RepoIconGlyph } from '@/components/repo/repo-icon'
@@ -648,13 +649,17 @@ const WorktreeCard = React.memo(function WorktreeCard({
},
[metaReview, openTaskPage, repo]
)
- const handleUnlinkReview = useCallback(
- (e: React.MouseEvent) => {
- e.stopPropagation()
- void updateWorktreeMeta(worktree.id, { linkedPR: null })
- },
- [updateWorktreeMeta, worktree.id]
- )
+ const detailsHoverControl = useWorktreeCardDetailsHoverControl()
+ const hasExplicitLinkedReview =
+ (metaReview?.provider === 'github' && worktree.linkedPR !== null) ||
+ (metaReview?.provider === 'gitlab' && linkedGitLabMR !== null)
+ const handleUnlinkReview = useCallback(() => {
+ if (metaReview?.provider === 'gitlab') {
+ void updateWorktreeMeta(worktree.id, { linkedGitLabMR: null })
+ return
+ }
+ void updateWorktreeMeta(worktree.id, { linkedPR: null })
+ }, [metaReview?.provider, updateWorktreeMeta, worktree.id])
const handleOpenLinearIssueInOrca = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
@@ -737,6 +742,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
workspaceTitle={worktree.displayName}
detailsAfter={hasPorts ? : null}
openDelay={100}
+ hoverControl={detailsHoverControl}
onEditIssue={handleEditIssue}
onEditComment={handleEditComment}
onOpenGitHubIssueInOrca={
@@ -752,11 +758,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
}
// Why: compact mode hides the metadata badge row, so title hover
// carries the same explicit-link affordance without adding chrome.
- onUnlinkReview={
- metaReview?.provider === 'github' && worktree.linkedPR !== null
- ? handleUnlinkReview
- : undefined
- }
+ onUnlinkReview={hasExplicitLinkedReview ? handleUnlinkReview : undefined}
>
{title}
@@ -771,6 +773,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
review={metaReview}
comment={metaComment}
detailsAfter={hasPorts ? : null}
+ hoverControl={detailsHoverControl}
onEditIssue={handleEditIssue}
onEditComment={handleEditComment}
onOpenGitHubIssueInOrca={
@@ -780,13 +783,9 @@ const WorktreeCard = React.memo(function WorktreeCard({
onOpenReviewInOrca={
metaReview?.url && metaReview.provider === 'github' ? handleOpenReviewInOrca : undefined
}
- // Why: branch lookup can show a PR without persisted metadata. Only
- // expose unlink when this workspace has an explicit GitHub linkedPR.
- onUnlinkReview={
- metaReview?.provider === 'github' && worktree.linkedPR !== null
- ? handleUnlinkReview
- : undefined
- }
+ // Why: branch lookup can show a review without persisted metadata. Only
+ // expose unlink when this workspace has an explicit linked PR/MR.
+ onUnlinkReview={hasExplicitLinkedReview ? handleUnlinkReview : undefined}
>
{hasPorts &&
}
diff --git a/src/renderer/src/components/sidebar/WorktreeCardMeta.interaction.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardMeta.interaction.test.tsx
new file mode 100644
index 000000000..07c73ec56
--- /dev/null
+++ b/src/renderer/src/components/sidebar/WorktreeCardMeta.interaction.test.tsx
@@ -0,0 +1,181 @@
+// @vitest-environment happy-dom
+
+import { act, type ReactNode } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { WorktreeCardDetailsHover } from './WorktreeCardMeta'
+
+const interactionMocks = vi.hoisted(() => ({
+ hoverOpen: false,
+ onHoverOpenChange: undefined as ((open: boolean) => void) | undefined,
+ reviewMenuOpen: false,
+ onReviewMenuOpenChange: undefined as ((open: boolean) => void) | undefined,
+ onUnlinkSelect: undefined as (() => void) | undefined
+}))
+
+vi.mock('@/components/ui/hover-card', () => ({
+ HoverCard: ({
+ children,
+ open,
+ onOpenChange
+ }: {
+ children: ReactNode
+ open?: boolean
+ onOpenChange?: (open: boolean) => void
+ }) => {
+ interactionMocks.hoverOpen = open ?? false
+ interactionMocks.onHoverOpenChange = onOpenChange
+ return
{children}
+ },
+ HoverCardContent: ({ children }: { children: ReactNode }) =>
{children}
,
+ HoverCardTrigger: ({ children }: { children: ReactNode }) => <>{children}>
+}))
+
+vi.mock('@/components/ui/tooltip', () => ({
+ Tooltip: ({ children, open }: { children: ReactNode; open?: boolean }) => (
+
{children}
+ ),
+ TooltipContent: ({ children }: { children: ReactNode }) => <>{children}>,
+ TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}>
+}))
+
+vi.mock('@/components/ui/dropdown-menu', () => ({
+ DropdownMenu: ({
+ children,
+ open,
+ onOpenChange
+ }: {
+ children: ReactNode
+ open?: boolean
+ onOpenChange?: (open: boolean) => void
+ }) => {
+ interactionMocks.reviewMenuOpen = open ?? false
+ interactionMocks.onReviewMenuOpenChange = onOpenChange
+ return
{children}
+ },
+ DropdownMenuTrigger: ({ children }: { children: ReactNode; asChild?: boolean }) => (
+ <>{children}>
+ ),
+ DropdownMenuContent: ({ children }: { children: ReactNode }) =>
{children}
,
+ DropdownMenuItem: ({ children, onSelect }: { children: ReactNode; onSelect?: () => void }) => (
+
+ )
+}))
+
+const reviewFixture = {
+ provider: 'github' as const,
+ number: 456,
+ title: 'Fix stale GH PR',
+ state: 'open' as const,
+ url: 'https://github.com/acme/orca/pull/456',
+ status: 'success' as const,
+ updatedAt: '2026-05-17T00:00:00.000Z',
+ mergeable: 'MERGEABLE' as const
+}
+
+describe('WorktreeCardDetailsHover interactions', () => {
+ let container: HTMLDivElement
+ let root: Root
+
+ afterEach(() => {
+ act(() => {
+ root.unmount()
+ })
+ container.remove()
+ interactionMocks.hoverOpen = false
+ interactionMocks.reviewMenuOpen = false
+ interactionMocks.onHoverOpenChange = undefined
+ interactionMocks.onReviewMenuOpenChange = undefined
+ interactionMocks.onUnlinkSelect = undefined
+ })
+
+ function renderHover(onUnlinkReview = vi.fn()): ReturnType
{
+ container = document.createElement('div')
+ root = createRoot(container)
+ act(() => {
+ root.render(
+
+ Linked PR
+
+ )
+ })
+ return onUnlinkReview
+ }
+
+ it('defers hover close while the review menu is open', () => {
+ renderHover()
+
+ act(() => {
+ interactionMocks.onHoverOpenChange?.(true)
+ interactionMocks.onReviewMenuOpenChange?.(true)
+ interactionMocks.onHoverOpenChange?.(false)
+ })
+
+ expect(container.querySelector('[data-hover-open]')?.getAttribute('data-hover-open')).toBe(
+ 'true'
+ )
+ })
+
+ it('closes the hover after the review menu dismisses a deferred close', () => {
+ renderHover()
+
+ act(() => {
+ interactionMocks.onHoverOpenChange?.(true)
+ interactionMocks.onReviewMenuOpenChange?.(true)
+ interactionMocks.onHoverOpenChange?.(false)
+ interactionMocks.onReviewMenuOpenChange?.(false)
+ })
+
+ expect(container.querySelector('[data-hover-open]')?.getAttribute('data-hover-open')).toBe(
+ 'false'
+ )
+ })
+
+ it('suppresses the tooltip while the review menu is open', () => {
+ renderHover()
+
+ act(() => {
+ interactionMocks.onReviewMenuOpenChange?.(true)
+ })
+
+ expect(container.querySelector('[data-tooltip-open]')?.getAttribute('data-tooltip-open')).toBe(
+ 'false'
+ )
+ })
+
+ it('invokes unlink and closes the hover from the menu item', () => {
+ const onUnlinkReview = renderHover()
+
+ act(() => {
+ interactionMocks.onHoverOpenChange?.(true)
+ interactionMocks.onReviewMenuOpenChange?.(true)
+ })
+
+ const unlinkButton = Array.from(container.querySelectorAll('button')).find((button) =>
+ button.textContent?.includes('Unlink PR')
+ )
+
+ act(() => {
+ unlinkButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
+ })
+
+ expect(onUnlinkReview).toHaveBeenCalledTimes(1)
+ expect(container.querySelector('[data-hover-open]')?.getAttribute('data-hover-open')).toBe(
+ 'false'
+ )
+ expect(
+ container.querySelector('[data-review-menu-open]')?.getAttribute('data-review-menu-open')
+ ).toBe('false')
+ })
+})
diff --git a/src/renderer/src/components/sidebar/WorktreeCardMeta.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardMeta.test.tsx
index 49f66e86b..02851b42c 100644
--- a/src/renderer/src/components/sidebar/WorktreeCardMeta.test.tsx
+++ b/src/renderer/src/components/sidebar/WorktreeCardMeta.test.tsx
@@ -15,6 +15,17 @@ vi.mock('@/components/ui/tooltip', () => ({
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}>
}))
+vi.mock('@/components/ui/dropdown-menu', () => ({
+ DropdownMenu: ({ children }: { children: ReactNode }) => {children}
,
+ DropdownMenuTrigger: ({ children }: { children: ReactNode; asChild?: boolean }) => (
+ <>{children}>
+ ),
+ DropdownMenuContent: ({ children }: { children: ReactNode }) => {children}
,
+ DropdownMenuItem: ({ children }: { children: ReactNode; onSelect?: () => void }) => (
+ {children}
+ )
+}))
+
describe('WorktreeCardDetailsHover', () => {
it('includes branch identity before metadata details', () => {
const markup = renderToStaticMarkup(
@@ -46,7 +57,7 @@ describe('WorktreeCardDetailsHover', () => {
expect(markup).toContain('Fix stale GH PR')
})
- it('shows an unlink action for linked PR details when provided', () => {
+ it('puts unlink behind the first PR actions menu and keeps GitHub last', () => {
const markup = renderToStaticMarkup(
{
comment={null}
onEditIssue={vi.fn()}
onEditComment={vi.fn()}
+ onOpenReviewInOrca={vi.fn()}
onUnlinkReview={vi.fn()}
>
Linked PR
)
- expect(markup).toContain('aria-label="Unlink PR"')
+ const moreActionsIndex = markup.indexOf('aria-label="More PR actions"')
+ const openInOrcaIndex = markup.indexOf('aria-label="Open in Orca"')
+ const viewOnGitHubIndex = markup.indexOf('aria-label="View on GitHub"')
+
+ expect(moreActionsIndex).toBeGreaterThan(-1)
+ expect(markup).toContain('More PR actions')
expect(markup).toContain('Unlink PR')
+ expect(moreActionsIndex).toBeLessThan(openInOrcaIndex)
+ expect(openInOrcaIndex).toBeLessThan(viewOnGitHubIndex)
+ expect(markup).not.toContain('aria-label="Unlink PR"')
+ })
+
+ it('labels GitLab unlink actions with MR terminology', () => {
+ const markup = renderToStaticMarkup(
+
+ Linked MR
+
+ )
+
+ expect(markup).toContain('aria-label="More MR actions"')
+ expect(markup).toContain('Unlink MR')
+ expect(markup).toContain('View on GitLab')
})
it('displays Linear issue details with link', () => {
diff --git a/src/renderer/src/components/sidebar/WorktreeCardMeta.tsx b/src/renderer/src/components/sidebar/WorktreeCardMeta.tsx
index 37b8e5fba..6ceb28f24 100644
--- a/src/renderer/src/components/sidebar/WorktreeCardMeta.tsx
+++ b/src/renderer/src/components/sidebar/WorktreeCardMeta.tsx
@@ -3,18 +3,25 @@ import { Badge } from '@/components/ui/badge'
import { HoverCard, HoverCardTrigger, HoverCardContent } from '@/components/ui/hover-card'
import {
CircleDot,
+ Ellipsis,
ExternalLink,
- GitMerge,
MonitorUp,
Pencil,
StickyNote,
Unlink
} from 'lucide-react'
+import { Button } from '@/components/ui/button'
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger
+} from '@/components/ui/dropdown-menu'
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import { LinearIcon } from '@/components/icons/LinearIcon'
import { SelectedTextCopyMenu } from '@/components/SelectedTextCopyMenu'
import CommentMarkdown from './CommentMarkdown'
-import { PullRequestIcon } from './WorktreeCardHelpers'
import { WORKTREE_NATIVE_CONTEXT_MENU_ATTR } from './WorktreeContextMenu'
import {
WorktreeCardDetailSection,
@@ -27,50 +34,22 @@ import {
ReviewChecksBadge,
ReviewStateBadge
} from './WorktreeCardMetadataStatusBadges'
-import type { WorktreeCardPrDisplay } from './worktree-card-pr-display'
-import type { IssueInfo } from '../../../../shared/types'
+import { useWorktreeCardDetailsHoverControl } from './worktree-card-details-hover-state'
+import { getReviewLabel, getProviderName, ReviewIcon } from './worktree-review-helpers'
+import type {
+ WorktreeCardIssueDisplay,
+ WorktreeCardLinearIssueDisplay,
+ WorktreeCardMetaBadgesProps,
+ WorktreeCardMetaBadgesRootProps,
+ WorktreeCardDetailsHoverProps
+} from './worktree-card-meta-types'
-export type WorktreeCardIssueDisplay =
- | IssueInfo
- | {
- number: number
- title: string
- state?: IssueInfo['state']
- url?: string
- labels?: string[]
- }
-
-export type WorktreeCardLinearIssueDisplay = {
- identifier: string
- title: string
- url?: string
- stateName?: string
- labels?: string[]
-}
-
-type WorktreeCardMetaBadgesProps = {
- issue: WorktreeCardIssueDisplay | null
- linearIssue: WorktreeCardLinearIssueDisplay | null
- review: WorktreeCardPrDisplay | null
- comment: string | null
-}
-
-type WorktreeCardMetaBadgesRootProps = WorktreeCardMetaBadgesProps &
- React.HTMLAttributes
-
-type WorktreeCardDetailsHoverProps = WorktreeCardMetaBadgesProps & {
- children: React.ReactElement
- branchName?: string
- workspaceTitle?: string
- detailsAfter?: React.ReactNode
- openDelay?: number
- closeDelay?: number
- onEditIssue: (event: React.MouseEvent) => void
- onEditComment: (event: React.MouseEvent) => void
- onOpenGitHubIssueInOrca?: (event: React.MouseEvent) => void
- onOpenLinearIssueInOrca?: (event: React.MouseEvent) => void
- onOpenReviewInOrca?: (event: React.MouseEvent) => void
- onUnlinkReview?: (event: React.MouseEvent) => void
+export type {
+ WorktreeCardIssueDisplay,
+ WorktreeCardLinearIssueDisplay,
+ WorktreeCardMetaBadgesProps,
+ WorktreeCardMetaBadgesRootProps,
+ WorktreeCardDetailsHoverProps
}
function hasComment(comment: string | null): boolean {
@@ -86,61 +65,6 @@ export function hasWorktreeCardDetails({
return Boolean(issue || linearIssue || review || hasComment(comment))
}
-function getReviewLabel(review: WorktreeCardPrDisplay): 'MR' | 'PR' {
- return review.provider === 'gitlab' ? 'MR' : 'PR'
-}
-
-function getProviderName(review: WorktreeCardPrDisplay): string {
- if (review.provider === 'gitlab') {
- return 'GitLab'
- }
- if (review.provider === 'bitbucket') {
- return 'Bitbucket'
- }
- if (review.provider === 'azure-devops') {
- return 'Azure DevOps'
- }
- if (review.provider === 'gitea') {
- return 'Gitea'
- }
- return 'GitHub'
-}
-
-function ReviewIcon({
- review,
- className
-}: {
- review: WorktreeCardPrDisplay
- className?: string
-}): React.JSX.Element {
- const Icon = review.provider === 'gitlab' ? GitMerge : PullRequestIcon
- // Why: the standalone CI glyph was removed from the card header, so linked
- // PR metadata carries check health unless the review is already merged.
- const checkTone =
- review.state !== 'merged' && review.status === 'failure'
- ? 'text-rose-500/85'
- : review.state !== 'merged' && review.status === 'pending'
- ? 'text-amber-500/85'
- : review.state === 'open' && review.status === 'success'
- ? 'text-emerald-500/80'
- : null
- return (
-
- )
-}
-
export const WorktreeCardMetaBadges = React.forwardRef<
HTMLDivElement,
WorktreeCardMetaBadgesRootProps
@@ -201,15 +125,23 @@ export function WorktreeCardDetailsHover({
onOpenGitHubIssueInOrca,
onOpenLinearIssueInOrca,
onOpenReviewInOrca,
- onUnlinkReview
+ onUnlinkReview,
+ hoverControl
}: WorktreeCardDetailsHoverProps): React.JSX.Element {
- const [open, setOpen] = React.useState(false)
+ const internalHoverControl = useWorktreeCardDetailsHoverControl()
+ const {
+ hoverOpen,
+ reviewMenuOpen,
+ handleHoverOpenChange,
+ handleReviewMenuOpenChange,
+ closeHover
+ } = hoverControl ?? internalHoverControl
const dismissAndRun = React.useCallback(
(handler: ((event: React.MouseEvent) => void) | undefined) => (event: React.MouseEvent) => {
- setOpen(false)
+ closeHover()
handler?.(event)
},
- []
+ [closeHover]
)
const showIdentityHeader = Boolean(branchName || workspaceTitle)
@@ -227,7 +159,12 @@ export function WorktreeCardDetailsHover({
const issueLabels = issue?.labels ?? []
return (
-
+
{children}
+ {onUnlinkReview && (
+
+
+
+
+
+
+
+
+ More {reviewLabel} actions
+
+
+
+ {
+ closeHover()
+ onUnlinkReview?.()
+ }}
+ >
+
+ Unlink {reviewLabel}
+
+
+
+ )}
{review.url && onOpenReviewInOrca && (
)}
- {onUnlinkReview && (
-
-
-
- )}
>
}
/>
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
new file mode 100644
index 000000000..d985c2d78
--- /dev/null
+++ b/src/renderer/src/components/sidebar/worktree-card-details-hover-state.test.tsx
@@ -0,0 +1,108 @@
+// @vitest-environment happy-dom
+
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, describe, expect, it } from 'vitest'
+import { useWorktreeCardDetailsHoverControl } from './worktree-card-details-hover-state'
+
+type HoverControlSnapshot = ReturnType
+
+function HoverControlProbe({
+ onChange
+}: {
+ onChange: (control: HoverControlSnapshot) => void
+}): null {
+ const control = useWorktreeCardDetailsHoverControl()
+ onChange(control)
+ return null
+}
+
+describe('useWorktreeCardDetailsHoverControl', () => {
+ let container: HTMLDivElement
+ let root: Root
+ let control: HoverControlSnapshot | null = null
+
+ afterEach(() => {
+ act(() => {
+ root.unmount()
+ })
+ container.remove()
+ control = null
+ })
+
+ function mountProbe(): void {
+ container = document.createElement('div')
+ root = createRoot(container)
+ act(() => {
+ root.render(
+ {
+ control = next
+ }}
+ />
+ )
+ })
+ }
+
+ it('keeps the hover open while the review menu is open', () => {
+ mountProbe()
+ expect(control).not.toBeNull()
+
+ act(() => {
+ control?.handleHoverOpenChange(true)
+ control?.handleReviewMenuOpenChange(true)
+ })
+ expect(control?.hoverOpen).toBe(true)
+
+ act(() => {
+ control?.handleHoverOpenChange(false)
+ })
+ expect(control?.hoverOpen).toBe(true)
+ })
+
+ it('closes the hover after the review menu dismisses a deferred close', () => {
+ mountProbe()
+ expect(control).not.toBeNull()
+
+ act(() => {
+ control?.handleHoverOpenChange(true)
+ control?.handleReviewMenuOpenChange(true)
+ control?.handleHoverOpenChange(false)
+ })
+ expect(control?.hoverOpen).toBe(true)
+
+ act(() => {
+ control?.handleReviewMenuOpenChange(false)
+ })
+ expect(control?.hoverOpen).toBe(false)
+ })
+
+ it('clears a deferred close when the pointer returns before the menu closes', () => {
+ mountProbe()
+ expect(control).not.toBeNull()
+
+ act(() => {
+ control?.handleHoverOpenChange(true)
+ control?.handleReviewMenuOpenChange(true)
+ control?.handleHoverOpenChange(false)
+ control?.handleHoverOpenChange(true)
+ control?.handleReviewMenuOpenChange(false)
+ })
+
+ expect(control?.hoverOpen).toBe(true)
+ })
+
+ it('closes both layers from closeHover', () => {
+ mountProbe()
+ expect(control).not.toBeNull()
+
+ act(() => {
+ control?.handleHoverOpenChange(true)
+ control?.handleReviewMenuOpenChange(true)
+ control?.closeHover()
+ })
+
+ expect(control?.hoverOpen).toBe(false)
+ expect(control?.reviewMenuOpen).toBe(false)
+ })
+})
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
new file mode 100644
index 000000000..b90b20d55
--- /dev/null
+++ b/src/renderer/src/components/sidebar/worktree-card-details-hover-state.ts
@@ -0,0 +1,45 @@
+import { useCallback, useRef, useState } from 'react'
+
+export function useWorktreeCardDetailsHoverControl() {
+ const [open, setOpen] = useState(false)
+ const [reviewMenuOpen, setReviewMenuOpen] = useState(false)
+ const pendingHoverCloseRef = useRef(false)
+
+ const closeHover = useCallback(() => {
+ pendingHoverCloseRef.current = false
+ setReviewMenuOpen(false)
+ setOpen(false)
+ }, [])
+
+ const handleHoverOpenChange = useCallback(
+ (next: boolean) => {
+ // Why: the portaled PR menu sits outside HoverCardContent — keep the card
+ // mounted until the menu closes so the unlink item stays clickable.
+ if (reviewMenuOpen) {
+ pendingHoverCloseRef.current = !next
+ return
+ }
+ pendingHoverCloseRef.current = false
+ setOpen(next)
+ },
+ [reviewMenuOpen]
+ )
+
+ const handleReviewMenuOpenChange = useCallback((next: boolean) => {
+ setReviewMenuOpen(next)
+ if (!next && pendingHoverCloseRef.current) {
+ pendingHoverCloseRef.current = false
+ setOpen(false)
+ }
+ }, [])
+
+ return {
+ hoverOpen: open || reviewMenuOpen,
+ reviewMenuOpen,
+ handleHoverOpenChange,
+ handleReviewMenuOpenChange,
+ closeHover
+ }
+}
+
+export type WorktreeCardDetailsHoverControl = ReturnType
diff --git a/src/renderer/src/components/sidebar/worktree-card-meta-types.ts b/src/renderer/src/components/sidebar/worktree-card-meta-types.ts
new file mode 100644
index 000000000..2e5975f21
--- /dev/null
+++ b/src/renderer/src/components/sidebar/worktree-card-meta-types.ts
@@ -0,0 +1,47 @@
+import type { IssueInfo } from '../../../../shared/types'
+import type { WorktreeCardPrDisplay } from './worktree-card-pr-display'
+import type { WorktreeCardDetailsHoverControl } from './worktree-card-details-hover-state'
+
+export type WorktreeCardIssueDisplay =
+ | IssueInfo
+ | {
+ number: number
+ title: string
+ state?: IssueInfo['state']
+ url?: string
+ labels?: string[]
+ }
+
+export type WorktreeCardLinearIssueDisplay = {
+ identifier: string
+ title: string
+ url?: string
+ stateName?: string
+ labels?: string[]
+}
+
+export type WorktreeCardMetaBadgesProps = {
+ issue: WorktreeCardIssueDisplay | null
+ linearIssue: WorktreeCardLinearIssueDisplay | null
+ review: WorktreeCardPrDisplay | null
+ comment: string | null
+}
+
+export type WorktreeCardMetaBadgesRootProps = WorktreeCardMetaBadgesProps &
+ React.HTMLAttributes
+
+export type WorktreeCardDetailsHoverProps = WorktreeCardMetaBadgesProps & {
+ children: React.ReactElement
+ branchName?: string
+ workspaceTitle?: string
+ detailsAfter?: React.ReactNode
+ openDelay?: number
+ closeDelay?: number
+ onEditIssue: (event: React.MouseEvent) => void
+ onEditComment: (event: React.MouseEvent) => void
+ onOpenGitHubIssueInOrca?: (event: React.MouseEvent) => void
+ onOpenLinearIssueInOrca?: (event: React.MouseEvent) => void
+ onOpenReviewInOrca?: (event: React.MouseEvent) => void
+ onUnlinkReview?: () => void
+ hoverControl?: WorktreeCardDetailsHoverControl
+}
diff --git a/src/renderer/src/components/sidebar/worktree-review-helpers.tsx b/src/renderer/src/components/sidebar/worktree-review-helpers.tsx
new file mode 100644
index 000000000..7251281f4
--- /dev/null
+++ b/src/renderer/src/components/sidebar/worktree-review-helpers.tsx
@@ -0,0 +1,57 @@
+import { GitMerge } from 'lucide-react'
+import { cn } from '@/lib/utils'
+import { PullRequestIcon } from './WorktreeCardHelpers'
+import type { WorktreeCardPrDisplay } from './worktree-card-pr-display'
+
+export function getReviewLabel(review: WorktreeCardPrDisplay): 'MR' | 'PR' {
+ return review.provider === 'gitlab' ? 'MR' : 'PR'
+}
+
+export function getProviderName(review: WorktreeCardPrDisplay): string {
+ if (review.provider === 'gitlab') {
+ return 'GitLab'
+ }
+ if (review.provider === 'bitbucket') {
+ return 'Bitbucket'
+ }
+ if (review.provider === 'azure-devops') {
+ return 'Azure DevOps'
+ }
+ if (review.provider === 'gitea') {
+ return 'Gitea'
+ }
+ return 'GitHub'
+}
+
+export function ReviewIcon({
+ review,
+ className
+}: {
+ review: WorktreeCardPrDisplay
+ className?: string
+}): React.JSX.Element {
+ const Icon = review.provider === 'gitlab' ? GitMerge : PullRequestIcon
+ const checkTone =
+ review.state !== 'merged' && review.status === 'failure'
+ ? 'text-rose-500/85'
+ : review.state !== 'merged' && review.status === 'pending'
+ ? 'text-amber-500/85'
+ : review.state === 'open' && review.status === 'success'
+ ? 'text-emerald-500/80'
+ : null
+ return (
+
+ )
+}