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
76 changes: 70 additions & 6 deletions apps/mobile/src/components/agents/question-card.mounted.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,12 +112,18 @@ function optionButton(
root: TestRenderer.ReactTestInstance,
label: string
): TestRenderer.ReactTestInstance | undefined {
return root.findAll(
node =>
typeof node.type === 'string' &&
(node.type as string) === 'Button' &&
node.props.accessibilityLabel === label
)[0];
// A described option joins its subtitle into the accessible name
// (`<label>, <description>`), so match the leading label too.
return root.findAll(node => {
if (typeof node.type !== 'string' || (node.type as string) !== 'Button') {
return false;
}
const accessibleLabel = node.props.accessibilityLabel;
return (
accessibleLabel === label ||
(typeof accessibleLabel === 'string' && accessibleLabel.startsWith(`${label},`))
);
})[0];
}

function press(node: TestRenderer.ReactTestInstance | undefined): void {
Expand Down Expand Up @@ -148,6 +154,19 @@ function typeCustomText(root: TestRenderer.ReactTestInstance, text: string): voi
});
}

/** The row of text lines rendered inside one preset option button. */
function optionTextLines(root: TestRenderer.ReactTestInstance, label: string): string[] {
const button = optionButton(root, label);
if (!button) {
throw new Error(`option button "${label}" not found`);
}
return button
.findAll(node => typeof node.type === 'string' && (node.type as string) === 'Text')
.map(node =>
node.children.filter((child): child is string => typeof child === 'string').join('')
);
}

describe('QuestionCard custom answer selection', () => {
beforeEach(() => {
a11yMocks.announceForA11y.mockReset();
Expand Down Expand Up @@ -199,6 +218,51 @@ describe('QuestionCard custom answer selection', () => {
expect(customChoiceChecked(renderer.root)).toBe(false);
});

it('renders an option description as a subtitle under its label', async () => {
const renderer = await renderCard([
{
question: 'How should the agent proceed?',
header: 'Agent needs input',
options: [
{ label: 'Continue', description: 'Deploy to production' },
{ label: 'Stop', description: '' },
],
custom: true,
},
]);

expect(optionTextLines(renderer.root, 'Continue')).toEqual([
'Continue',
'Deploy to production',
]);
// No description means no empty subtitle line under the label.
expect(optionTextLines(renderer.root, 'Stop')).toEqual(['Stop']);
});

it('announces an option description in its accessible name, never only as a hint', async () => {
const renderer = await renderCard([
{
question: 'How should the agent proceed?',
header: 'Agent needs input',
options: [
{ label: 'Continue', description: 'Deploy to production' },
{ label: 'Stop', description: '' },
],
custom: true,
},
]);

// TalkBack reads a node's hint text, not the tooltip React Native fills
// from `accessibilityHint`, so the subtitle has to be part of the name.
const described = optionButton(renderer.root, 'Continue');
expect(described?.props.accessibilityLabel).toBe('Continue, Deploy to production');
expect(described?.props.accessibilityHint).toBeUndefined();

// An option without a description keeps its bare label and no hint.
expect(optionButton(renderer.root, 'Stop')?.props.accessibilityLabel).toBe('Stop');
expect(optionButton(renderer.root, 'Stop')?.props.accessibilityHint).toBeUndefined();
});

it('cancels the delayed focus retry when a new request replaces the card', async () => {
vi.useFakeTimers();
// First mount misses the node handle (schedules a retry); the replacement
Expand Down
49 changes: 39 additions & 10 deletions apps/mobile/src/components/agents/question-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,14 @@ export function QuestionCard({
<View className="gap-1">
{question.options.map((option, oIndex) => {
const isSelected = selectedOptions[qIndex]?.has(oIndex) ?? false;
// The description is content, not an action hint: TalkBack
// (Android) reads the node's hint text, never the tooltip
// React Native fills from `accessibilityHint`, so a hint
// would leave the subtitle unannounced. Join it into the
// accessible name, the way the Kilo Pass card does.
const optionLabel = option.description
? `${option.label}, ${option.description}`
: option.label;
return (
<Button
key={oIndex}
Expand All @@ -239,22 +247,43 @@ export function QuestionCard({
accessibilityRole="button"
accessibilityLabel={
isSelected
? t('agentChat.questionCard.optionSelected', { label: option.label })
: t('agentChat.questionCard.option', { label: option.label })
? t('agentChat.questionCard.optionSelected', { label: optionLabel })
: t('agentChat.questionCard.option', { label: optionLabel })
}
className={cn(
'h-auto justify-start py-2.5',
isSelected ? 'bg-primary' : 'bg-background'
)}
>
<Text
className={cn(
'text-sm',
isSelected ? 'text-primary-foreground' : 'text-foreground'
)}
>
{option.label}
</Text>
{/*
The agent may attach an explanation to a choice; the
CLI, web, and extension all show it as a muted
subtitle under the label. Stack the two lines so the
label stays the prominent line and the description
reads as supporting text.
*/}
<View className="flex-1 flex-col items-start gap-0.5">
<Text
className={cn(
'text-sm',
isSelected ? 'text-primary-foreground' : 'text-foreground'
)}
>
{option.label}
</Text>
{option.description ? (
<Text
className={cn(
'text-xs',
isSelected
? 'text-primary-foreground opacity-70'
: 'text-muted-foreground'
)}
>
{option.description}
</Text>
) : null}
</View>
</Button>
);
})}
Expand Down