Unify single and multiple PR hover details (#32284)

* Unify single and multiple PR hover details

* Refine review metadata actions and hover scrolling titles

* Place review checks after identifiers and show full URL tooltips

* Show GitHub mark before review identifiers and fix localization checks

* Reuse the existing GitHub mark across reference and share cards

* Update compact hover expectation for split reference metadata
This commit is contained in:
Neil
2026-10-23 12:40:42 -07:00
committed by GitHub
parent 6882870850
commit e73ca2aec7
25 changed files with 848 additions and 480 deletions
+25
View File
@@ -3409,3 +3409,28 @@ html.native-shell .app-layout {
.icon-superscript-count[data-multiple-digits='true'] {
letter-spacing: -0.02em;
}
.workspace-reference-title {
overflow: hidden;
white-space: nowrap;
}
/* The positioning wrapper must also let pointer events reach the controls below it. */
[data-radix-popper-content-wrapper]:has(> [data-cursor-tooltip]) {
pointer-events: none;
}
.workspace-reference-title > span {
display: block;
overflow: hidden;
text-overflow: ellipsis;
}
@media (prefers-reduced-motion: no-preference) {
.workspace-reference-title[data-overflow='true']:is(:hover, :focus-visible) > span {
width: max-content;
overflow: visible;
transform: translateX(var(--reference-title-offset));
transition: transform var(--reference-title-duration) linear 400ms;
}
}
@@ -26,7 +26,8 @@ import { useWorktreeCardReviewDetails } from '../sidebar/use-worktree-card-revie
import { useWorktreeCardLinkedDetails } from '../sidebar/use-worktree-card-linked-details'
import { useWorktreeCardLifecycleEffects } from '../sidebar/use-worktree-card-lifecycle-effects'
import { useWorktreeCardSecondaryDetails } from '../sidebar/use-worktree-card-secondary-details'
import { getReviewLabel } from '../sidebar/worktree-review-helpers'
import { useWorkspaceReferenceLinkActions } from '../sidebar/use-workspace-reference-link-actions'
import { LinkActionPopover } from '@/components/link-actions/LinkActionPopover'
import { ActivityThreadHoverCardSummary } from './activity-thread-hover-card-summary'
import { ActivityThreadHoverCardTrigger } from './activity-thread-hover-card-trigger'
import type { AgentPaneThread } from './activity-thread-types'
@@ -54,6 +55,10 @@ 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
)
return (
<HoverCard
@@ -62,6 +67,9 @@ export function ActivityThreadHoverCard({
openDelay={openDelay}
closeDelay={closeDelay}
>
<span className="contents" onClick={(event) => event.stopPropagation()}>
<LinkActionPopover request={linkActionRequest} onClose={closeLinkActions} />
</span>
<ActivityThreadHoverCardTrigger
open={open}
onOpenChange={detailsHoverControl.handleHoverOpenChange}
@@ -71,6 +79,7 @@ export function ActivityThreadHoverCard({
{open ? (
<ActivityThreadHoverCardContent
thread={thread}
onOpenLink={onOpenLink}
detailsHoverControl={detailsHoverControl}
onJumpToWorkspace={onJumpToWorkspace}
canJumpToWorkspace={canJumpToWorkspace}
@@ -82,11 +91,13 @@ export function ActivityThreadHoverCard({
function ActivityThreadHoverCardContent({
thread,
onOpenLink,
detailsHoverControl,
onJumpToWorkspace,
canJumpToWorkspace
}: {
thread: AgentPaneThread
onOpenLink: (url: string, event: React.MouseEvent) => void
detailsHoverControl: ReturnType<typeof useWorktreeCardDetailsHoverControl>
onJumpToWorkspace?: (thread: AgentPaneThread) => void
canJumpToWorkspace?: boolean
@@ -194,18 +205,6 @@ function ActivityThreadHoverCardContent({
)
}, [copyLinkedWorkItemLink, detailsHoverControl, secondary.hoverIssue?.url])
const handleCopyReviewLink = useCallback(() => {
if (!secondary.hoverReview?.url) {
return
}
void copyLinkedWorkItemLink(
secondary.hoverReview.url,
translate('auto.components.sidebar.WorktreeCardMeta.reviewLinkLabel', '{{value0}} link', {
value0: getReviewLabel(secondary.hoverReview)
})
)
}, [copyLinkedWorkItemLink, secondary.hoverReview])
const dismissAndRun = useCallback(
(handler: ((event: React.MouseEvent) => void) | undefined) => (event: React.MouseEvent) => {
detailsHoverControl.closeHover()
@@ -237,6 +236,7 @@ function ActivityThreadHoverCardContent({
{/* GitHub / GitLab Issue */}
<WorktreeCardIssueDetailSection
issue={secondary.hoverIssue}
onManageLinks={dismissAndRun(foundation.handleManageLinks)}
issueMenuOpen={detailsHoverControl.issueMenuOpen}
onIssueMenuOpenChange={detailsHoverControl.handleIssueMenuOpenChange}
onCopyIssueLink={secondary.hoverIssue?.url ? handleCopyIssueLink : undefined}
@@ -348,13 +348,11 @@ function ActivityThreadHoverCardContent({
{/* Pull Request / Review */}
<WorktreeCardReviewDetailSection
review={secondary.hoverReview}
onManageLinks={dismissAndRun(foundation.handleManageLinks)}
reviewMenuOpen={detailsHoverControl.reviewMenuOpen}
onReviewMenuOpenChange={detailsHoverControl.handleReviewMenuOpenChange}
onOpenReviewInOrca={secondary.handleOpenReviewInOrca}
onOpenReviewInBrowser={
secondary.hoverReview?.url ? secondary.handleOpenReviewInBrowser : undefined
}
onCopyReviewLink={secondary.hoverReview?.url ? handleCopyReviewLink : undefined}
onOpenLink={onOpenLink}
onUnlinkReview={secondary.canUnlinkReview ? secondary.handleUnlinkReview : undefined}
closeHover={detailsHoverControl.closeHover}
/>
@@ -101,6 +101,7 @@ export function FilePathCursorTooltip({
{/* Anchored under the cursor the way a system tooltip is, rather than to
the row. Collision shifting still applies near the viewport edge. */}
<TooltipContent
data-cursor-tooltip=""
side="bottom"
align="start"
sideOffset={offset.side}
@@ -0,0 +1,9 @@
import type { SVGProps } from 'react'
export function GitHubIcon(props: SVGProps<SVGSVGElement>): React.JSX.Element {
return (
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true" {...props}>
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
</svg>
)
}
@@ -293,7 +293,8 @@ describe('WorktreeCard compact hover details', () => {
expect(markup).toContain('data-hover-open-delay="100"')
expectIdentityBodyIsHoverTrigger(markup)
expect(markup).toContain('Issue #123')
expect(markup).toContain('ENG-123 · Linear')
expect(markup).toContain('ENG-123</span>')
expect(markup).toContain('· Linear')
expect(markup).toContain('Reviewer handoff note')
expect(markup).toContain('Live Ports')
expect(markup).toContain('58941')
@@ -25,6 +25,7 @@ type WorktreeCardIssueDetailSectionProps = {
onOpenIssueInBrowser?: (url: string) => void
onEditIssue?: (event: React.MouseEvent) => void
onOpenGitHubIssueInOrca?: (event: React.MouseEvent) => void
onManageLinks?: (event: React.MouseEvent) => void
}
export function WorktreeCardIssueDetailSection({
@@ -34,7 +35,8 @@ export function WorktreeCardIssueDetailSection({
onCopyIssueLink,
onOpenIssueInBrowser,
onEditIssue,
onOpenGitHubIssueInOrca
onOpenGitHubIssueInOrca,
onManageLinks
}: WorktreeCardIssueDetailSectionProps): React.JSX.Element | null {
if (!issue) {
return null
@@ -63,6 +65,7 @@ export function WorktreeCardIssueDetailSection({
return (
<WorktreeCardDetailSection>
<DetailHeader
onAddReference={onManageLinks}
icon={<CircleDot className="size-3 text-muted-foreground" />}
label={translate(
'auto.components.sidebar.WorktreeCardMeta.e97d8f2876',
@@ -136,10 +136,11 @@ describe('workspace linked references in hover details', () => {
}
const { container } = render(<Fixture onManage={vi.fn()} referenceDetails={details} />)
expect(screen.getByText('Merged sibling')).toBeTruthy()
expect(screen.getByText('State: Merged')).toBeTruthy()
expect(screen.getByLabelText('State: Merged')).toBeTruthy()
expect(screen.queryByText('State: Merged')).toBeNull()
expect(screen.getByText('Target: release')).toBeTruthy()
expect(screen.getByText('State: In Progress')).toBeTruthy()
expect(screen.getAllByText('Checks: Passing')).toHaveLength(1)
expect(screen.getAllByLabelText('Checks: Passing')).toHaveLength(1)
expect(screen.queryByText('Used for checks')).toBeNull()
const siblingRow = Array.from(
container.querySelectorAll('[data-workspace-hover-reference]')
@@ -153,18 +154,26 @@ describe('workspace linked references in hover details', () => {
expect(screen.getByText('Reviews · 2')).toBeTruthy()
expect(screen.getByText('Issues & tasks · 2')).toBeTruthy()
expect(screen.getAllByText('Active review')).toHaveLength(1)
expect(screen.getByText('MR !42 · GitLab · gitlab.example.com/team/orca')).toBeTruthy()
expect(screen.getByText('ENG-215 · Linear · acme')).toBeTruthy()
expect(screen.getByText('MR !42')).toBeTruthy()
expect(screen.getByText('· GitLab · gitlab.example.com/team/orca')).toBeTruthy()
expect(screen.getByText('ENG-215')).toBeTruthy()
expect(screen.getByText('· Linear · acme')).toBeTruthy()
const checks = screen.getByLabelText('Checks: Passing')
expect(checks.previousElementSibling?.textContent).toBe('PR #57')
expect(checks.previousElementSibling?.previousElementSibling?.getAttribute('aria-label')).toBe(
'GitHub'
)
expect(checks.nextElementSibling?.textContent).not.toContain('GitHub')
const key = getWorkspaceAttachmentKey(attachments[0])
expect(
Array.from(container.querySelectorAll('[data-workspace-hover-reference]')).find(
(node) => node.getAttribute('data-workspace-hover-reference') === key
)?.textContent
).toContain('Checks: Passing')
Array.from(container.querySelectorAll('[data-workspace-hover-reference]'))
.find((node) => node.getAttribute('data-workspace-hover-reference') === key)
?.querySelector('[aria-label="Checks: Passing"]')
).toBeTruthy()
expect(screen.queryByText('Used for checks')).toBeNull()
expect(screen.getAllByText('Checks: Passing')).toHaveLength(1)
expect(screen.getAllByLabelText('Checks: Passing')).toHaveLength(1)
})
it('opens a reference without activating the parent and dismisses hover', () => {
it('offers Orca link destinations without activating the parent and dismisses hover', () => {
const openUrl = vi.fn()
Object.defineProperty(window, 'api', { configurable: true, value: { shell: { openUrl } } })
const parentClick = vi.fn()
@@ -175,22 +184,29 @@ describe('workspace linked references in hover details', () => {
)
fireEvent.click(screen.getByRole('button', { name: 'Hover workspace' }))
parentClick.mockClear()
fireEvent.click(screen.getByRole('button', { name: 'Open MR !42' }))
expect(openUrl).toHaveBeenCalledWith('https://gitlab.example.com/team/orca/-/merge_requests/42')
fireEvent.click(screen.getByRole('link', { name: 'Open MR !42' }))
expect(openUrl).not.toHaveBeenCalled()
expect(screen.getByRole('button', { name: /System Browser/ })).toBeTruthy()
expect(parentClick).not.toHaveBeenCalled()
expect(container.querySelector('[data-hover-open]')?.getAttribute('data-hover-open')).toBe(
'false'
)
})
it('dismisses hover before managing links and exposes no second picker', () => {
it('opens link management from either section header and dismisses hover', () => {
const onManage = vi.fn()
const { container } = render(<Fixture onManage={onManage} />)
fireEvent.click(screen.getByRole('button', { name: 'Hover workspace' }))
fireEvent.click(screen.getByRole('button', { name: 'Manage links…' }))
expect(onManage).toHaveBeenCalledTimes(1)
expect(container.querySelector('[data-hover-open]')?.getAttribute('data-hover-open')).toBe(
'false'
)
const addButtons = screen.getAllByRole('button', { name: 'Add reference' })
expect(addButtons).toHaveLength(2)
expect(screen.queryByText('Manage links…')).toBeNull()
for (const button of addButtons) {
fireEvent.click(screen.getByRole('button', { name: 'Hover workspace' }))
fireEvent.click(button)
expect(container.querySelector('[data-hover-open]')?.getAttribute('data-hover-open')).toBe(
'false'
)
}
expect(onManage).toHaveBeenCalledTimes(2)
expect(screen.queryByRole('combobox')).toBeNull()
})
it('opens metadata management for a URL-less reference and handles attachment-only folders', () => {
@@ -1,6 +1,5 @@
import React from 'react'
import { CircleDot, ExternalLink, GitMerge, GitPullRequest, Link2, Plus } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Link2 } from 'lucide-react'
import { translate } from '@/i18n/i18n'
import type { WorkspaceAttachment, Worktree } from '../../../../shared/worktree/types'
import {
@@ -10,14 +9,9 @@ import {
import { getWorkspaceAttachmentSourceLabel } from './workspace-attachment-source-label'
import { WorktreeCardDetailSection } from './WorktreeCardDetailSection'
import { DetailHeader } from './WorktreeCardMetadataControls'
import {
IssueStateBadge,
LinearStateBadge,
ReviewChecksBadge,
ReviewStateBadge
} from './WorktreeCardMetadataStatusBadges'
import { IssueStateBadge, LinearStateBadge } from './WorktreeCardMetadataStatusBadges'
import { WorkspaceAttachmentOrigins } from './WorkspaceAttachmentOrigins'
import { getReviewLabel, ReviewIcon } from './worktree-review-helpers'
import { WorktreeCardReferenceRow } from './WorktreeCardReferenceRow'
import type { WorkspaceReferenceDetailsMap } from './workspace-reference-details'
import { getWorkspaceLinkedItemRows } from './workspace-hover-attachments'
import type {
@@ -36,7 +30,8 @@ export function WorktreeCardLinkedItemsSection({
jiraIssue,
showItems,
onManage,
onOpen
onOpen,
renderReviewActions
}: {
workspace?: Worktree
referenceDetails?: WorkspaceReferenceDetailsMap
@@ -46,7 +41,11 @@ export function WorktreeCardLinkedItemsSection({
jiraIssue?: WorktreeCardJiraIssueDisplay | null
showItems: boolean
onManage?: (event: React.MouseEvent) => void
onOpen?: (url: string) => void
onOpen?: (url: string, event: React.MouseEvent) => void
renderReviewActions?: (
item: WorkspaceAttachment,
review: WorktreeCardPrDisplay | null
) => React.ReactNode
}): React.JSX.Element | null {
if (!workspace) {
return null
@@ -66,15 +65,20 @@ export function WorktreeCardLinkedItemsSection({
rows: rows.filter((row) => row.item.type === 'issue')
}
].filter((group) => group.rows.length)
if (!showItems && (rows.length > 0 || review || issue || linearIssue || jiraIssue || !onManage)) {
return null
}
const open = (item: WorkspaceAttachment, event: React.MouseEvent): void => {
event.stopPropagation()
if (item.url) {
if (onOpen) {
onOpen(item.url)
onOpen(item.url, event)
} else {
event.preventDefault()
void window.api.shell.openUrl(item.url)
}
} else {
event.preventDefault()
onManage?.(event)
}
}
@@ -86,6 +90,7 @@ export function WorktreeCardLinkedItemsSection({
<DetailHeader
icon={<Link2 className="size-3" />}
label={`${group.label} · ${group.rows.length}`}
onAddReference={onManage}
/>
<div className="divide-y divide-border">
{group.rows.map(({ item, key, url, detail, live }) => {
@@ -97,54 +102,32 @@ export function WorktreeCardLinkedItemsSection({
className="space-y-1 py-1.5"
data-workspace-hover-reference={key}
>
<div className="flex min-w-0 items-start gap-2">
{live ? (
<ReviewIcon review={live} className="mt-0.5 size-3.5 shrink-0" />
) : item.type === 'mr' ? (
<GitMerge className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" />
) : item.type === 'pr' ? (
<GitPullRequest className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" />
) : (
<CircleDot className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" />
)}
<button
type="button"
className="min-w-0 flex-1 rounded-sm text-left outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
disabled={!url && !onManage}
aria-label={translate('workspace.links.openItem', 'Open {{label}}', {
label
})}
onClick={(event) => open({ ...item, url }, event)}
onKeyDown={(event) => event.stopPropagation()}
>
<p className="line-clamp-2 text-xs font-medium leading-snug">
{detail?.title || item.title || (live?.url ? live.title : label)}
</p>
<p className="truncate text-[11px] text-muted-foreground">
{label} · {workspaceAttachmentProviderLabel(item)}
{source ? ` · ${source}` : ''}
</p>
</button>
{url ? (
<ExternalLink
aria-hidden
className="mt-0.5 size-3 shrink-0 text-muted-foreground"
/>
) : null}
</div>
{live?.state || live?.status || detail?.stateName || detail?.issueState ? (
<WorktreeCardReferenceRow
review={live}
kind={item.type}
title={detail?.title || item.title || (live?.url ? live.title : label)}
label={label}
provider={workspaceAttachmentProviderLabel(item)}
source={source}
url={url}
onOpen={(url, event) => open({ ...item, url }, event)}
onManage={onManage ? (event) => open(item, event) : undefined}
actions={
item.type !== 'issue' ? renderReviewActions?.(item, live) : undefined
}
/>
{detail?.stale ||
(live && 'baseRefName' in live && live.baseRefName) ||
detail?.stateName ||
detail?.issueState ? (
<div className="flex flex-wrap items-center gap-1 pl-5">
{live?.state ? (
<ReviewStateBadge state={live.state} label={getReviewLabel(live)} />
) : null}
{live?.status ? <ReviewChecksBadge status={live.status} /> : null}
{detail?.stale ? (
<span className="text-[11px] text-muted-foreground">
<span className="text-2xs text-muted-foreground">
{translate('workspace.links.lastKnownStatus', 'Last known status')}
</span>
) : null}
{live && 'baseRefName' in live && live.baseRefName ? (
<span className="text-[11px] text-muted-foreground">
<span className="text-2xs text-muted-foreground">
{translate('workspace.links.reviewTarget', 'Target: {{branch}}', {
branch: live.baseRefName
})}
@@ -171,19 +154,12 @@ export function WorktreeCardLinkedItemsSection({
</div>
))
: null}
{onManage ? (
<Button
type="button"
variant="ghost"
size="xs"
onClick={onManage}
onKeyDown={(event) => event.stopPropagation()}
>
<Plus />
{rows.length
? translate('workspace.links.manage', 'Manage links…')
: translate('workspace.links.addReference', 'Add reference')}
</Button>
{!rows.length && onManage ? (
<DetailHeader
icon={<Link2 className="size-3" />}
label={translate('workspace.links.title', 'Linked work')}
onAddReference={onManage}
/>
) : null}
</WorktreeCardDetailSection>
)
@@ -66,8 +66,22 @@ vi.mock('@/components/ui/dropdown-menu', () => ({
<>{children}</>
),
DropdownMenuContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DropdownMenuItem: ({ children, onSelect }: { children: ReactNode; onSelect?: () => void }) => (
<button type="button" onClick={() => onSelect?.()}>
DropdownMenuItem: ({
children,
onSelect,
onClick
}: {
children: ReactNode
onSelect?: () => void
onClick?: React.MouseEventHandler<HTMLButtonElement>
}) => (
<button
type="button"
onClick={(event) => {
onSelect?.()
onClick?.(event)
}}
>
{children}
</button>
)
@@ -264,98 +278,36 @@ describe('WorktreeCardDetailsHover interactions', () => {
).toBe('false')
})
it('copies the review URL and closes the hover from the menu item', async () => {
const onUnlinkReview = renderHover()
it('opens the native review from the dropdown and closes the hover', () => {
const onOpenReviewInOrca = vi.fn()
container = document.createElement('div')
root = createRoot(container)
act(() =>
root.render(
<WorktreeCardDetailsHover
issue={null}
linearIssue={null}
review={reviewFixture}
comment={null}
onOpenReviewInOrca={onOpenReviewInOrca}
>
<span>Linked PR</span>
</WorktreeCardDetailsHover>
)
)
act(() => {
interactionMocks.onHoverOpenChange?.(true)
interactionMocks.onReviewMenuOpenChange?.(true)
})
const copyButton = Array.from(container.querySelectorAll('button')).find((button) =>
button.textContent?.includes('Copy link')
const nativeAction = Array.from(container.querySelectorAll('button')).find((button) =>
button.textContent?.includes('Open in Orca')
)
await act(async () => {
copyButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await Promise.resolve()
})
expect(writeClipboardText).toHaveBeenCalledWith('https://github.com/acme/orca/pull/456')
expect(onUnlinkReview).not.toHaveBeenCalled()
expect(toastMocks.success).toHaveBeenCalledWith('PR link copied')
expect(nativeAction).toBeDefined()
act(() => nativeAction?.dispatchEvent(new MouseEvent('click', { bubbles: true })))
expect(onOpenReviewInOrca).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')
})
it('opens the review URL in Orca browser and leaves existing actions independent', () => {
const onOpenReviewInBrowser = vi.fn()
const onUnlinkReview = renderHover(vi.fn(), onOpenReviewInBrowser)
act(() => {
interactionMocks.onHoverOpenChange?.(true)
interactionMocks.onReviewMenuOpenChange?.(true)
})
const browserButton = Array.from(container.querySelectorAll('button')).find((button) =>
button.textContent?.includes('Open in Orca browser')
)
act(() => {
browserButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(onOpenReviewInBrowser).toHaveBeenCalledWith('https://github.com/acme/orca/pull/456')
expect(onUnlinkReview).not.toHaveBeenCalled()
expect(container.querySelector('[data-hover-open]')?.getAttribute('data-hover-open')).toBe(
'false'
)
})
it('preserves repeated-click behavior by forwarding each browser action', () => {
const onOpenReviewInBrowser = vi.fn()
renderHover(vi.fn(), onOpenReviewInBrowser)
act(() => {
interactionMocks.onReviewMenuOpenChange?.(true)
})
const browserButton = Array.from(container.querySelectorAll('button')).find((button) =>
button.textContent?.includes('Open in Orca browser')
)
act(() => {
browserButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
browserButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(onOpenReviewInBrowser).toHaveBeenCalledTimes(2)
})
it('reports clipboard failures without unlinking the review', async () => {
writeClipboardText.mockRejectedValueOnce(new Error('clipboard unavailable'))
const onUnlinkReview = renderHover()
act(() => {
interactionMocks.onHoverOpenChange?.(true)
interactionMocks.onReviewMenuOpenChange?.(true)
})
const copyButton = Array.from(container.querySelectorAll('button')).find((button) =>
button.textContent?.includes('Copy link')
)
await act(async () => {
copyButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await Promise.resolve()
})
expect(writeClipboardText).toHaveBeenCalledWith('https://github.com/acme/orca/pull/456')
expect(onUnlinkReview).not.toHaveBeenCalled()
expect(toastMocks.error).toHaveBeenCalledWith('Failed to copy link')
})
it('passes a linked issue URL to the embedded-browser action', () => {
@@ -57,7 +57,7 @@ describe('WorktreeCardDetailsHover', () => {
expect(markup.indexOf('feature/local-branch')).toBeLessThan(markup.indexOf('PR #456'))
})
it('puts unlink behind the first PR actions menu and keeps GitHub last', () => {
it('puts review-only actions after the metadata and adds references from the header', () => {
const markup = renderToStaticMarkup(
<WorktreeCardDetailsHover
issue={null}
@@ -78,28 +78,33 @@ describe('WorktreeCardDetailsHover', () => {
onOpenReviewInOrca={vi.fn()}
onOpenReviewInBrowser={vi.fn()}
onUnlinkReview={vi.fn()}
onManageLinks={vi.fn()}
>
<span>Linked PR</span>
</WorktreeCardDetailsHover>
)
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('Copy link')
expect(markup).toContain('Reviews · 1')
expect(markup).toContain('aria-label="More PR actions"')
expect(markup.indexOf('PR #456</span>')).toBeGreaterThan(-1)
expect(markup.indexOf('PR #456</span>')).toBeLessThan(
markup.indexOf('aria-label="Checks: Passing"')
)
expect(markup.indexOf('role="img" aria-label="GitHub"')).toBeLessThan(
markup.indexOf('PR #456</span>')
)
expect(markup).not.toContain('· GitHub')
expect(markup).toContain('aria-label="Add reference"')
expect(markup).not.toContain('Manage links…')
expect(markup).toContain('href="https://github.com/acme/orca/pull/456"')
expect(markup).toContain('Open in Orca')
expect(markup).toContain('Unlink PR from workspace')
expect(markup).toContain(
'Orca will hide PR #456 details for this workspace. The PR and branch on GitHub won’t be changed.'
)
expect(markup).toContain('Open in Orca browser')
expect(moreActionsIndex).toBeLessThan(openInOrcaIndex)
expect(openInOrcaIndex).toBeLessThan(viewOnGitHubIndex)
expect(markup.indexOf('Open in Orca browser')).toBeLessThan(markup.indexOf('Copy link'))
expect(markup.indexOf('Copy link')).toBeLessThan(markup.indexOf('Unlink PR from workspace'))
expect(markup).not.toContain('aria-label="Unlink PR from workspace"')
expect(markup).not.toContain('Copy link')
expect(markup).not.toContain('Open in Orca browser')
expect(markup).not.toContain('View on GitHub')
})
it('puts issue copy menu before edit and open actions and keeps GitHub last', () => {
@@ -169,8 +174,8 @@ describe('WorktreeCardDetailsHover', () => {
expect(markup).toContain(
'Orca will hide MR !77 details for this workspace. The MR and branch on GitLab won’t be changed.'
)
expect(markup).toContain('View on GitLab')
expect(markup).toContain('Open in Orca browser')
expect(markup).toContain('href="https://gitlab.com/acme/orca/-/merge_requests/77"')
expect(markup).not.toContain('Open in Orca browser')
})
it('hides the embedded-browser action when a linked review has no URL', () => {
@@ -194,7 +199,7 @@ describe('WorktreeCardDetailsHover', () => {
expect(markup).not.toContain('Open in Orca browser')
})
it('keeps the embedded-browser action provider-neutral for unsupported review URLs', () => {
it('keeps the title link for unsupported review URLs', () => {
const markup = renderToStaticMarkup(
<WorktreeCardDetailsHover
issue={null}
@@ -216,7 +221,7 @@ describe('WorktreeCardDetailsHover', () => {
</WorktreeCardDetailsHover>
)
expect(markup).toContain('Open in Orca browser')
expect(markup).toContain('href="https://code.example.test/reviews/12"')
})
it('displays Linear issue details with link', () => {
@@ -1,4 +1,6 @@
import React from 'react'
import { useWorkspaceReferenceLinkActions } from './use-workspace-reference-link-actions'
import { LinkActionPopover } from '@/components/link-actions/LinkActionPopover'
import { getWorkspaceAttachments } from '../../../../shared/workspace-attachments'
import { WorktreeCardLinkedItemsSection } from './WorktreeCardLinkedItemsSection'
import { Badge } from '@/components/ui/badge'
@@ -17,7 +19,7 @@ import { DetailHeader, MetadataActionIcon } from './WorktreeCardMetadataControls
import { hasWorktreeCardDetails, WorktreeCardMetaBadges } from './WorktreeCardMetaBadges'
import { LinearStateBadge } from './WorktreeCardMetadataStatusBadges'
import { useWorktreeCardDetailsHoverControl } from './worktree-card-details-hover-state'
import { getReviewLabel } from './worktree-review-helpers'
import { WorktreeCardLinkedReviewActions } from './WorktreeCardReviewActions'
import type {
WorktreeCardIssueDisplay,
WorktreeCardJiraIssueDisplay,
@@ -81,7 +83,6 @@ export function WorktreeCardDetailsHover({
onOpenIssueInBrowser,
onOpenLinearIssueInOrca,
onOpenReviewInOrca,
onOpenReviewInBrowser,
onUnlinkReview,
onOpenAutomation,
onOpenAutomationRun,
@@ -97,6 +98,10 @@ export function WorktreeCardDetailsHover({
handleReviewMenuOpenChange,
closeHover
} = hoverControl ?? internalHoverControl
const { linkActionRequest, onOpenLink, closeLinkActions } = useWorkspaceReferenceLinkActions(
workspace?.id,
closeHover
)
const [workspaceTitleEditing, setWorkspaceTitleEditing] = React.useState(false)
const pendingWorkspaceTitleCloseRef = React.useRef(false)
const handleWorkspaceTitleEditingChange = React.useCallback(
@@ -154,18 +159,6 @@ export function WorktreeCardDetailsHover({
translate('auto.components.sidebar.WorktreeCardMeta.issueLinkLabel', 'Issue link')
)
}, [closeHover, copyLinkedWorkItemLink, issue?.url])
const handleCopyReviewLink = React.useCallback((): void => {
if (!review?.url) {
return
}
void copyLinkedWorkItemLink(
review.url,
translate('auto.components.sidebar.WorktreeCardMeta.reviewLinkLabel', '{{value0}} link', {
value0: getReviewLabel(review)
})
)
}, [copyLinkedWorkItemLink, review])
const showIdentityHeader = Boolean(branchName || workspaceTitle)
const attachments = getWorkspaceAttachments(workspace ?? {})
const showAttachmentCollection =
@@ -197,6 +190,9 @@ export function WorktreeCardDetailsHover({
openDelay={openDelay}
closeDelay={closeDelay}
>
<span className="contents" onClick={(event) => event.stopPropagation()}>
<LinkActionPopover request={linkActionRequest} onClose={closeLinkActions} />
</span>
<HoverCardTrigger
asChild
onPointerEnter={hasComment(comment) ? preloadCommentMarkdown : undefined}
@@ -228,6 +224,7 @@ export function WorktreeCardDetailsHover({
<>
<WorktreeCardIssueDetailSection
issue={issue}
onManageLinks={onManageLinks ? dismissAndRun(onManageLinks) : undefined}
issueMenuOpen={issueMenuOpen}
onIssueMenuOpenChange={handleIssueMenuOpenChange}
onCopyIssueLink={issue?.url ? handleCopyIssueLink : undefined}
@@ -249,6 +246,7 @@ export function WorktreeCardDetailsHover({
<WorktreeCardDetailSection>
<DetailHeader
icon={<LinearIcon className="size-3 text-muted-foreground" />}
onAddReference={onManageLinks ? dismissAndRun(onManageLinks) : undefined}
label={translate(
'auto.components.sidebar.WorktreeCardMeta.5e982e6128',
'Linear {{value0}}',
@@ -309,6 +307,7 @@ export function WorktreeCardDetailsHover({
<WorktreeCardDetailSection>
<DetailHeader
icon={<JiraIcon className="size-3 text-muted-foreground" />}
onAddReference={onManageLinks ? dismissAndRun(onManageLinks) : undefined}
label={translate(
'auto.components.sidebar.WorktreeCardMeta.jiraIssue',
'Jira {{value0}}',
@@ -336,13 +335,11 @@ export function WorktreeCardDetailsHover({
<WorktreeCardReviewDetailSection
review={review}
onManageLinks={onManageLinks ? dismissAndRun(onManageLinks) : undefined}
reviewMenuOpen={reviewMenuOpen}
onReviewMenuOpenChange={handleReviewMenuOpenChange}
onOpenReviewInOrca={onOpenReviewInOrca}
onOpenReviewInBrowser={
onOpenReviewInBrowser && review?.url ? onOpenReviewInBrowser : undefined
}
onCopyReviewLink={review?.url ? handleCopyReviewLink : undefined}
onOpenLink={onOpenLink}
onUnlinkReview={onUnlinkReview}
closeHover={closeHover}
/>
@@ -357,10 +354,17 @@ export function WorktreeCardDetailsHover({
jiraIssue={jiraIssue}
showItems={showAttachmentCollection}
onManage={onManageLinks ? dismissAndRun(onManageLinks) : undefined}
onOpen={(url) => {
closeHover()
void window.api.shell.openUrl(url)
}}
onOpen={onOpenLink}
renderReviewActions={(item, live) => (
<WorktreeCardLinkedReviewActions
item={item}
review={live}
onOpenChange={handleReviewMenuOpenChange}
onOpenInOrca={onOpenReviewInOrca}
onUnlink={onUnlinkReview}
closeHover={closeHover}
/>
)}
/>
{automationProvenance && (
@@ -1,5 +1,25 @@
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { Plus } from 'lucide-react'
import { translate } from '@/i18n/i18n'
function AddReferenceAction({
onClick
}: {
onClick?: (event: React.MouseEvent) => void
}): React.JSX.Element | null {
if (!onClick) {
return null
}
return (
<MetadataActionIcon
label={translate('workspace.links.addReference', 'Add reference')}
onClick={onClick}
>
<Plus className="size-3" />
</MetadataActionIcon>
)
}
export function MetaIconBadge({
label,
@@ -19,11 +39,13 @@ export function MetaIconBadge({
export function DetailHeader({
icon,
label,
actions
actions,
onAddReference
}: {
icon: React.ReactNode
label: string
actions?: React.ReactNode
onAddReference?: (event: React.MouseEvent) => void
}): React.JSX.Element {
return (
<div className="flex items-center justify-between gap-2">
@@ -31,7 +53,12 @@ export function DetailHeader({
{icon}
<span className="truncate">{label}</span>
</div>
{actions ? <div className="flex shrink-0 items-center gap-0.5">{actions}</div> : null}
{actions || onAddReference ? (
<div className="flex shrink-0 items-center gap-0.5">
{actions}
<AddReferenceAction onClick={onAddReference} />
</div>
) : null}
</div>
)
}
@@ -1,4 +1,6 @@
import React from 'react'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { getReviewChecksTone } from './worktree-review-helpers'
import { Badge } from '@/components/ui/badge'
import { CircleCheck, CircleDot, CircleX, Clock, GitMerge } from 'lucide-react'
import { cn } from '@/lib/utils'
@@ -112,10 +114,7 @@ export function ReviewStateBadge({
if (state === 'merged') {
return (
<MetadataStatusBadge
label={translate(
'auto.components.sidebar.WorktreeCardMetadataStatusBadges.f394b3e86e',
'State: Merged'
)}
label={reviewStateLabel(state, label)}
className="border-purple-500/25 bg-purple-500/5 text-purple-600 dark:text-purple-300"
>
<GitMerge />
@@ -126,10 +125,7 @@ export function ReviewStateBadge({
if (state === 'closed') {
return (
<MetadataStatusBadge
label={translate(
'auto.components.sidebar.WorktreeCardMetadataStatusBadges.e888362def',
'State: Closed'
)}
label={reviewStateLabel(state, label)}
className="border-rose-500/25 bg-rose-500/5 text-rose-600 dark:text-rose-300"
>
<CircleX />
@@ -140,11 +136,7 @@ export function ReviewStateBadge({
if (state === 'draft') {
return (
<MetadataStatusBadge
label={translate(
'auto.components.sidebar.WorktreeCardMetadataStatusBadges.2931b42b09',
'State: Draft {{value0}}',
{ value0: label }
)}
label={reviewStateLabel(state, label)}
className="border-border bg-muted/30 text-muted-foreground"
>
<CircleDot />
@@ -154,10 +146,7 @@ export function ReviewStateBadge({
return (
<MetadataStatusBadge
label={translate(
'auto.components.sidebar.WorktreeCardMetadataStatusBadges.fe188062a1',
'State: Open'
)}
label={reviewStateLabel(state, label)}
className="border-emerald-500/25 bg-emerald-500/5 text-emerald-600 dark:text-emerald-300"
>
{label === 'MR' ? <GitMerge /> : <PullRequestIcon />}
@@ -207,3 +196,66 @@ export function ReviewChecksBadge({
</MetadataStatusBadge>
)
}
export function reviewStateLabel(
state: WorktreeCardPrDisplay['state'],
label: 'MR' | 'PR'
): string {
if (state === 'merged') {
return translate(
'auto.components.sidebar.WorktreeCardMetadataStatusBadges.f394b3e86e',
'State: Merged'
)
}
if (state === 'closed') {
return translate(
'auto.components.sidebar.WorktreeCardMetadataStatusBadges.e888362def',
'State: Closed'
)
}
if (state === 'draft') {
return translate(
'auto.components.sidebar.WorktreeCardMetadataStatusBadges.2931b42b09',
'State: Draft {{value0}}',
{ value0: label }
)
}
if (state === 'open') {
return translate(
'auto.components.sidebar.WorktreeCardMetadataStatusBadges.fe188062a1',
'State: Open'
)
}
return translate('workspace.links.checksUnknown', 'Unknown')
}
export function ReviewChecksIndicator({
status
}: {
status: WorktreeCardPrDisplay['status']
}): React.JSX.Element | null {
if (!status || status === 'neutral') {
return null
}
const label = `Checks: ${checksLabel(status)}`
const Icon = status === 'success' ? CircleCheck : status === 'failure' ? CircleX : Clock
return (
<Tooltip>
<TooltipTrigger asChild>
<span
tabIndex={0}
aria-label={label}
className={cn(
'inline-flex shrink-0 rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-ring',
getReviewChecksTone(status)
)}
>
<Icon className="size-2.5" aria-hidden />
</span>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{label}
</TooltipContent>
</Tooltip>
)
}
@@ -0,0 +1,127 @@
import type { MouseEvent, ReactNode } from 'react'
import { CircleDot, GitMerge, GitPullRequest } from 'lucide-react'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { GitHubIcon } from '@/components/icons/GitHubIcon'
import { FilePathCursorTooltip } from '@/components/file-path-cursor-tooltip'
import { translate } from '@/i18n/i18n'
import { ReviewChecksIndicator, reviewStateLabel } from './WorktreeCardMetadataStatusBadges'
import { getReviewLabel, ReviewIcon } from './worktree-review-helpers'
import type { WorktreeCardPrDisplay } from './worktree-card-pr-display'
function measureTitleOverflow(element: HTMLElement): void {
const text = element.firstElementChild
if (!(text instanceof HTMLElement)) {
return
}
const overflow = Math.max(0, text.scrollWidth - element.clientWidth)
element.dataset.overflow = String(overflow > 0)
element.style.setProperty('--reference-title-offset', `${-overflow}px`)
element.style.setProperty('--reference-title-duration', `${Math.max(1.5, overflow / 35)}s`)
}
export function WorktreeCardReferenceRow({
review,
kind,
title,
label,
provider,
source,
url,
onOpen,
onManage,
actions
}: {
review: WorktreeCardPrDisplay | null
kind: 'pr' | 'mr' | 'issue'
title: string
label: string
provider: string
source?: string | null
url?: string
onOpen: (url: string, event: MouseEvent) => void
onManage?: (event: MouseEvent) => void
actions?: ReactNode
}): React.JSX.Element {
return (
<div className="flex min-w-0 items-start gap-2">
<div className="mt-0.5 flex w-3.5 shrink-0 items-center">
{review ? (
<Tooltip>
<TooltipTrigger asChild>
<span
tabIndex={0}
aria-label={reviewStateLabel(review.state, getReviewLabel(review))}
className="inline-flex shrink-0 rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<ReviewIcon review={review} className="size-3.5" showChecks={false} />
</span>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{reviewStateLabel(review.state, getReviewLabel(review))}
</TooltipContent>
</Tooltip>
) : kind === 'mr' ? (
<GitMerge className="size-3.5 shrink-0 text-muted-foreground" />
) : kind === 'pr' ? (
<GitPullRequest className="size-3.5 shrink-0 text-muted-foreground" />
) : (
<CircleDot className="size-3.5 shrink-0 text-muted-foreground" />
)}
</div>
<div className="min-w-0 flex-1">
{url ? (
<FilePathCursorTooltip path={url}>
<a
href={url}
target="_blank"
rel="noreferrer"
className="workspace-reference-title block w-full rounded-sm text-xs font-medium leading-snug underline decoration-muted-foreground/50 underline-offset-2 outline-none hover:decoration-foreground focus-visible:ring-2 focus-visible:ring-ring"
onPointerEnter={(event) => measureTitleOverflow(event.currentTarget)}
onFocus={(event) => measureTitleOverflow(event.currentTarget)}
aria-label={translate('workspace.links.openItem', 'Open {{label}}', {
label
})}
onClick={(event) => {
event.stopPropagation()
onOpen(url, event)
}}
onAuxClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
>
<span>{title}</span>
</a>
</FilePathCursorTooltip>
) : (
<button
type="button"
className="workspace-reference-title block w-full rounded-sm text-left text-xs font-medium leading-snug outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring"
onPointerEnter={(event) => measureTitleOverflow(event.currentTarget)}
onFocus={(event) => measureTitleOverflow(event.currentTarget)}
disabled={!onManage}
aria-label={translate('workspace.links.openItem', 'Open {{label}}', {
label
})}
onClick={onManage}
onKeyDown={(event) => event.stopPropagation()}
>
<span>{title}</span>
</button>
)}
<div className="mt-0.5 flex min-w-0 items-center gap-1.5 text-2xs text-muted-foreground">
{provider === 'GitHub' ? (
<span className="inline-flex shrink-0" role="img" aria-label={provider}>
<GitHubIcon className="size-3" />
</span>
) : null}
<span className="shrink-0">{label}</span>
{review?.status ? <ReviewChecksIndicator status={review.status} /> : null}
<span className="min-w-0 flex-1 truncate">
{provider === 'GitHub' ? null : `· ${provider}`}
{source ? ` · ${source}` : ''}
</span>
{actions}
</div>
</div>
</div>
)
}
@@ -0,0 +1,152 @@
import { useState, type MouseEvent } from 'react'
import type { WorkspaceAttachment } from '../../../../shared/worktree/types'
import type { WorktreeCardPrDisplay } from './worktree-card-pr-display'
import { workspaceAttachmentProviderLabel } from './worktree-attachment-editing'
import { Ellipsis, MonitorUp } 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 { HostedReviewUnlinkMenuItem } from '@/components/HostedReviewUnlinkMenuItem'
import { translate } from '@/i18n/i18n'
export function WorktreeCardReviewActions({
label,
number,
provider,
open,
onOpenChange,
onOpenInOrca,
onUnlink,
closeHover
}: {
label: 'PR' | 'MR'
number: number
provider: string
open?: boolean
onOpenChange: (open: boolean) => void
onOpenInOrca?: (event: MouseEvent) => void
onUnlink?: () => void
closeHover: () => void
}): React.JSX.Element | null {
const [internalOpen, setInternalOpen] = useState(false)
const isOpen = open ?? internalOpen
if (!onOpenInOrca && !onUnlink) {
return null
}
const moreActionsLabel = translate(
'auto.components.sidebar.WorktreeCardMeta.dbe2d18972',
'More {{value0}} actions',
{ value0: label }
)
const trigger = (
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="size-5 shrink-0"
aria-label={moreActionsLabel}
onClick={(event) => event.stopPropagation()}
>
<Ellipsis className="size-3" />
</Button>
</DropdownMenuTrigger>
)
return (
<DropdownMenu
modal={false}
open={isOpen}
onOpenChange={(next) => {
setInternalOpen(next)
onOpenChange(next)
}}
>
{isOpen ? (
trigger
) : (
<Tooltip>
<TooltipTrigger asChild>{trigger}</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{moreActionsLabel}
</TooltipContent>
</Tooltip>
)}
<DropdownMenuContent
align="end"
className="w-52"
onClick={(event) => event.stopPropagation()}
>
{onOpenInOrca ? (
<DropdownMenuItem
onClick={(event) => {
closeHover()
onOpenInOrca(event)
}}
>
<MonitorUp className="size-3.5" />
{translate('auto.components.sidebar.WorktreeCardMeta.2c67730e07', 'Open in Orca')}
</DropdownMenuItem>
) : null}
{onUnlink ? (
<HostedReviewUnlinkMenuItem
reviewLabel={label}
reviewIdentifier={`${label === 'MR' ? '!' : '#'}${number}`}
providerLabel={provider}
onSelect={() => {
closeHover()
onUnlink()
}}
/>
) : null}
</DropdownMenuContent>
</DropdownMenu>
)
}
export function WorktreeCardLinkedReviewActions({
item,
review,
onOpenChange,
onOpenInOrca,
onUnlink,
closeHover
}: {
item: WorkspaceAttachment
review: WorktreeCardPrDisplay | null
onOpenChange: (open: boolean) => void
onOpenInOrca?: (event: MouseEvent, review?: WorktreeCardPrDisplay) => void
onUnlink?: (item?: WorkspaceAttachment) => void
closeHover: () => void
}): React.JSX.Element {
const url = item.url ?? review?.url
const label = item.type === 'mr' ? 'MR' : 'PR'
return (
<WorktreeCardReviewActions
label={label}
number={item.number}
provider={workspaceAttachmentProviderLabel(item)}
onOpenChange={onOpenChange}
onOpenInOrca={
url && item.provider === 'github' && onOpenInOrca
? (event) =>
onOpenInOrca(
event,
review ?? {
provider: 'github',
number: item.number,
title: item.title ?? '',
url
}
)
: undefined
}
onUnlink={onUnlink ? () => onUnlink(item) : undefined}
closeHover={closeHover}
/>
)
}
@@ -1,182 +1,79 @@
import React from '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 { Copy, Ellipsis, ExternalLink, Globe, MonitorUp } from 'lucide-react'
import type { MouseEvent } from 'react'
import { Link2 } from 'lucide-react'
import { translate } from '@/i18n/i18n'
import {
WorktreeCardDetailSection,
WorktreeCardDetailSectionContent
} from './WorktreeCardDetailSection'
import { DetailHeader, MetadataActionIcon } from './WorktreeCardMetadataControls'
import { ReviewChecksBadge, ReviewStateBadge } from './WorktreeCardMetadataStatusBadges'
import { WorktreeCardDetailSection } from './WorktreeCardDetailSection'
import { DetailHeader } from './WorktreeCardMetadataControls'
import type { WorktreeCardPrDisplay } from './worktree-card-pr-display'
import { getProviderName, getReviewLabel, ReviewIcon } from './worktree-review-helpers'
import { HostedReviewUnlinkMenuItem } from '@/components/HostedReviewUnlinkMenuItem'
type WorktreeCardReviewDetailSectionProps = {
review: WorktreeCardPrDisplay | null
reviewMenuOpen: boolean
onReviewMenuOpenChange: (open: boolean) => void
onOpenReviewInOrca?: (event: React.MouseEvent) => void
onCopyReviewLink?: () => void
onOpenReviewInBrowser?: (url: string) => void
onUnlinkReview?: () => void
closeHover: () => void
}
import { getProviderName, getReviewLabel } from './worktree-review-helpers'
import { WorktreeCardReferenceRow } from './WorktreeCardReferenceRow'
import { WorktreeCardReviewActions } from './WorktreeCardReviewActions'
import { getWorkspaceAttachmentSourceLabel } from './workspace-attachment-source-label'
export function WorktreeCardReviewDetailSection({
review,
reviewMenuOpen,
onReviewMenuOpenChange,
onOpenReviewInOrca,
onCopyReviewLink,
onOpenReviewInBrowser,
onUnlinkReview,
closeHover
}: WorktreeCardReviewDetailSectionProps): React.JSX.Element | null {
closeHover,
onOpenLink,
onManageLinks
}: {
review: WorktreeCardPrDisplay | null
reviewMenuOpen: boolean
onReviewMenuOpenChange: (open: boolean) => void
onOpenReviewInOrca?: (event: MouseEvent) => void
onUnlinkReview?: () => void
closeHover: () => void
onOpenLink: (url: string, event: MouseEvent) => void
onManageLinks?: (event: MouseEvent) => void
}): React.JSX.Element | null {
if (!review) {
return null
}
const reviewLabel = getReviewLabel(review)
const reviewProvider = getProviderName(review)
const moreActionsLabel = translate(
'auto.components.sidebar.WorktreeCardMeta.dbe2d18972',
'More {{value0}} actions',
{ value0: reviewLabel }
)
const moreActionsTrigger = (
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="size-6"
aria-label={moreActionsLabel}
onClick={(event) => event.stopPropagation()}
>
<Ellipsis className="size-3" />
</Button>
</DropdownMenuTrigger>
)
const dismissAndOpenReview = (event: React.MouseEvent): void => {
closeHover()
onOpenReviewInOrca?.(event)
}
const label = getReviewLabel(review)
const source =
review.provider === 'unsupported'
? null
: getWorkspaceAttachmentSourceLabel({
provider: review.provider,
type: label === 'MR' ? 'mr' : 'pr',
number: review.number,
url: review.url
})
return (
<WorktreeCardDetailSection>
<DetailHeader
icon={<ReviewIcon review={review} className="size-3" />}
label={translate(
'auto.components.sidebar.WorktreeCardReviewDetailSection.reviewHeader',
'{{value0}} #{{value1}}',
{ value0: reviewLabel, value1: review.number }
)}
actions={
<>
{(onCopyReviewLink || onOpenReviewInBrowser || onUnlinkReview) && (
<DropdownMenu
modal={false}
open={reviewMenuOpen}
onOpenChange={onReviewMenuOpenChange}
>
{reviewMenuOpen ? (
moreActionsTrigger
) : (
<Tooltip>
<TooltipTrigger asChild>{moreActionsTrigger}</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{moreActionsLabel}
</TooltipContent>
</Tooltip>
)}
<DropdownMenuContent align="end" className="w-52">
{onOpenReviewInBrowser && (
<DropdownMenuItem
onSelect={() => {
closeHover()
onOpenReviewInBrowser(review.url!)
}}
>
<Globe className="size-3.5" />
{translate(
'auto.components.sidebar.WorktreeCardMeta.openInOrcaBrowser',
'Open in Orca browser'
)}
</DropdownMenuItem>
)}
{onCopyReviewLink && (
<DropdownMenuItem
onSelect={() => {
closeHover()
onCopyReviewLink()
}}
>
<Copy className="size-3.5" />
{translate(
'auto.components.sidebar.WorktreeCardReviewDetailSection.copyLink',
'Copy link'
)}
</DropdownMenuItem>
)}
{onUnlinkReview && (
<HostedReviewUnlinkMenuItem
reviewLabel={reviewLabel}
reviewIdentifier={`${reviewLabel === 'MR' ? '!' : '#'}${review.number}`}
providerLabel={reviewProvider}
onSelect={() => {
closeHover()
onUnlinkReview()
}}
/>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
{review.url && onOpenReviewInOrca && (
<MetadataActionIcon
label={translate(
'auto.components.sidebar.WorktreeCardMeta.2c67730e07',
'Open in Orca'
)}
onClick={dismissAndOpenReview}
>
<MonitorUp className="size-3" />
</MetadataActionIcon>
)}
{review.url && (
<MetadataActionIcon
label={translate(
'auto.components.sidebar.WorktreeCardMeta.ad25c3ff05',
'View on {{value0}}',
{ value0: reviewProvider }
)}
href={review.url}
>
<ExternalLink className="size-3" />
</MetadataActionIcon>
)}
</>
}
icon={<Link2 className="size-3" />}
label={`${translate('workspace.links.reviews', 'Reviews')} · 1`}
onAddReference={onManageLinks}
/>
<WorktreeCardDetailSectionContent className="space-y-1.5">
<div className="text-[13px] font-semibold leading-snug text-foreground break-words">
{review.title}
</div>
{(review.state || (review.status && review.status !== 'neutral')) && (
<div className="flex flex-wrap gap-1">
<ReviewStateBadge state={review.state} label={reviewLabel} />
<ReviewChecksBadge status={review.status} />
</div>
)}
</WorktreeCardDetailSectionContent>
<div className="py-1.5">
<WorktreeCardReferenceRow
review={review}
kind={label === 'MR' ? 'mr' : 'pr'}
title={review.title}
label={`${label} ${label === 'MR' ? '!' : '#'}${review.number}`}
provider={getProviderName(review)}
source={source}
url={review.url}
onOpen={onOpenLink}
actions={
<WorktreeCardReviewActions
label={label}
number={review.number}
provider={getProviderName(review)}
open={reviewMenuOpen}
onOpenChange={onReviewMenuOpenChange}
onOpenInOrca={
review.url && review.provider === 'github' ? onOpenReviewInOrca : undefined
}
onUnlink={onUnlinkReview}
closeHover={closeHover}
/>
}
/>
</div>
</WorktreeCardDetailSection>
)
}
@@ -0,0 +1,39 @@
import { useState, type MouseEvent } from 'react'
import type { 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 { useAppStore } from '@/store'
export function useWorkspaceReferenceLinkActions(
workspaceId: string | undefined,
closeHover: () => void
) {
const [linkActionRequest, setLinkActionRequest] = useState<LinkActionRequest | null>(null)
const onOpenLink = (url: string, event: MouseEvent): void => {
if (!workspaceId) {
event.preventDefault()
event.stopPropagation()
void window.api.shell.openUrl(url)
closeHover()
return
}
const state = useAppStore.getState()
const anchor = event.currentTarget
const plainClickBehavior = nativeChatPlainLinkClickBehavior(state.settings)
handleNativeChatWebLink(event, url, {
worktreeId: workspaceId,
...resolveNativeChatHttpLinkRouting(state, workspaceId),
actionsEnabled: plainClickBehavior === 'actions',
plainClickBehavior,
restoreFocus: () => {
if (anchor instanceof HTMLElement && anchor.isConnected) {
anchor.focus({ preventScroll: true })
}
},
request: setLinkActionRequest
})
closeHover()
}
return { linkActionRequest, onOpenLink, closeLinkActions: () => setLinkActionRequest(null) }
}
@@ -7,7 +7,7 @@ import { getWorktreeCardDetails } from '../../../../shared/worktree/card-propert
import { useAppStore } from '@/store'
import { readStoreListenerCount } from '@/store/store-listener-census'
import type { GlobalSettings } from '../../../../shared/global-settings-types'
import type { Worktree } from '../../../../shared/worktree/types'
import type { Worktree, WorkspaceAttachment } from '../../../../shared/worktree/types'
import { usePromptCacheCountdownStartedAt } from './CacheTimer'
import { useWorktreeCardSecondaryDetails } from './use-worktree-card-secondary-details'
import { useWorktreeAgentRows } from './useWorktreeAgentRows'
@@ -222,4 +222,42 @@ describe('useWorktreeCardSecondaryDetails store subscriptions', () => {
'Update the remote runtime to unlink GitHub pull requests'
)
})
it('unlinks the chosen sibling while retaining the selected review and other sources', async () => {
const primary: WorkspaceAttachment = {
provider: 'github',
type: 'pr',
number: 42,
url: 'https://github.com/acme/orca/pull/42'
}
const sibling: WorkspaceAttachment = {
provider: 'github',
type: 'pr',
number: 42,
url: 'https://github.com/other/orca/pull/42'
}
const updateWorktreeMeta = vi.fn().mockResolvedValue({ ok: true })
let unlink: ((item?: WorkspaceAttachment) => Promise<void>) | null = null
function Probe(): null {
const details = useWorktreeCardSecondaryDetails({
...secondaryDetailsArgs(makeSettings(300_000)),
worktree: {
...makeWorktree(),
hostId: 'ssh:builder',
linkedPR: 42,
linkedItems: [primary, sibling]
},
prDisplay: { provider: 'github', number: 42, title: 'Primary', url: primary.url },
updateWorktreeMeta
})
unlink = details.handleUnlinkReview
return null
}
mount(<Probe />)
await act(() => unlink?.(sibling))
expect(updateWorktreeMeta).toHaveBeenCalledWith(
WORKTREE_ID,
{ linkedItemsBase: [primary, sibling], linkedItems: [primary] },
{ executionHostId: 'ssh:builder' }
)
})
})
@@ -1,4 +1,8 @@
import React, { useCallback } from 'react'
import type { WorkspaceAttachment } from '../../../../shared/worktree/types'
import { getWorkspaceAttachmentKey } from '../../../../shared/workspace-attachment-normalization'
import { buildWorkspaceAttachmentEdits } from './worktree-attachment-editing'
import type { WorktreeCardPrDisplay } from './worktree-card-pr-display'
import { getWorkspaceAttachments } from '../../../../shared/workspace-attachments'
import { toast } from 'sonner'
@@ -123,22 +127,22 @@ export function useWorktreeCardSecondaryDetails({
[hoverIssue, openTaskPage, repo]
)
const handleOpenReviewInOrca = useCallback(
(e: React.MouseEvent) => {
(e: React.MouseEvent, review: WorktreeCardPrDisplay | null = hoverReview) => {
e.stopPropagation()
if (!repo || !hoverReview?.url || hoverReview.provider !== 'github') {
if (!repo || !review?.url || review.provider !== 'github') {
return
}
const item: GitHubWorkItem = {
id: hoverReview.url,
id: review.url,
type: 'pr',
number: hoverReview.number,
title: hoverReview.title,
state: hoverReview.state ?? 'open',
url: hoverReview.url,
number: review.number,
title: review.title,
state: review.state ?? 'open',
url: review.url,
labels: [],
updatedAt: 'updatedAt' in hoverReview ? hoverReview.updatedAt : new Date().toISOString(),
updatedAt: 'updatedAt' in review ? review.updatedAt : new Date().toISOString(),
author: null,
headSha: 'headSha' in hoverReview ? hoverReview.headSha : undefined,
headSha: 'headSha' in review ? review.headSha : undefined,
repoId: repo.id
}
openTaskPage({ taskSource: 'github', preselectedRepoId: repo.id, openGitHubWorkItem: item })
@@ -186,38 +190,63 @@ export function useWorktreeCardSecondaryDetails({
(hoverReviewProvider === 'bitbucket' && linkedBitbucketPR !== null) ||
(hoverReviewProvider === 'azure-devops' && linkedAzureDevOpsPR !== null) ||
(hoverReviewProvider === 'gitea' && linkedGiteaPR !== null)
const handleUnlinkReview = useCallback(async () => {
const options = { executionHostId: worktree.hostId ?? 'local' }
switch (hoverReviewProvider) {
case 'github':
if (hoverReview) {
const result = await updateWorktreeMeta(
worktree.id,
{ linkedPR: null, suppressedGitHubPR: hoverReview.number },
options
)
if (!result.ok) {
toast.error(result.error)
}
const handleUnlinkReview = useCallback(
async (item?: WorkspaceAttachment) => {
const options = { executionHostId: worktree.hostId ?? 'local' }
if (item) {
const initial = getWorkspaceAttachments(worktree)
const key = getWorkspaceAttachmentKey(item)
const draft = initial.filter((entry) => getWorkspaceAttachmentKey(entry) !== key)
const result = await updateWorktreeMeta(
worktree.id,
{
...buildWorkspaceAttachmentEdits({ initial, draft }),
...(item.provider === 'github' &&
hoverReview?.provider === 'github' &&
hoverReview.number === item.number &&
(!item.url || item.url === hoverReview.url)
? { suppressedGitHubPR: item.number }
: {})
},
options
)
if (!result.ok) {
toast.error(result.error)
}
return
case 'gitlab':
void updateWorktreeMeta(worktree.id, { linkedGitLabMR: null }, options)
return
case 'bitbucket':
void updateWorktreeMeta(worktree.id, { linkedBitbucketPR: null }, options)
return
case 'azure-devops':
void updateWorktreeMeta(worktree.id, { linkedAzureDevOpsPR: null }, options)
return
case 'gitea':
void updateWorktreeMeta(worktree.id, { linkedGiteaPR: null }, options)
break
case 'unsupported':
case undefined:
break
}
}, [hoverReview, hoverReviewProvider, updateWorktreeMeta, worktree.hostId, worktree.id])
}
switch (hoverReviewProvider) {
case 'github':
if (hoverReview) {
const result = await updateWorktreeMeta(
worktree.id,
{ linkedPR: null, suppressedGitHubPR: hoverReview.number },
options
)
if (!result.ok) {
toast.error(result.error)
}
}
return
case 'gitlab':
void updateWorktreeMeta(worktree.id, { linkedGitLabMR: null }, options)
return
case 'bitbucket':
void updateWorktreeMeta(worktree.id, { linkedBitbucketPR: null }, options)
return
case 'azure-devops':
void updateWorktreeMeta(worktree.id, { linkedAzureDevOpsPR: null }, options)
return
case 'gitea':
void updateWorktreeMeta(worktree.id, { linkedGiteaPR: null }, options)
break
case 'unsupported':
case undefined:
break
}
},
[hoverReview, hoverReviewProvider, updateWorktreeMeta, worktree]
)
const handleOpenLinearIssueInOrca = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
@@ -68,9 +68,9 @@ export type WorktreeCardDetailsHoverProps = WorktreeCardMetaBadgesProps & {
onOpenGitHubIssueInOrca?: (event: React.MouseEvent) => void
onOpenIssueInBrowser?: (url: string) => void
onOpenLinearIssueInOrca?: (event: React.MouseEvent) => void
onOpenReviewInOrca?: (event: React.MouseEvent) => void
onOpenReviewInOrca?: (event: React.MouseEvent, review?: WorktreeCardPrDisplay) => void
onOpenReviewInBrowser?: (url: string) => void
onUnlinkReview?: () => void
onUnlinkReview?: (item?: WorkspaceAttachment) => void
onOpenAutomation?: (event: React.MouseEvent) => void
onOpenAutomationRun?: (event: React.MouseEvent) => void
hoverControl?: WorktreeCardDetailsHoverControl
@@ -20,6 +20,7 @@ export function WorktreeCardParentContent({
}): React.JSX.Element {
const {
worktree,
repo,
affiliateListMode,
newCardStyle,
showStatus,
@@ -110,13 +111,17 @@ export function WorktreeCardParentContent({
hoverIssue && 'url' in hoverIssue && hoverIssue.url ? handleOpenIssueInBrowser : undefined
}
onOpenLinearIssueInOrca={linearIssue?.url ? handleOpenLinearIssueInOrca : undefined}
onOpenReviewInOrca={
hoverReview?.url && hoverReview.provider === 'github' ? handleOpenReviewInOrca : undefined
}
onOpenReviewInOrca={repo ? handleOpenReviewInOrca : undefined}
onOpenReviewInBrowser={hoverReview?.url ? handleOpenReviewInBrowser : undefined}
onOpenAutomation={affiliateListMode ? undefined : handleOpenAutomation}
onOpenAutomationRun={affiliateListMode ? undefined : handleOpenAutomationRun}
onUnlinkReview={!affiliateListMode && canUnlinkReview ? handleUnlinkReview : undefined}
onUnlinkReview={
!affiliateListMode &&
(canUnlinkReview ||
getWorkspaceAttachments(worktree).some((item) => item.type !== 'issue'))
? handleUnlinkReview
: undefined
}
>
{identityContent}
</WorktreeCardDetailsHover>
@@ -236,13 +236,17 @@ export function buildWorktreeCardPresentation(card: WorktreeCardController) {
metaIssue && 'url' in metaIssue && metaIssue.url ? handleOpenIssueInBrowser : undefined
}
onOpenLinearIssueInOrca={linearIssue?.url ? handleOpenLinearIssueInOrca : undefined}
onOpenReviewInOrca={
metaReview?.url && metaReview.provider === 'github' ? handleOpenReviewInOrca : undefined
}
onOpenReviewInOrca={repo ? handleOpenReviewInOrca : undefined}
onOpenReviewInBrowser={metaReview?.url ? handleOpenReviewInBrowser : undefined}
onOpenAutomation={affiliateListMode ? undefined : handleOpenAutomation}
onOpenAutomationRun={affiliateListMode ? undefined : handleOpenAutomationRun}
onUnlinkReview={!affiliateListMode && canUnlinkReview ? handleUnlinkReview : undefined}
onUnlinkReview={
!affiliateListMode &&
(canUnlinkReview ||
getWorkspaceAttachments(worktree).some((item) => item.type !== 'issue'))
? handleUnlinkReview
: undefined
}
>
{detailsAndPortsContent}
</WorktreeCardDetailsHover>
@@ -69,16 +69,21 @@ function getStateTone(state: WorktreeCardPrDisplay['state']): string {
export function ReviewIcon({
review,
className,
variant = 'provider'
variant = 'provider',
showChecks = true
}: {
review: WorktreeCardPrDisplay
className?: string
variant?: 'provider' | 'generic'
showChecks?: boolean
}): React.JSX.Element {
const providerIcon =
variant === 'provider' && review.provider === 'gitlab' ? GitMerge : PullRequestIcon
const Icon = getReviewStateIcon(review.state) ?? providerIcon
return createElement(Icon, {
className: cn(className, getCheckTone(review) ?? getStateTone(review.state))
className: cn(
className,
(showChecks ? getCheckTone(review) : null) ?? getStateTone(review.state)
)
})
}
@@ -4,6 +4,7 @@ import type {
} from '../../../../shared/claude-usage-types'
import type { CodexUsageDailyPoint, CodexUsageSummary } from '../../../../shared/codex-usage-types'
import { translate } from '@/i18n/i18n'
import { GitHubIcon } from '@/components/icons/GitHubIcon'
export type ClaudeShareData = {
provider: 'claude'
@@ -202,7 +203,12 @@ export function CardFooter(props: {
</div>
<div style={{ display: 'table-cell', verticalAlign: 'middle', textAlign: 'right' }}>
<span style={{ display: 'inline-block', verticalAlign: 'middle' }}>
<GitHubIcon />
<GitHubIcon
width={13}
height={13}
fill="#888"
style={{ opacity: 0.6, verticalAlign: 'middle' }}
/>
</span>
<span
style={{
@@ -222,17 +228,3 @@ export function CardFooter(props: {
</div>
)
}
function GitHubIcon(): React.JSX.Element {
return (
<svg
width={13}
height={13}
viewBox="0 0 16 16"
fill="#888"
style={{ opacity: 0.6, verticalAlign: 'middle' }}
>
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
</svg>
)
}
+12 -1
View File
@@ -2166,8 +2166,10 @@
"ca74db7550": "Project on SSH host"
},
"WorktreeCardMeta": {
"ad25c3ff05": "View on {{value0}}",
"ae76907ca6": "Unlink {{value0}}",
"eace1d2cf6": "neutral"
"eace1d2cf6": "neutral",
"reviewLinkLabel": "{{value0}} link"
},
"WorktreeCardMetadataStatusBadges": {
"29df45afa2": "MR"
@@ -2176,6 +2178,10 @@
"34f733dda2": "Go to Worktree",
"3e5f66564e": "Workspace unavailable"
},
"WorktreeCardReviewDetailSection": {
"copyLink": "Copy link",
"reviewHeader": "{{value0}} #{{value1}}"
},
"WorktreeIssueLinkField": {
"0a7a2c6efd": "Not a GitHub issue number or issue URL.",
"161b2d053a": "Open linked issue",
@@ -3476,6 +3482,11 @@
"resumed_other": "{{count}} agent sessions resumed."
}
},
"workspace": {
"links": {
"manage": "Manage links…"
}
},
"workspaceSpace": {
"chart": {
"workspaceSizes": "Workspace sizes"