Skip to content

Commit 75e6ea8

Browse files
authored
Notifications - Add cloud agent push notifications (#2954)
feat(notifications): add cloud agent push notifications
1 parent d9f88f8 commit 75e6ea8

36 files changed

Lines changed: 4099 additions & 430 deletions

apps/mobile/src/components/agents/session-detail-content.tsx

Lines changed: 13 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,20 @@
1-
import { useCallback, useEffect, useMemo, useState } from 'react';
2-
import { ActivityIndicator, FlatList, KeyboardAvoidingView, Platform, View } from 'react-native';
3-
import { useAtomValue } from 'jotai';
41
import { type CloudStatus, type KiloSessionId, type StoredMessage } from 'cloud-agent-sdk';
5-
import { toast } from 'sonner-native';
2+
import { useAtomValue } from 'jotai';
3+
import { useCallback, useEffect, useMemo } from 'react';
4+
import { ActivityIndicator, FlatList, KeyboardAvoidingView, Platform, View } from 'react-native';
65
import { useSafeAreaInsets } from 'react-native-safe-area-context';
6+
import { toast } from 'sonner-native';
77

88
import { ChatComposer } from '@/components/agents/chat-composer';
99
import { ConnectivityBanner } from '@/components/agents/connectivity-banner';
1010
import { MessageBubble } from '@/components/agents/message-bubble';
11-
import { normalizeAgentMode } from '@/components/agents/mode-options';
12-
import { type AgentMode } from '@/components/agents/mode-selector';
1311
import { PermissionCard } from '@/components/agents/permission-card';
1412
import { QuestionCard } from '@/components/agents/question-card';
1513
import { useSessionManager } from '@/components/agents/session-provider';
1614
import { SessionStatusIndicator } from '@/components/agents/session-status-indicator';
1715
import { useInteractionHandlers } from '@/components/agents/use-interaction-handlers';
1816
import { useSessionAutoScroll } from '@/components/agents/use-session-auto-scroll';
17+
import { useSessionConfigSync } from '@/components/agents/use-session-config-sync';
1918
import { WorkingIndicator } from '@/components/agents/working-indicator';
2019
import { ScreenHeader } from '@/components/screen-header';
2120
import { Text } from '@/components/ui/text';
@@ -71,51 +70,14 @@ export function SessionDetailContent({ sessionId }: Readonly<SessionDetailConten
7170

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

74-
const [currentMode, setCurrentMode] = useState<AgentMode>(() =>
75-
normalizeAgentMode(fetchedData?.mode)
76-
);
77-
78-
const [currentModel, setCurrentModel] = useState<string>(fetchedData?.model ?? '');
79-
const [currentVariant, setCurrentVariant] = useState<string>(fetchedData?.variant ?? '');
80-
81-
// Sync mode/model/variant from session data and SDK session config.
82-
// The SDK's sessionConfig is updated from assistant messages during snapshot
83-
// replay, so it captures the model actually used in the conversation.
84-
useEffect(() => {
85-
const mode = sessionConfig?.mode ?? fetchedData?.mode;
86-
if (mode) {
87-
setCurrentMode(normalizeAgentMode(mode));
88-
}
89-
90-
const model = sessionConfig?.model ?? fetchedData?.model;
91-
if (model) {
92-
setCurrentModel(model);
93-
}
94-
95-
const variant = sessionConfig?.variant ?? fetchedData?.variant;
96-
if (variant) {
97-
setCurrentVariant(variant);
98-
}
99-
}, [
100-
sessionConfig?.mode,
101-
sessionConfig?.model,
102-
sessionConfig?.variant,
103-
fetchedData?.mode,
104-
fetchedData?.model,
105-
fetchedData?.variant,
106-
]);
107-
108-
// Auto-select first available model when session has no model (e.g. remote CLI sessions)
109-
useEffect(() => {
110-
if (currentModel || modelOptions.length === 0 || fetchedData === null) {
111-
return;
112-
}
113-
const firstModel = modelOptions[0];
114-
if (firstModel) {
115-
setCurrentModel(firstModel.id);
116-
setCurrentVariant(firstModel.variants[0] ?? '');
117-
}
118-
}, [currentModel, modelOptions, fetchedData]);
73+
const {
74+
currentMode,
75+
currentModel,
76+
currentVariant,
77+
setCurrentMode,
78+
setCurrentModel,
79+
setCurrentVariant,
80+
} = useSessionConfigSync({ fetchedData, sessionConfig, modelOptions });
11981

12082
const {
12183
flatListRef,
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { useEffect, useState } from 'react';
2+
3+
import { normalizeAgentMode } from '@/components/agents/mode-options';
4+
import { type AgentMode } from '@/components/agents/mode-selector';
5+
import { type ModelOption } from '@/lib/hooks/use-available-models';
6+
7+
type SessionConfigSnapshot = {
8+
mode?: string | null;
9+
model?: string | null;
10+
variant?: string | null;
11+
};
12+
13+
type UseSessionConfigSyncOptions = {
14+
fetchedData: SessionConfigSnapshot | null;
15+
sessionConfig: SessionConfigSnapshot | null | undefined;
16+
modelOptions: ModelOption[];
17+
};
18+
19+
type UseSessionConfigSyncResult = {
20+
currentMode: AgentMode;
21+
currentModel: string;
22+
currentVariant: string;
23+
setCurrentMode: (mode: AgentMode) => void;
24+
setCurrentModel: (model: string) => void;
25+
setCurrentVariant: (variant: string) => void;
26+
};
27+
28+
// Keeps the composer's mode/model/variant in sync with the session's
29+
// fetched data and the SDK session config (which is updated from assistant
30+
// messages during snapshot replay). For sessions without a configured model
31+
// (e.g. remote CLI sessions), auto-selects the first available model.
32+
export function useSessionConfigSync({
33+
fetchedData,
34+
sessionConfig,
35+
modelOptions,
36+
}: UseSessionConfigSyncOptions): UseSessionConfigSyncResult {
37+
const [currentMode, setCurrentMode] = useState<AgentMode>(() =>
38+
normalizeAgentMode(fetchedData?.mode)
39+
);
40+
const [currentModel, setCurrentModel] = useState<string>(fetchedData?.model ?? '');
41+
const [currentVariant, setCurrentVariant] = useState<string>(fetchedData?.variant ?? '');
42+
43+
useEffect(() => {
44+
const mode = sessionConfig?.mode ?? fetchedData?.mode;
45+
if (mode) {
46+
setCurrentMode(normalizeAgentMode(mode));
47+
}
48+
49+
const model = sessionConfig?.model ?? fetchedData?.model;
50+
if (model) {
51+
setCurrentModel(model);
52+
}
53+
54+
const variant = sessionConfig?.variant ?? fetchedData?.variant;
55+
if (variant) {
56+
setCurrentVariant(variant);
57+
}
58+
}, [
59+
sessionConfig?.mode,
60+
sessionConfig?.model,
61+
sessionConfig?.variant,
62+
fetchedData?.mode,
63+
fetchedData?.model,
64+
fetchedData?.variant,
65+
]);
66+
67+
useEffect(() => {
68+
if (currentModel || modelOptions.length === 0 || fetchedData === null) {
69+
return;
70+
}
71+
const firstModel = modelOptions[0];
72+
if (firstModel) {
73+
setCurrentModel(firstModel.id);
74+
setCurrentVariant(firstModel.variants[0] ?? '');
75+
}
76+
}, [currentModel, modelOptions, fetchedData]);
77+
78+
return {
79+
currentMode,
80+
currentModel,
81+
currentVariant,
82+
setCurrentMode,
83+
setCurrentModel,
84+
setCurrentVariant,
85+
};
86+
}

apps/mobile/src/lib/notification-path.test.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,15 @@ describe('notificationPathForData', () => {
4545
})
4646
).toBe('/(app)/(tabs)/(1_kiloclaw)/chat/ki_deadbeef');
4747
});
48+
49+
it('routes cloud agent notifications to the matching agent session', () => {
50+
expect(
51+
notificationPathForData({
52+
type: 'cloud_agent_session',
53+
cliSessionId: 'ses_1',
54+
})
55+
).toBe('/(app)/agent-chat/ses_1');
56+
});
4857
});
4958

5059
describe('pushDataSchema', () => {
@@ -75,7 +84,7 @@ describe('pushDataSchema', () => {
7584
).toBe(false);
7685
});
7786

78-
it('accepts valid chat and lifecycle notification data', () => {
87+
it('accepts valid chat, lifecycle, and cloud agent notification data', () => {
7988
expect(
8089
pushDataSchema.safeParse({
8190
type: 'chat.message',
@@ -91,5 +100,20 @@ describe('pushDataSchema', () => {
91100
sandboxId: 'sandbox-1',
92101
}).success
93102
).toBe(true);
103+
expect(
104+
pushDataSchema.safeParse({
105+
type: 'cloud_agent_session',
106+
cliSessionId: 'ses_1',
107+
}).success
108+
).toBe(true);
109+
});
110+
111+
it('rejects empty cloud agent session IDs', () => {
112+
expect(
113+
pushDataSchema.safeParse({
114+
type: 'cloud_agent_session',
115+
cliSessionId: '',
116+
}).success
117+
).toBe(false);
94118
});
95119
});

apps/mobile/src/lib/notification-path.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ import { type PushData } from '@kilocode/notifications';
33
import { chatConversationRoute, chatSandboxRoute } from './kilo-chat-routes';
44

55
export function notificationPathForData(data: PushData): string {
6+
if (data.type === 'cloud_agent_session') {
7+
return `/(app)/agent-chat/${data.cliSessionId}`;
8+
}
69
if (data.type === 'chat.message') {
710
return chatConversationRoute(data.sandboxId, data.conversationId);
811
}

apps/mobile/src/lib/notifications.ts

Lines changed: 22 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,20 @@ import expoConstants from 'expo-constants';
22
import * as Notifications from 'expo-notifications';
33
import { type Href, router } from 'expo-router';
44
import { Platform } from 'react-native';
5+
import { z } from 'zod';
56

67
import { type PushData, pushDataSchema } from '@kilocode/notifications';
78

89
import { notificationPathForData } from './notification-path';
910

11+
const easConfigSchema = z.object({ projectId: z.string().min(1) });
12+
1013
function getProjectId(): string {
11-
const eas = expoConstants.expoConfig?.extra?.eas as { projectId?: string } | undefined;
12-
const projectId = eas?.projectId;
13-
if (!projectId) {
14+
const parsed = easConfigSchema.safeParse(expoConstants.expoConfig?.extra?.eas);
15+
if (!parsed.success) {
1416
throw new Error('Missing extra.eas.projectId in app config');
1517
}
16-
return projectId;
18+
return parsed.data.projectId;
1719
}
1820

1921
// Tracks which conversation screen is currently focused.
@@ -43,15 +45,15 @@ const shown = {
4345
shouldSetBadge: true,
4446
shouldShowBanner: true,
4547
shouldShowList: true,
46-
} as const;
48+
} satisfies Notifications.NotificationBehavior;
4749

4850
const suppressed = {
4951
shouldShowAlert: false,
5052
shouldPlaySound: false,
5153
shouldSetBadge: false,
5254
shouldShowBanner: false,
5355
shouldShowList: false,
54-
} as const;
56+
} satisfies Notifications.NotificationBehavior;
5557

5658
export function setupNotificationHandler() {
5759
Notifications.setNotificationHandler({
@@ -66,7 +68,6 @@ export function setupNotificationHandler() {
6668
) {
6769
return suppressed;
6870
}
69-
7071
return shown;
7172
},
7273
});
@@ -82,23 +83,16 @@ export function getPendingNotificationLink(): string | null {
8283
return link;
8384
}
8485

85-
function instanceChatPath(data: PushData | null): string | null {
86-
if (!data) {
87-
return null;
88-
}
89-
return notificationPathForData(data);
90-
}
91-
9286
export function setupNotificationResponseHandler() {
9387
const subscription = Notifications.addNotificationResponseReceivedListener(response => {
9488
const data = parseNotificationData(response.notification.request.content.data);
95-
const path = instanceChatPath(data);
96-
if (!path) {
89+
if (!data) {
9790
return;
9891
}
9992

100-
// If the router is ready (has segments), navigate immediately.
101-
// Otherwise store as pending for consumption after auth completes.
93+
const path = notificationPathForData(data);
94+
Notifications.clearLastNotificationResponse();
95+
// If the router is ready, navigate immediately; otherwise store as pending.
10296
try {
10397
router.replace(path as Href);
10498
} catch {
@@ -116,9 +110,8 @@ export function checkInitialNotification(): void {
116110
return;
117111
}
118112
const data = parseNotificationData(response.notification.request.content.data);
119-
const path = instanceChatPath(data);
120-
if (path) {
121-
pendingNotificationLink = path;
113+
if (data) {
114+
pendingNotificationLink = notificationPathForData(data);
122115
}
123116
Notifications.clearLastNotificationResponse();
124117
}
@@ -163,5 +156,12 @@ export async function getNotificationPermissionStatus(): Promise<
163156
}
164157

165158
export function getPlatform(): 'ios' | 'android' {
166-
return Platform.OS as 'ios' | 'android';
159+
if (Platform.OS === 'ios') {
160+
return 'ios';
161+
}
162+
if (Platform.OS === 'android') {
163+
return 'android';
164+
}
165+
166+
throw new Error('Unsupported platform for push notifications');
167167
}

dev/local/services.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,13 @@ const groups: ServiceGroup[] = [
2121
alwaysOn: false,
2222
sectionBreakBefore: true,
2323
},
24-
{ id: 'kiloclaw', label: 'KiloClaw', alwaysOn: false },
24+
{ id: 'notifications', label: 'Notifications', alwaysOn: false },
25+
{ id: 'kiloclaw', label: 'KiloClaw', alwaysOn: false, groupDependsOn: ['notifications'] },
2526
{
2627
id: 'cloud-agent',
2728
label: 'Cloud Agent',
2829
alwaysOn: false,
29-
groupDependsOn: ['git-token-service'],
30+
groupDependsOn: ['git-token-service', 'notifications'],
3031
},
3132
{ id: 'code-review', label: 'Code Review', alwaysOn: false, groupDependsOn: ['cloud-agent'] },
3233
{ id: 'app-builder', label: 'App Builder', alwaysOn: false, groupDependsOn: ['cloud-agent'] },
@@ -72,7 +73,13 @@ const serviceMeta: Record<string, ServiceMeta> = {
7273
// cloud-agent
7374
'cloud-agent-next': {
7475
group: 'cloud-agent',
75-
dependsOn: ['postgres', 'nextjs', 'cloudflare-session-ingest', 'cloudflare-git-token-service'],
76+
dependsOn: [
77+
'postgres',
78+
'nextjs',
79+
'cloudflare-session-ingest',
80+
'cloudflare-git-token-service',
81+
'notifications',
82+
],
7683
dir: 'services/cloud-agent-next',
7784
useLanIp: true,
7885
},
@@ -143,7 +150,7 @@ const serviceMeta: Record<string, ServiceMeta> = {
143150
'kiloclaw-tunnel': { group: 'kiloclaw', dependsOn: [] },
144151
'kiloclaw-docker-tcp': { group: 'kiloclaw', dependsOn: [] },
145152
notifications: {
146-
group: 'kiloclaw',
153+
group: 'notifications',
147154
dependsOn: ['postgres'],
148155
dir: 'services/notifications',
149156
},

packages/notifications/src/push-data.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ export const pushDataSchema = z.discriminatedUnion('type', [
2626
event: scheduledActionEventSchema,
2727
sandboxId: z.string().min(1),
2828
}),
29+
z.object({
30+
type: z.literal('cloud_agent_session'),
31+
cliSessionId: nonEmptyStringSchema,
32+
}),
2933
]);
3034

3135
export type PushData = z.infer<typeof pushDataSchema>;

0 commit comments

Comments
 (0)