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;