diff --git a/apps/mobile/src/app/(app)/chat/[sandbox-id]/[conversation-id].tsx b/apps/mobile/src/app/(app)/chat/[sandbox-id]/[conversation-id].tsx
new file mode 100644
index 0000000000..a81779108f
--- /dev/null
+++ b/apps/mobile/src/app/(app)/chat/[sandbox-id]/[conversation-id].tsx
@@ -0,0 +1,20 @@
+import { useLocalSearchParams } from 'expo-router';
+
+import { ConversationScreen } from '@/components/kilo-chat/conversation-screen';
+import { useConversationDetail } from '@/components/kilo-chat/hooks/use-conversations';
+import { useKiloChatClient } from '@/components/kilo-chat/hooks/use-kilo-chat-client';
+
+export default function ChatConversationRoute() {
+ const params = useLocalSearchParams<{ 'sandbox-id': string; 'conversation-id': string }>();
+ const sandboxId = params['sandbox-id'];
+ const conversationId = params['conversation-id'];
+ const client = useKiloChatClient();
+ const { data } = useConversationDetail(client, conversationId);
+ return (
+
+ );
+}
diff --git a/apps/mobile/src/app/(app)/chat/[sandbox-id]/_layout.tsx b/apps/mobile/src/app/(app)/chat/[sandbox-id]/_layout.tsx
new file mode 100644
index 0000000000..6d1a690211
--- /dev/null
+++ b/apps/mobile/src/app/(app)/chat/[sandbox-id]/_layout.tsx
@@ -0,0 +1,5 @@
+import { Stack } from 'expo-router';
+
+export default function ChatSandboxLayout() {
+ return ;
+}
diff --git a/apps/mobile/src/app/(app)/chat/[sandbox-id]/index.tsx b/apps/mobile/src/app/(app)/chat/[sandbox-id]/index.tsx
new file mode 100644
index 0000000000..d99324b468
--- /dev/null
+++ b/apps/mobile/src/app/(app)/chat/[sandbox-id]/index.tsx
@@ -0,0 +1,11 @@
+import { useLocalSearchParams } from 'expo-router';
+
+import { ConversationListScreen } from '@/components/kilo-chat/conversation-list-screen';
+import { useAllKiloClawInstances } from '@/lib/hooks/use-instance-context';
+
+export default function ChatSandboxIndex() {
+ const { 'sandbox-id': sandboxId } = useLocalSearchParams<{ 'sandbox-id': string }>();
+ const { data: instances } = useAllKiloClawInstances();
+ const sandboxLabel = instances?.find(i => i.sandboxId === sandboxId)?.name ?? 'Chat';
+ return ;
+}
diff --git a/apps/mobile/src/components/kilo-chat/conversation-screen.tsx b/apps/mobile/src/components/kilo-chat/conversation-screen.tsx
index ac42c770a8..000c4a76a7 100644
--- a/apps/mobile/src/components/kilo-chat/conversation-screen.tsx
+++ b/apps/mobile/src/components/kilo-chat/conversation-screen.tsx
@@ -12,6 +12,7 @@ import { useKiloChatClient } from './hooks/use-kilo-chat-client';
import { useMarkRead } from './hooks/use-mark-read';
import { useMessages, useSendMessage } from './hooks/use-messages';
import { useCurrentUserId } from './hooks/use-current-user-id';
+import { setActiveChatLocation } from '@/lib/notifications';
type Props = { sandboxId: string; conversationId: string; conversationTitle: string };
@@ -46,7 +47,10 @@ export function ConversationScreen({ sandboxId, conversationId, conversationTitl
useFocusEffect(
useCallback(() => {
markRead(sandboxId, conversationId);
- // Active-conversation suppression wiring added in PR 5d (Task 50).
+ setActiveChatLocation({ sandboxId, conversationId });
+ return () => {
+ setActiveChatLocation(null);
+ };
}, [sandboxId, conversationId, markRead])
);
diff --git a/apps/mobile/src/components/kiloclaw/chat.tsx b/apps/mobile/src/components/kiloclaw/chat.tsx
index e75b60862b..709ddaba30 100644
--- a/apps/mobile/src/components/kiloclaw/chat.tsx
+++ b/apps/mobile/src/components/kiloclaw/chat.tsx
@@ -9,6 +9,8 @@ import { type Channel as StreamChannel, StreamChat } from 'stream-chat';
import { Channel, Chat, MessageInput, MessageList, OverlayProvider } from 'stream-chat-expo';
import { toast } from 'sonner-native';
+import { badgeBucketForConversation } from '@kilocode/notifications';
+
import { KiloClawMessageAvatar } from '@/components/kiloclaw/chat-avatar';
import { ChatPlaceholder } from '@/components/kiloclaw/chat-placeholder';
import { ChatHeader, ChatShell } from '@/components/kiloclaw/chat-shell';
@@ -19,11 +21,7 @@ import { badgeBucketForInstance } from '@/lib/badge-buckets';
import { useAppLifecycle } from '@/lib/hooks/use-app-lifecycle';
import { useStreamChatCredentials } from '@/lib/hooks/use-kiloclaw-queries';
import { setLastActiveInstance } from '@/lib/last-active-instance';
-import {
- getNotificationSandboxId,
- parseNotificationData,
- setActiveChatInstance,
-} from '@/lib/notifications';
+import { parseNotificationData } from '@/lib/notifications';
import { useTRPC } from '@/lib/trpc';
type KiloClawChatProps = {
@@ -78,7 +76,6 @@ export function KiloClawChat({
useCallback(() => {
const badgeBucket = badgeBucketForInstance(instanceId);
isFocusedRef.current = true;
- setActiveChatInstance(instanceId);
setLastActiveInstance(instanceId);
markChatRead({ badgeBucket });
@@ -87,14 +84,15 @@ export function KiloClawChat({
// it immediately so the badge never drifts above 0 while the user is reading.
const subscription = Notifications.addNotificationReceivedListener(notification => {
const data = parseNotificationData(notification.request.content.data);
- if (data && getNotificationSandboxId(data) === instanceId) {
- markChatRead({ badgeBucket });
+ if (data?.type === 'chat.message' && data.sandboxId === instanceId) {
+ markChatRead({
+ badgeBucket: badgeBucketForConversation(data.sandboxId, data.conversationId),
+ });
}
});
return () => {
isFocusedRef.current = false;
- setActiveChatInstance(null);
subscription.remove();
};
}, [instanceId, markChatRead])
diff --git a/apps/mobile/src/lib/hooks/use-unread-counts-invalidation.ts b/apps/mobile/src/lib/hooks/use-unread-counts-invalidation.ts
index b6b361ae9c..4d9846244e 100644
--- a/apps/mobile/src/lib/hooks/use-unread-counts-invalidation.ts
+++ b/apps/mobile/src/lib/hooks/use-unread-counts-invalidation.ts
@@ -33,7 +33,7 @@ export function useUnreadCountsInvalidation() {
const received = Notifications.addNotificationReceivedListener(notification => {
const data = parseNotificationData(notification.request.content.data);
- if (data?.type === 'chat') {
+ if (data?.type === 'chat.message') {
invalidate();
}
});
diff --git a/apps/mobile/src/lib/notifications.ts b/apps/mobile/src/lib/notifications.ts
index 7045ad9b22..273680ff92 100644
--- a/apps/mobile/src/lib/notifications.ts
+++ b/apps/mobile/src/lib/notifications.ts
@@ -2,7 +2,8 @@ import expoConstants from 'expo-constants';
import * as Notifications from 'expo-notifications';
import { type Href, router } from 'expo-router';
import { Platform } from 'react-native';
-import { z } from 'zod';
+
+import { pushDataSchema } from '@kilocode/notifications';
function getProjectId(): string {
const eas = expoConstants.expoConfig?.extra?.eas as { projectId?: string } | undefined;
@@ -13,44 +14,27 @@ function getProjectId(): string {
return projectId;
}
-// Tracks which chat instance screen is currently focused.
+// Tracks which conversation screen is currently focused.
// Read by the foreground notification handler to suppress notifications
-// when the user is already viewing that chat.
+// when the user is already viewing that conversation.
// A module-level variable (not React state) because the notification handler
// is registered once and must always read the latest value without stale closures.
-let activeChatInstanceId: string | null = null;
+let activeChatLocation: { sandboxId: string; conversationId: string } | null = null;
-export function setActiveChatInstance(instanceId: string | null) {
- activeChatInstanceId = instanceId;
+export function setActiveChatLocation(
+ location: { sandboxId: string; conversationId: string } | null
+) {
+ activeChatLocation = location;
}
-const notificationDataSchema = z.discriminatedUnion('type', [
- z.object({
- type: z.literal('chat'),
- instanceId: z.string().min(1),
- }),
- z.object({
- type: z.literal('chat.message'),
- sandboxId: z.string().min(1),
- conversationId: z.string().min(1),
- messageId: z.string().min(1),
- }),
-]);
-
-type NotificationData = z.infer;
-
// Runtime-validates that an arbitrary notification `data` payload matches the
// shape we care about. Push producers can evolve independently of the app, so
// always parse before reading fields from the OS-provided notification content.
-export function parseNotificationData(data: unknown): NotificationData | null {
- const parsed = notificationDataSchema.safeParse(data);
+export function parseNotificationData(data: unknown) {
+ const parsed = pushDataSchema.safeParse(data);
return parsed.success ? parsed.data : null;
}
-export function getNotificationSandboxId(data: NotificationData): string {
- return data.type === 'chat' ? data.instanceId : data.sandboxId;
-}
-
const shown = {
shouldShowAlert: true,
shouldPlaySound: true,
@@ -73,8 +57,11 @@ export function setupNotificationHandler() {
handleNotification: async notification => {
const data = parseNotificationData(notification.request.content.data);
- // Suppress only if the user is already viewing this exact chat
- if (data && getNotificationSandboxId(data) === activeChatInstanceId) {
+ if (
+ data?.type === 'chat.message' &&
+ activeChatLocation?.sandboxId === data.sandboxId &&
+ activeChatLocation.conversationId === data.conversationId
+ ) {
return suppressed;
}
@@ -98,7 +85,7 @@ export function setupNotificationResponseHandler() {
const data = parseNotificationData(response.notification.request.content.data);
if (data) {
- const path = `/(app)/chat/${getNotificationSandboxId(data)}`;
+ const path = `/(app)/chat/${data.sandboxId}/${data.conversationId}`;
// If the router is ready (has segments), navigate immediately.
// Otherwise store as pending for consumption after auth completes.
try {
@@ -120,7 +107,7 @@ export function checkInitialNotification(): void {
}
const data = parseNotificationData(response.notification.request.content.data);
if (data) {
- pendingNotificationLink = `/(app)/chat/${getNotificationSandboxId(data)}`;
+ pendingNotificationLink = `/(app)/chat/${data.sandboxId}/${data.conversationId}`;
}
}
diff --git a/packages/notifications/src/badge-buckets.ts b/packages/notifications/src/badge-buckets.ts
index bb3213307c..56917b3d34 100644
--- a/packages/notifications/src/badge-buckets.ts
+++ b/packages/notifications/src/badge-buckets.ts
@@ -5,7 +5,5 @@
* don't collide as more surfaces start emitting badge updates.
*/
-export const badgeBucketForInstance = (sandboxId: string) => `kiloclaw:${sandboxId}` as const;
-
export const badgeBucketForConversation = (sandboxId: string, conversationId: string) =>
`kiloclaw:${sandboxId}:${conversationId}` as const;