Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion apps/mobile/plugins/branded-splash.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/src/app/(app)/agent-chat/[session-id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ export default function SessionDetailScreen() {
titleNumberOfLines={SESSION_HEADER_TITLE_LINES}
backFallback="/(app)/(tabs)/(2_agents)"
headerRight={
<View className="flex-row items-center gap-2">
<View className="min-w-0 shrink flex-row items-center gap-2">
<SessionContextMetrics
info={undefined}
totalCostMicrodollars={null}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import * as React from 'react';

/**
* Walks the element tree these tests render by calling the component as a plain
* function, and returns every element the predicate accepts.
*/
export 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;
}
72 changes: 43 additions & 29 deletions apps/mobile/src/components/agents/session-context-metrics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }));
Expand All @@ -26,35 +27,6 @@ function render(props: React.ComponentProps<typeof SessionContextMetrics>): 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',
Expand All @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
18 changes: 14 additions & 4 deletions apps/mobile/src/components/agents/session-context-metrics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
<>
Expand All @@ -85,13 +88,20 @@ export function SessionContextMetrics({
tone={content.tone}
/>
{content.primary != null ? (
<View className="flex-row items-baseline gap-1">
<Text className={cn('text-xs font-semibold tabular-nums', toneTextClass(content.tone))}>
<View className="min-w-0 shrink flex-row items-baseline gap-1">
<Text
numberOfLines={1}
className={cn(
'min-w-0 shrink text-xs font-semibold tabular-nums',
toneTextClass(content.tone)
)}
>
{content.primary}
</Text>
{content.hasCost && content.secondary ? (
<Text
className="text-xs tabular-nums text-muted-foreground"
numberOfLines={1}
className="min-w-0 shrink text-xs tabular-nums text-muted-foreground"
accessibilityElementsHidden
importantForAccessibility="no"
>
Expand Down
19 changes: 19 additions & 0 deletions apps/mobile/src/components/agents/session-detail-content.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1541,7 +1541,7 @@ export function SessionDetailContent({
const handleRenameSave = rename.submit;
const handleRenameClose = rename.closeModal;
const headerRight = (
<View className="flex-row items-center gap-2">
<View className="min-w-0 shrink flex-row items-center gap-2">
<SessionPrBadge pr={fetchedData?.associatedPr ?? null} loading={shouldShowLoading} />
<SessionContextMetrics
info={contextInfo}
Expand Down
Loading