From bc301de7be8c3246e8e65507cf2e889d7e9fc0ce Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:43:14 -0700 Subject: [PATCH 01/22] fix(worktree-palette): guard undefined review title in matcher (crash c5d87873) (#10024) Co-authored-by: Orca --- .../lib/worktree-palette-review-match.test.ts | 102 ++++++++++++++++++ .../src/lib/worktree-palette-review-match.ts | 9 +- 2 files changed, 108 insertions(+), 3 deletions(-) create mode 100644 src/renderer/src/lib/worktree-palette-review-match.test.ts diff --git a/src/renderer/src/lib/worktree-palette-review-match.test.ts b/src/renderer/src/lib/worktree-palette-review-match.test.ts new file mode 100644 index 00000000000..c72710d2931 --- /dev/null +++ b/src/renderer/src/lib/worktree-palette-review-match.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest' +import { matchWorktreePaletteReview } from './worktree-palette-review-match' +import { searchWorktrees } from './worktree-palette-search' +import type { Repo, Worktree } from '../../../shared/types' +import type { HostedReviewInfo } from '../../../shared/hosted-review' + +// Regression tests for the production crash (report c5d87873, macOS, Orca 1.4.147): +// TypeError: Cannot read properties of undefined (reading 'toLowerCase') +// at matchWorktreePaletteReview -> searchWorktrees -> WorktreeJumpPalette useMemo +// A rehydrated review/PR cache entry can carry an undefined `title` even though the +// type declares it non-optional, so the Cmd+J worktree palette crashed on any text query. + +type MatcherReview = Parameters[0] + +function makeWorktree(overrides: Partial = {}): Worktree { + return { + id: 'wt-1', + repoId: 'repo-1', + path: '/tmp/wt-1', + head: 'abc123', + branch: 'refs/heads/feature/worktree-jump', + isBare: false, + isMainWorktree: false, + displayName: 'Jump Palette', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + ...overrides + } +} + +const repoMap = new Map([ + [ + 'repo-1', + { + id: 'repo-1', + path: '/repo/orca', + displayName: 'stablyai/orca', + badgeColor: '#22c55e', + addedAt: 0 + } + ] +]) + +describe('matchWorktreePaletteReview title null-safety', () => { + it('returns null instead of throwing when a cached review has no title', () => { + const review = { number: 42, provider: 'github' } as unknown as MatcherReview + expect(() => matchWorktreePaletteReview(review, 'feature', 'feature')).not.toThrow() + expect(matchWorktreePaletteReview(review, 'feature', 'feature')).toBeNull() + }) + + it('still matches on the title text when a title is present', () => { + const review = { + number: 42, + title: 'Fix the thing', + provider: 'github' + } as unknown as MatcherReview + const match = matchWorktreePaletteReview(review, 'thing', 'thing') + expect(match).not.toBeNull() + expect(match?.text).toBe('Fix the thing') + expect(match?.matchRange).toEqual({ start: 8, end: 13 }) + }) + + it('still matches on the PR number even when the title is missing', () => { + const review = { number: 42, provider: 'github' } as unknown as MatcherReview + const match = matchWorktreePaletteReview(review, '42', '42') + expect(match?.text).toBe('PR #42') + }) +}) + +describe('searchWorktrees with a titleless cached review (Cmd+J palette crash path)', () => { + it('does not throw when the checks-review cache entry has no title', () => { + const worktree = makeWorktree() + const titlelessReview = { + number: 7, + provider: 'github', + state: 'open', + url: 'https://example.test/pr/7' + } as unknown as HostedReviewInfo + const checksReviewByWorktree = new Map([ + [worktree, titlelessReview] + ]) + + expect(() => + searchWorktrees( + [worktree], + 'nonmatchingtext', + repoMap, + null, + null, + undefined, + checksReviewByWorktree + ) + ).not.toThrow() + }) +}) diff --git a/src/renderer/src/lib/worktree-palette-review-match.ts b/src/renderer/src/lib/worktree-palette-review-match.ts index 9945722a366..726ec1a64d3 100644 --- a/src/renderer/src/lib/worktree-palette-review-match.ts +++ b/src/renderer/src/lib/worktree-palette-review-match.ts @@ -1,6 +1,7 @@ import type { HostedReviewInfo } from '../../../shared/hosted-review' -type SearchableReview = Pick +// title is intentionally optional: rehydrated review/PR caches can hold entries without one. +type SearchableReview = Pick & { title?: string } type WorktreePaletteReviewMatch = { labelKind: 'pr' | 'mr' text: string @@ -35,13 +36,15 @@ export function matchWorktreePaletteReview( } } - const titleIndex = review.title.toLowerCase().indexOf(query) + // Null-safe: a cached review may have no title, so fall back to '' (query is non-empty, so it won't match). + const title = review.title ?? '' + const titleIndex = title.toLowerCase().indexOf(query) if (titleIndex === -1) { return null } return { labelKind: isMergeRequest ? 'mr' : 'pr', - text: review.title, + text: title, matchRange: { start: titleIndex, end: titleIndex + query.length } } } From 6a8e992c326188d8164493a81fdd3b029dcb293b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 23 Jul 2026 00:55:31 +0000 Subject: [PATCH 02/22] Update README downloads badge --- docs/assets/readme-downloads.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index 7cc5ece98ff..50ca9cde0e2 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 7.5m + + downloads: 7.6m @@ -15,7 +15,7 @@ downloads downloads - 7.5m - 7.5m + 7.6m + 7.6m From 72a0a4dbb83ba52fb0d0a8b845981496f1bf3f6b Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:57:32 -0700 Subject: [PATCH 03/22] Revert "Show current branch in Source Control header (#9787)" (#10032) This reverts commit 56a31a5af0ab3d5b4585f6060fa9d601a7bd00c2. --- .../src/components/DetachedHeadBadge.tsx | 8 +- ...Control.hosted-review-header-link.test.tsx | 157 ++++++++++++++++++ .../right-sidebar/SourceControl.tsx | 39 ++++- .../hosted-review-header-chrome.tsx | 74 +++++++++ ...rce-control-create-pr-intent-state.test.ts | 43 +++++ .../source-control-create-pr-intent-state.ts | 11 ++ .../source-control-header-toolbar.test.tsx | 90 ---------- .../source-control-header-toolbar.tsx | 121 +++++++++----- src/renderer/src/i18n/locales/en.json | 3 +- src/renderer/src/i18n/locales/es.json | 3 +- src/renderer/src/i18n/locales/ja.json | 3 +- src/renderer/src/i18n/locales/ko.json | 3 +- src/renderer/src/i18n/locales/zh.json | 3 +- 13 files changed, 413 insertions(+), 145 deletions(-) create mode 100644 src/renderer/src/components/right-sidebar/SourceControl.hosted-review-header-link.test.tsx create mode 100644 src/renderer/src/components/right-sidebar/hosted-review-header-chrome.tsx create mode 100644 src/renderer/src/components/right-sidebar/source-control-create-pr-intent-state.test.ts delete mode 100644 src/renderer/src/components/right-sidebar/source-control-header-toolbar.test.tsx diff --git a/src/renderer/src/components/DetachedHeadBadge.tsx b/src/renderer/src/components/DetachedHeadBadge.tsx index bc03ce2ab52..58a12e6395e 100644 --- a/src/renderer/src/components/DetachedHeadBadge.tsx +++ b/src/renderer/src/components/DetachedHeadBadge.tsx @@ -12,15 +12,13 @@ type DetachedHeadBadgeProps = { label?: 'sidebar' | 'source-control' side?: React.ComponentProps['side'] className?: string - tabIndex?: number } export function DetachedHeadBadge({ display, label = 'source-control', side = 'right', - className, - tabIndex + className }: DetachedHeadBadgeProps): React.JSX.Element { const visibleLabel = label === 'sidebar' ? display.sidebarLabel : display.sourceControlLabel @@ -29,8 +27,6 @@ export function DetachedHeadBadge({ - {visibleLabel} + {visibleLabel} diff --git a/src/renderer/src/components/right-sidebar/SourceControl.hosted-review-header-link.test.tsx b/src/renderer/src/components/right-sidebar/SourceControl.hosted-review-header-link.test.tsx new file mode 100644 index 00000000000..ee90c607ada --- /dev/null +++ b/src/renderer/src/components/right-sidebar/SourceControl.hosted-review-header-link.test.tsx @@ -0,0 +1,157 @@ +import React from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { HostedReviewInfo } from '../../../../shared/hosted-review' +import { HostedReviewHeaderLink } from './hosted-review-header-chrome' + +const { openHttpLinkMock } = vi.hoisted(() => ({ openHttpLinkMock: vi.fn() })) + +vi.mock('@/lib/http-link-routing', () => ({ + openHttpLink: openHttpLinkMock, + registerHttpLinkStoreAccessor: vi.fn() +})) + +function makeReview(overrides: Partial = {}): HostedReviewInfo { + return { + provider: 'github', + number: 2192, + title: 'Open PR in checks', + state: 'open', + url: 'https://github.com/stablyai/orca/pull/2192', + status: 'pending', + updatedAt: '2026-05-17T00:00:00Z', + mergeable: 'UNKNOWN', + ...overrides + } +} + +type MinimalClickEvent = Pick< + React.MouseEvent, + 'nativeEvent' | 'stopPropagation' +> +type ClickModifiers = Partial> + +function clickEvent(modifiers: ClickModifiers = {}): MinimalClickEvent { + return { + nativeEvent: { + metaKey: modifiers.metaKey ?? false, + ctrlKey: modifiers.ctrlKey ?? false, + shiftKey: modifiers.shiftKey ?? false + } as MouseEvent, + stopPropagation: vi.fn() + } +} + +beforeEach(() => { + openHttpLinkMock.mockReset() +}) + +describe('HostedReviewHeaderLink', () => { + it('opens GitHub PRs in the Checks tab instead of rendering an external link', () => { + const onOpenHostedReviewInChecks = vi.fn() + const element = HostedReviewHeaderLink({ + review: makeReview(), + onOpenHostedReviewInChecks + }) + const markup = renderToStaticMarkup(element) + + expect(markup).toContain(' void)(event) + expect(event.stopPropagation).toHaveBeenCalledTimes(1) + expect(onOpenHostedReviewInChecks).toHaveBeenCalledTimes(1) + expect(openHttpLinkMock).not.toHaveBeenCalled() + }) + + it.each<[string, ClickModifiers]>([ + ['Cmd-click', { metaKey: true }], + ['Ctrl-click', { ctrlKey: true }], + ['Shift+Cmd-click', { metaKey: true, shiftKey: true }], + ['Shift+Ctrl-click', { ctrlKey: true, shiftKey: true }] + ])('opens GitHub PRs in the Checks tab on %s', (_label, modifiers) => { + const onOpenHostedReviewInChecks = vi.fn() + const element = HostedReviewHeaderLink({ + review: makeReview(), + onOpenHostedReviewInChecks + }) + + const event = clickEvent(modifiers) + ;(element.props.onClick as (event: MinimalClickEvent) => void)(event) + + expect(event.stopPropagation).toHaveBeenCalledTimes(1) + expect(onOpenHostedReviewInChecks).toHaveBeenCalledTimes(1) + expect(openHttpLinkMock).not.toHaveBeenCalled() + }) + + it('opens GitLab MRs in the Checks tab instead of rendering an external link', () => { + const onOpenHostedReviewInChecks = vi.fn() + const element = HostedReviewHeaderLink({ + review: makeReview({ + provider: 'gitlab', + number: 31, + url: 'https://gitlab.com/acme/widgets/-/merge_requests/31' + }), + onOpenHostedReviewInChecks + }) + const markup = renderToStaticMarkup(element) + + expect(markup).toContain(' void)(event) + expect(event.stopPropagation).toHaveBeenCalledTimes(1) + expect(onOpenHostedReviewInChecks).toHaveBeenCalledTimes(1) + expect(openHttpLinkMock).not.toHaveBeenCalled() + }) + + it.each<[string, ClickModifiers]>([ + ['Cmd-click', { metaKey: true }], + ['Ctrl-click', { ctrlKey: true }], + ['Shift+Cmd-click', { metaKey: true, shiftKey: true }], + ['Shift+Ctrl-click', { ctrlKey: true, shiftKey: true }] + ])('opens GitLab MRs in the Checks tab on %s', (_label, modifiers) => { + const onOpenHostedReviewInChecks = vi.fn() + const element = HostedReviewHeaderLink({ + review: makeReview({ + provider: 'gitlab', + number: 31, + url: 'https://gitlab.com/acme/widgets/-/merge_requests/31' + }), + onOpenHostedReviewInChecks + }) + + const event = clickEvent(modifiers) + ;(element.props.onClick as (event: MinimalClickEvent) => void)(event) + + expect(event.stopPropagation).toHaveBeenCalledTimes(1) + expect(onOpenHostedReviewInChecks).toHaveBeenCalledTimes(1) + expect(openHttpLinkMock).not.toHaveBeenCalled() + }) + + it('keeps other provider reviews as external hosted-review links', () => { + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain(' activeWorktreeId @@ -2802,6 +2806,11 @@ function SourceControlInner(): React.JSX.Element { ] ) + const openHostedReviewInChecks = useCallback(() => { + setRightSidebarOpen(true) + setRightSidebarTab('checks') + }, [setRightSidebarOpen, setRightSidebarTab]) + const handleBranchChangedByPullRequestGeneration = useCallback(async (): Promise => { // Why: AI PR detail generation may rebase before summarizing, so refresh status if HEAD moved before the user submits the draft. await refreshActiveGitStatusAfterMutation() @@ -4230,6 +4239,10 @@ function SourceControlInner(): React.JSX.Element { (!createPrHeaderAction.disabled || isCreatingPr || prGenerating) ? createPrHeaderAction : null + const visibleCreatePrHeaderAction = resolveVisibleCreatePrHeaderAction({ + createPrHeaderAction + }) + const dropdownItems: DropdownEntry[] = useMemo( () => resolveDropdownItems({ @@ -4697,6 +4710,19 @@ function SourceControlInner(): React.JSX.Element { runCreatePrIntent ]) + const handleCreatePrHeaderClick = useCallback((): void => { + if (!createPrHeaderAction || createPrHeaderAction.disabled) { + return + } + if (createPrHeaderAction.kind === 'create_pr') { + void handleCreatePullRequest() + return + } + if (createPrHeaderAction.kind === 'create_pr_intent') { + void runCreatePrIntent() + } + }, [createPrHeaderAction, handleCreatePullRequest, runCreatePrIntent]) + const branchCompareInFlightRef = useRef(false) const branchCompareRerunRef = useRef(false) const branchCompareRunPromiseRef = useRef | null>(null) @@ -5426,11 +5452,16 @@ function SourceControlInner(): React.JSX.Element { <>
+ {detachedHeadDisplay && ( +
+ +
+ )} + {/* Why: hidden when count is 0 — notes are created from the diff view, so an empty Notes shelf here is pure chrome. */} {activeWorktreeId && worktreePath && diffCommentCount > 0 && (
diff --git a/src/renderer/src/components/right-sidebar/hosted-review-header-chrome.tsx b/src/renderer/src/components/right-sidebar/hosted-review-header-chrome.tsx new file mode 100644 index 00000000000..66733779c77 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/hosted-review-header-chrome.tsx @@ -0,0 +1,74 @@ +import React from 'react' +import { GitMerge } from 'lucide-react' +import type { HostedReviewInfo } from '../../../../shared/hosted-review' +import { cn } from '@/lib/utils' +import { PullRequestIcon } from './checks-panel-content' + +function hostedReviewStateClass(review: HostedReviewInfo): string { + if (review.state === 'merged') { + return 'text-purple-500/80' + } + if (review.state === 'open') { + return 'text-emerald-500/80' + } + if (review.state === 'closed') { + return 'text-muted-foreground/60' + } + return 'text-muted-foreground/50' +} + +export function HostedReviewIcon({ + review, + className +}: { + review: HostedReviewInfo + className?: string +}): React.JSX.Element { + const Icon = review.provider === 'gitlab' ? GitMerge : PullRequestIcon + return +} + +function hostedReviewLabel(review: HostedReviewInfo): string { + return `${review.provider === 'gitlab' ? 'MR' : 'PR'} #${review.number}` +} + +export function HostedReviewHeaderLink({ + review, + onOpenHostedReviewInChecks +}: { + review: HostedReviewInfo + onOpenHostedReviewInChecks: () => void +}): React.JSX.Element { + const label = hostedReviewLabel(review) + const className = + 'shrink-0 border-0 bg-transparent p-0 text-left font-medium leading-none text-foreground underline decoration-border underline-offset-2 opacity-80 hover:text-foreground hover:decoration-foreground' + + if (review.provider === 'github' || review.provider === 'gitlab') { + return ( + + ) + } + + return ( + e.stopPropagation()} + > + {label} + + ) +} diff --git a/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-state.test.ts b/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-state.test.ts new file mode 100644 index 00000000000..b7214084d24 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-state.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import { resolveVisibleCreatePrHeaderAction } from './source-control-create-pr-intent-state' +import type { PrimaryAction } from './source-control-primary-action-types' + +const disabledCreatePrAction: PrimaryAction = { + kind: 'create_pr', + label: 'Create PR', + title: 'Publish commits before creating a pull request.', + disabled: true +} + +const enabledCreatePrAction: PrimaryAction = { + kind: 'create_pr', + label: 'Create PR', + title: 'Create a pull request for this branch', + disabled: false +} + +describe('resolveVisibleCreatePrHeaderAction', () => { + it('returns null when no header action is available', () => { + expect( + resolveVisibleCreatePrHeaderAction({ + createPrHeaderAction: null + }) + ).toBeNull() + }) + + it('keeps a disabled Create PR header visible as a stable toolbar anchor', () => { + expect( + resolveVisibleCreatePrHeaderAction({ + createPrHeaderAction: disabledCreatePrAction + }) + ).toEqual(disabledCreatePrAction) + }) + + it('keeps an enabled Create PR header visible even when the body composer is open', () => { + expect( + resolveVisibleCreatePrHeaderAction({ + createPrHeaderAction: enabledCreatePrAction + }) + ).toEqual(enabledCreatePrAction) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-state.ts b/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-state.ts index 82752fdac66..b40976642e1 100644 --- a/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-state.ts +++ b/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-state.ts @@ -1,3 +1,4 @@ +import type { PrimaryAction } from './source-control-primary-action-types' import { resolveCreateReviewIntentEligibility, type CreateReviewIntentEligibility, @@ -10,3 +11,13 @@ export type CreatePrIntentKind = CreateReviewIntentKind export type CreatePrIntentEligibility = CreateReviewIntentEligibility export const resolveCreatePrIntentEligibility = resolveCreateReviewIntentEligibility + +export function resolveVisibleCreatePrHeaderAction({ + createPrHeaderAction +}: { + createPrHeaderAction: PrimaryAction | null +}): PrimaryAction | null { + // Why: keep a stable header anchor; disable Create PR when the branch is not + // ready instead of hiding it and shifting the toolbar layout. + return createPrHeaderAction +} diff --git a/src/renderer/src/components/right-sidebar/source-control-header-toolbar.test.tsx b/src/renderer/src/components/right-sidebar/source-control-header-toolbar.test.tsx deleted file mode 100644 index 77ca1a6ac52..00000000000 --- a/src/renderer/src/components/right-sidebar/source-control-header-toolbar.test.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { renderToStaticMarkup } from 'react-dom/server' -import type { ReactNode } from 'react' -import { describe, expect, it, vi } from 'vitest' -import type { WorktreeGitIdentityDisplay } from '@/lib/worktree-git-identity-display' -import { SourceControlHeaderToolbar } from './source-control-header-toolbar' - -vi.mock('@/components/ui/tooltip', () => ({ - Tooltip: ({ children }: { children: ReactNode }) => <>{children}, - TooltipContent: ({ children }: { children: ReactNode }) => <>{children}, - TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children} -})) - -vi.mock('./source-control-header-overflow-menu', () => ({ - SourceControlHeaderOverflowMenu: () => -})) - -vi.mock('./source-control-branch-context-row', () => ({ - shouldShowSourceControlBranchContextRow: () => false, - SourceControlBranchContextRow: () => null -})) - -function renderToolbar( - gitIdentityDisplay: WorktreeGitIdentityDisplay | null = { - kind: 'branch', - branchName: 'brennanb2025/source-control-branch-name' - } -): string { - return renderToStaticMarkup( - - ) -} - -describe('SourceControlHeaderToolbar', () => { - it('renders the truncating branch identity before source control actions', () => { - const markup = renderToolbar() - const branchIndex = markup.indexOf('brennanb2025/source-control-branch-name') - const filterIndex = markup.indexOf('data-testid="source-control-filter-toggle"') - - expect(branchIndex).toBeGreaterThan(-1) - expect(filterIndex).toBeGreaterThan(branchIndex) - expect(markup).toContain('aria-label="Current branch: brennanb2025/source-control-branch-name"') - expect(markup).toContain('tabindex="0"') - expect(markup).toContain('min-w-0 truncate') - expect(markup).not.toContain('Create PR') - }) - - it('renders detached HEAD in the same identity slot', () => { - const markup = renderToolbar({ - kind: 'detached', - shortHead: '8cec248', - sidebarLabel: 'Detached HEAD @ 8cec248', - sourceControlLabel: 'Detached HEAD · 8cec248', - tooltip: 'Detached HEAD at 8cec248. You are viewing a commit, not a branch.' - }) - const identityIndex = markup.indexOf('Detached HEAD · 8cec248') - const filterIndex = markup.indexOf('data-testid="source-control-filter-toggle"') - - expect(markup).not.toContain('aria-label="Current branch:') - expect(identityIndex).toBeGreaterThan(-1) - expect(filterIndex).toBeGreaterThan(identityIndex) - expect(markup).toContain( - 'aria-label="Detached HEAD at 8cec248. You are viewing a commit, not a branch."' - ) - expect(markup).toContain('tabindex="0"') - expect(markup).toContain('lucide-git-commit-horizontal') - }) - - it('keeps the identity slot empty until git identity is known', () => { - const markup = renderToolbar(null) - - expect(markup).not.toContain('aria-label="Current branch:') - expect(markup).not.toContain('Detached HEAD') - }) -}) diff --git a/src/renderer/src/components/right-sidebar/source-control-header-toolbar.tsx b/src/renderer/src/components/right-sidebar/source-control-header-toolbar.tsx index 9c8a32397fe..3cac51e1e9f 100644 --- a/src/renderer/src/components/right-sidebar/source-control-header-toolbar.tsx +++ b/src/renderer/src/components/right-sidebar/source-control-header-toolbar.tsx @@ -1,16 +1,17 @@ import React, { useCallback, useEffect, useRef } from 'react' -import { GitBranch, Search, X } from 'lucide-react' +import { GitPullRequestArrow, Loader2, Search, X } from 'lucide-react' import type { GitBranchCompareSummary, GitUpstreamStatus, SourceControlViewMode } from '../../../../shared/types' +import type { HostedReviewInfo } from '../../../../shared/hosted-review' +import type { PrimaryAction } from './source-control-primary-action' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' -import { DetachedHeadBadge } from '@/components/DetachedHeadBadge' -import type { WorktreeGitIdentityDisplay } from '@/lib/worktree-git-identity-display' +import { HostedReviewHeaderLink, HostedReviewIcon } from './hosted-review-header-chrome' import { shouldShowSourceControlBranchContextRow, SourceControlBranchContextRow @@ -18,11 +19,16 @@ import { import { SourceControlHeaderOverflowMenu } from './source-control-header-overflow-menu' type SourceControlHeaderToolbarProps = { - gitIdentityDisplay: WorktreeGitIdentityDisplay | null filterQuery: string filterExpanded: boolean onFilterQueryChange: (value: string) => void onFilterExpandedChange: (expanded: boolean) => void + visibleCreatePrHeaderAction: PrimaryAction | null + hostedReview: HostedReviewInfo | null + isCreatePrIntentInFlight: boolean + isCreatingPr: boolean + onCreatePrHeaderClick: () => void + onOpenHostedReviewInChecks: () => void sourceControlViewMode: SourceControlViewMode viewModeToggleDisabled: boolean onToggleViewMode: () => void @@ -37,45 +43,65 @@ type SourceControlHeaderToolbarProps = { manualReviewUrl?: string | null } -function SourceControlGitIdentityLabel({ - display +function HostedReviewToolbarLink({ + review, + onOpenHostedReviewInChecks, + compact }: { - display: WorktreeGitIdentityDisplay + review: HostedReviewInfo + onOpenHostedReviewInChecks: () => void + compact?: boolean }): React.JSX.Element { - if (display.kind === 'detached') { - return ( - - - - ) - } - - const branchName = display.branchName - const label = translate( - 'auto.components.right.sidebar.SourceControl.a4e93c21d7', - 'Current branch: {{value0}}', - { value0: branchName } + return ( +
+ + +
) +} +function CreatePrHeaderButton({ + action, + isCreatePrIntentInFlight, + isCreatingPr, + onClick +}: { + action: PrimaryAction + isCreatePrIntentInFlight: boolean + isCreatingPr: boolean + onClick: () => void +}): React.JSX.Element { return ( - - - - {branchName} + + {action.title} ) @@ -98,11 +124,16 @@ function renderOverflowMenu( } export function SourceControlHeaderToolbar({ - gitIdentityDisplay, filterQuery, filterExpanded, onFilterQueryChange, onFilterExpandedChange, + visibleCreatePrHeaderAction, + hostedReview, + isCreatePrIntentInFlight, + isCreatingPr, + onCreatePrHeaderClick, + onOpenHostedReviewInChecks, sourceControlViewMode, viewModeToggleDisabled, onToggleViewMode, @@ -165,11 +196,25 @@ export function SourceControlHeaderToolbar({ > {showCollapsedToolbar ? ( <> - {gitIdentityDisplay ? ( - + {hostedReview ? ( + + ) : visibleCreatePrHeaderAction ? ( + ) : (