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 @@ -13,8 +13,45 @@ const currentInfo: SessionContextInfo = {
};

describe('getContextSheetMountState', () => {
it('opens permission controls before the first usage report and stays open when usage arrives', () => {
const identity = { sessionId: 'current-session' };
const session = { sessionId: 'current-session', autoApproveAvailable: true };
expect(getContextSheetMountState(undefined, identity, session)).toEqual({
mounted: true,
visible: true,
info: undefined,
});
expect(getContextSheetMountState(currentInfo, identity, session)).toEqual({
mounted: true,
visible: true,
info: currentInfo,
});
});

it('keeps the no-usage sheet mounted for dismissal without carrying it to another session', () => {
const session = { sessionId: 'current-session', autoApproveAvailable: true };
expect(getContextSheetMountState(undefined, null, session)).toEqual({
mounted: true,
visible: false,
info: undefined,
});
expect(
getContextSheetMountState(undefined, { sessionId: 'previous-session' }, session)
).toEqual({ mounted: true, visible: false, info: undefined });
});

it('unmounts the no-usage sheet when permission controls become unavailable', () => {
expect(
getContextSheetMountState(
undefined,
{ sessionId: 'current-session' },
{ sessionId: 'current-session', autoApproveAvailable: false }
)
).toEqual({ mounted: false });
});

it('unmounts when there is no context info regardless of open state', () => {
expect(getContextSheetMountState(undefined, null, 'current-session')).toEqual({
expect(getContextSheetMountState(undefined, null, { sessionId: 'current-session' })).toEqual({
mounted: false,
});
expect(
Expand All @@ -25,7 +62,7 @@ describe('getContextSheetMountState', () => {
providerID: currentInfo.providerID,
modelID: currentInfo.modelID,
},
'current-session'
{ sessionId: 'current-session' }
)
).toEqual({ mounted: false });
});
Expand All @@ -38,14 +75,14 @@ describe('getContextSheetMountState', () => {
providerID: currentInfo.providerID,
modelID: currentInfo.modelID,
},
'current-session'
{ sessionId: 'current-session' }
);

expect(result).toEqual({ mounted: true, visible: true, info: currentInfo });
});

it('mounts hidden when context info exists but the sheet is closed', () => {
const result = getContextSheetMountState(currentInfo, null, 'current-session');
const result = getContextSheetMountState(currentInfo, null, { sessionId: 'current-session' });

expect(result).toEqual({ mounted: true, visible: false, info: currentInfo });
});
Expand All @@ -59,7 +96,7 @@ describe('getContextSheetMountState', () => {
providerID: currentInfo.providerID,
modelID: currentInfo.modelID,
},
'current-session'
{ sessionId: 'current-session' }
)
).toEqual({ mounted: true, visible: false, info: currentInfo });
});
Expand All @@ -75,7 +112,7 @@ describe('getContextSheetMountState', () => {
providerID: 'kilo',
modelID: 'previous-model',
},
'current-session'
{ sessionId: 'current-session' }
)
).toEqual({ mounted: true, visible: false, info: nextInfo });
});
Expand Down
12 changes: 12 additions & 0 deletions apps/mobile/src/components/agents/context-usage-display.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,18 @@ describe('getHeaderPillContent', () => {
});

describe('getContextSheetContent', () => {
it('does not invent usage before the first completed model step', () => {
expect(getContextSheetContent(undefined, null)).toMatchObject({
usedTokens: '-',
windowTokens: null,
capacityKnown: false,
percentage: null,
remainingTokens: null,
cost: null,
tone: 'neutral',
});
});

it('describes exact usage and remaining when capacity is known', () => {
const content = getContextSheetContent(
info({ contextTokens: 84_000, contextWindow: 200_000, percentage: 42 }),
Expand Down
30 changes: 16 additions & 14 deletions apps/mobile/src/components/agents/context-usage-display.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,13 +146,13 @@ type ContextSheetContent = {
};

export function getContextSheetContent(
info: SessionContextInfo,
info: SessionContextInfo | undefined,
totalCostMicrodollars: number | null
): ContextSheetContent {
const tone = getContextTone(info.percentage);
const usedTokens = formatExactTokens(info.contextTokens);
const tone = getContextTone(info?.percentage);
const usedTokens = info ? formatExactTokens(info.contextTokens) : '-';
const cost = formatSessionTotalCost(totalCostMicrodollars);
if (info.contextWindow === undefined) {
if (info?.contextWindow === undefined) {
return {
usedTokens,
windowTokens: null,
Expand Down Expand Up @@ -199,7 +199,8 @@ export function getMetricsAccessibilityLabel({
const tapPart = interactive ? ` ${i18n.t('agentChat.contextUsage.tapToViewDetails')}` : '';

if (!info) {
return spoken ? i18n.t('agents.sessionRow.costSpoken', { cost: spoken }) : '';
const body = spoken ? i18n.t('agents.sessionRow.costSpoken', { cost: spoken }) : '';
return `${body}${tapPart}`.trim();
}

const costPart = spoken ? i18n.t('agentChat.contextUsage.costSuffix', { cost: spoken }) : '';
Expand All @@ -218,30 +219,31 @@ export function getMetricsAccessibilityLabel({

type SheetMountState =
| { mounted: false }
| { mounted: true; visible: boolean; info: SessionContextInfo };
| { mounted: true; visible: boolean; info: SessionContextInfo | undefined };

export type ContextSheetIdentity = {
sessionId: string;
providerID: string;
modelID: string;
providerID?: string;
modelID?: string;
};

/**
* Controls when the native Modal is mounted and when it is visible. Keeping
* the sheet mounted while contextInfo exists lets `visible` transition from
* true → false so the native pageSheet dismissal animation runs.
* the sheet mounted while usage or permission controls are available lets
* `visible` transition from true → false for native dismissal. Permission
* controls belong to the session, not the model reporting the latest usage.
*/
export function getContextSheetMountState(
info: SessionContextInfo | undefined,
openIdentity: ContextSheetIdentity | null,
sessionId: string
{ sessionId, autoApproveAvailable = false }: { sessionId: string; autoApproveAvailable?: boolean }
): SheetMountState {
if (!info) {
if (!info && !autoApproveAvailable) {
return { mounted: false };
}
const visible =
openIdentity?.sessionId === sessionId &&
openIdentity.providerID === info.providerID &&
openIdentity.modelID === info.modelID;
(autoApproveAvailable ||
(openIdentity.providerID === info?.providerID && openIdentity.modelID === info?.modelID));
return { mounted: true, visible, info };
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer for RN trees under vitest (node env, no jsdom). */

import { createElement } from 'react';
import TestRenderer, { act } from 'react-test-renderer';
import { describe, expect, it, vi } from 'vitest';

import '@/i18n';
import { type SessionAutoApproveState } from './session-auto-approve';
import { SessionAutoApproveRow } from './session-auto-approve-row';

vi.mock('react-native', () => ({
View: 'View',
Switch: 'Switch',
Text: 'Text',
I18nManager: { isRTL: false },
}));
vi.mock('@/components/ui/text', () => ({ Text: 'Text' }));

type R = TestRenderer.ReactTestRenderer;
type I = TestRenderer.ReactTestInstance;

function renderRow(state: SessionAutoApproveState, onValueChange: (value: boolean) => void) {
const ref: { current: R | undefined } = { current: undefined };
act(() => {
ref.current = TestRenderer.create(
createElement(SessionAutoApproveRow, { state, onValueChange })
);
});
const renderer = ref.current;
if (!renderer) {
throw new Error('renderer was not created');
}
return renderer;
}

function switchNodes(root: I): I[] {
return root.findAll(node => typeof node.type === 'string' && (node.type as string) === 'Switch');
}

function switchProps(root: I): { value: boolean; disabled: boolean } {
const node = switchNodes(root)[0];
if (!node) {
throw new Error('Switch was not rendered');
}
return { value: node.props.value as boolean, disabled: node.props.disabled as boolean };
}

function renderedTexts(root: I): string[] {
return root
.findAll(
node =>
typeof node.type === 'string' &&
(node.type as string) === 'Text' &&
typeof node.props.children === 'string'
)
.map(node => node.props.children as string);
}

describe('SessionAutoApproveRow', () => {
it('renders the title, the on switch, and the warning copy for state=on', () => {
const root = renderRow('on', vi.fn<(value: boolean) => void>());

expect(
root.root.findAll(node => node.props.testID === 'session-auto-approve-row')
).toHaveLength(1);
expect(switchProps(root.root)).toEqual({ value: true, disabled: false });
expect(renderedTexts(root.root)).toContain('Auto-approve');
expect(renderedTexts(root.root)).toContain(
'Approve permission asks for this session automatically. Tools then run without a prompt.'
);
});

it('renders the off switch with the warning copy for state=off', () => {
const root = renderRow('off', vi.fn<(value: boolean) => void>());

expect(switchProps(root.root)).toEqual({ value: false, disabled: false });
expect(renderedTexts(root.root)).toContain(
'Approve permission asks for this session automatically. Tools then run without a prompt.'
);
});

it('disables the switch and shows the unavailable copy for state=unavailable', () => {
const root = renderRow('unavailable', vi.fn<(value: boolean) => void>());

expect(switchProps(root.root)).toEqual({ value: false, disabled: true });
expect(renderedTexts(root.root)).toContain('This session cannot receive permission asks.');
expect(renderedTexts(root.root)).not.toContain(
'Approve permission asks for this session automatically. Tools then run without a prompt.'
);
});

it('forwards the next value on change', () => {
const onValueChange = vi.fn<(value: boolean) => void>();
const root = renderRow('off', onValueChange);

const switchNode = switchNodes(root.root)[0];
if (!switchNode) {
throw new Error('Switch was not rendered');
}
act(() => {
(switchNode.props.onValueChange as (next: boolean) => void)(true);
});

expect(onValueChange).toHaveBeenCalledWith(true);
expect(onValueChange).toHaveBeenCalledTimes(1);
});
});
47 changes: 47 additions & 0 deletions apps/mobile/src/components/agents/session-auto-approve-row.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { Switch, View } from 'react-native';
import { useTranslation } from 'react-i18next';

import { Text } from '@/components/ui/text';

import { type SessionAutoApproveState } from './session-auto-approve';

/**
* Per-session auto-approve toggle: title + subtitle on the left, a native
* `Switch` on the right. A selection haptic is a capability both iOS and
* Android have, so there is one implementation: the screen that owns the
* session state fires the single `Haptics.selectionAsync()` for the commit,
* and this row adds no platform-specific module of its own. Disabled and
* labelled "unavailable" for a session that cannot receive permission asks.
*/
export function SessionAutoApproveRow({
state,
onValueChange,
}: Readonly<{
state: SessionAutoApproveState;
onValueChange: (value: boolean) => void;
}>) {
const { t } = useTranslation();
const title = t('agentChat.autoApprove.title');
return (
<View
testID="session-auto-approve-row"
className="flex-row items-center justify-between rounded-lg bg-secondary p-4"
>
<View className="flex-1 pr-3">
<Text className="text-sm font-medium">{title}</Text>
<Text variant="muted" className="text-xs">
{state === 'unavailable'
? t('agentChat.autoApprove.unavailable')
: t('agentChat.autoApprove.description')}
</Text>
</View>
<Switch
testID="session-auto-approve-switch"
accessibilityLabel={title}
value={state === 'on'}
disabled={state === 'unavailable'}
onValueChange={onValueChange}
/>
</View>
);
}
Loading