Skip to content
Open
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
3 changes: 3 additions & 0 deletions apps/mobile/src/app/(app)/(tabs)/(0_home)/_layout.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { Stack } from 'expo-router';

import { useRouteForegroundRefresh } from '@/lib/hooks/use-route-foreground-refresh';

export const unstable_settings = {
initialRouteName: 'index',
};

export default function HomeLayout() {
useRouteForegroundRefresh([[['activeSessions']]]);
return <Stack screenOptions={{ headerShown: false }} />;
}
9 changes: 9 additions & 0 deletions apps/mobile/src/app/(app)/(tabs)/(3_profile)/_layout.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
import { Stack } from 'expo-router';

import { useRouteForegroundRefresh } from '@/lib/hooks/use-route-foreground-refresh';

export const unstable_settings = {
initialRouteName: 'index',
};

export default function ProfileLayout() {
useRouteForegroundRefresh([
[['user']],
[['organizations']],
[['personalReviewAgent']],
[['securityAgent']],
[['kiloPass']],
]);
return <Stack screenOptions={{ headerShown: false }} />;
}
21 changes: 21 additions & 0 deletions apps/mobile/src/app/(app)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ import {
import { useFormSheetDetents } from '@/lib/form-sheet';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
import { useCurrentUserId } from '@/lib/hooks/use-current-user-id';
import { useRouteForegroundRefresh } from '@/lib/hooks/use-route-foreground-refresh';
import { CachePersistenceMount } from '@/lib/persist/cache-persistence-mount';
import { useTRPC } from '@/lib/trpc';

/**
* Attempts failed logout cleanup on every "next authenticated opportunity":
Expand Down Expand Up @@ -82,6 +84,24 @@ function PushRegistrationMount() {
return null;
}

/**
* Refreshes app-wide freshness on foreground regain: the signed-in user,
* their organizations, and kilo-chat conversations. The root `(app)` layout
* is always focused, so the hook's focus gate never blocks this mount.
*/
function AppWideFreshnessMount() {
const trpc = useTRPC();
useRouteForegroundRefresh([
trpc.user.getMe.queryKey(),
trpc.organizations.list.queryKey(),
// Kilo-chat keys are FLAT (['kilo-chat', 'conversations', …]), so the
// partial key is the flat ['kilo-chat']; the nested tRPC form
// [['kilo-chat']] does not prefix-match flat keys.
['kilo-chat'],
]);
return null;
}

export default function AppLayout() {
const colors = useThemeColors();
const { fullSheetDetent } = useFormSheetDetents();
Expand All @@ -92,6 +112,7 @@ export default function AppLayout() {
<CachePersistenceMount />
<LogoutReconciliationMount />
<PushRegistrationMount />
<AppWideFreshnessMount />
<SharePayloadNavigator />
<KiloChatProvider>
<KiloChatPresenceMount>
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/app/(app)/agent-chat/[session-id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { QueryError } from '@/components/query-error';
import { ScreenHeader } from '@/components/screen-header';
import { Button } from '@/components/ui/button';
import { Text } from '@/components/ui/text';
import { useRouteForegroundRefresh } from '@/lib/hooks/use-route-foreground-refresh';
import { shouldRetryNotFoundOnSpawnedRoute } from '@/lib/spawned-not-found-retry';
import { useTRPC } from '@/lib/trpc';

Expand Down Expand Up @@ -54,6 +55,7 @@ export default function SessionDetailScreen() {
const spawnedMode = Array.isArray(modeParam) ? modeParam[0] : modeParam;
const trpc = useTRPC();
const router = useRouter();
useRouteForegroundRefresh([[['cliSessionsV2']], [['modelPreferences']]]);
const sessionQuery = useQuery({
...trpc.cliSessionsV2.get.queryOptions(
{ session_id: sessionId },
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/app/(app)/agent-chat/model-picker.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { ModelPickerContent } from '@/components/agents/model-picker-content';
import { useRouteForegroundRefresh } from '@/lib/hooks/use-route-foreground-refresh';

export default function ModelPickerScreen() {
useRouteForegroundRefresh([[['modelPreferences']]]);
return <ModelPickerContent />;
}
2 changes: 2 additions & 0 deletions apps/mobile/src/app/(app)/device-sessions.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { DeviceSessionsScreen } from '@/components/device-sessions-screen';
import { useRouteForegroundRefresh } from '@/lib/hooks/use-route-foreground-refresh';

export default function DeviceSessionsRoute() {
useRouteForegroundRefresh([[['user']]]);
return <DeviceSessionsScreen />;
}
2 changes: 2 additions & 0 deletions apps/mobile/src/app/(app)/kilo-pass.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { KiloPassSubscriptionScreen } from '@/components/kilo-pass/kilo-pass-subscription-screen';
import { useRouteForegroundRefresh } from '@/lib/hooks/use-route-foreground-refresh';

export default function KiloPassRoute() {
useRouteForegroundRefresh([[['kiloPass']]]);
return <KiloPassSubscriptionScreen />;
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { type Href, Stack, useLocalSearchParams } from 'expo-router';
import { InvalidRouteState } from '@/components/invalid-route-state';
import { PrReviewConnectGate } from '@/components/pr-review/pr-review-connect-gate';
import { useCurrentUserId } from '@/lib/hooks/use-current-user-id';
import { useRouteForegroundRefresh } from '@/lib/hooks/use-route-foreground-refresh';
import {
pendingReviewDraftKey,
PendingReviewProvider,
Expand Down Expand Up @@ -39,6 +40,7 @@ export default function PrReviewNumberLayout() {
const number = rawNumber ? Number.parseInt(rawNumber, 10) : Number.NaN;
const { fullSheetDetent } = useFormSheetDetents();
const { userId } = useCurrentUserId();
useRouteForegroundRefresh([[['githubPrReview']]]);

if (!owner || !repo || !Number.isInteger(number) || number <= 0) {
return <InvalidRouteState backTo={'/(app)/pr-review' as Href} />;
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/app/(app)/pr-review/index.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { PrReviewConnectGate } from '@/components/pr-review/pr-review-connect-gate';
import { PrReviewEntryScreen } from '@/components/pr-review/pr-review-entry-screen';
import { useRouteForegroundRefresh } from '@/lib/hooks/use-route-foreground-refresh';

export default function PrReviewIndexRoute() {
useRouteForegroundRefresh([[['githubPrReview']]]);
return (
<PrReviewConnectGate>
<PrReviewEntryScreen />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,14 @@ function resolve(input: {
activeSessionType: 'remote' | 'cloud-agent' | 'read-only' | null;
agentStatusType: StatusType;
userWebConnected: boolean;
reconnectExhausted?: boolean;
}): SessionConnectionState {
return resolveSessionConnectionState(input);
return resolveSessionConnectionState({
activeSessionType: input.activeSessionType,
agentStatusType: input.agentStatusType,
userWebConnected: input.userWebConnected,
reconnectExhausted: input.reconnectExhausted ?? false,
});
}

describe('resolveSessionConnectionState - remote', () => {
Expand Down Expand Up @@ -91,3 +97,98 @@ describe('resolveSessionConnectionState - no transport', () => {
}
});
});

describe('resolveSessionConnectionState - exhausted', () => {
it('reports exhausted for a remote session when the user-web leg is down and reconnects are exhausted', () => {
expect(
resolve({
activeSessionType: 'remote',
agentStatusType: 'idle',
userWebConnected: false,
reconnectExhausted: true,
})
).toBe('exhausted');
});

it('reports exhausted for a remote disconnected agent status while reconnects are exhausted', () => {
expect(
resolve({
activeSessionType: 'remote',
agentStatusType: 'disconnected',
userWebConnected: true,
reconnectExhausted: true,
})
).toBe('exhausted');
});

it('reports down (not exhausted) for a remote session while the user-web leg is down and reconnects remain', () => {
expect(
resolve({
activeSessionType: 'remote',
agentStatusType: 'idle',
userWebConnected: false,
reconnectExhausted: false,
})
).toBe('down');
});

it('reports up for a connected remote session even while reconnects are exhausted', () => {
for (const status of STATUSES.filter(item => item !== 'disconnected')) {
expect(
resolve({
activeSessionType: 'remote',
agentStatusType: status,
userWebConnected: true,
reconnectExhausted: true,
})
).toBe('up');
}
});
});

describe('resolveSessionConnectionState - exhausted precedence', () => {
it('never overrides none for read-only sessions while reconnects are exhausted', () => {
for (const status of STATUSES) {
for (const userWebConnected of CONNECTION_VALUES) {
expect(
resolve({
activeSessionType: 'read-only',
agentStatusType: status,
userWebConnected,
reconnectExhausted: true,
})
).toBe('none');
}
}
});

it('never overrides none for unresolved session types while reconnects are exhausted', () => {
for (const status of STATUSES) {
for (const userWebConnected of CONNECTION_VALUES) {
expect(
resolve({
activeSessionType: null,
agentStatusType: status,
userWebConnected,
reconnectExhausted: true,
})
).toBe('none');
}
}
});

it('never applies to cloud-agent sessions', () => {
for (const status of STATUSES) {
for (const userWebConnected of CONNECTION_VALUES) {
expect(
resolve({
activeSessionType: 'cloud-agent',
agentStatusType: status,
userWebConnected,
reconnectExhausted: true,
})
).toBe(status === 'disconnected' ? 'down' : 'up');
}
}
});
});
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
import { type AgentStatus, type ResolvedSession } from '@kilocode/cloud-agent-sdk';

export type SessionConnectionState = 'up' | 'down' | 'none';
export type SessionConnectionState = 'up' | 'down' | 'exhausted' | 'none';

export function resolveSessionConnectionState(input: {
activeSessionType: ResolvedSession['type'] | null;
agentStatusType: AgentStatus['type'];
userWebConnected: boolean;
reconnectExhausted: boolean;
}): SessionConnectionState {
if (input.activeSessionType === 'remote') {
return !input.userWebConnected || input.agentStatusType === 'disconnected' ? 'down' : 'up';
if (!input.userWebConnected || input.agentStatusType === 'disconnected') {
return input.reconnectExhausted ? 'exhausted' : 'down';
}
return 'up';
}
if (input.activeSessionType === 'cloud-agent') {
return input.agentStatusType === 'disconnected' ? 'down' : 'up';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,15 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';

import { SessionConnectionIndicator } from './session-connection-indicator';

const connection = vi.hoisted(() => ({ connected: true }));
const connection = vi.hoisted(() => ({
connected: true,
exhausted: false,
retryConnection: vi.fn(),
}));

vi.mock('react-native', () => ({
View: 'View',
Pressable: 'Pressable',
}));
vi.mock('@/components/ui/icons', () => ({
WifiOff: 'WifiOff',
Expand All @@ -20,7 +25,13 @@ vi.mock('@/lib/hooks/use-theme-colors', () => ({
useThemeColors: () => ({ mutedForeground: '#666666' }),
}));
vi.mock('@/lib/hooks/use-user-web-connection-state', () => ({
useUserWebConnectionState: () => connection.connected,
useUserWebConnectionHealth: () => ({
isConnected: connection.connected,
reconnectExhausted: connection.exhausted,
}),
}));
vi.mock('@/components/agents/user-web-connection-provider', () => ({
useUserWebConnection: () => ({ retryConnection: connection.retryConnection }),
}));

type IndicatorProps = Parameters<typeof SessionConnectionIndicator>[0];
Expand Down Expand Up @@ -59,6 +70,8 @@ function findHost(
describe('SessionConnectionIndicator mounted', () => {
beforeEach(() => {
connection.connected = true;
connection.exhausted = false;
connection.retryConnection.mockClear();
});

it('renders a blank fixed row with no text for default (pending/error) props', async () => {
Expand Down Expand Up @@ -146,4 +159,60 @@ describe('SessionConnectionIndicator mounted', () => {
expect(findHost(renderer.root, 'Text')).toHaveLength(0);
expect(findHost(renderer.root, 'WifiOff')).toHaveLength(0);
});

it('renders Connection lost with a Retry action when reconnects are exhausted', async () => {
connection.connected = false;
connection.exhausted = true;
const renderer = await mount({ activeSessionType: 'remote', agentStatusType: 'idle' });

const view = findHost(renderer.root, 'View')[0];
expect(view).toBeDefined();
if (!view) {
throw new Error('view not found');
}
expect(view.props.accessibilityElementsHidden).toBe(false);
expect(findHost(renderer.root, 'WifiOff')).toHaveLength(1);
const texts = findHost(renderer.root, 'Text');
expect(texts.some(node => node.props.children === 'Connection lost')).toBe(true);
expect(texts.some(node => node.props.children === 'Retry')).toBe(true);
expect(findHost(renderer.root, 'Pressable')).toHaveLength(1);
});

it('calls retryConnection when the Retry action is pressed', async () => {
connection.connected = false;
connection.exhausted = true;
const renderer = await mount({ activeSessionType: 'remote', agentStatusType: 'idle' });

const pressables = findHost(renderer.root, 'Pressable');
expect(pressables).toHaveLength(1);
const pressable = pressables[0];
expect(pressable).toBeDefined();
if (!pressable) {
throw new Error('pressable not found');
}

await act(async () => {
await Promise.resolve();
(pressable.props.onPress as () => void)();
});

expect(connection.retryConnection).toHaveBeenCalledTimes(1);
});

it('clears the label when the exhaustion edge flips false and the transport recovers', async () => {
connection.connected = false;
connection.exhausted = true;
const renderer = await mount({ activeSessionType: 'remote', agentStatusType: 'idle' });
expect(
findHost(renderer.root, 'Text').some(node => node.props.children === 'Connection lost')
).toBe(true);

connection.exhausted = false;
connection.connected = true;
await update(renderer, { activeSessionType: 'remote', agentStatusType: 'idle' });

expect(findHost(renderer.root, 'Text')).toHaveLength(0);
expect(findHost(renderer.root, 'WifiOff')).toHaveLength(0);
expect(findHost(renderer.root, 'Pressable')).toHaveLength(0);
});
});
Loading