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
4 changes: 4 additions & 0 deletions apps/mobile/.oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@
{
"name": "lucide-react-native",
"message": "Import icons and Lucide types from @/components/ui/icons."
},
{
"name": "expo-file-system/legacy",
"message": "Use the modern expo-file-system API (File, Directory, Paths, UploadType). The legacy module is deprecated."
}
]
}
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
"expo": "~57.0.21",
"expo-apple-authentication": "~57.0.1",
"expo-application": "~57.0.2",
"expo-audio": "~57.0.4",
"expo-battery": "~57.0.2",
"expo-blur": "~57.0.2",
"expo-build-properties": "~57.0.13",
Expand Down
9 changes: 9 additions & 0 deletions apps/mobile/src/app/(app)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,15 @@ export default function AppLayout() {
headerShown: false,
}}
/>
<Stack.Screen
name="transcription-model-picker"
options={{
presentation: 'formSheet',
sheetAllowedDetents: [0.5, fullSheetDetent],
sheetGrabberVisible: true,
headerShown: false,
}}
/>
<Stack.Screen
name="kilo-pass"
options={{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,28 @@ describe('useNewSessionDiscardGuard', () => {
});
});

it.each(['PUSH', 'NAVIGATE', 'JUMP_TO'])(
'replays a forward %s removal unconfirmed — the durable draft survives it',
actionType => {
const { renderer } = mountGuard(true, noOpDiscard);

// Tapping another screen (e.g. Preferences from the tab bar) can remove
// this screen as a side effect; that is forward navigation, not an
// abandon, so the discard confirm must not hijack it (spot check
// e12-tap-prefs: the dialog blocked the Preferences screen from opening).
const action = { type: actionType };
usePreventRemoveHolder.callback?.({ data: { action } });

expect(alertMock).not.toHaveBeenCalled();
expect(dispatchMock).toHaveBeenCalledTimes(1);
expect(dispatchMock).toHaveBeenCalledWith(action);

act(() => {
renderer.unmount();
});
}
);

it('Discard runs onDiscard before dispatching the captured action', async () => {
const order: string[] = [];
dispatchMock.mockImplementation(() => {
Expand Down
7 changes: 7 additions & 0 deletions apps/mobile/src/app/(app)/transcription-model-picker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { TranscriptionModelPickerSheet } from '@/components/transcription-model-picker-sheet';

/** Route shell for the transcription model picker: the sheet owns the flow and
* writes the SecureStore-backed store directly, so no picker bridge is needed. */
export default function TranscriptionModelPickerScreen() {
return <TranscriptionModelPickerSheet />;
}
71 changes: 61 additions & 10 deletions apps/mobile/src/components/agents/chat-composer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,18 +301,27 @@ vi.mock('@/lib/share-prefill', () => ({
useSharePrefill: vi.fn(),
}));

vi.mock('@/lib/voice-input/use-voice-input', () => ({
useVoiceInput: () => ({
available: false,
isActive: false,
settleBeforeSubmit: vi.fn(async () => true),
status: 'idle',
toggle: vi.fn(),
}),
// The voice hook options (getDraft/onDraftChange) are captured so a test can
// drive the transcript-into-draft path through the composer's wiring; the
// draft module stays unmocked (pure logic) so the real splice runs.
const voiceHookOptions = vi.hoisted(() => ({
current: null as {
getDraft: () => string;
onDraftChange: (draft: string) => void;
} | null,
}));

vi.mock('@/lib/voice-input/voice-input-draft', () => ({
applyVoiceDraftToInput: vi.fn(),
vi.mock('@/lib/voice-input/use-voice-input', () => ({
useVoiceInput: (options: { getDraft: () => string; onDraftChange: (draft: string) => void }) => {
voiceHookOptions.current = options;
return {
available: false,
isActive: false,
settleBeforeSubmit: vi.fn(async () => true),
status: 'idle',
toggle: vi.fn(),
};
},
}));

vi.mock('@/lib/hooks/use-return-sends-message-preference', () => ({
Expand Down Expand Up @@ -560,7 +569,49 @@ describe('ChatComposer draft restore', () => {
onOptimisticSend: expect.any(Function),
});
});

// The gateway transcript lands after the Stop tap (the upload resolves
// post-stop). The remounted input row must show it: a stop-time snapshot
// restored the empty pre-transcript text while the live ref and the durable
// draft kept the transcript, so the next dictation appended to the hidden
// text and the draft showed the same transcript twice (spot check e12-back).
it('restores a transcript that lands after the Stop tap onto the remounted row', async () => {
const setNativeProps = vi.fn();
const props = makeProps({ draftKey: 'agent-composer:sess-1' });
const render = await mount(props);
// The first useRef slot is textRef; the second is the TextInput ref.
const inputRefSlot = refSlots.slots[1];
if (inputRefSlot === undefined) {
throw new Error('TextInput ref slot was not mounted');
}
inputRefSlot.current = { setNativeProps };
const voice = voiceHookOptions.current;
if (voice === null) {
throw new Error('useVoiceInput options were not captured');
}
voice.getDraft();
const onStop = findInputRowProps(render)?.onStop as (() => void) | undefined;
if (onStop === undefined) {
throw new Error('ChatComposerInputRow element did not carry an onStop handler');
}

onStop();
await settle();
voice.onDraftChange('Gateway transcription online');
expect(setNativeProps).toHaveBeenCalledTimes(1);

// The first re-render lets the stop-remount machine bump `inputEpoch`
// and lets the restore effect write the live text into the (new) row;
// the second re-render proves the restore is a one-shot.
await rerender(props);
await rerender(props);

expect(setNativeProps).toHaveBeenCalledTimes(2);
const restoreCall = setNativeProps.mock.calls[1]?.[0] as { text?: string };
expect(restoreCall.text).toBe('Gateway transcription online');
});
});

describe('ChatComposer return-sends wiring', () => {
it('wires the return-sends preference and an insert-newline handler to the input row', async () => {
returnSendsPref.returnSendsMessage = true;
Expand Down
30 changes: 24 additions & 6 deletions apps/mobile/src/components/agents/chat-composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,8 @@ export function ChatComposer({
// mounts with editable=true from the start. Draft text is restored after
// remount.
const [inputEpoch, setInputEpoch] = useState(0);
const pendingDraftRestoreRef = useRef<string | null>(null);
// Armed by handleStop; the remount effect restores the live text once.
const pendingDraftRestoreRef = useRef(false);
const stopRemountPhaseRef = useRef<StopRemountPhase>('idle');
const [stopCompleted, setStopCompleted] = useState(false);
const stopGenerationRef = useRef(0);
Expand Down Expand Up @@ -460,11 +461,18 @@ export function ChatComposer({
}, []);

useEffect(() => {
const draft = pendingDraftRestoreRef.current;
if (draft === null) {
if (!pendingDraftRestoreRef.current) {
return;
}
pendingDraftRestoreRef.current = null;
pendingDraftRestoreRef.current = false;
// Read the live text at remount time, never the stop-tap snapshot: the
// gateway transcript lands after Stop (the upload resolves post-stop),
// so a stop-time snapshot is the pre-transcript text. Restoring it — or
// skipping the restore when it is empty — left the remounted row blank
// while the live ref and the durable draft kept the transcript, and the
// next dictation then appended to the hidden text: the draft showed the
// same transcript twice (spot check e12-back).
const draft = textRef.current;
if (!draft) {
return;
}
Expand Down Expand Up @@ -1053,7 +1061,10 @@ export function ChatComposer({

function handleStop() {
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
pendingDraftRestoreRef.current = textRef.current;
// Arm the remount restore; the remount effect reads the live text, so
// a transcript that lands between this tap and the remount is kept (the
// gateway upload resolves after Stop).
pendingDraftRestoreRef.current = true;
Keyboard.dismiss();
setIsFocused(false);
// Arm the state machine and clear the completion flag. The effect
Expand Down Expand Up @@ -1194,7 +1205,14 @@ export function ChatComposer({
</Animated.View>
) : null}

<View className={cn('px-3', voiceInput.status === 'listening' ? 'pb-1' : 'pb-0')}>
<View
className={cn(
'px-3',
voiceInput.status === 'listening' || voiceInput.status === 'transcribing'
? 'pb-1'
: 'pb-0'
)}
>
<VoiceInputStatus status={voiceInput.status} />
</View>

Expand Down
22 changes: 22 additions & 0 deletions apps/mobile/src/components/agents/use-new-session-discard-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,27 @@ import { toast } from 'sonner-native';
import { i18n } from '@/i18n';
import { usePreventRemove } from '@/lib/navigation/prevent-remove';

/**
* Navigation action types that mean "the user is leaving this screen": the
* header back button and Android hardware back (`GO_BACK`), the iOS swipe-back
* gesture and programmatic `router.back()` (`POP`). Every other action that
* can remove this screen — a `NAVIGATE`/`PUSH` to another route (e.g. tapping
* Preferences from the tab bar behind this screen), a `RESET` — is forward
* navigation: the user is going somewhere else, not abandoning the prompt.
* The draft is durable (saved on every change), so a forward leave loses
* nothing and must not be blocked by a discard confirm (spot check
* e12-tap-prefs: the confirm hijacked a Preferences push and the screen never
* opened).
*/
const LEAVE_ACTION_TYPES: ReadonlySet<string> = new Set(['GO_BACK', 'POP', 'POP_TO', 'POP_TO_TOP']);

/**
* New-session discard confirm. Registers a predictive-Back-safe guard via
* `usePreventRemove`, which fires for every way the screen can be removed —
* header back, Android hardware back, and the iOS swipe-back gesture — so all
* three paths get the same confirmation instead of only the header button.
* Forward navigation (any other action type) is replayed unconfirmed: the
* durable draft survives the leave.
*
* Mirrors the `usePreventRemove` + `Alert.alert` pattern of
* `useSettingsBackGuard` without any Security-specific helpers: when the
Expand Down Expand Up @@ -54,6 +70,12 @@ export function useNewSessionDiscardGuard({
return;
}
const action = data.action;
if (!LEAVE_ACTION_TYPES.has(action.type)) {
// Forward navigation (push/navigate/reset): replay it now. The durable
// draft keeps the prompt, so there is nothing to confirm away.
navigation.dispatch(data.action);
return;
}
Alert.alert(
i18n.t('agentChat.newSession.discardDraftTitle'),
i18n.t(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,6 @@ vi.mock('sonner-native', () => ({

vi.mock('expo-crypto', () => ({ randomUUID: () => 'share-id-fixed' }));

vi.mock('expo-file-system/legacy', () => ({
cacheDirectory: null,
copyAsync: vi.fn().mockResolvedValue('/tmp/copy'),
deleteAsync: vi.fn().mockResolvedValue(undefined),
}));

const expoFileSystemMock = vi.hoisted(() => {
const files = new Map<string, string>();
const File = vi.fn(function FileMock(_base: unknown, ...rest: unknown[]) {
Expand Down
5 changes: 5 additions & 0 deletions apps/mobile/src/components/app-root-providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,13 @@ export function AppRootProviders({
toasts render BEHIND Expo formSheets despite FullWindowOverlay; this reordering
addresses Portal overlays only — sheets/modals still need inline errors (P2);
re-verification scheduled in the final device pass.
bottom-center: sonner-native's default top-center placement renders a toast
over the screen header, hiding the back control for the toast's whole
lifetime (spot check e4-end). Bottom is the transient-message convention:
a toast may cover the composer briefly, never the navigation.
*/}
<Toaster
position="bottom-center"
icons={{
success: <CheckCircle2 size={20} color={colors.good} />,
error: <XCircle size={20} color={colors.destructive} />,
Expand Down
3 changes: 3 additions & 0 deletions apps/mobile/src/components/app-unlock-screen.test-helpers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ vi.mock('react-native-reanimated', () => ({
useSharedValue: (value: number) => ({ value }),
useAnimatedStyle: (build: () => unknown) => build(),
}));
vi.mock('@sentry/react-native', () => ({ captureException: vi.fn() }));
vi.mock('@/components/centered-state', () => ({ CenteredState: 'CenteredState' }));
vi.mock('@/components/centered-state-surface', () => ({
NativeStateSurface: ({ children }: { children: ReactElement }) => children,
Expand All @@ -105,11 +106,13 @@ vi.mock('@/components/ui/icons', () => ({
Brain: 'Icon',
CheckCircle2: 'Icon',
CornerDownLeft: 'Icon',
Cpu: 'Icon',
Gauge: 'Icon',
Globe: 'Icon',
Info: 'Icon',
Loader: 'Icon',
MessageSquare: 'Icon',
Mic: 'Icon',
Shield: 'Icon',
Smartphone: 'Icon',
TriangleAlert: 'Icon',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,18 @@ import {
getKiloPassSubscriptionCardContentState,
} from '@/lib/kilo-pass/subscription-card-state';

export function KiloPassSubscriptionCard() {
export function KiloPassSubscriptionCard({
hideLoadingSkeleton = false,
}: Readonly<{
/**
* Render no loading shimmer while the card's queries are still in flight:
* the credits section already shows its one loading indicator (the balance
* skeleton), and stacking a second skeleton card reads as two loaders at
* once. The slot keeps the card's final height so the swap in and out of
* this state never moves the sections below.
*/
hideLoadingSkeleton?: boolean;
}>) {
const colors = useThemeColors();
const router = useRouter();
const trpc = useTRPC();
Expand Down Expand Up @@ -138,7 +149,14 @@ export function KiloPassSubscriptionCard() {

return (
<View className="gap-2">
{contentState.kind === 'loading' ? (
{contentState.kind === 'loading' && hideLoadingSkeleton ? (
// p-3 (24) + the h-10 icon row (40) + the 1px borders (2): the
// exact height every card state below renders at, so the swap to
// content or to the shimmering skeleton never moves layout.
<View className="h-[66px]" />
) : null}

{contentState.kind === 'loading' && !hideLoadingSkeleton ? (
<View
accessibilityLabel={t('kiloPass.subscriptionLoading')}
accessibilityState={{ busy: true }}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,10 @@ vi.mock('@/components/ui/icons', () => ({
Bell: 'Bell',
Brain: 'Brain',
CornerDownLeft: 'CornerDownLeft',
Gauge: 'Gauge',
Cpu: 'Cpu',
Globe: 'Globe',
MessageSquare: 'MessageSquare',
Mic: 'Mic',
Shield: 'Shield',
Smartphone: 'Smartphone',
}));
Expand Down Expand Up @@ -112,6 +113,17 @@ vi.mock('@/lib/hooks/use-theme-preference', () => ({
setThemePreference: vi.fn(),
useThemePreference: () => ({ preference: 'system' }),
}));
// The gateway preference store loads Sentry at module scope; the real RN CJS
// it transitively requires cannot resolve under vitest (see
// preferences-screen.mounted.test.tsx for the same mock).
vi.mock('@/lib/voice-input/gateway/gateway-transcription-preference', () => ({
useGatewayTranscriptionPreference: () => ({
gatewayTranscriptionEnabled: false,
hasLoaded: true,
setGatewayTranscriptionEnabled: vi.fn(),
}),
useGatewayTranscriptionModel: () => null,
}));
vi.mock('@/lib/hooks/use-return-sends-message-preference', () => ({
useReturnSendsMessagePreference: () => ({
returnSendsMessage: false,
Expand Down
Loading