Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
84 changes: 36 additions & 48 deletions apps/mobile/src/app/(app)/_layout.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { Stack } from 'expo-router';
import { appUnlockScreenLayout } from '@/components/app-unlock-screen';
import { useEffect } from 'react';
import { AppState } from 'react-native';
import { useEffect, useRef } from 'react';

import { UserWebConnectionProvider } from '@/components/agents/user-web-connection-provider';
import { KiloChatPresenceMount } from '@/components/kilo-chat/kilo-chat-presence-mount';
Expand All @@ -21,6 +20,7 @@ import {
import { useFormSheetScreenOptions } from '@/lib/form-sheet';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
import { useCurrentUserId } from '@/lib/hooks/use-current-user-id';
import { useAppLifecycle } from '@/lib/hooks/use-app-lifecycle';
import { useRouteForegroundRefresh } from '@/lib/hooks/use-route-foreground-refresh';
import { useSecurityLifecycleInvalidation } from '@/lib/hooks/use-security-lifecycle-invalidation';
import { CachePersistenceMount } from '@/lib/persist/cache-persistence-mount';
Expand All @@ -29,67 +29,56 @@ import { ToolSummaryTranslationRetryMount } from '@/lib/tool-summary-translation
import { useTRPC } from '@/lib/trpc';

/**
* Attempts failed logout cleanup on every "next authenticated opportunity":
* once `user.getMe` has resolved on the authenticated mount, and on each
* AppState return to `active` while authenticated. The attempt itself is
* single-flight with 60 s spacing, so foreground flaps do not hammer the
* server and a transient failure retries on the next foreground.
* The single owner of the app's authenticated foreground reconciliation work,
* replacing the two independent `AppState` listeners the logout and push
* mounts used to register. Every pass runs the same ordered queue: failed
* logout cleanup first, then push-token ownership.
*
* It fires on the same triggers as before — once `user.getMe` has resolved on
* the authenticated mount (plus the push-token rotation subscription), and on
* each return to the foreground — but the foreground path is the app's shared
* `useAppLifecycle()` store, so the whole tree keeps one `AppState` listener
* and the pair runs only on the background -> active edge. The store seeds
* `isActive` as `true`, so a mount while already active is not an edge and
* only the mount attempt runs.
*
* No minimum interval is added at this layer: both attempts are already
* single-flight with 60 s spacing, and
* `attemptPushRegistrationReconciliation` additionally skips when the stored
* token, locale and app version already match the device
* (`src/lib/auth/push-registration-reconciliation.ts`). Re-gating them here
* would duplicate that contract without adding a skip.
*/
function LogoutReconciliationMount() {
function ForegroundReconciliationMount() {
const { userId, isLoading, isError } = useCurrentUserId();
const { isActive } = useAppLifecycle();
const wasActiveRef = useRef(isActive);

// Mount attempt and push-token rotation subscription.
useEffect(() => {
if (!userId || isLoading || isError) {
return undefined;
}

void attemptLogoutReconciliation(userId);

const subscription = AppState.addEventListener('change', nextState => {
if (nextState === 'active') {
void attemptLogoutReconciliation(userId);
}
});

return () => {
subscription.remove();
};
}, [userId, isLoading, isError]);

return null;
}

/**
* Ensures the signed-in user owns the device's Expo push token on every
* authenticated opportunity: once `user.getMe` has resolved on the
* authenticated mount, on each AppState return to `active`, and on each push
* token rotation. The attempt itself is single-flight with 60 s spacing, so
* foreground flaps do not hammer the server and a transient failure retries
* on the next foreground.
*/
function PushRegistrationMount() {
const { userId, isLoading, isError } = useCurrentUserId();

useEffect(() => {
if (!userId || isLoading || isError) {
return undefined;
}

void attemptPushRegistrationReconciliation(userId);

const appState = AppState.addEventListener('change', next => {
if (next === 'active') {
void attemptPushRegistrationReconciliation(userId);
}
});
const unsubscribeRotation = subscribeToPushTokenRotation(userId);

return () => {
appState.remove();
unsubscribeRotation();
};
}, [userId, isLoading, isError]);

// Foreground regain: the false -> true edge only, so an active -> active
// echo (or a duplicate `active` from the OS) never re-runs the pair.
useEffect(() => {
if (!wasActiveRef.current && isActive && userId && !isLoading && !isError) {
void attemptLogoutReconciliation(userId);
void attemptPushRegistrationReconciliation(userId);
}
wasActiveRef.current = isActive;
}, [isActive, userId, isLoading, isError]);

return null;
}

Expand Down Expand Up @@ -126,8 +115,7 @@ export default function AppLayout() {
<LauncherSurfacesMount />
<CachePersistenceMount />
<ToolSummaryTranslationRetryMount />
<LogoutReconciliationMount />
<PushRegistrationMount />
<ForegroundReconciliationMount />
<AppWideFreshnessMount />
<SharePayloadNavigator />
<TourAutoOpen />
Expand Down
28 changes: 26 additions & 2 deletions apps/mobile/src/app/(app)/agent-chat/[session-id].mounted.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ vi.mock('@/components/ui/refresh-control', () => ({ RefreshControl: 'RefreshCont
const useRouterMock = vi.hoisted(() => vi.fn());
const useQueryMock = vi.hoisted(() => vi.fn());
const queryOptionsMock = vi.hoisted(() => vi.fn());
// The route builds its narrowed foreground key from this session-scoped input.
const cliSessionGetQueryKeyMock = vi.hoisted(() => vi.fn(() => [['cliSessionsV2', 'get']]));
const organizations = vi.hoisted(() => [
{ organizationId: 'org-a', organizationName: 'Session organization' },
]);
Expand Down Expand Up @@ -168,8 +170,11 @@ vi.mock('@tanstack/react-query', async importOriginal => ({
}));

// Foreground query refresh is separate from route parsing and provider lifetime.
const useRouteForegroundRefreshMock = vi.hoisted(() =>
vi.fn<(queryKeys: readonly (readonly unknown[])[]) => void>()
);
vi.mock('@/lib/hooks/use-route-foreground-refresh', () => ({
useRouteForegroundRefresh: vi.fn(),
useRouteForegroundRefresh: useRouteForegroundRefreshMock,
}));

vi.mock('@/lib/auth/auth-context', () => ({
Expand All @@ -196,7 +201,7 @@ vi.mock('@/lib/trpc', () => ({
cliSessionsV2: {
get: {
queryOptions: queryOptionsMock,
queryKey: () => [['cliSessionsV2', 'get']],
queryKey: cliSessionGetQueryKeyMock,
},
},
}),
Expand Down Expand Up @@ -488,6 +493,8 @@ beforeEach(() => {
);
useLocalSearchParamsMock.mockReset();
useRouterMock.mockReset();
useRouteForegroundRefreshMock.mockClear();
cliSessionGetQueryKeyMock.mockClear();
navigationRoutes.splice(0, navigationRoutes.length, 'session-detail');
useRouterMock.mockReturnValue({
canGoBack: () => navigationRoutes.length > 1,
Expand Down Expand Up @@ -938,6 +945,23 @@ describe('SessionDetailScreen valid session-id', () => {
});
});

describe('SessionDetailScreen foreground refresh scope', () => {
it('invalidates only the opened session metadata and model preferences', async () => {
useLocalSearchParamsMock.mockReturnValue({ 'session-id': 'sess-1' });
await mountRoute();

// The key is built from the opened session's own input, not a procedure-wide
// prefix and not the empty-id fallback.
expect(cliSessionGetQueryKeyMock).toHaveBeenCalledWith({ session_id: 'sess-1' });
expect(useRouteForegroundRefreshMock).toHaveBeenCalled();
const keys = useRouteForegroundRefreshMock.mock.calls.at(-1)?.[0];
expect(keys).toEqual([[['cliSessionsV2', 'get']], [['modelPreferences']]]);
// The whole-procedure prefix would refetch every retained `cliSessionsV2`
// query — the stored `cliSessionsV2.list` pages behind this pushed route.
expect(keys).not.toContainEqual([['cliSessionsV2']]);
});
});

function transcriptPage(sessionId: KiloSessionId, messageId: string, text: string) {
return {
kind: 'success',
Expand Down
11 changes: 10 additions & 1 deletion apps/mobile/src/app/(app)/agent-chat/[session-id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,16 @@ export default function SessionDetailScreen() {
if (openStart.current.sessionId !== rawSessionId) {
openStart.current = { sessionId: rawSessionId, startedAt: Date.now() };
}
useRouteForegroundRefresh([[['cliSessionsV2']], [['modelPreferences']]]);
// Narrow the metadata invalidation to this session's own query key. The whole
// `cliSessionsV2` procedure prefix would also invalidate the stored
// `cliSessionsV2.list` pages behind this pushed route, so returning to the app
// (or refocusing the route) refetched every retained list page alongside the
// metadata. `SessionDetailContent` already refetches `getWithRuntimeState` on
// focus, and this key still refreshes the opened session's metadata.
useRouteForegroundRefresh([
trpc.cliSessionsV2.get.queryKey({ session_id: sessionId ?? '' }),
[['modelPreferences']],
]);
const sessionQuery = useQuery({
...trpc.cliSessionsV2.get.queryOptions(
{ session_id: sessionId ?? '' },
Expand Down
Loading
Loading