Skip to content
Open
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { createElement } from 'react';
import type * as ReactI18next from 'react-i18next';
import { describe, expect, it, vi } from 'vitest';
import { act, TestRenderer } from '@/test/renderer';

import { i18n } from '@/i18n';
import { PermissionUnreadableNotice } from './permission-unreadable-notice';

const platform = vi.hoisted(() => ({ os: 'android' as string }));
const announceForA11y = vi.hoisted(() => vi.fn<(message: string) => void>());

vi.mock('react-i18next', async importOriginal => {
const actual = await importOriginal<typeof ReactI18next>();
return { ...actual, useTranslation: () => ({ t: actual.getI18n().t.bind(actual.getI18n()) }) };
});
vi.mock('react-native', () => ({
View: 'View',
Platform: {
get OS() {
return platform.os;
},
},
}));
vi.mock('@/lib/a11y/announce', () => ({ announceForA11y }));
vi.mock('@/components/ui/text', () => ({ Text: 'Text' }));

async function renderNotice() {
const rendererRef: { current: TestRenderer.ReactTestRenderer | undefined } = {
current: undefined,
};
await act(async () => {
await vi.waitFor(() => {
expect(i18n.t('agentChat.permissionCard.unreadableTitle')).toBe(
'Permission request could not be read'
);
});
rendererRef.current = TestRenderer.create(createElement(PermissionUnreadableNotice));
});
const renderer = rendererRef.current;
if (!renderer) {
throw new Error('renderer was not created');
}
return renderer;
}

describe('PermissionUnreadableNotice mounted', () => {
it('explains that the request cannot be shown and points at the CLI', async () => {
const renderer = await renderNotice();

const text = renderer.root
.findAll(node => typeof node.props.children === 'string')
.map(node => node.props.children as string)
.join(' ');

expect(text).toContain('Permission request could not be read');
expect(text).toContain('The agent is waiting for a permission decision this app cannot show.');
expect(text).toContain('answer the request in the CLI');
});

it('carries the notice as an alert and renders no decision action', async () => {
const renderer = await renderNotice();

const alerts = renderer.root.findAll(node => node.props.accessibilityRole === 'alert');
expect(alerts).toHaveLength(1);
expect(alerts[0]?.props.accessibilityLiveRegion).toBe('polite');
expect(renderer.root.findAll(node => node.props.accessibilityRole === 'button')).toHaveLength(
0
);
});

it('lets Android read the live region instead of announcing as well', async () => {
platform.os = 'android';
announceForA11y.mockClear();
await renderNotice();

expect(announceForA11y).not.toHaveBeenCalled();
});

it('announces once on iOS, which has no live region', async () => {
platform.os = 'ios';
announceForA11y.mockClear();
await renderNotice();

expect(announceForA11y).toHaveBeenCalledTimes(1);
expect(announceForA11y).toHaveBeenCalledWith(
'The agent is waiting for a permission decision this app cannot show. Update the app or the CLI, or answer the request in the CLI to continue.'
);
});
});
42 changes: 42 additions & 0 deletions apps/mobile/src/components/agents/permission-unreadable-notice.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Platform, View } from 'react-native';

import { Text } from '@/components/ui/text';
import { announceForA11y } from '@/lib/a11y/announce';

/**
* Shown while the agent waits on a permission request this app cannot read. No
* consent card is rendered, because a card would describe a scope the boundary
* never parsed; the CLI can still answer the request, so the notice points the
* user there instead of leaving the run stalled without explanation.
*
* The notice appears without any user action, so a screen reader has to learn
* about it. Android reads the polite live region as the node is added; iOS has
* no live region, so the same copy is announced once there. Announcing on both
* would read it twice on Android.
*/
export function PermissionUnreadableNotice() {
const { t } = useTranslation();
const announcement = t('agentChat.permissionCard.unreadableBody');

useEffect(() => {
if (Platform.OS !== 'android') {
announceForA11y(announcement);
}
}, [announcement]);

return (
<View
accessible
accessibilityRole="alert"
Comment thread
iscekic marked this conversation as resolved.
accessibilityLiveRegion="polite"
className="mx-4 my-2 rounded-xl border border-warn-tile-border bg-warn-tile-bg p-4"
>
<Text className="text-sm font-medium text-warn">
{t('agentChat.permissionCard.unreadableTitle')}
</Text>
<Text className="mt-1 text-xs text-muted-foreground">{announcement}</Text>
</View>
);
}
47 changes: 47 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 @@ -27,6 +27,7 @@ import {
type StandalonePermission,
type StoredMessage,
type ToolPart,
type UnreadablePermissionState,
} from '@kilocode/cloud-agent-sdk';
import { cloudAgentId, kiloId, stubTextPart } from '@kilocode/cloud-agent-sdk/test-helpers';

Expand All @@ -43,6 +44,7 @@ import {
} from '@/components/agents/exit-remote-session-with-feedback';
import { RemoteSessionExitFailure } from '@/components/agents/remote-session-exit-failure';
import { PermissionCard } from '@/components/agents/permission-card';
import { PermissionUnreadableNotice } from '@/components/agents/permission-unreadable-notice';
import { setSessionAutoApproveEnabled } from '@/components/agents/session-auto-approve';
import {
isSessionGoalCollapsed,
Expand Down Expand Up @@ -2354,6 +2356,51 @@ describe('session detail per-session auto-approve', () => {
});
});

describe('session detail unreadable permission notice', () => {
const waiting: readonly UnreadablePermissionState[] = [{ requestId: 'perm-1' }];
const none: readonly UnreadablePermissionState[] = [];

/** An answerable session, so the composer claim is about the notice, not read-only mode. */
function makeAnswerable(view: Awaited<ReturnType<typeof mountDetails>>): void {
act(() => {
view.store.set(view.manager.atoms.activeSessionType, 'remote');
view.store.set(view.manager.atoms.isReadOnly, false);
view.store.set(view.manager.atoms.canSend, true);
});
}

it('explains the waiting request and leaves the composer usable', async () => {
const view = await mountDetails([]);
makeAnswerable(view);
act(() => {
view.store.set(view.manager.atoms.unreadablePermissions, waiting);
});

expect(view.renderer.root.findAllByType(PermissionUnreadableNotice)).toHaveLength(1);
expect(view.renderer.root.findAllByType(PermissionCard)).toHaveLength(0);
expect(renderedText(view.renderer.root)).toContain(
'The agent is waiting for a permission decision this app cannot show.'
);

const composer = view.renderer.root.findAll(node => Object.is(node.type, 'ChatComposer'));
expect(composer).toHaveLength(1);
expect(composer[0]?.parent?.parent?.props.className ?? '').not.toContain('hidden');
Comment thread
iscekic marked this conversation as resolved.
expect(composer[0]?.parent?.parent?.props.accessibilityElementsHidden).toBe(false);
});

it('renders nothing once the request is answered', async () => {
const view = await mountDetails([]);
act(() => {
view.store.set(view.manager.atoms.unreadablePermissions, waiting);
});
act(() => {
view.store.set(view.manager.atoms.unreadablePermissions, none);
});

expect(view.renderer.root.findAllByType(PermissionUnreadableNotice)).toHaveLength(0);
});
});

describe.each([true, false])('session detail return with history=%s', hasHistory => {
beforeEach(() => {
if (hasHistory) {
Expand Down
4 changes: 4 additions & 0 deletions apps/mobile/src/components/agents/session-detail-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import { MessageErrorBoundary } from '@/components/agents/message-error-boundary
import { ModelPickerSelectionScopeProvider } from '@/components/agents/model-selector';
import { nextHeldQueuedIds } from '@/components/agents/queued-badge-hold';
import { PermissionCard } from '@/components/agents/permission-card';
import { PermissionUnreadableNotice } from '@/components/agents/permission-unreadable-notice';
import { QuestionCard } from '@/components/agents/question-card';
import {
type ContextSheetIdentity,
Expand Down Expand Up @@ -327,6 +328,7 @@ export function SessionDetailContent({
const activeSuggestion = useAtomValue(manager.atoms.activeSuggestion);
const pendingQuestions = useAtomValue(manager.atoms.pendingQuestions);
const pendingPermissions = useAtomValue(manager.atoms.pendingPermissions);
const unreadablePermissions = useAtomValue(manager.atoms.unreadablePermissions);
const totalCost = useAtomValue(manager.atoms.totalCost);
const { totalMicrodollars, breakdownCostUsd } = selectSessionCostInputs(
fetchedData?.kiloSessionId === sessionId ? fetchedData.totalCostMicrodollars : null,
Expand Down Expand Up @@ -2563,6 +2565,8 @@ export function SessionDetailContent({
</Animated.View>
) : null}

{unreadablePermissions.length > 0 ? <PermissionUnreadableNotice /> : null}

{blockingInteraction === 'question' && activeQuestion ? (
<QuestionCard
key={activeQuestion.requestId}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -612,6 +612,7 @@ function makeManager() {
activeSuggestion: { value: null },
pendingQuestions: { value: [] },
pendingPermissions: { value: [] },
unreadablePermissions: { value: [] },
totalCost: { value: null },
childMessages: { value: () => new Map() },
childSessionHydrationState: { value: () => ({ status: 'idle' }) },
Expand Down
4 changes: 3 additions & 1 deletion apps/mobile/src/i18n/locales/af.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion apps/mobile/src/i18n/locales/am.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion apps/mobile/src/i18n/locales/ar.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion apps/mobile/src/i18n/locales/az.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion apps/mobile/src/i18n/locales/be.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion apps/mobile/src/i18n/locales/bg.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion apps/mobile/src/i18n/locales/bn.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion apps/mobile/src/i18n/locales/bs.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion apps/mobile/src/i18n/locales/ca.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion apps/mobile/src/i18n/locales/ckb.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion apps/mobile/src/i18n/locales/cs.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion apps/mobile/src/i18n/locales/cy.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion apps/mobile/src/i18n/locales/da.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion apps/mobile/src/i18n/locales/de.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading