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
31 changes: 20 additions & 11 deletions apps/mobile/src/components/agents/context-sheet-mount-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,33 +40,42 @@ describe('getContextSheetMountState', () => {
).toEqual({ mounted: true, visible: false, info: undefined });
});

it('unmounts the no-usage sheet when permission controls become unavailable', () => {
it('keeps an opened no-usage sheet mounted for a session without permission controls', () => {
expect(
getContextSheetMountState(
undefined,
{ sessionId: 'current-session' },
{ sessionId: 'current-session', autoApproveAvailable: false }
)
).toEqual({ mounted: false });
).toEqual({ mounted: true, visible: true, info: undefined });
});

it('unmounts when there is no context info regardless of open state', () => {
expect(getContextSheetMountState(undefined, null, { sessionId: 'current-session' })).toEqual({
mounted: false,
});
it('stays open when usage arrives after opening a session without permission controls', () => {
const session = { sessionId: 'current-session', autoApproveAvailable: false };
expect(getContextSheetMountState(undefined, { sessionId: 'current-session' }, session)).toEqual(
{ mounted: true, visible: true, info: undefined }
);
expect(
getContextSheetMountState(currentInfo, { sessionId: 'current-session' }, session)
).toEqual({ mounted: true, visible: true, info: currentInfo });
});

it('does not open an unmounted sheet for another session', () => {
expect(
getContextSheetMountState(
undefined,
{
sessionId: 'current-session',
providerID: currentInfo.providerID,
modelID: currentInfo.modelID,
},
{ sessionId: 'previous-session' },
{ sessionId: 'current-session' }
)
).toEqual({ mounted: false });
});

it('unmounts when there is no context info and the sheet was never opened', () => {
expect(getContextSheetMountState(undefined, null, { sessionId: 'current-session' })).toEqual({
mounted: false,
});
});

it('mounts visible when context info exists and its identity is open', () => {
const result = getContextSheetMountState(
currentInfo,
Expand Down
34 changes: 28 additions & 6 deletions apps/mobile/src/components/agents/context-usage-display.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,23 +227,45 @@ export type ContextSheetIdentity = {
modelID?: string;
};

/**
* Whether an open request still matches the model usage currently reports.
*
* An open request recorded before the first usage report carries no model, and
* must keep matching whatever model usage later names — otherwise the sheet
* dismisses itself on arrival of the very report it was opened to read. An
* open request that did record a model stops matching once usage names another.
*/
function isOpenIdentityForModel(
info: SessionContextInfo | undefined,
openIdentity: ContextSheetIdentity | null
): boolean {
const providerID = openIdentity?.providerID;
const modelID = openIdentity?.modelID;
if (providerID === undefined || modelID === undefined) {
return true;
}
return providerID === info?.providerID && modelID === info.modelID;
}

/**
* Controls when the native Modal is mounted and when it is visible. Keeping
* the sheet mounted while usage or permission controls are available lets
* `visible` transition from true → false for native dismissal. Permission
* the sheet mounted after it has been opened lets `visible` transition from
* true → false for native dismissal. The sheet is the session's own
* context/permission surface — it always has the session identity and the
* auto-approve row to show — so an open request mounts it even before usage is
* reported or permission controls are known to be available. Permission
* controls belong to the session, not the model reporting the latest usage.
*/
export function getContextSheetMountState(
info: SessionContextInfo | undefined,
openIdentity: ContextSheetIdentity | null,
{ sessionId, autoApproveAvailable = false }: { sessionId: string; autoApproveAvailable?: boolean }
): SheetMountState {
if (!info && !autoApproveAvailable) {
const openedForSession = openIdentity?.sessionId === sessionId;
if (!info && !autoApproveAvailable && !openedForSession) {
return { mounted: false };
}
const visible =
openIdentity?.sessionId === sessionId &&
(autoApproveAvailable ||
(openIdentity.providerID === info?.providerID && openIdentity.modelID === info?.modelID));
openedForSession && (autoApproveAvailable || isOpenIdentityForModel(info, openIdentity));
return { mounted: true, visible, info };
}
33 changes: 31 additions & 2 deletions apps/mobile/src/components/agents/session-auto-approve.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';

import {
canAutoApprovePermissions,
canAutoApproveReply,
clearSessionAutoApprove,
getSessionAutoApproveEnabled,
MAX_REMEMBERED_REQUEST_IDS,
Expand All @@ -23,11 +24,18 @@ describe('canAutoApprovePermissions', () => {
);
});

it('rejects the read-only transport and an unresolved transport', () => {
it('rejects the read-only transport', () => {
expect(canAutoApprovePermissions({ activeSessionType: 'read-only', isReadOnly: false })).toBe(
false
);
expect(canAutoApprovePermissions({ activeSessionType: null, isReadOnly: false })).toBe(false);
});

// The toggle is a client-side per-session preference: the transport resolves
// only after the metadata and transcript, so the settings must stay usable
// while the session is still opening (or has failed to open). Only a session
// known to be read-only is excluded.
it('allows an unresolved transport so the session settings stay reachable', () => {
expect(canAutoApprovePermissions({ activeSessionType: null, isReadOnly: false })).toBe(true);
});

it('rejects every transport when the session is read-only', () => {
Expand All @@ -44,6 +52,27 @@ describe('canAutoApprovePermissions', () => {
});
});

describe('canAutoApproveReply', () => {
it('allows a resolved remote or cloud-agent transport', () => {
expect(canAutoApproveReply({ activeSessionType: 'remote', isReadOnly: false })).toBe(true);
expect(canAutoApproveReply({ activeSessionType: 'cloud-agent', isReadOnly: false })).toBe(true);
});

// The settings row stays reachable while the transport is unresolved, but an
// unresolved transport cannot deliver a permission ask, so it must not make
// an auto-reply eligible.
it('rejects an unresolved transport even though its settings stay reachable', () => {
expect(canAutoApprovePermissions({ activeSessionType: null, isReadOnly: false })).toBe(true);
expect(canAutoApproveReply({ activeSessionType: null, isReadOnly: false })).toBe(false);
});

it('rejects a read-only session', () => {
expect(canAutoApproveReply({ activeSessionType: 'read-only', isReadOnly: false })).toBe(false);
expect(canAutoApproveReply({ activeSessionType: 'remote', isReadOnly: true })).toBe(false);
expect(canAutoApproveReply({ activeSessionType: null, isReadOnly: true })).toBe(false);
});
});

describe('resolveSessionAutoApproveState', () => {
it('returns unavailable whenever the session is not available', () => {
expect(resolveSessionAutoApproveState({ enabled: false, available: false })).toBe(
Expand Down
29 changes: 25 additions & 4 deletions apps/mobile/src/components/agents/session-auto-approve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,15 @@ export type SessionAutoApproveState = 'on' | 'off' | 'unavailable';
export type SessionAutoApproveSessionType = 'remote' | 'cloud-agent' | 'read-only' | null;

/**
* A session can auto-approve permissions only when its transport can answer a
* permission ask. The read-only transport cannot send, and an unresolved
* transport (`null`) is not assumed answerable.
* Whether the session's permission settings may be used.
*
* The auto-approve toggle is a client-side, per-session preference, so it must
* be usable while the session is still opening: the transport resolves only
* after the session metadata and transcript, and a session whose transcript is
* still loading (or failed to load) still owns its settings. Treating the
* unresolved transport (`null`) as unavailable hid the only way into the
* context sheet for exactly that phase. A session known to be read-only can
* never receive a permission ask, so it stays unavailable.
*/
export function canAutoApprovePermissions(input: {
activeSessionType: SessionAutoApproveSessionType;
Expand All @@ -31,7 +37,22 @@ export function canAutoApprovePermissions(input: {
if (input.isReadOnly) {
return false;
}
return input.activeSessionType === 'remote' || input.activeSessionType === 'cloud-agent';
return input.activeSessionType !== 'read-only';
Comment thread
iscekic marked this conversation as resolved.
}

/**
* Whether the session can actually answer a permission ask, which is what
* makes an auto-reply eligible.
*
* Distinct from {@link canAutoApprovePermissions}: the settings row must stay
* reachable while the transport is unresolved, but an unresolved transport
* cannot deliver an ask, so it must never make an auto-reply eligible.
*/
export function canAutoApproveReply(input: {
activeSessionType: SessionAutoApproveSessionType;
isReadOnly: boolean;
}): boolean {
return input.activeSessionType !== null && canAutoApprovePermissions(input);
}

/** Resolve the row state: unavailable wins over the stored enabled flag. */
Expand Down
96 changes: 75 additions & 21 deletions apps/mobile/src/components/agents/session-context-metrics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,15 +114,86 @@ describe('SessionContextMetrics', () => {
expect(onPress).toHaveBeenCalledOnce();
});

it('does not expose permission controls while the session is loading', () => {
// The header control is the only way to the session's permission settings,
// so it must register a tap while the page is still loading instead of
// rendering a dead, invisible reserved box.
it('keeps the context control tappable while the session page loads', () => {
const onPress = vi.fn<() => void>();
const root = render({
info: undefined,
totalCostMicrodollars: null,
hasMessages: false,
autoApproveAvailable: true,
loading: true,
onPress,
});
expect(root.type).toBe('Pressable');
const props = root.props as {
accessibilityRole?: string;
onPress?: () => void;
className?: string;
};
expect(props.accessibilityRole).toBe('button');
expect(props.className ?? '').not.toContain('opacity-0');
props.onPress?.();
expect(onPress).toHaveBeenCalledOnce();
});

// Usage is already known, so the control must be live instead of an invisible
// zero-size hit area.
it('keeps the context control tappable for an empty session that reports usage', () => {
const onPress = vi.fn<() => void>();
const root = render({
info: info(),
totalCostMicrodollars: 150_000,
hasMessages: false,
onPress,
});
expect(root.type).toBe('Pressable');
const props = root.props as {
accessibilityRole?: string;
onPress?: () => void;
className?: string;
};
expect(props.accessibilityRole).toBe('button');
expect(props.className ?? '').not.toContain('opacity-0');
props.onPress?.();
expect(onPress).toHaveBeenCalledOnce();
});

// Usage and permission controls are both still unknown while a session
// opens (or after a failed open), but the parent can still open the sheet,
// so the control must register a tap instead of a dead reserved box.
it('keeps the context control tappable with no usage and no permission controls', () => {
const onPress = vi.fn<() => void>();
const root = render({
info: undefined,
totalCostMicrodollars: null,
hasMessages: false,
autoApproveAvailable: false,
onPress,
});
expect(root.type).toBe('Pressable');
const props = root.props as {
accessibilityRole?: string;
accessibilityLabel?: string;
onPress?: () => void;
className?: string;
};
expect(props.accessibilityRole).toBe('button');
expect(props.accessibilityLabel).toContain('Tap to view context details.');
expect(props.className ?? '').not.toContain('opacity-0');
props.onPress?.();
expect(onPress).toHaveBeenCalledOnce();
});

it('does not expose permission controls while the session is loading and no sheet can open', () => {
expectHiddenReservedBox(
render({
info: undefined,
totalCostMicrodollars: null,
hasMessages: true,
autoApproveAvailable: true,
hasMessages: false,
loading: true,
onPress: vi.fn<() => void>(),
})
);
});
Expand Down Expand Up @@ -250,21 +321,4 @@ describe('SessionContextMetrics', () => {
}
expect((ring.props as { arcFraction?: number }).arcFraction).toBe(0);
});

it('empty session with interactive info and onPress stays a hidden reserved view', () => {
const onPress = vi.fn(() => undefined);
const root = render({
hasMessages: false,
info: info(),
totalCostMicrodollars: 150_000,
onPress,
});
expectHiddenReservedBox(root);
const props = root.props as {
onPress?: () => void;
accessibilityRole?: string;
};
expect(props.onPress).toBeUndefined();
expect(props.accessibilityRole).not.toBe('button');
});
});
28 changes: 19 additions & 9 deletions apps/mobile/src/components/agents/session-context-metrics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,18 @@ import {
type SessionContextMetricsProps = {
info: SessionContextInfo | undefined;
totalCostMicrodollars: number | null;
/** Empty sessions reserve the pill's layout unless auto-approve is available. */
/**
* Empty sessions reserve the pill's layout box. `onPress` decides whether the
* pill is live — the parent passes it whenever the context sheet can open —
* so the reserved box never swallows a tap.
*/
hasMessages: boolean;
autoApproveAvailable?: boolean;
onPress?: () => void;
/**
* Hide the pill while the session page loads; the layout box stays reserved so the header does not
* shift.
* Marks the unresolved-session placeholder. The layout box stays reserved so
* the header does not shift; the pill itself stays hidden only while there is
* nothing to open.
*/
loading?: boolean;
};
Expand Down Expand Up @@ -48,12 +53,17 @@ export function SessionContextMetrics({
loading = false,
}: Readonly<SessionContextMetricsProps>) {
const content = getHeaderPillContent({ info, totalCostMicrodollars, hasMessages });
// Permission controls must be reachable before the first tool finishes and
// reports usage. Other empty sessions keep their reserved, hidden layout box.
const hidden = loading || (!hasMessages && !autoApproveAvailable);
// Single source for element kind and a11y affordance wording so a future
// caller with interactive content but no onPress cannot advertise a tap.
const pressable = !hidden && (content.interactive || autoApproveAvailable) && onPress != null;
// This pill is the only way into the session's context sheet, which owns the
// session's permission settings (auto-approve). The parent provides `onPress`
// whenever the sheet can open for this session, so pressability follows that
// and never the page's load phase or whether usage has arrived: a session
// whose transcript is still loading — or failed to load — must not leave its
// settings behind a control that looks dead or an invisible tap target.
const pressable = onPress != null;
// Without an `onPress` this is the unresolved-session placeholder: the layout
// box keeps its space in every state, and stays invisible while there is
// nothing to open.
const hidden = !pressable && (loading || (!hasMessages && !autoApproveAvailable));
const accessibilityLabel = getMetricsAccessibilityLabel({
info,
totalCostMicrodollars,
Expand Down
Loading