From e73ca2aec7c98773f9b3027a6eb12dabbd89cfa2 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:40:42 -0700 Subject: [PATCH] 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 --- src/renderer/src/assets/main.css | 25 ++ .../activity/activity-thread-hover-card.tsx | 32 ++- .../components/file-path-cursor-tooltip.tsx | 1 + .../src/components/icons/GitHubIcon.tsx | 9 + .../WorktreeCard.compact-hover.test.tsx | 3 +- .../WorktreeCardIssueDetailSection.tsx | 5 +- .../WorktreeCardLinkedItemsSection.test.tsx | 52 ++-- .../WorktreeCardLinkedItemsSection.tsx | 110 ++++----- .../WorktreeCardMeta.interaction.test.tsx | 124 +++------- .../sidebar/WorktreeCardMeta.test.tsx | 41 ++-- .../components/sidebar/WorktreeCardMeta.tsx | 48 ++-- .../sidebar/WorktreeCardMetadataControls.tsx | 31 ++- .../WorktreeCardMetadataStatusBadges.tsx | 86 +++++-- .../sidebar/WorktreeCardReferenceRow.tsx | 127 ++++++++++ .../sidebar/WorktreeCardReviewActions.tsx | 152 ++++++++++++ .../WorktreeCardReviewDetailSection.tsx | 223 +++++------------- .../use-workspace-reference-link-actions.ts | 39 +++ ...ndary-details.store-subscriptions.test.tsx | 40 +++- .../use-worktree-card-secondary-details.ts | 107 ++++++--- .../sidebar/worktree-card-meta-types.ts | 4 +- .../sidebar/worktree-card-parent-content.tsx | 13 +- .../sidebar/worktree-card-presentation.tsx | 12 +- .../sidebar/worktree-review-helpers.tsx | 9 +- .../src/components/stats/share-card-utils.tsx | 22 +- .../src/i18n/en-runtime-required.json | 13 +- 25 files changed, 848 insertions(+), 480 deletions(-) create mode 100644 src/renderer/src/components/icons/GitHubIcon.tsx create mode 100644 src/renderer/src/components/sidebar/WorktreeCardReferenceRow.tsx create mode 100644 src/renderer/src/components/sidebar/WorktreeCardReviewActions.tsx create mode 100644 src/renderer/src/components/sidebar/use-workspace-reference-link-actions.ts diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index d9da4b9f78c..ec1a8070afd 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -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; + } +} 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 bd016ef9de8..9ffd287cb1d 100644 --- a/src/renderer/src/components/activity/activity-thread-hover-card.tsx +++ b/src/renderer/src/components/activity/activity-thread-hover-card.tsx @@ -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 ( + event.stopPropagation()}> + + void detailsHoverControl: ReturnType 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 */} diff --git a/src/renderer/src/components/file-path-cursor-tooltip.tsx b/src/renderer/src/components/file-path-cursor-tooltip.tsx index f346a2c32b0..63916c6cb6c 100644 --- a/src/renderer/src/components/file-path-cursor-tooltip.tsx +++ b/src/renderer/src/components/file-path-cursor-tooltip.tsx @@ -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. */} ): React.JSX.Element { + return ( + + ) +} diff --git a/src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx index 2b4622546d3..71236d95b41 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx @@ -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') + expect(markup).toContain('· Linear') expect(markup).toContain('Reviewer handoff note') expect(markup).toContain('Live Ports') expect(markup).toContain('58941') diff --git a/src/renderer/src/components/sidebar/WorktreeCardIssueDetailSection.tsx b/src/renderer/src/components/sidebar/WorktreeCardIssueDetailSection.tsx index 4cc4ab7837b..1e1026c3d31 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardIssueDetailSection.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardIssueDetailSection.tsx @@ -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 ( } label={translate( 'auto.components.sidebar.WorktreeCardMeta.e97d8f2876', diff --git a/src/renderer/src/components/sidebar/WorktreeCardLinkedItemsSection.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardLinkedItemsSection.test.tsx index eb70753efa9..038adb7fa41 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardLinkedItemsSection.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardLinkedItemsSection.test.tsx @@ -136,10 +136,11 @@ describe('workspace linked references in hover details', () => { } const { container } = render() 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() 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', () => { diff --git a/src/renderer/src/components/sidebar/WorktreeCardLinkedItemsSection.tsx b/src/renderer/src/components/sidebar/WorktreeCardLinkedItemsSection.tsx index 754f5927bf2..c6c195002bd 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardLinkedItemsSection.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardLinkedItemsSection.tsx @@ -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({ } label={`${group.label} · ${group.rows.length}`} + onAddReference={onManage} />
{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} > -
- {live ? ( - - ) : item.type === 'mr' ? ( - - ) : item.type === 'pr' ? ( - - ) : ( - - )} - - {url ? ( - - ) : null} -
- {live?.state || live?.status || detail?.stateName || detail?.issueState ? ( + 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 ? (
- {live?.state ? ( - - ) : null} - {live?.status ? : null} {detail?.stale ? ( - + {translate('workspace.links.lastKnownStatus', 'Last known status')} ) : null} {live && 'baseRefName' in live && live.baseRefName ? ( - + {translate('workspace.links.reviewTarget', 'Target: {{branch}}', { branch: live.baseRefName })} @@ -171,19 +154,12 @@ export function WorktreeCardLinkedItemsSection({
)) : null} - {onManage ? ( - + {!rows.length && onManage ? ( + } + label={translate('workspace.links.title', 'Linked work')} + onAddReference={onManage} + /> ) : null} ) diff --git a/src/renderer/src/components/sidebar/WorktreeCardMeta.interaction.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardMeta.interaction.test.tsx index 1bcf3c696e1..c3c9ae902d8 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardMeta.interaction.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardMeta.interaction.test.tsx @@ -66,8 +66,22 @@ vi.mock('@/components/ui/dropdown-menu', () => ({ <>{children} ), DropdownMenuContent: ({ children }: { children: ReactNode }) =>
{children}
, - DropdownMenuItem: ({ children, onSelect }: { children: ReactNode; onSelect?: () => void }) => ( - ) @@ -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( + + Linked PR + + ) + ) 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', () => { diff --git a/src/renderer/src/components/sidebar/WorktreeCardMeta.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardMeta.test.tsx index d1f01d124bc..5b66e084450 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardMeta.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardMeta.test.tsx @@ -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( { onOpenReviewInOrca={vi.fn()} onOpenReviewInBrowser={vi.fn()} onUnlinkReview={vi.fn()} + onManageLinks={vi.fn()} > Linked 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('Copy link') + expect(markup).toContain('Reviews · 1') + expect(markup).toContain('aria-label="More PR actions"') + expect(markup.indexOf('PR #456')).toBeGreaterThan(-1) + expect(markup.indexOf('PR #456')).toBeLessThan( + markup.indexOf('aria-label="Checks: Passing"') + ) + expect(markup.indexOf('role="img" aria-label="GitHub"')).toBeLessThan( + markup.indexOf('PR #456') + ) + 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( { ) - expect(markup).toContain('Open in Orca browser') + expect(markup).toContain('href="https://code.example.test/reviews/12"') }) 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 d6bef752035..f0137bf2da0 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardMeta.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardMeta.tsx @@ -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} > + event.stopPropagation()}> + + } + onAddReference={onManageLinks ? dismissAndRun(onManageLinks) : undefined} label={translate( 'auto.components.sidebar.WorktreeCardMeta.5e982e6128', 'Linear {{value0}}', @@ -309,6 +307,7 @@ export function WorktreeCardDetailsHover({ } + onAddReference={onManageLinks ? dismissAndRun(onManageLinks) : undefined} label={translate( 'auto.components.sidebar.WorktreeCardMeta.jiraIssue', 'Jira {{value0}}', @@ -336,13 +335,11 @@ export function WorktreeCardDetailsHover({ @@ -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) => ( + + )} /> {automationProvenance && ( diff --git a/src/renderer/src/components/sidebar/WorktreeCardMetadataControls.tsx b/src/renderer/src/components/sidebar/WorktreeCardMetadataControls.tsx index bda7638db6c..a0b3086d70e 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardMetadataControls.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardMetadataControls.tsx @@ -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 ( + + + + ) +} 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 (
@@ -31,7 +53,12 @@ export function DetailHeader({ {icon} {label}
- {actions ?
{actions}
: null} + {actions || onAddReference ? ( +
+ {actions} + +
+ ) : null}
) } diff --git a/src/renderer/src/components/sidebar/WorktreeCardMetadataStatusBadges.tsx b/src/renderer/src/components/sidebar/WorktreeCardMetadataStatusBadges.tsx index d9cf841fb7b..bd586d851d2 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardMetadataStatusBadges.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardMetadataStatusBadges.tsx @@ -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 ( @@ -126,10 +125,7 @@ export function ReviewStateBadge({ if (state === 'closed') { return ( @@ -140,11 +136,7 @@ export function ReviewStateBadge({ if (state === 'draft') { return ( @@ -154,10 +146,7 @@ export function ReviewStateBadge({ return ( {label === 'MR' ? : } @@ -207,3 +196,66 @@ export function ReviewChecksBadge({ ) } + +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 ( + + + + + + + + {label} + + + ) +} diff --git a/src/renderer/src/components/sidebar/WorktreeCardReferenceRow.tsx b/src/renderer/src/components/sidebar/WorktreeCardReferenceRow.tsx new file mode 100644 index 00000000000..c77ca916f3f --- /dev/null +++ b/src/renderer/src/components/sidebar/WorktreeCardReferenceRow.tsx @@ -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 ( +
+
+ {review ? ( + + + + + + + + {reviewStateLabel(review.state, getReviewLabel(review))} + + + ) : kind === 'mr' ? ( + + ) : kind === 'pr' ? ( + + ) : ( + + )} +
+
+ {url ? ( + + 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()} + > + {title} + + + ) : ( + + )} +
+ {provider === 'GitHub' ? ( + + + + ) : null} + {label} + {review?.status ? : null} + + {provider === 'GitHub' ? null : `· ${provider}`} + {source ? ` · ${source}` : ''} + + {actions} +
+
+
+ ) +} diff --git a/src/renderer/src/components/sidebar/WorktreeCardReviewActions.tsx b/src/renderer/src/components/sidebar/WorktreeCardReviewActions.tsx new file mode 100644 index 00000000000..729a9cbe9d4 --- /dev/null +++ b/src/renderer/src/components/sidebar/WorktreeCardReviewActions.tsx @@ -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 = ( + + + + ) + return ( + { + setInternalOpen(next) + onOpenChange(next) + }} + > + {isOpen ? ( + trigger + ) : ( + + {trigger} + + {moreActionsLabel} + + + )} + event.stopPropagation()} + > + {onOpenInOrca ? ( + { + closeHover() + onOpenInOrca(event) + }} + > + + {translate('auto.components.sidebar.WorktreeCardMeta.2c67730e07', 'Open in Orca')} + + ) : null} + {onUnlink ? ( + { + closeHover() + onUnlink() + }} + /> + ) : null} + + + ) +} + +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 ( + + onOpenInOrca( + event, + review ?? { + provider: 'github', + number: item.number, + title: item.title ?? '', + url + } + ) + : undefined + } + onUnlink={onUnlink ? () => onUnlink(item) : undefined} + closeHover={closeHover} + /> + ) +} diff --git a/src/renderer/src/components/sidebar/WorktreeCardReviewDetailSection.tsx b/src/renderer/src/components/sidebar/WorktreeCardReviewDetailSection.tsx index 13d54133a35..50fa49c20da 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardReviewDetailSection.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardReviewDetailSection.tsx @@ -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 = ( - - - - ) - 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 ( } - label={translate( - 'auto.components.sidebar.WorktreeCardReviewDetailSection.reviewHeader', - '{{value0}} #{{value1}}', - { value0: reviewLabel, value1: review.number } - )} - actions={ - <> - {(onCopyReviewLink || onOpenReviewInBrowser || onUnlinkReview) && ( - - {reviewMenuOpen ? ( - moreActionsTrigger - ) : ( - - {moreActionsTrigger} - - {moreActionsLabel} - - - )} - - {onOpenReviewInBrowser && ( - { - closeHover() - onOpenReviewInBrowser(review.url!) - }} - > - - {translate( - 'auto.components.sidebar.WorktreeCardMeta.openInOrcaBrowser', - 'Open in Orca browser' - )} - - )} - {onCopyReviewLink && ( - { - closeHover() - onCopyReviewLink() - }} - > - - {translate( - 'auto.components.sidebar.WorktreeCardReviewDetailSection.copyLink', - 'Copy link' - )} - - )} - {onUnlinkReview && ( - { - closeHover() - onUnlinkReview() - }} - /> - )} - - - )} - {review.url && onOpenReviewInOrca && ( - - - - )} - {review.url && ( - - - - )} - - } + icon={} + label={`${translate('workspace.links.reviews', 'Reviews')} · 1`} + onAddReference={onManageLinks} /> - -
- {review.title} -
- {(review.state || (review.status && review.status !== 'neutral')) && ( -
- - -
- )} -
+
+ + } + /> +
) } 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 new file mode 100644 index 00000000000..ff49f2fac2a --- /dev/null +++ b/src/renderer/src/components/sidebar/use-workspace-reference-link-actions.ts @@ -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(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) } +} diff --git a/src/renderer/src/components/sidebar/use-worktree-card-secondary-details.store-subscriptions.test.tsx b/src/renderer/src/components/sidebar/use-worktree-card-secondary-details.store-subscriptions.test.tsx index 915a90d42f5..a55b864ffff 100644 --- a/src/renderer/src/components/sidebar/use-worktree-card-secondary-details.store-subscriptions.test.tsx +++ b/src/renderer/src/components/sidebar/use-worktree-card-secondary-details.store-subscriptions.test.tsx @@ -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) | 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() + await act(() => unlink?.(sibling)) + expect(updateWorktreeMeta).toHaveBeenCalledWith( + WORKTREE_ID, + { linkedItemsBase: [primary, sibling], linkedItems: [primary] }, + { executionHostId: 'ssh:builder' } + ) + }) }) diff --git a/src/renderer/src/components/sidebar/use-worktree-card-secondary-details.ts b/src/renderer/src/components/sidebar/use-worktree-card-secondary-details.ts index a213efc8a01..78ff52153e8 100644 --- a/src/renderer/src/components/sidebar/use-worktree-card-secondary-details.ts +++ b/src/renderer/src/components/sidebar/use-worktree-card-secondary-details.ts @@ -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() diff --git a/src/renderer/src/components/sidebar/worktree-card-meta-types.ts b/src/renderer/src/components/sidebar/worktree-card-meta-types.ts index a20f309a219..fd927004e0b 100644 --- a/src/renderer/src/components/sidebar/worktree-card-meta-types.ts +++ b/src/renderer/src/components/sidebar/worktree-card-meta-types.ts @@ -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 diff --git a/src/renderer/src/components/sidebar/worktree-card-parent-content.tsx b/src/renderer/src/components/sidebar/worktree-card-parent-content.tsx index fc29e8aa23b..aa680bd2553 100644 --- a/src/renderer/src/components/sidebar/worktree-card-parent-content.tsx +++ b/src/renderer/src/components/sidebar/worktree-card-parent-content.tsx @@ -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} diff --git a/src/renderer/src/components/sidebar/worktree-card-presentation.tsx b/src/renderer/src/components/sidebar/worktree-card-presentation.tsx index 99e9da0fee9..86c70962a3f 100644 --- a/src/renderer/src/components/sidebar/worktree-card-presentation.tsx +++ b/src/renderer/src/components/sidebar/worktree-card-presentation.tsx @@ -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} diff --git a/src/renderer/src/components/sidebar/worktree-review-helpers.tsx b/src/renderer/src/components/sidebar/worktree-review-helpers.tsx index 0b7e4d3c254..567363a6fc9 100644 --- a/src/renderer/src/components/sidebar/worktree-review-helpers.tsx +++ b/src/renderer/src/components/sidebar/worktree-review-helpers.tsx @@ -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) + ) }) } diff --git a/src/renderer/src/components/stats/share-card-utils.tsx b/src/renderer/src/components/stats/share-card-utils.tsx index 364f3040159..3b0a3eabc1f 100644 --- a/src/renderer/src/components/stats/share-card-utils.tsx +++ b/src/renderer/src/components/stats/share-card-utils.tsx @@ -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: {
- + ) } - -function GitHubIcon(): React.JSX.Element { - return ( - - - - ) -} diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 1317ff5f144..37b23062a40 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -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"