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
Expand Up @@ -334,3 +334,33 @@ describe('ModelPickerContent deferred search', () => {
});
});
});

// The composer's current model arrives as the bridge's `currentValue`. The row
// for that id must carry `selected`, or the picker shows no visible selected
// state for the model the composer is editing (model-selected finding: the
// DeepSeek V4.1 Flash row read as star-only). `selected` is what renders the
// trailing Check (see model-selector.mounted.test.tsx).
describe('ModelPickerContent selected row', () => {
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
buildSearchCalls.values.length = 0;
listCommitLengths.values.length = 0;
slotState.bridge = { ...makeBridge(), currentValue: 'remote-model-17' };
});

it('marks exactly the bridge current model as the selected row', async () => {
const renderer = await mount();

/* eslint-disable typescript-eslint/no-unsafe-member-access -- react-test-renderer props are an index signature */
const selectedIds = findByType(renderer.root, 'ModelPickerOptionRow')
.filter(node => node.props.selected === true)
.map(node => (node.props.option as SessionModelOption).id);
/* eslint-enable typescript-eslint/no-unsafe-member-access */

expect(selectedIds).toEqual(['remote-model-17']);

act(() => {
renderer.unmount();
});
});
});
17 changes: 17 additions & 0 deletions apps/mobile/src/components/agents/question-card.mounted.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -303,3 +303,20 @@ describe('QuestionCard custom answer selection', () => {
expect(a11yMocks.moveA11yFocus).toHaveBeenCalledTimes(1);
});
});

describe('QuestionCard custom answer placeholder contrast', () => {
// The custom answer field swaps its fill to `bg-primary` once its radio or
// checkbox is active. A placeholder painted for the plain background
// (`mutedForeground`, near-black) is invisible on that fill, so the field's
// purpose cannot be read (ios session-answer-kb-up finding). The placeholder
// colour must follow the fill, the same way the typed text does.
it('paints the placeholder with the fill foreground once the custom answer is active', async () => {
const renderer = await renderCard(makeQuestion());

expect(customInput(renderer.root)?.props.placeholderTextColor).toBe('#6F6A61');

press(customChoice(renderer.root));

expect(customInput(renderer.root)?.props.placeholderTextColor).toBe('#ffffff');
});
});
9 changes: 8 additions & 1 deletion apps/mobile/src/components/agents/question-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,14 @@ export function QuestionCard({
handleCustomTextChange(qIndex, text);
}}
placeholder={t('agentChat.questionCard.typeYourOwnAnswerPlaceholder')}
placeholderTextColor={colors.mutedForeground}
// The active field swaps its fill to `bg-primary`, so the
// placeholder follows the fill's foreground like the
// typed text and the option label do; the muted grey is
// painted for the plain background and is unreadable on
// the olive fill.
placeholderTextColor={
isCustomActive ? colors.primaryForeground : colors.mutedForeground
}
editable={!interactionDisabled}
accessibilityLabel={t('agentChat.questionCard.typeYourOwnAnswer')}
accessibilityState={{ disabled: interactionDisabled }}
Expand Down
29 changes: 29 additions & 0 deletions apps/mobile/src/components/consent/consent-card.mounted.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { createElement } from 'react';
import { act, TestRenderer } from '@/test/renderer';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { CONSENT_DISCLOSURE_MAX_FONT_SCALE, ConsentCard } from './consent-card';
import { i18n } from '@/i18n';

const mockedAcceptConsent = vi.hoisted(() => vi.fn());
const mockedReadConsent = vi.hoisted(() => vi.fn());
Expand Down Expand Up @@ -195,6 +196,34 @@ describe('ConsentCard', () => {
expect(singleSwitch(renderer.root).props.value).toBe(true);
});

it('keeps the privacy line clear of the pinned actions', () => {
// Finding 2 (the sentence cut in half at the footer boundary) is fixed at
// the base revision by pinning the disclosure into the footer above the
// actions, so the footer edge can no longer cut it and the actions cannot
// overlay it. The earlier assertion that the sentence was the tail of the
// scrolling body no longer holds: the sentence has left the scroller.
const renderer = mountCard('onboarding');
const scroller = renderer.root.findByType('ScrollView' as never);

const privacyPrefix = i18n.t('consent.privacyPolicyPrefix');
const privacyLine = renderer.root.findAll(node => {
const text = node.children
.filter((child): child is string => typeof child === 'string')
.join('');
return (node.type as string) === 'Text' && text.length > 0 && text.includes(privacyPrefix);
});
expect(privacyLine).toHaveLength(1);
const line = privacyLine[0];
if (!line) {
throw new Error('privacy line not found');
}
expect(hasAncestorOfType(line, 'ScrollView')).toBe(false);

// The pinned actions are outside the scroller, so they never cover it.
const primary = findButton(renderer.root, 'Accept and continue');
expect(scroller.findAll(node => node === primary)).toHaveLength(0);
});

it('accepts with optional on when the switch is untouched', async () => {
const renderer = mountCard('onboarding');
const btn = findButton(renderer.root, 'Accept and continue');
Expand Down
9 changes: 6 additions & 3 deletions apps/mobile/src/components/tour/tour-screen.mounted.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -218,15 +218,18 @@ describe('TourScreen', () => {
unmount();
});

it('presents the header as a modal with a top-left eyebrow and no back control', async () => {
it('presents the header as a modal with a centred eyebrow and no back control', async () => {
const { renderer, unmount } = await mountTour();

// The tour is presented `modal`, so the header must take the modal
// clearance rather than re-adding the status-bar inset the native sheet
// already owns (the dead band above the eyebrow the owner reported). The
// eyebrow stays top-left and the fork keeps no back control.
// eyebrow is centred because every other element on the screen — the icon,
// the title, the cards and the Skip action — is centred, and a top-left
// eyebrow read as a stranded label beside that column (home-quick-tour
// finding). The fork keeps no back control.
const header = renderer.root.findByProps({ modal: true });
expect(header.props.centerTitle).toBe(false);
expect(header.props.centerTitle).toBe(true);
expect(header.props.showBackButton).toBe(false);
expect(header.props.onBack).toBeUndefined();
expect(header.props.eyebrow).toBe('tour.eyebrow');
Expand Down
8 changes: 5 additions & 3 deletions apps/mobile/src/components/tour/tour-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -182,9 +182,11 @@ export function TourScreen() {
{/* The tour is presented `modal` (apps/mobile/src/app/(app)/_layout.tsx),
so the header takes the modal clearance instead of re-adding the
status-bar inset the native sheet already owns — no dead band above
the eyebrow. The eyebrow stays top-left and the fork has no back
control to return to. */}
<ScreenHeader eyebrow={t('tour.eyebrow')} modal centerTitle={false} showBackButton={false} />
the eyebrow. The eyebrow is centred (`centerTitle`): the fork body,
which is the only other content, is a centred column, so a top-left
eyebrow read as a stranded label beside it (home-quick-tour finding).
The fork has no back control to return to. */}
<ScreenHeader eyebrow={t('tour.eyebrow')} modal centerTitle showBackButton={false} />

<View className="flex-1">
<ForkStep onChoose={choosePath} />
Expand Down
Loading