diff --git a/apps/mobile/plugins/branded-splash.test.ts b/apps/mobile/plugins/branded-splash.test.ts index d3f381d452d..e129d59ffdb 100644 --- a/apps/mobile/plugins/branded-splash.test.ts +++ b/apps/mobile/plugins/branded-splash.test.ts @@ -135,8 +135,13 @@ describe('shared branded splash', () => { ).toBeTypeOf('function'); expect(config.mods?.android?.styles).toBeTypeOf('function'); + // Introspect against a fresh project root: `compileModsAsync` seeds the + // Android color/style mods from the resources already on disk, so pointing + // it at this package's root would read a local prebuild's `colors.xml` + // (absent in CI) and make the assertion depend on the developer's machine. + const { root } = createAndroidProject(); const evaluated = await compileModsAsync(config, { - projectRoot, + projectRoot: root, platforms: ['ios', 'android'], introspect: true, }); diff --git a/apps/mobile/src/app/(app)/agent-chat/[session-id].tsx b/apps/mobile/src/app/(app)/agent-chat/[session-id].tsx index 1ec5d8c19f3..7b1ff528fa7 100644 --- a/apps/mobile/src/app/(app)/agent-chat/[session-id].tsx +++ b/apps/mobile/src/app/(app)/agent-chat/[session-id].tsx @@ -206,7 +206,7 @@ export default function SessionDetailScreen() { titleNumberOfLines={SESSION_HEADER_TITLE_LINES} backFallback="/(app)/(tabs)/(2_agents)" headerRight={ - + boolean +): React.ReactElement[] { + const matches: React.ReactElement[] = []; + + function walk(value: unknown): void { + if (value == null || typeof value === 'string' || typeof value === 'number') { + return; + } + if (Array.isArray(value)) { + for (const child of value) { + walk(child); + } + return; + } + if (React.isValidElement(value)) { + if (predicate(value)) { + matches.push(value); + } + const props = value.props as { children?: unknown }; + walk(props.children); + } + } + + walk(node); + return matches; +} diff --git a/apps/mobile/src/components/agents/session-context-metrics.test.ts b/apps/mobile/src/components/agents/session-context-metrics.test.ts index 4ce147fa3ef..90c400189f9 100644 --- a/apps/mobile/src/components/agents/session-context-metrics.test.ts +++ b/apps/mobile/src/components/agents/session-context-metrics.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it, vi } from 'vitest'; import { type SessionContextInfo } from '@/lib/session-context-info'; import { SessionContextMetrics } from './session-context-metrics'; +import { findAll } from './session-context-metrics-test-helpers'; vi.mock('react-native', () => ({ Pressable: 'Pressable', View: 'View' })); vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); @@ -26,35 +27,6 @@ function render(props: React.ComponentProps): Reac return SessionContextMetrics(props) as React.ReactElement; } -function findAll( - node: unknown, - predicate: (el: React.ReactElement) => boolean -): React.ReactElement[] { - const matches: React.ReactElement[] = []; - - function walk(value: unknown): void { - if (value == null || typeof value === 'string' || typeof value === 'number') { - return; - } - if (Array.isArray(value)) { - for (const child of value) { - walk(child); - } - return; - } - if (React.isValidElement(value)) { - if (predicate(value)) { - matches.push(value); - } - const props = value.props as { children?: unknown }; - walk(props.children); - } - } - - walk(node); - return matches; -} - const PILL_LAYOUT_TOKENS = [ 'h-[44px]', 'flex-row', @@ -65,6 +37,11 @@ const PILL_LAYOUT_TOKENS = [ 'border-border', 'bg-secondary', 'px-3', + // The header row hands the trailing cluster a capped 50% box, but RN's + // default flexShrink is 0. Without these the pill keeps its natural width + // and paints past the row's right edge instead of compressing. + 'shrink', + 'min-w-0', ] as const; function expectHiddenReservedBox(root: React.ReactElement): void { @@ -301,6 +278,43 @@ describe('SessionContextMetrics', () => { expect(label).not.toContain('Tap to view'); }); + // The header row hands the trailing cluster a capped 50% box, but RN's + // default flexShrink is 0, so the pill must opt in to shrinking and let its + // inner text row compress. Otherwise the pill keeps its natural width and + // paints past the row's right edge — off-screen on a narrow phone. + it('lets the pill and its text row compress inside a constrained header row', () => { + const root = render({ + hasMessages: true, + info: info(), + totalCostMicrodollars: 150_000, + onPress: vi.fn(() => undefined), + }); + const textRow = findAll( + root, + el => + el.type === 'View' && + ((el.props as { className?: string }).className ?? '').includes('items-baseline') + )[0]; + expect(textRow).toBeDefined(); + if (textRow == null) { + throw new Error('expected the inner text row'); + } + const textRowClassName = (textRow.props as { className?: string }).className ?? ''; + expect(textRowClassName).toContain('min-w-0'); + expect(textRowClassName).toContain('shrink'); + const texts = findAll(root, el => el.type === 'Text'); + expect(texts.length).toBeGreaterThan(0); + for (const text of texts) { + // The row shrinks, but the texts inside it keep RN's default + // flexShrink: 0 unless they opt in too, so a squeezed row can never + // compress them and the second text paints past the pill's right edge. + const textClassName = (text.props as { className?: string }).className ?? ''; + expect(textClassName).toContain('shrink'); + expect(textClassName).toContain('min-w-0'); + expect((text.props as { numberOfLines?: number }).numberOfLines).toBe(1); + } + }); + it('messages with neither info nor cost show a visible track-only ring', () => { const root = render({ hasMessages: true, diff --git a/apps/mobile/src/components/agents/session-context-metrics.tsx b/apps/mobile/src/components/agents/session-context-metrics.tsx index 82282171b69..f9f07ad3524 100644 --- a/apps/mobile/src/components/agents/session-context-metrics.tsx +++ b/apps/mobile/src/components/agents/session-context-metrics.tsx @@ -73,8 +73,11 @@ export function SessionContextMetrics({ // Exactly 44pt via h-[44px]. rem-scaled h-11 measured ~38.7pt on device with // NativeWind 5 preview (rem ≈ 14px here), so an arbitrary px value is required // for the 44pt minimum touch target; height is identical in every pill state. + // `shrink min-w-0` lets the pill compress inside the header's capped trailing + // slot: RN's default flexShrink is 0, so without them the pill keeps its + // natural width and paints past the row's right edge, off-screen. const pillClassName = - 'h-[44px] flex-row items-center gap-2 rounded-full border border-border bg-secondary px-3'; + 'h-[44px] shrink min-w-0 flex-row items-center gap-2 rounded-full border border-border bg-secondary px-3'; const body = ( <> @@ -85,13 +88,20 @@ export function SessionContextMetrics({ tone={content.tone} /> {content.primary != null ? ( - - + + {content.primary} {content.hasCost && content.secondary ? ( diff --git a/apps/mobile/src/components/agents/session-detail-content.test.ts b/apps/mobile/src/components/agents/session-detail-content.test.ts index 396c579ef19..0658996c19d 100644 --- a/apps/mobile/src/components/agents/session-detail-content.test.ts +++ b/apps/mobile/src/components/agents/session-detail-content.test.ts @@ -889,6 +889,25 @@ describe('SessionDetailContent header title', () => { expect(title.props.numberOfLines).toBe(SESSION_HEADER_TITLE_LINES); expect(title.props.ellipsizeMode).toBe('tail'); }); + + // `ScreenHeader` caps the trailing slot at 50% of the row, but RN's default + // flexShrink is 0: unless the cluster and the pill opt in, their children + // keep their natural width and paint past the row's right edge, off-screen. + it('lets the trailing header cluster shrink instead of spilling off-screen', async () => { + const { renderer } = await mountDetails(); + const headerRight = renderer.root.findByType(ScreenHeader).props.headerRight as { + props: { className: string }; + }; + expect(headerRight.props.className).toContain('min-w-0'); + expect(headerRight.props.className).toContain('shrink'); + const metricsClassName = ( + renderer.root.findByProps({ testID: 'session-context-metrics' }).props as { + className?: string; + } + ).className; + expect(metricsClassName).toContain('shrink'); + expect(metricsClassName).toContain('min-w-0'); + }); }); describe('session detail status placement', () => { diff --git a/apps/mobile/src/components/agents/session-detail-content.tsx b/apps/mobile/src/components/agents/session-detail-content.tsx index 0836275d6f8..678036d1fc0 100644 --- a/apps/mobile/src/components/agents/session-detail-content.tsx +++ b/apps/mobile/src/components/agents/session-detail-content.tsx @@ -1541,7 +1541,7 @@ export function SessionDetailContent({ const handleRenameSave = rename.submit; const handleRenameClose = rename.closeModal; const headerRight = ( - +