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
4 changes: 2 additions & 2 deletions apps/mobile/src/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import * as Sentry from '@sentry/react-native';
import { QueryClientProvider } from '@tanstack/react-query';
import { isRunningInExpoGo } from 'expo';
import { useFonts } from 'expo-font';
import { type Href, Slot, useNavigationContainerRef, useRouter, useSegments } from 'expo-router';
import { Slot, useNavigationContainerRef, useRouter, useSegments } from 'expo-router';
import * as SplashScreen from 'expo-splash-screen';
import { StatusBar } from 'expo-status-bar';
import { requestTrackingPermissionsAsync } from 'expo-tracking-transparency';
Expand Down Expand Up @@ -130,7 +130,7 @@ function RootLayoutNav() {
// Navigate to pending notification deep link (cold start / background tap)
const pendingLink = getPendingNotificationLink();
if (pendingLink) {
router.push(pendingLink as Href);
router.push(pendingLink);
}
}
}, [token, isLoading, updateRequired, inAuthGroup, inForceUpdate, router]);
Expand Down
167 changes: 167 additions & 0 deletions apps/mobile/src/components/agents/cloud-agent-notification-prompt.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import * as SecureStore from 'expo-secure-store';
import { Bell } from 'lucide-react-native';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Alert, Linking, View } from 'react-native';
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated';
import { toast } from 'sonner-native';

import { Button } from '@/components/ui/button';
import { Text } from '@/components/ui/text';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
import {
getDevicePushToken,
getNotificationPermissionStatus,
getPlatform,
registerForPushNotifications,
} from '@/lib/notifications';
import { CLOUD_AGENT_NOTIFICATION_PROMPT_SEEN_KEY } from '@/lib/storage-keys';
import { useTRPC } from '@/lib/trpc';

const promptDelayMs = 10_000;

export function CloudAgentNotificationPrompt({ enabled }: { enabled: boolean }) {
const [visible, setVisible] = useState(false);
const colors = useThemeColors();
const trpc = useTRPC();
const queryClient = useQueryClient();

const pushTokensOptions = trpc.user.getMyPushTokens.queryOptions();
const pushTokensQueryKey = useMemo(() => pushTokensOptions.queryKey, [pushTokensOptions]);

const pushTokensQuery = useQuery({
...pushTokensOptions,
enabled,
retry: false,
});

const registerToken = useMutation(
trpc.user.registerPushToken.mutationOptions({
onError: error => {
toast.error(error.message);
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: pushTokensQueryKey });
},
})
);

useEffect(() => {
if (!enabled || pushTokensQuery.isPending || pushTokensQuery.isError) {
return undefined;
}

const abortController = new AbortController();
const { signal } = abortController;
let timeout: ReturnType<typeof setTimeout> | null = null;

// oxlint's flow analysis can't tell that `signal.aborted` flips
// asynchronously from the cleanup callback, so it flags each read as
// "always falsy". The check is load-bearing — bail if the effect was
// cleaned up while an `await` was pending.
/* eslint-disable @typescript-eslint/no-unnecessary-condition */
async function check() {
const seen = await SecureStore.getItemAsync(CLOUD_AGENT_NOTIFICATION_PROMPT_SEEN_KEY);
if (seen || signal.aborted) {
return;
}

const status = await getNotificationPermissionStatus();
if (signal.aborted) {
return;
}
if (status === 'granted') {
const deviceToken = await getDevicePushToken();
if (signal.aborted) {
return;
}
const alreadyRegistered = Boolean(
deviceToken && (pushTokensQuery.data ?? []).some(t => t.token === deviceToken)
);
if (alreadyRegistered) {
return;
}
}

timeout = setTimeout(() => {
if (!signal.aborted) {
setVisible(true);
}
}, promptDelayMs);
}
/* eslint-enable @typescript-eslint/no-unnecessary-condition */

void check();

return () => {
abortController.abort();
if (timeout) {
clearTimeout(timeout);
}
};
}, [enabled, pushTokensQuery.data, pushTokensQuery.isError, pushTokensQuery.isPending]);

const handleEnable = useCallback(async () => {
const currentStatus = await getNotificationPermissionStatus();

if (currentStatus === 'denied') {
Alert.alert(
'Notifications Disabled',
'To enable notifications, turn them on in your device settings.',
[
{ text: 'Cancel', style: 'cancel' },
{ text: 'Open Settings', onPress: () => void Linking.openSettings() },
]
);
return;
}

const token = await registerForPushNotifications();
if (!token) {
toast.error('Notification permission was not granted');
return;
}

registerToken.mutate(
{ token, platform: getPlatform() },
{
onSuccess: () => {
void SecureStore.setItemAsync(CLOUD_AGENT_NOTIFICATION_PROMPT_SEEN_KEY, 'true');
setVisible(false);
toast.success('Notifications enabled');
},
}
);
}, [registerToken]);

const handleDismiss = useCallback(async () => {
await SecureStore.setItemAsync(CLOUD_AGENT_NOTIFICATION_PROMPT_SEEN_KEY, 'true');
setVisible(false);
}, []);

if (!visible) {
return null;
}

return (
<Animated.View entering={FadeIn.duration(300)} exiting={FadeOut.duration(200)}>
<View className="mx-3 mb-2 flex-row items-center gap-3 rounded-xl bg-secondary p-4">
<Bell size={20} color={colors.foreground} />
<View className="min-w-0 flex-1">
<Text className="text-sm font-medium">Get notified when your agent finishes</Text>
<Text variant="muted" className="text-xs">
We'll ping your phone when a task completes, so you can close the app.
</Text>
</View>
<View className="shrink-0 flex-row gap-2">
<Button variant="ghost" size="sm" onPress={() => void handleDismiss()}>
<Text className="text-xs text-muted-foreground">Later</Text>
</Button>
<Button size="sm" disabled={registerToken.isPending} onPress={() => void handleEnable()}>
<Text className="text-xs text-primary-foreground">Enable</Text>
</Button>
</View>
</View>
</Animated.View>
);
}
77 changes: 26 additions & 51 deletions apps/mobile/src/components/agents/session-detail-content.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,22 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { ActivityIndicator, FlatList, KeyboardAvoidingView, Platform, View } from 'react-native';
import { useAtomValue } from 'jotai';
import { type CloudStatus, type KiloSessionId, type StoredMessage } from 'cloud-agent-sdk';
import { toast } from 'sonner-native';
import { useAtomValue } from 'jotai';
import { useCallback, useEffect, useMemo } from 'react';
import { ActivityIndicator, FlatList, KeyboardAvoidingView, Platform, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { toast } from 'sonner-native';

import { ChatComposer } from '@/components/agents/chat-composer';
import { CloudAgentNotificationPrompt } from '@/components/agents/cloud-agent-notification-prompt';
import { ConnectivityBanner } from '@/components/agents/connectivity-banner';
import { MessageBubble } from '@/components/agents/message-bubble';
import { normalizeAgentMode } from '@/components/agents/mode-options';
import { type AgentMode } from '@/components/agents/mode-selector';
import { PermissionCard } from '@/components/agents/permission-card';
import { QuestionCard } from '@/components/agents/question-card';
import { useSessionManager } from '@/components/agents/session-provider';
import { SessionStatusIndicator } from '@/components/agents/session-status-indicator';
import { useInteractionHandlers } from '@/components/agents/use-interaction-handlers';
import { useMarkSessionRead } from '@/components/agents/use-mark-session-read';
import { useSessionAutoScroll } from '@/components/agents/use-session-auto-scroll';
import { useSessionConfigSync } from '@/components/agents/use-session-config-sync';
import { WorkingIndicator } from '@/components/agents/working-indicator';
import { ScreenHeader } from '@/components/screen-header';
import { Text } from '@/components/ui/text';
Expand Down Expand Up @@ -59,6 +60,8 @@ export function SessionDetailContent({ sessionId }: Readonly<SessionDetailConten
const { isConnected } = useAppLifecycle();
const { bottom } = useSafeAreaInsets();

useMarkSessionRead(sessionId);

const {
isAnswering,
isRespondingToPermission,
Expand All @@ -71,51 +74,14 @@ export function SessionDetailContent({ sessionId }: Readonly<SessionDetailConten

const { models: modelOptions } = useAvailableModels(organizationId);

const [currentMode, setCurrentMode] = useState<AgentMode>(() =>
normalizeAgentMode(fetchedData?.mode)
);

const [currentModel, setCurrentModel] = useState<string>(fetchedData?.model ?? '');
const [currentVariant, setCurrentVariant] = useState<string>(fetchedData?.variant ?? '');

// Sync mode/model/variant from session data and SDK session config.
// The SDK's sessionConfig is updated from assistant messages during snapshot
// replay, so it captures the model actually used in the conversation.
useEffect(() => {
const mode = sessionConfig?.mode ?? fetchedData?.mode;
if (mode) {
setCurrentMode(normalizeAgentMode(mode));
}

const model = sessionConfig?.model ?? fetchedData?.model;
if (model) {
setCurrentModel(model);
}

const variant = sessionConfig?.variant ?? fetchedData?.variant;
if (variant) {
setCurrentVariant(variant);
}
}, [
sessionConfig?.mode,
sessionConfig?.model,
sessionConfig?.variant,
fetchedData?.mode,
fetchedData?.model,
fetchedData?.variant,
]);

// Auto-select first available model when session has no model (e.g. remote CLI sessions)
useEffect(() => {
if (currentModel || modelOptions.length === 0 || fetchedData === null) {
return;
}
const firstModel = modelOptions[0];
if (firstModel) {
setCurrentModel(firstModel.id);
setCurrentVariant(firstModel.variants[0] ?? '');
}
}, [currentModel, modelOptions, fetchedData]);
const {
currentMode,
currentModel,
currentVariant,
setCurrentMode,
setCurrentModel,
setCurrentVariant,
} = useSessionConfigSync({ fetchedData, sessionConfig, modelOptions });

const {
flatListRef,
Expand Down Expand Up @@ -181,6 +147,13 @@ export function SessionDetailContent({ sessionId }: Readonly<SessionDetailConten
(requiresModel && !currentModel);
const showInteractionCards = activeQuestion ?? activePermission;
const composerPlaceholder = getComposerPlaceholder(cloudStatus?.type);
const isLoadedCloudAgentSession =
fetchedData !== null &&
fetchedData.kiloSessionId === sessionId &&
fetchedData.cloudAgentSessionId !== null;
const showNotificationPrompt =
isLoadedCloudAgentSession &&
(isStreaming || cloudStatus?.type === 'preparing' || cloudStatus?.type === 'ready');

const handleSend = useCallback(
async (text: string) => {
Expand Down Expand Up @@ -221,6 +194,8 @@ export function SessionDetailContent({ sessionId }: Readonly<SessionDetailConten
>
<View className="flex-1">{renderContent()}</View>

<CloudAgentNotificationPrompt enabled={showNotificationPrompt} />

{activeQuestion ? (
<QuestionCard
questions={activeQuestion.questions}
Expand Down
68 changes: 68 additions & 0 deletions apps/mobile/src/components/agents/use-mark-session-read.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { useMutation } from '@tanstack/react-query';
import * as Notifications from 'expo-notifications';
import { useFocusEffect } from 'expo-router';
import { useCallback, useEffect, useRef } from 'react';
import { toast } from 'sonner-native';

import { parseNotificationData, setActiveCliSession } from '@/lib/notifications';
import { useTRPC } from '@/lib/trpc';
import { useAppLifecycle } from '@/lib/hooks/use-app-lifecycle';

/**
* Clears the server-side unread count for the given agent session whenever the
* screen is focused or the app returns to the foreground while focused.
*
* Also listens for push notifications arriving while the screen is open and
* re-clears the unread count so the badge on the app icon stays accurate.
*/
export function useMarkSessionRead(sessionId: string): void {
const trpc = useTRPC();
const { isActive } = useAppLifecycle();
const isFocusedRef = useRef(false);

const { mutate: markChatRead } = useMutation(
trpc.user.markChatRead.mutationOptions({
onSuccess: ({ badgeCount }) => {
void Notifications.setBadgeCountAsync(badgeCount);
},
onError: err => {
toast.error(err.message || 'Failed to update badge count');
},
})
);

useFocusEffect(
useCallback(() => {
isFocusedRef.current = true;
setActiveCliSession(sessionId);
markChatRead({ channelId: sessionId });

// If a notification for this session arrives while the screen is already open it is
// visually suppressed, but the worker still incremented the server-side count.
const subscription = Notifications.addNotificationReceivedListener(notification => {
const data = parseNotificationData(notification.request.content.data);
if (data?.type === 'cloud_agent_session' && data.cliSessionId === sessionId) {
markChatRead({ channelId: sessionId });
}
});

return () => {
isFocusedRef.current = false;
setActiveCliSession(null);
subscription.remove();
};
}, [sessionId, markChatRead])
);

// Clear badge when the app returns to the foreground while this session is focused.
// `useFocusEffect` already handles the focus/sessionId change case; this effect
// only fires on the inactive -> active transition to avoid a duplicate call.
const wasActiveRef = useRef(isActive);
useEffect(() => {
const becameActive = isActive && !wasActiveRef.current;
wasActiveRef.current = isActive;
if (becameActive && isFocusedRef.current) {
markChatRead({ channelId: sessionId });
}
}, [isActive, sessionId, markChatRead]);
}
Loading