diff --git a/apps/mobile/src/components/pr-review/pr-review-screen.test.tsx b/apps/mobile/src/components/pr-review/pr-review-screen.test.tsx index a3911aa1d6b..22806e93f85 100644 --- a/apps/mobile/src/components/pr-review/pr-review-screen.test.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-screen.test.tsx @@ -127,22 +127,11 @@ vi.mock('@/lib/pr-review/recent-prs', () => ({ markRecentPrFailed: vi.fn(), })); -vi.mock('@/components/screen-header', () => { - // The screen passes `headerRight` as a named slot prop. We render it - // alongside the `children` slot so the tree walk can find the - // Submit-review Button inside. - const MockScreenHeader = (props: { - headerRight?: React.ReactNode; - children?: React.ReactNode; - }): React.ReactElement => - React.createElement( - 'ScreenHeader', - { hasHeaderRight: props.headerRight != null }, - props.headerRight, - props.children - ); - return { ScreenHeader: MockScreenHeader }; -}); +// A host-node mock keeps every prop the screen passes (including +// `eyebrowNumberOfLines`) on the node the `findElement` walk inspects, so the +// test can assert the header's contract directly. `headerRight` stays a named +// slot prop, which the walker follows into. +vi.mock('@/components/screen-header', () => ({ ScreenHeader: 'ScreenHeader' })); vi.mock('@/components/pr-review/merge/pr-merge-partial-success-banner', () => ({ PrMergePartialSuccessBanner: 'PrMergePartialSuccessBanner', })); @@ -666,3 +655,26 @@ describe('PrReviewScreen recents backfill per provider', () => { }); }); }); + +describe('PrReviewScreen header eyebrow cap', () => { + beforeEach(() => { + prQueryResult = { + data: undefined, + isLoading: true, + isError: false, + isFetching: false, + }; + }); + + it('caps the repository eyebrow to a single line so it cannot wrap onto the title', () => { + // eslint-disable-next-line new-cap + const element = PrReviewScreen({ owner: 'octocat', repo: 'hello', number: 7 }); + const header = findElement({ + node: element, + type: 'ScreenHeader', + prop: 'eyebrowNumberOfLines', + value: 1, + }); + expect(header).not.toBeNull(); + }); +}); diff --git a/apps/mobile/src/components/pr-review/pr-review-screen.tsx b/apps/mobile/src/components/pr-review/pr-review-screen.tsx index f83b68a3589..20759e50600 100644 --- a/apps/mobile/src/components/pr-review/pr-review-screen.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-screen.tsx @@ -306,6 +306,7 @@ export function PrReviewScreen({ owner, repo, number }: PrReviewScreenProps) { : t('prReview.screen.title', { number }) } eyebrow={`${owner}/${repo}`} + eyebrowNumberOfLines={1} headerRight={ {webUrl ? ( diff --git a/apps/mobile/src/components/screen-header.mounted.test.tsx b/apps/mobile/src/components/screen-header.mounted.test.tsx index 395c37dfb3d..611fd7f7cd7 100644 --- a/apps/mobile/src/components/screen-header.mounted.test.tsx +++ b/apps/mobile/src/components/screen-header.mounted.test.tsx @@ -679,4 +679,26 @@ describe('ScreenHeader mounted', () => { expect(iosStyle).toEqual({ paddingTop: 32 }); expect(androidStyle).toEqual(iosStyle); }); + + it('caps the eyebrow to one line with a middle ellipsis when eyebrowNumberOfLines is set', () => { + const renderer = renderHeader({ + title: '#1', + eyebrow: 'KILO-STUB/DISCUSSION-MIXED', + eyebrowNumberOfLines: 1, + }); + + const eyebrow = renderer.root.findByType('Eyebrow'); + expect(eyebrow.props.numberOfLines).toBe(1); + expect(eyebrow.props.ellipsizeMode).toBe('middle'); + expect(eyebrow.children).toEqual(['KILO-STUB/DISCUSSION-MIXED']); + }); + + it('keeps the reserved eyebrow placeholder on the same single capped line', () => { + const renderer = renderHeader({ reserveEyebrow: true, eyebrowNumberOfLines: 1 }); + + const eyebrow = renderer.root.findByType('Eyebrow'); + expect(eyebrow.props.numberOfLines).toBe(1); + expect(eyebrow.props.ellipsizeMode).toBe('middle'); + expect(eyebrow.children).toEqual(['\u00A0']); + }); }); diff --git a/apps/mobile/src/components/screen-header.tsx b/apps/mobile/src/components/screen-header.tsx index 8a969094d67..7a6f73e138b 100644 --- a/apps/mobile/src/components/screen-header.tsx +++ b/apps/mobile/src/components/screen-header.tsx @@ -34,6 +34,14 @@ type ScreenHeaderProps = { titleContent?: React.ReactNode; /** Line cap for the title, at most {@link MAX_TITLE_LINES}. */ titleNumberOfLines?: number; + /** + * Cap the eyebrow to this many lines, overriding the header's default single + * line. When set the eyebrow ellipsizes in the middle: the repo name is the + * distinguishing tail of a path, so a middle ellipsis keeps more of it than a + * tail ellipsis. Left undefined every caller keeps the default one-line tail + * ellipsis, so a long repository label never reflows the header. + */ + eyebrowNumberOfLines?: number; /** Reserve the title's line cap so state changes do not move the screen body. */ reserveTitleSpace?: boolean; /** Optional mono-uppercase line above the title. */ @@ -90,6 +98,7 @@ export function ScreenHeader({ title, titleContent, titleNumberOfLines = 2, + eyebrowNumberOfLines, reserveTitleSpace = false, eyebrow, reserveEyebrow = false, @@ -224,8 +233,8 @@ export function ScreenHeader({ {eyebrow || reserveEyebrow ? (