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
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { describe, expect, it, vi } from 'vitest';

import { i18n } from '@/i18n';
import { type TestRenderer } from '@/test/renderer';
import { group, mountSection } from './new-session-repository-section.test-helpers';

vi.mock('react-native', () => ({
ActivityIndicator: 'ActivityIndicator',
Pressable: 'Pressable',
View: 'View',
}));
vi.mock('@/components/ui/text', async () => {
const React = await import('react');
return { Text: 'Text', TextClassContext: React.createContext<string | undefined>(undefined) };
});
vi.mock('@/components/ui/button', () => ({ Button: 'Button' }));
vi.mock('@/components/ui/icons', () => ({
ChevronDown: 'ChevronDown',
ExternalLink: 'ExternalLink',
RefreshCw: 'RefreshCw',
}));
vi.mock('@/components/ui/activity-indicator', () => ({ ActivityIndicator: 'ActivityIndicator' }));
vi.mock('@/components/query-error', () => ({ QueryError: 'QueryError' }));
vi.mock('@/components/agents/repo-selector', () => ({ RepoSelector: 'RepoSelector' }));
vi.mock('@/components/agents/repository-branch-selector', () => ({
RepositoryBranchSelector: 'RepositoryBranchSelector',
}));
vi.mock('@/lib/hooks/use-theme-colors', () => ({
useThemeColors: () => ({ foreground: '#000000', mutedForeground: '#777777' }),
}));
vi.mock('@/lib/a11y/motion', () => ({
useMotionPolicy: () => ({ reducedMotion: false, scrollAnimated: true }),
selectReducedMotionEntrance: <T>(reducedMotion: boolean, entrance: T) =>
reducedMotion ? undefined : entrance,
}));
vi.mock('react-native-reanimated', () => ({
default: { View: 'Animated.View' },
useSharedValue: (value: unknown) => ({ value }),
useAnimatedStyle: () => ({}),
withTiming: (value: number, config: unknown) => ({ value, config }),
FadeIn: { duration: (ms: number) => ({ __fadeIn: ms }) },
LinearTransition: { duration: (ms: number) => ({ __linearTransition: ms }) },
}));
vi.mock('@/lib/hooks/use-collapsed-connect-ctas-preference', () => ({
useCollapsedConnectCtas: () => ({ collapsedCtas: [], hasLoaded: true }),
setConnectCtaCollapsed: vi.fn(),
}));

/** The connect action's own label node, matched by the copy it renders. */
function actionLabel(renderer: TestRenderer.ReactTestRenderer, copy: string) {
const node = renderer.root
.findAllByType('Text' as never)
.find(candidate => candidate.children.includes(copy));
if (!node) {
throw new Error(`no action label for ${copy}`);
}
return node;
}

// The connect action is a fixed one-row control: an icon, the label, and the
// refresh button beside it. A label that wraps grows its own text block without
// growing the row, so "Open GitLab" broke onto two lines inside a button left at
// the height of the one-line "Open GitHub" sibling (explorer: new-session-filled
// / new-session-kb-down). Every action label is held to one line, like the
// segmented control's options; a longer locale ellipsizes and the full copy
// stays the control's accessible name.
describe('NewSessionRepositorySection connect action labels', () => {
it.each(['openGithub', 'openGitlab', 'openBitbucket'] as const)(
'keeps the %s action label on one line',
key => {
const renderer = mountSection({
groups: [
group('github', 'connect'),
group('gitlab', 'connect'),
group('bitbucket', 'connect'),
],
});

expect(actionLabel(renderer, i18n.t(`agentChat.newSession.${key}`)).props.numberOfLines).toBe(
1
);
}
);

it('keeps the selected-provider action label on one line', () => {
const renderer = mountSection({
value: 'github:owner/repo',
groups: [group('github', 'repos'), group('gitlab', 'connect')],
});

expect(actionLabel(renderer, i18n.t('common.connectGitlab')).props.numberOfLines).toBe(1);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,8 @@ export function NewSessionRepositorySection({
than the glyphs Android lays out, so "Open GitLab" wrapped onto two
lines and grew the button taller than its one-line siblings; giving
the label the free space keeps its box wider than the text, and
`numberOfLines` pins the line.
`numberOfLines` pins the line. The explorer captures
new-session-filled and new-session-kb-down both showed that wrap.
*/}
<Text className="me-[24px] flex-1 text-center" numberOfLines={1}>
{t(selectedRepository === null ? copy.openLabel : copy.connectTitle)}
Expand Down
16 changes: 14 additions & 2 deletions apps/mobile/src/components/agents/session-filter-button.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { SlidersHorizontal } from '@/components/ui/icons';
import { Pressable, View } from 'react-native';
import { type Insets, Pressable, View } from 'react-native';
import { useTranslation } from 'react-i18next';

import { filterButtonAccessibilityLabel } from '@/components/agents/session-filter-button-label';
Expand All @@ -12,6 +12,13 @@ type SessionFilterButtonProps = {
activeCount: number;
onPress: () => void;
testID?: string;
/**
* Per-side reach override. The control's own default is the same slop on
* every side; a caller that shares a row with another control needs to state
* the facing sides, so the two touch regions can be checked against the row
* gap instead of overlapping inside it.
*/
hitSlop?: number | Insets;
};

/**
Expand Down Expand Up @@ -43,6 +50,7 @@ export function SessionFilterButton({
activeCount,
onPress,
testID,
hitSlop = FILTER_HIT_SLOP,
}: Readonly<SessionFilterButtonProps>) {
const colors = useThemeColors();
const { t } = useTranslation();
Expand All @@ -51,7 +59,11 @@ export function SessionFilterButton({
return (
<Pressable
onPress={onPress}
hitSlop={FILTER_HIT_SLOP}
// The frame is the tap target the size audit measures, not the 20pt
// glyph; `FILTER_HIT_SLOP` above carries it past the 44pt minimum. A
// caller sharing a row may state the sides it faces, so the two touch
// regions can meet inside the row gap instead of overlapping.
hitSlop={hitSlop}
accessibilityRole="button"
// The count is spoken as part of the name, so no new translated string is
// needed to announce "Filter sessions, 2".
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,22 @@ import { useThemeColors } from '@/lib/hooks/use-theme-colors';
// (`@/lib/a11y/tap-target`), so the new-session control's right side is capped
// at 14 - 8 = 6 and the two facing slops meet at the row's gap without
// overlapping (6 + 8 = 14). 32 + 8 + 6 = 46pt still clears `DESIGN.md:364`'s
// 44pt.
// 44pt. The filter's slop is spelled per side too, so that meeting can be
// checked instead of only the smallest of its four sides.
const NEW_SESSION_HIT_SLOP = {
top: COMPACT_CONTROL_HIT_SLOP_DP,
bottom: COMPACT_CONTROL_HIT_SLOP_DP,
left: COMPACT_CONTROL_HIT_SLOP_DP,
right: 6,
};

const FILTER_HIT_SLOP = {
top: COMPACT_CONTROL_HIT_SLOP_DP,
bottom: COMPACT_CONTROL_HIT_SLOP_DP,
left: COMPACT_CONTROL_HIT_SLOP_DP,
right: COMPACT_CONTROL_HIT_SLOP_DP,
};

type SessionListHeaderActionsProps = {
/** How many filters are applied; drives the filter button's count badge. */
activeFilterCount: number;
Expand Down Expand Up @@ -52,6 +60,7 @@ export function SessionListHeaderActions({
) : null}
<SessionFilterButton
activeCount={activeFilterCount}
hitSlop={FILTER_HIT_SLOP}
onPress={onOpenFilters}
testID="agents-open-filters"
/>
Expand Down
Loading