Skip to content
Open
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
11 changes: 8 additions & 3 deletions apps/mobile/src/components/agents/child-session-message.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { ChildSessionMessage, ChildSessionSection } from './child-session-sectio
import { ChildSessionModelLabel } from './child-session-model-label';
import { MessageErrorBoundary } from './message-error-boundary';

import '@/i18n';
import { i18n as i18nInstance } from '@/i18n';
import type * as ReactI18next from 'react-i18next';

vi.mock('react-i18next', async importOriginal => {
Expand Down Expand Up @@ -304,8 +304,13 @@ describe('ChildSessionMessage completion', () => {
await Promise.resolve();
});

expect(texts()).toEqual(['General', 'child task', 'Test Model', 'completed']);
expect(button().props.accessibilityLabel).toContain('completed');
expect(texts()).toEqual([
'General',
'child task',
'Test Model',
i18nInstance.t('common.completed'),
]);
expect(button().props.accessibilityLabel).toContain(i18nInstance.t('common.completed'));
expect(button().props.accessibilityLabel).not.toContain('Considering next steps');
expect(renderer.root.findAllByType(SpinningIcon)).toHaveLength(0);
const { onPress } = button().props as { onPress: () => void };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -425,18 +425,18 @@ describe('ChildSessionSection live task status', () => {

feedTaskPart(storage, taskPart('pending'), 100);
const pendingRenderer = await renderCard(storedTaskPart(storage));
expect(statusBadge(pendingRenderer.root).props.children).toBe('pending');
expect(statusBadge(pendingRenderer.root).props.children).toBe(i18n.t('common.pending'));
expect(findByType(pendingRenderer.root, 'SpinningIcon')).toHaveLength(1);
expect(textValues(pendingRenderer.root)).not.toContain('completed');
expect(textValues(pendingRenderer.root)).not.toContain(i18n.t('common.completed'));

feedTaskPart(storage, taskPart('running'), LIVE_RUNNING_AT);
expect(storedTaskPart(storage).state.status).toBe('running');
const runningRenderer = await renderCard(storedTaskPart(storage));
expect(statusBadge(runningRenderer.root).props.children).toBe('running');
expect(statusBadge(runningRenderer.root).props.children).toBe(i18n.t('common.running'));
expect(statusBadge(runningRenderer.root).props.className).toContain('text-info');
expect(findByType(runningRenderer.root, 'SpinningIcon')).toHaveLength(1);
expect(textValues(runningRenderer.root)).toContain(i18n.t('agentChat.partDetail.thinking'));
expect(textValues(runningRenderer.root)).not.toContain('completed');
expect(textValues(runningRenderer.root)).not.toContain(i18n.t('common.completed'));
});

it('cannot be flipped to completed by a stale or unordered terminal replay', async () => {
Expand All @@ -456,15 +456,15 @@ describe('ChildSessionSection live task status', () => {
expect(storedTaskPart(storage).state.status).toBe('running');

const renderer = await renderCard(storedTaskPart(storage));
expect(statusBadge(renderer.root).props.children).toBe('running');
expect(statusBadge(renderer.root).props.children).toBe(i18n.t('common.running'));
expect(findByType(renderer.root, 'SpinningIcon')).toHaveLength(1);
expect(textValues(renderer.root)).not.toContain('completed');
expect(textValues(renderer.root)).not.toContain(i18n.t('common.completed'));

// The guard must not swallow a terminal that really does postdate the run.
feedTaskPart(storage, taskPart('completed', FRESH_SETTLED_AT), FRESH_SETTLED_AT);
expect(storedTaskPart(storage).state.status).toBe('completed');
const settledRenderer = await renderCard(storedTaskPart(storage));
expect(statusBadge(settledRenderer.root).props.children).toBe('completed');
expect(statusBadge(settledRenderer.root).props.children).toBe(i18n.t('common.completed'));
expect(findByType(settledRenderer.root, 'SpinningIcon')).toHaveLength(0);
});

Expand All @@ -478,14 +478,14 @@ describe('ChildSessionSection live task status', () => {

expect(storedTaskPart(storage).state.status).toBe('error');
const renderer = await renderCard(storedTaskPart(storage));
expect(statusBadge(renderer.root).props.children).toBe('error');
expect(statusBadge(renderer.root).props.children).toBe(i18n.t('common.failed'));
expect(statusBadge(renderer.root).props.className).toContain('text-destructive');
expect(findByType(renderer.root, 'AnimatedView')[0]?.props.style).toEqual({
borderStartWidth: 2,
borderStartColor: '#BE4E3F',
});
expect(findByType(renderer.root, 'SpinningIcon')).toHaveLength(0);
expect(textValues(renderer.root)).not.toContain('completed');
expect(textValues(renderer.root)).not.toContain(i18n.t('common.completed'));
});

it('keeps a running task live when no terminal event ever arrives', async () => {
Expand All @@ -499,9 +499,9 @@ describe('ChildSessionSection live task status', () => {
// No terminal event: the card has no local completion inference to fall
// back on, and there is no card-without-part state to show instead.
const renderer = await renderCard(storedTaskPart(storage));
expect(statusBadge(renderer.root).props.children).toBe('running');
expect(statusBadge(renderer.root).props.children).toBe(i18n.t('common.running'));
expect(findByType(renderer.root, 'SpinningIcon')).toHaveLength(1);
expect(textValues(renderer.root)).not.toContain('completed');
expect(textValues(renderer.root)).not.toContain(i18n.t('common.completed'));
});

it('survives a page replay while running and then a re-applied stale terminal', async () => {
Expand All @@ -524,9 +524,9 @@ describe('ChildSessionSection live task status', () => {
expect(storedTaskPart(storage).state.status).toBe('running');

const renderer = await renderCard(storedTaskPart(storage));
expect(statusBadge(renderer.root).props.children).toBe('running');
expect(statusBadge(renderer.root).props.children).toBe(i18n.t('common.running'));
expect(findByType(renderer.root, 'SpinningIcon')).toHaveLength(1);
expect(textValues(renderer.root)).not.toContain('completed');
expect(textValues(renderer.root)).not.toContain(i18n.t('common.completed'));
});

it('drops a stale terminal carried by the replayed page itself', async () => {
Expand All @@ -540,7 +540,34 @@ describe('ChildSessionSection live task status', () => {

expect(storedTaskPart(storage).state.status).toBe('running');
const renderer = await renderCard(storedTaskPart(storage));
expect(statusBadge(renderer.root).props.children).toBe('running');
expect(textValues(renderer.root)).not.toContain('completed');
expect(statusBadge(renderer.root).props.children).toBe(i18n.t('common.running'));
expect(textValues(renderer.root)).not.toContain(i18n.t('common.completed'));
});

it('draws and announces the status in the reader’s language', async () => {
const { storage } = await createTaskManager(
vi.fn<NonNullable<SessionManagerConfig['fetchSnapshotPage']>>()
);
feedTaskPart(storage, taskPart('running'), LIVE_RUNNING_AT);

const previousLanguage = i18n.language;
await act(async () => {
await i18n.changeLanguage('hu');
});
try {
const renderer = await renderCard(storedTaskPart(storage));
const badgeLabel = statusBadge(renderer.root).props.children;
expect(badgeLabel).toBe(i18n.t('common.running'));
expect(badgeLabel).not.toBe('Running');

const pressable = findByType(renderer.root, 'Pressable')[0];
const accessibilityLabel = pressable?.props.accessibilityLabel as string;
expect(accessibilityLabel).toContain(badgeLabel);
expect(accessibilityLabel).not.toContain('running');
} finally {
await act(async () => {
await i18n.changeLanguage(previousLanguage);
});
}
});
});
19 changes: 15 additions & 4 deletions apps/mobile/src/components/agents/child-session-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,16 @@ import { useOptionalSessionManager } from './session-manager-context';

const MAX_NESTING_DEPTH = 5;

type ChildSessionStatus = ToolPart['state']['status'];

/** The card's visible and announced state, in the reader's language. */
const STATUS_LABEL_KEY = {
pending: 'common.pending',
running: 'common.running',
completed: 'common.completed',
error: 'common.failed',
} satisfies Record<ChildSessionStatus, string>;

export type RenderPartFn = (props: {
part: Part;
getChildMessages?: (sessionId: string) => StoredMessage[];
Expand Down Expand Up @@ -65,6 +75,7 @@ export function ChildSessionSection({
const modelLabel = getChildSessionModelLabel(childMessages, modelOptions ?? []);

const { status } = part.state;
const statusLabel = t(STATUS_LABEL_KEY[status]);
const isRunning = status === 'running' || status === 'pending';
const sessionId = getTaskToolSessionId(part);

Expand All @@ -91,7 +102,7 @@ export function ChildSessionSection({
taskName: shownTaskName,
modelLabel: modelLabel ? `, ${modelLabel}` : '',
latestActivityLabel,
status,
status: statusLabel,
})}
accessibilityHint={sessionId ? t('agentChat.childSession.openHint') : undefined}
accessibilityState={{ disabled: !sessionId }}
Expand Down Expand Up @@ -129,7 +140,7 @@ export function ChildSessionSection({
) : null}
</View>

<StatusBadge status={status} />
<StatusBadge status={status} label={statusLabel} />
</Pressable>
</Animated.View>
);
Expand Down Expand Up @@ -279,13 +290,13 @@ function getStatusBorderColor(status: string, colors: ThemeColors): string {
return colors.info;
}

function StatusBadge({ status }: Readonly<{ status: string }>) {
function StatusBadge({ status, label }: Readonly<{ status: ChildSessionStatus; label: string }>) {
const bgClass = getStatusBgClass(status);
const textClass = getStatusTextClass(status);

return (
<View className={`rounded px-1.5 py-0.5 ${bgClass}`}>
<Text className={`text-xs ${textClass}`}>{status}</Text>
<Text className={`text-xs ${textClass}`}>{label}</Text>
</View>
);
}
Expand Down
21 changes: 14 additions & 7 deletions apps/mobile/src/components/agents/session-detail-content.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2447,32 +2447,39 @@ describe('shouldRefuseSilentAttachmentDrop', () => {

// These tests run in the existing detail suite with the DOM-free renderer.
// Request order and rendered state are deterministic; native paint timing is not.
/** The card's visible and announced state, in the active language. */
const STATUS_LABEL_KEY = {
pending: 'common.pending',
running: 'common.running',
completed: 'common.completed',
error: 'common.failed',
} satisfies Record<'pending' | 'running' | 'completed' | 'error', string>;

describe('child transcript requests', () => {
it.each([
{
sessionId: SELECTED_ID,
status: 'completed',
text: 'Researcher\nTask ses-selected\ncompleted',
textRows: 3,
activity: null,
},
{
sessionId: kiloId('ses-sibling-0'),
status: 'running',
text: 'Researcher\nTask ses-sibling-0\nThinking\nrunning',
textRows: 4,
activity: 'Thinking',
},
{
sessionId: kiloId('ses-sibling-1'),
status: 'error',
text: 'Researcher\nTask ses-sibling-1\nerror',
textRows: 3,
activity: null,
},
] as const)(
'renders the $status card without fetching a child transcript for labels',
async ({ sessionId, status, text, textRows, activity }) => {
async ({ sessionId, status, textRows, activity }) => {
const statusLabel = i18n.t(STATUS_LABEL_KEY[status]);
const text = `Researcher\nTask ${sessionId}${activity ? `\n${activity}` : ''}\n${statusLabel}`;
const view = await mountDetails();
const card = cardFor(view.renderer, sessionId);
const button = card.findByProps({ accessibilityRole: 'button' }).props as ComponentProps<
Expand All @@ -2489,7 +2496,7 @@ describe('child transcript requests', () => {
});
expect(button.accessibilityLabel).toContain('Researcher');
expect(button.accessibilityLabel).toContain(`Task ${sessionId}`);
expect(button.accessibilityLabel).toContain(status);
expect(button.accessibilityLabel).toContain(statusLabel);
expect(button.accessibilityLabel?.includes('Waiting for activity')).toBe(false);
if (activity) {
expect(button.accessibilityLabel).toContain(activity);
Expand Down Expand Up @@ -2538,7 +2545,7 @@ describe('child transcript requests', () => {
expect(selectedCard.findAllByType(ChildSessionModelLabel)).toHaveLength(1);
const nestedCard = cardFor(view.renderer, nestedId);
expect(renderedText(nestedCard)).toBe(
`Researcher\nTask ${nestedId}${isRunning ? '\nThinking' : ''}\n${status}`
`Researcher\nTask ${nestedId}${isRunning ? '\nThinking' : ''}\n${i18n.t(STATUS_LABEL_KEY[status])}`
);
expect(nestedCard.findAll(node => (node.type as string) === 'Text')).toHaveLength(
isRunning ? 4 : 3
Expand All @@ -2551,7 +2558,7 @@ describe('child transcript requests', () => {
accessibilityHint: i18n.t('agentChat.childSession.openHint'),
});
expect(nestedButton.accessibilityLabel).toContain(`Task ${nestedId}`);
expect(nestedButton.accessibilityLabel).toContain(status);
expect(nestedButton.accessibilityLabel).toContain(i18n.t(STATUS_LABEL_KEY[status]));
expect(nestedButton.accessibilityLabel?.includes('Waiting for activity')).toBe(false);
if (isRunning) {
expect(nestedButton.accessibilityLabel).toContain('Thinking');
Expand Down
15 changes: 12 additions & 3 deletions apps/mobile/src/components/agents/tool-part-renderer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { Text } from '@/components/ui/text';
import { type SessionModelOption } from '@/lib/hooks/use-session-model-options';
import { renderWithProviders } from '@/test/render-with-providers';

import '@/i18n';
import { i18n } from '@/i18n';

import {
BashToolCard,
Expand Down Expand Up @@ -242,6 +242,14 @@ const toolActivities: [string, Record<string, unknown>, string][] = [
'unknown-tool',
].map(tool => [tool, {}, tool] satisfies [string, Record<string, unknown>, string]),
];
/** The card's visible and announced state, in the active language. */
const STATUS_LABEL_KEY = {
pending: 'common.pending',
running: 'common.running',
completed: 'common.completed',
error: 'common.failed',
} satisfies Record<'pending' | 'running' | 'completed' | 'error', string>;

const activityHistories: [string, StoredMessage[], string][] = [
['empty history', [], 'Thinking'],
['incomplete history', [makeStoredMessage()], 'Thinking'],
Expand Down Expand Up @@ -303,15 +311,16 @@ describe.each(['top-level', 'nested'] as const)('%s child-card initial history',
depth: 1,
});
const { renderer, unmount } = await renderWithProviders(element);
const statusLabel = i18n.t(STATUS_LABEL_KEY[status]);
try {
expect(textContent(renderer.root)).toBe(
`Generalchild task${modelLabel}${expectedActivity}${status}`
`Generalchild task${modelLabel}${expectedActivity}${statusLabel}`
);
expect(renderer.root.findAllByType(SpinningIcon)).toHaveLength(active ? 1 : 0);
const button = renderer.root.findByType(Pressable);
expect(button.props.accessibilityRole).toBe('button');
expect(button.props.accessibilityLabel).toContain('General, child task');
expect(button.props.accessibilityLabel).toContain(status);
expect(button.props.accessibilityLabel).toContain(statusLabel);
if (modelLabel) {
expect(button.props.accessibilityLabel).toContain(modelLabel);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -414,7 +414,7 @@ describe('ReviewDetailScreen details metadata', () => {
it('labels the completion-timestamp row in the Details list', () => {
// The Details key-value list pairs every label with its value in one
// MetaRow. The completion row must pass the localized
// `codeReviewer.status.completed` copy as MetaRow's label beside its
// `common.completed` copy as MetaRow's label beside its
// timeAgo value; a value-only row would leave the label column blank
// (finding: review-detail.png).
detail.data = {
Expand All @@ -428,7 +428,7 @@ describe('ReviewDetailScreen details metadata', () => {
};

const renderer = mountScreen();
const completedLabel = i18n.t('codeReviewer.status.completed');
const completedLabel = i18n.t('common.completed');

// `timeAgo` is mocked to "now" in this suite, so the completion row is the
// one MetaRow that carries the completion label and the timeAgo value.
Expand Down Expand Up @@ -466,7 +466,7 @@ describe('ReviewDetailScreen details metadata', () => {
};

const renderer = mountScreen();
const completedLabel = i18n.t('codeReviewer.status.completed');
const completedLabel = i18n.t('common.completed');

expect(
renderer.root.findAll(node => node.type === MetaRow && node.props.label === completedLabel)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@ export function ReviewDetailScreen({
) : null}
{review.completed_at ? (
<MetaRow
label={t('codeReviewer.status.completed')}
label={t('common.completed')}
value={timeAgo(parseTimestamp(review.completed_at))}
/>
) : null}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@ const STATUS_CLASSNAME = {
const STATUS_KEY = {
pending: 'common.pending',
queued: 'common.queued',
running: 'codeReviewer.status.running',
completed: 'codeReviewer.status.completed',
running: 'common.running',
completed: 'common.completed',
failed: 'common.failed',
cancelled: 'common.cancelled',
interrupted: 'codeReviewer.status.interrupted',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ export function KiloCommandFormSheet({
disabled={isSaving}
autoCapitalize="none"
autoCorrect={false}
placeholder={t('agentChat.modeOptions.code')}
placeholder={t('profiles.slashCommands.agentPlaceholder')}
onChangeText={value => {
agentRef.current = value;
}}
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/src/i18n/label-reference.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ describe('catalog copy', () => {
for (const key of [
'codeReviewer.reviewDetail.tokens',
'codeReviewer.reviewDetail.transcriptLive',
'codeReviewer.status.running',
'common.running',
'agentChat.childSession.task',
'chat.messageActions.edit',
'securityAgent.dismiss.reasonNotUsed',
Expand Down
Loading
Loading